20 KiB
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
- Role Management: Define roles with mandate scope
- Access Rules: Matrix of permissions per role/table/field
- Database Integration: Native filtering in SQL queries
- 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:
- Generic Rules (
tableName = null,fieldName = null): Apply to all tables and fields - Table Rules (
tableName = "UserInDB",fieldName = null): Apply to all fields in a specific table - Field Rules (
tableName = "UserInDB",fieldName = "email"): Apply to a specific field in a specific table
Rule Resolution Order (most specific wins):
- Field-specific rule for the exact table and field
- Table-specific rule for the exact table
- 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
idfields 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
- Create
AccessRuletable - Add
roleLabelcolumn toUsertable - Create indexes for performance
Phase 2: Data Migration
- Create default access rules for each role label
- Migrate existing users to appropriate role labels
- Create access rules based on current UAM logic
Phase 3: Code Migration
- Update database connector with RBAC support
- Replace
_uam()calls withgetRecordsetWithRBAC() - Update all interface methods
Phase 4: Testing & Optimization
- Performance testing
- Security validation
- 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
- Default Permissions: Set baseline permissions for all tables
- New Table Support: Automatically apply permissions to new tables
- Bulk Permission Changes: Update permissions across all tables with one rule
- Role Templates: Create role templates that work out-of-the-box
Rule Management Best Practices
- Start with Generic Rules: Define broad permissions first
- Override Selectively: Add specific rules only where needed
- Use Descriptive Names: Make role labels clear and meaningful
- Test Incrementally: Add rules one at a time and test
- 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", ...}