wiki/poweron/appdoc/doc_security_role_based_access.md
2025-09-22 00:39:23 +02:00

20 KiB
Raw Blame History

Role-Based Access Control (RBAC) System

Executive Summary

This document describes the implementation of a comprehensive Role-Based Access Control (RBAC) system that addresses the critical database efficiency issues identified in the Database Efficiency Analysis. The new system moves access control logic from Python to the database level, providing granular permissions while dramatically improving performance.

Problem Statement

The current User Access Management (UAM) system has significant performance bottlenecks:

  • Full Table Scans: Loading entire tables before filtering in Python
  • Memory Overhead: Storing complete datasets in memory
  • Scalability Issues: Performance degrades with data growth
  • Code Complexity: Access control logic scattered across interfaces

Solution Overview

The new RBAC system implements a matrix-based access model with database-level filtering, providing:

  • Granular Permissions: Table and field-level access control
  • Role-Based Security: Centralized permission management
  • Database Optimization: Filtering at database level
  • Mandate Isolation: Secure multi-tenant access
  • Performance: 80-95% reduction in data transfer and memory usage

System Architecture

Core Components

  1. Role Management: Define roles with mandate scope
  2. Access Rules: Matrix of permissions per role/table/field
  3. Database Integration: Native filtering in SQL queries
  4. Migration Strategy: Seamless transition from current UAM

Data Models

Access Rule Model

class AccessRule(BaseModel, ModelMixin):
    """Data model for access control rules"""
    id: str = Field(
        default_factory=lambda: str(uuid.uuid4()),
        description="Unique ID of the access rule",
        frontend_type="text",
        frontend_readonly=True,
        frontend_required=False
    )
    roleLabel: str = Field(
        description="Role label this rule applies to",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "sysadmin", "label": {"en": "System Administrator", "fr": "Administrateur système"}},
            {"value": "admin", "label": {"en": "Administrator", "fr": "Administrateur"}},
            {"value": "user", "label": {"en": "User", "fr": "Utilisateur"}},
            {"value": "viewer", "label": {"en": "Viewer", "fr": "Observateur"}}
        ]
    )
    tableName: Optional[str] = Field(
        None,
        description="Name of the database table (null = all tables)",
        frontend_type="text",
        frontend_readonly=False,
        frontend_required=False
    )
    fieldName: Optional[str] = Field(
        None,
        description="Specific field name (null = entire table, requires tableName)",
        frontend_type="text",
        frontend_readonly=False,
        frontend_required=False
    )
    read: AccessLevel = Field(
        description="Read permission level",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
            {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
            {"value": "g", "label": {"en": "Mandate Records", "fr": "Enregistrements du mandat"}},
            {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
        ]
    )
    create: AccessLevel = Field(
        description="Create permission level",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
            {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
            {"value": "g", "label": {"en": "Mandate Records", "fr": "Enregistrements du mandat"}},
            {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
        ]
    )
    update: AccessLevel = Field(
        description="Update permission level",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
            {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
            {"value": "g", "label": {"en": "Mandate Records", "fr": "Enregistrements du mandat"}},
            {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
        ]
    )
    delete: AccessLevel = Field(
        description="Delete permission level",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
            {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
            {"value": "g", "label": {"en": "Mandate Records", "fr": "Enregistrements du mandat"}},
            {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
        ]
    )

Access Level Enum

class AccessLevel(str, Enum):
    """Access level enumeration"""
    ALL = "a"        # All records
    MY = "m"         # My records (created by me)
    MANDATE = "g"    # Mandate records (my mandate)
    NONE = "n"       # No access

Updated User Model

class User(BaseModel, ModelMixin):
    # ... existing fields ...
    roleLabel: str = Field(
        description="Role label assigned to this user",
        frontend_type="select",
        frontend_readonly=False,
        frontend_required=True,
        frontend_options=[
            {"value": "sysadmin", "label": {"en": "System Administrator", "fr": "Administrateur système"}},
            {"value": "admin", "label": {"en": "Administrator", "fr": "Administrateur"}},
            {"value": "user", "label": {"en": "User", "fr": "Utilisateur"}},
            {"value": "viewer", "label": {"en": "Viewer", "fr": "Observateur"}}
        ]
    )
    # ... rest of existing fields ...

Access Control Logic

Rule Hierarchy and Specificity

The AccessRule system supports a three-level hierarchy for maximum flexibility:

  1. Generic Rules (tableName = null, fieldName = null): Apply to all tables and fields
  2. Table Rules (tableName = "UserInDB", fieldName = null): Apply to all fields in a specific table
  3. Field Rules (tableName = "UserInDB", fieldName = "email"): Apply to a specific field in a specific table

Rule Resolution Order (most specific wins):

  1. Field-specific rule for the exact table and field
  2. Table-specific rule for the exact table
  3. Generic rule for all tables

Examples:

  • Generic rule: {roleLabel: "viewer", tableName: null, fieldName: null, read: "g"} - Viewers can read mandate records in all tables
  • Table rule: {roleLabel: "admin", tableName: "UserInDB", fieldName: null, read: "a"} - Admins can read all users
  • Field rule: {roleLabel: "user", tableName: "UserInDB", fieldName: "email", read: "m"} - Users can only read their own email

Opening Rights Principle

The system implements opening rights where read permission (R) is a prerequisite for create/update/delete operations (CUD):

  • If Read = "n": No CUD operations allowed
  • If Read = "m": CUD operations limited to "m" or "n"
  • If Read = "g": CUD operations limited to "g", "m", or "n"
  • If Read = "a": CUD operations can be "a", "g", "m", or "n"

Key Rule: You can ONLY create/update/delete (CUD) if you have read (R) right.

System Field Protection

Critical Security Rule: System fields are automatically protected and cannot be modified by users:

  • ID Fields: All id fields are read-only for all roles
  • System Fields: All fields starting with _ (underscore) are read-only for all roles
  • Database Connector Only: Only the database connector can modify these fields
  • Automatic Enforcement: This protection is enforced at the database level, not just application level

Examples of Protected Fields:

  • id - Primary key
  • _createdAt - Creation timestamp
  • _createdBy - Creator user ID
  • _updatedAt - Last update timestamp
  • _updatedBy - Last updater user ID
  • _version - Record version number

Access Rule Override: Even if an AccessRule grants CUD permissions to system fields, the database connector will automatically restrict these operations to read-only.

Role-Based Access Logic

Each user has one role label that directly maps to a set of access rules:

  • Simple Assignment: User.roleLabel → AccessRule.roleLabel
  • Direct Mapping: No complex role combinations
  • Clear Permissions: Each role has well-defined access patterns
  • Easy Debugging: Clear trace from user to permissions

Permission Validation

def validate_access_rule(rule: AccessRule) -> bool:
    """Validate that CUD permissions are allowed by read permission level"""
    read_level = AccessLevel(rule.read)
    
    # CUD operations are only allowed if read permission exists
    for operation in [rule.create, rule.update, rule.delete]:
        if operation == "n":
            continue  # No access is always valid
        if read_level == AccessLevel.NONE:
            return False  # No CUD allowed if no read access
        if read_level == AccessLevel.MY and operation not in ["n", "m"]:
            return False
        if read_level == AccessLevel.MANDATE and operation not in ["n", "m", "g"]:
            return False
    
    return True

def validate_rule_hierarchy(rule: AccessRule) -> bool:
    """Validate that field rules require table rules"""
    if rule.fieldName is not None and rule.tableName is None:
        return False  # Field rules must have a table
    return True

Database Integration

Enhanced Database Connector

The database connector will be extended to support RBAC filtering:

class DatabaseConnector:
    def getRecordsetWithRBAC(self, 
                           model_class: Type[BaseModel],
                           current_user: User,
                           record_filter: Dict = None,
                           order_by: str = None,
                           limit: int = None) -> List[Dict]:
        """Get records with RBAC filtering applied at database level"""
        
        # Get access rules for user's role
        access_rules = self.getAccessRulesForRole(current_user.roleLabel, model_class.__tablename__)
        
        # Build SQL query with RBAC WHERE clause
        where_clause = self.build_rbac_where_clause(access_rules, current_user)
        
        # Execute optimized query
        return self.execute_query_with_rbac(
            model_class, 
            record_filter, 
            where_clause, 
            order_by, 
            limit
        )
    
    def getAccessRulesForRole(self, role_label: str, table_name: str) -> List[AccessRule]:
        """Get access rules for a specific role and table"""
        # Get both table-specific and generic rules
        table_rules = self.getRecordset(AccessRule,
            recordFilter={
                "roleLabel": role_label,
                "tableName": table_name
            }
        )
        generic_rules = self.getRecordset(AccessRule,
            recordFilter={
                "roleLabel": role_label,
                "tableName": None
            }
        )
        return table_rules + generic_rules
    
    def is_system_field(self, field_name: str) -> bool:
        """Check if a field is a protected system field"""
        return field_name == "id" or field_name.startswith("_")
    
    def enforce_system_field_protection(self, data: Dict, operation: str) -> Dict:
        """Remove system fields from CUD operations"""
        if operation == "read":
            return data  # Read operations can access system fields
        
        # For CUD operations, remove system fields
        protected_data = {}
        for key, value in data.items():
            if not self.is_system_field(key):
                protected_data[key] = value
        
        return protected_data

SQL Query Generation

-- Example: Get workflows with RBAC filtering for single role
SELECT w.* 
FROM "ChatWorkflow" w
JOIN "AccessRule" ar ON ar.roleLabel = %s AND ar.tableName = 'ChatWorkflow'
WHERE 
  -- User access control based on role permissions
  (
    -- All records access
    (ar.read = 'a')
    OR
    -- Mandate records access
    (ar.read = 'g' AND w.mandateId = %s)
    OR
    -- My records access
    (ar.read = 'm' AND w.createdBy = %s)
  )
  -- Additional filters
  AND w.status = 'active'
ORDER BY w.createdAt DESC
LIMIT 100;

Migration Strategy

Phase 1: Database Schema

  1. Create AccessRule table
  2. Add roleLabel column to User table
  3. Create indexes for performance

Phase 2: Data Migration

  1. Create default access rules for each role label
  2. Migrate existing users to appropriate role labels
  3. Create access rules based on current UAM logic

Phase 3: Code Migration

  1. Update database connector with RBAC support
  2. Replace _uam() calls with getRecordsetWithRBAC()
  3. Update all interface methods

Phase 4: Testing & Optimization

  1. Performance testing
  2. Security validation
  3. Query optimization

Performance Impact

Expected Improvements

Operation Current Optimized Improvement
getUserByUsername() Full table scan Single record 100% reduction
getWorkflows() All workflows User's workflows 80-95% reduction
getAllFiles() All files User's files 80-95% reduction
Memory usage Full tables Filtered records 80-95% reduction
Query time O(n) Python O(log n) SQL 10-100x faster

Database Load Reduction

  • Critical Operations: 100% reduction in data transfer
  • High Impact Operations: 80-95% reduction
  • Memory Usage: 80-95% reduction for large tables
  • Query Performance: 10-100x improvement

Security Considerations

Mandate Isolation

  • Users can only access records within their mandate
  • Global roles can access all mandates
  • Cross-mandate access is explicitly controlled

Permission Inheritance

  • Global roles apply to all mandates
  • Mandate-specific roles override global roles
  • Most restrictive permission wins

Audit Trail

  • All access attempts logged
  • Permission changes tracked
  • Security events monitored

Implementation Timeline

Week 1-2: Database Schema

  • Create tables and indexes
  • Implement data models
  • Set up migration scripts

Week 3-4: Core RBAC Logic

  • Implement access rule validation
  • Build database connector extensions
  • Create permission checking utilities

Week 5-6: Interface Migration

  • Update interfaceAppObjects
  • Update interfaceChatObjects
  • Update interfaceComponentObjects

Week 7-8: Testing & Optimization

  • Performance testing
  • Security validation
  • Query optimization
  • Documentation updates

Default Roles

System Roles (Global)

  • SysAdmin: Full access to all tables and mandates
  • SystemUser: Limited system access

Mandate Roles

  • Admin: Full access within mandate
  • User: Limited access within mandate
  • Viewer: Read-only access within mandate

Role Management

Simple Role Assignment

Users have a single role label that directly maps to access rules:

{
  "userId": "user-123",
  "roleLabel": "admin",
  "mandateId": "mandate-456"
}

Role Label Options

  • sysadmin: System administrator with full access
  • admin: Administrator with mandate-level access
  • user: Regular user with own-record access
  • viewer: Read-only access within mandate

Access Rule Examples

Generic Rules (All Tables)

Generic rules provide a powerful way to set default permissions that apply across all tables:

Example 1: Viewer Role - Read-Only Access to All Tables

{
  "roleLabel": "viewer",
  "tableName": null,
  "fieldName": null,
  "read": "g",
  "create": "n",
  "update": "n",
  "delete": "n"
}

Effect: Viewers can read mandate records in ALL tables, but cannot create, update, or delete anything.

Example 2: SysAdmin Role - Full Access to All Tables

{
  "roleLabel": "sysadmin",
  "tableName": null,
  "fieldName": null,
  "read": "a",
  "create": "a",
  "update": "a",
  "delete": "a"
}

Effect: System administrators have full access to ALL tables and fields.

Example 3: User Role - Own Records Only (Generic)

{
  "roleLabel": "user",
  "tableName": null,
  "fieldName": null,
  "read": "m",
  "create": "m",
  "update": "m",
  "delete": "m"
}

Effect: Users can only access their own records in ALL tables (where _createdBy matches their user ID).

Table-Specific Rules

Table-specific rules override generic rules for particular tables:

Example 4: Admin Role - User Table Access

{
  "roleLabel": "admin",
  "tableName": "UserInDB",
  "fieldName": null,
  "read": "g",
  "create": "g", 
  "update": "g",
  "delete": "n"
}

Example 5: User Role - File Access (Override Generic Rule)

{
  "roleLabel": "user",
  "tableName": "FileItem",
  "fieldName": null,
  "read": "g",
  "create": "g",
  "update": "g", 
  "delete": "g"
}

Effect: Users can access all files in their mandate (overrides the generic "own records only" rule).

Example 6: Viewer Role - Workflow Access (Override Generic Rule)

{
  "roleLabel": "viewer",
  "tableName": "ChatWorkflow",
  "fieldName": null,
  "read": "a",
  "create": "n",
  "update": "n",
  "delete": "n"
}

Effect: Viewers can read ALL workflows (overrides the generic "mandate records only" rule).

Field-Specific Rules

Field-specific rules provide the most granular control:

Example 7: User Role - Email Field Access

{
  "roleLabel": "user",
  "tableName": "UserInDB",
  "fieldName": "email",
  "read": "a",
  "create": "a",
  "update": "a",
  "delete": "n"
}

Effect: Users can read and modify email addresses of all users, but cannot delete them.

Benefits of Generic Rules

Simplified Access Management

Generic rules dramatically reduce the number of access rules needed:

Without Generic Rules:

  • Need separate rules for each table (UserInDB, ChatWorkflow, FileItem, etc.)
  • 10 tables × 4 roles = 40+ access rules
  • Difficult to maintain and prone to inconsistencies

With Generic Rules:

  • 1 generic rule per role = 4 access rules
  • Override with specific rules only when needed
  • Much easier to maintain and understand

Common Use Cases for Generic Rules

  1. Default Permissions: Set baseline permissions for all tables
  2. New Table Support: Automatically apply permissions to new tables
  3. Bulk Permission Changes: Update permissions across all tables with one rule
  4. Role Templates: Create role templates that work out-of-the-box

Rule Management Best Practices

  1. Start with Generic Rules: Define broad permissions first
  2. Override Selectively: Add specific rules only where needed
  3. Use Descriptive Names: Make role labels clear and meaningful
  4. Test Incrementally: Add rules one at a time and test
  5. Document Exceptions: Keep track of why specific overrides exist

System Field Protection Examples

Example 1: User Attempts to Modify System Fields

# User tries to update a record with system fields
user_data = {
    "id": "new-id-123",           # ❌ Blocked - system field
    "name": "John Doe",           # ✅ Allowed - user field
    "_createdAt": 1640995200,     # ❌ Blocked - system field
    "_createdBy": "hacker-123",   # ❌ Blocked - system field
    "email": "john@example.com"   # ✅ Allowed - user field
}

# Database connector automatically filters out system fields
protected_data = db_connector.enforce_system_field_protection(user_data, "update")
# Result: {"name": "John Doe", "email": "john@example.com"}

Example 2: Access Rules Cannot Override System Protection

{
  "roleLabel": "admin",
  "tableName": "UserInDB",
  "fieldName": "id",
  "read": "a",
  "create": "a",    // ❌ Ignored - system field protection
  "update": "a",    // ❌ Ignored - system field protection  
  "delete": "a"     // ❌ Ignored - system field protection
}

Example 3: System Fields in Read Operations

# Read operations can access system fields
workflow = db_connector.getRecordsetWithRBAC(ChatWorkflow, user, {"id": "workflow-123"})
# Result includes: {"id": "workflow-123", "_createdAt": 1640995200, "_createdBy": "user-456", ...}