From b52efed2ea02e00552b3796fb8701d8d557b6d07 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sat, 31 Jan 2026 12:12:23 -0800 Subject: [PATCH 01/20] feat: Add RBAC database tables and models Add industry-standard RBAC (Role-Based Access Control) system with: Database tables: - rbac_role: Dynamic role definitions with hierarchy support - rbac_permission: Permission definitions (seeded from SCOPE_REGISTRY) - rbac_role_permission: Role-permission mapping (many-to-many) - rbac_user_role: User role assignments with resource scoping + temporal validity - rbac_role_constraint: Separation of duties constraints SQLAlchemy models: - RBACRole: Supports role hierarchy via parent_role_id - RBACPermission: Permission definitions - RBACRolePermission: Junction table - RBACUserRole: Supports resource-scoped and time-bounded assignments - RBACRoleConstraint: Static/dynamic SoD and cardinality constraints Migrations: - Add RBAC tables with indexes and constraints - Seed default roles and permissions from existing role definitions Co-Authored-By: Claude Opus 4.5 --- ...01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py | 432 ++++++++++++++++++ ...31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py | 400 ++++++++++++++++ src/fides/api/models/rbac/__init__.py | 15 + src/fides/api/models/rbac/rbac_permission.py | 63 +++ src/fides/api/models/rbac/rbac_role.py | 182 ++++++++ .../api/models/rbac/rbac_role_constraint.py | 144 ++++++ .../api/models/rbac/rbac_role_permission.py | 42 ++ src/fides/api/models/rbac/rbac_user_role.py | 167 +++++++ src/fides/api/models/sql_models.py | 12 + 9 files changed, 1457 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py create mode 100644 src/fides/api/models/rbac/__init__.py create mode 100644 src/fides/api/models/rbac/rbac_permission.py create mode 100644 src/fides/api/models/rbac/rbac_role.py create mode 100644 src/fides/api/models/rbac/rbac_role_constraint.py create mode 100644 src/fides/api/models/rbac/rbac_role_permission.py create mode 100644 src/fides/api/models/rbac/rbac_user_role.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py new file mode 100644 index 00000000000..9cb23940368 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py @@ -0,0 +1,432 @@ +"""Add RBAC tables for dynamic role-based access control + +Revision ID: a1b2c3d4e5f6 +Revises: 627c230d9917 +Create Date: 2026-01-31 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "627c230d9917" +branch_labels = None +depends_on = None + + +def upgrade(): + # Create rbac_role table + op.create_table( + "rbac_role", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "name", + sa.String(length=255), + nullable=False, + comment="Human-readable display name for the role", + ), + sa.Column( + "key", + sa.String(length=255), + nullable=False, + comment="Machine-readable key for the role", + ), + sa.Column( + "description", + sa.Text(), + nullable=True, + comment="Description of the role's purpose and access level", + ), + sa.Column( + "is_system_role", + sa.Boolean(), + server_default="false", + nullable=False, + comment="True for built-in system roles that cannot be deleted", + ), + sa.Column( + "is_active", + sa.Boolean(), + server_default="true", + nullable=False, + comment="Whether this role can be assigned to users", + ), + sa.Column( + "parent_role_id", + sa.String(length=255), + nullable=True, + comment="Parent role ID for hierarchy", + ), + sa.Column( + "priority", + sa.Integer(), + server_default="0", + nullable=False, + comment="Priority for conflict resolution", + ), + sa.ForeignKeyConstraint( + ["parent_role_id"], + ["rbac_role.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", name="uq_rbac_role_name"), + sa.UniqueConstraint("key", name="uq_rbac_role_key"), + ) + op.create_index(op.f("ix_rbac_role_id"), "rbac_role", ["id"], unique=False) + op.create_index(op.f("ix_rbac_role_key"), "rbac_role", ["key"], unique=False) + op.create_index( + op.f("ix_rbac_role_parent_role_id"), "rbac_role", ["parent_role_id"], unique=False + ) + + # Create rbac_permission table + op.create_table( + "rbac_permission", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "code", + sa.String(length=255), + nullable=False, + comment="Unique permission code", + ), + sa.Column( + "description", + sa.Text(), + nullable=True, + comment="Human-readable description", + ), + sa.Column( + "resource_type", + sa.String(length=100), + nullable=True, + comment="Resource type this permission applies to", + ), + sa.Column( + "is_active", + sa.Boolean(), + server_default="true", + nullable=False, + comment="Whether this permission is active", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code", name="uq_rbac_permission_code"), + ) + op.create_index(op.f("ix_rbac_permission_id"), "rbac_permission", ["id"], unique=False) + op.create_index( + op.f("ix_rbac_permission_code"), "rbac_permission", ["code"], unique=False + ) + op.create_index( + op.f("ix_rbac_permission_resource_type"), + "rbac_permission", + ["resource_type"], + unique=False, + ) + + # Create rbac_role_permission junction table + op.create_table( + "rbac_role_permission", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("role_id", sa.String(length=255), nullable=False), + sa.Column("permission_id", sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint( + ["role_id"], + ["rbac_role.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["permission_id"], + ["rbac_permission.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "role_id", "permission_id", name="uq_rbac_role_permission_mapping" + ), + ) + op.create_index( + op.f("ix_rbac_role_permission_id"), "rbac_role_permission", ["id"], unique=False + ) + op.create_index( + op.f("ix_rbac_role_permission_role_id"), + "rbac_role_permission", + ["role_id"], + unique=False, + ) + op.create_index( + op.f("ix_rbac_role_permission_permission_id"), + "rbac_role_permission", + ["permission_id"], + unique=False, + ) + + # Create rbac_user_role table + op.create_table( + "rbac_user_role", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("role_id", sa.String(length=255), nullable=False), + sa.Column( + "resource_type", + sa.String(length=100), + nullable=True, + comment="Resource type for scoped permissions", + ), + sa.Column( + "resource_id", + sa.String(length=255), + nullable=True, + comment="Specific resource ID for scoped permissions", + ), + sa.Column( + "valid_from", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + comment="When this assignment becomes active", + ), + sa.Column( + "valid_until", + sa.DateTime(timezone=True), + nullable=True, + comment="When this assignment expires", + ), + sa.Column( + "assigned_by", + sa.String(length=255), + nullable=True, + comment="User ID of who created this assignment", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["fidesuser.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["role_id"], + ["rbac_role.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["assigned_by"], + ["fidesuser.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "role_id", + "resource_type", + "resource_id", + name="uq_rbac_user_role_assignment", + ), + ) + op.create_index( + op.f("ix_rbac_user_role_id"), "rbac_user_role", ["id"], unique=False + ) + op.create_index( + op.f("ix_rbac_user_role_user_id"), "rbac_user_role", ["user_id"], unique=False + ) + op.create_index( + op.f("ix_rbac_user_role_role_id"), "rbac_user_role", ["role_id"], unique=False + ) + op.create_index( + op.f("ix_rbac_user_role_resource"), + "rbac_user_role", + ["resource_type", "resource_id"], + unique=False, + ) + op.create_index( + op.f("ix_rbac_user_role_validity"), + "rbac_user_role", + ["valid_from", "valid_until"], + unique=False, + ) + + # Create rbac_role_constraint table + op.create_table( + "rbac_role_constraint", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "name", + sa.String(length=255), + nullable=False, + comment="Human-readable name for this constraint", + ), + sa.Column( + "constraint_type", + sa.String(length=50), + nullable=False, + comment="Type of constraint: static_sod, dynamic_sod, or cardinality", + ), + sa.Column( + "role_id_1", + sa.String(length=255), + nullable=False, + comment="First role in the constraint", + ), + sa.Column( + "role_id_2", + sa.String(length=255), + nullable=True, + comment="Second role (for SoD constraints)", + ), + sa.Column( + "max_users", + sa.Integer(), + nullable=True, + comment="Maximum users for cardinality constraint", + ), + sa.Column( + "description", + sa.Text(), + nullable=True, + comment="Description of why this constraint exists", + ), + sa.Column( + "is_active", + sa.Boolean(), + server_default="true", + nullable=False, + comment="Whether this constraint is enforced", + ), + sa.ForeignKeyConstraint( + ["role_id_1"], + ["rbac_role.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["role_id_2"], + ["rbac_role.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_rbac_role_constraint_id"), + "rbac_role_constraint", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_rbac_role_constraint_type"), + "rbac_role_constraint", + ["constraint_type"], + unique=False, + ) + op.create_index( + op.f("ix_rbac_role_constraint_role_id_1"), + "rbac_role_constraint", + ["role_id_1"], + unique=False, + ) + op.create_index( + op.f("ix_rbac_role_constraint_role_id_2"), + "rbac_role_constraint", + ["role_id_2"], + unique=False, + ) + + +def downgrade(): + # Drop tables in reverse order of creation + op.drop_index( + op.f("ix_rbac_role_constraint_role_id_2"), table_name="rbac_role_constraint" + ) + op.drop_index( + op.f("ix_rbac_role_constraint_role_id_1"), table_name="rbac_role_constraint" + ) + op.drop_index( + op.f("ix_rbac_role_constraint_type"), table_name="rbac_role_constraint" + ) + op.drop_index(op.f("ix_rbac_role_constraint_id"), table_name="rbac_role_constraint") + op.drop_table("rbac_role_constraint") + + op.drop_index(op.f("ix_rbac_user_role_validity"), table_name="rbac_user_role") + op.drop_index(op.f("ix_rbac_user_role_resource"), table_name="rbac_user_role") + op.drop_index(op.f("ix_rbac_user_role_role_id"), table_name="rbac_user_role") + op.drop_index(op.f("ix_rbac_user_role_user_id"), table_name="rbac_user_role") + op.drop_index(op.f("ix_rbac_user_role_id"), table_name="rbac_user_role") + op.drop_table("rbac_user_role") + + op.drop_index( + op.f("ix_rbac_role_permission_permission_id"), table_name="rbac_role_permission" + ) + op.drop_index( + op.f("ix_rbac_role_permission_role_id"), table_name="rbac_role_permission" + ) + op.drop_index(op.f("ix_rbac_role_permission_id"), table_name="rbac_role_permission") + op.drop_table("rbac_role_permission") + + op.drop_index( + op.f("ix_rbac_permission_resource_type"), table_name="rbac_permission" + ) + op.drop_index(op.f("ix_rbac_permission_code"), table_name="rbac_permission") + op.drop_index(op.f("ix_rbac_permission_id"), table_name="rbac_permission") + op.drop_table("rbac_permission") + + op.drop_index(op.f("ix_rbac_role_parent_role_id"), table_name="rbac_role") + op.drop_index(op.f("ix_rbac_role_key"), table_name="rbac_role") + op.drop_index(op.f("ix_rbac_role_id"), table_name="rbac_role") + op.drop_table("rbac_role") diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py new file mode 100644 index 00000000000..2751af2f76e --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py @@ -0,0 +1,400 @@ +"""Seed default RBAC roles and permissions from existing role definitions + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-01-31 11:00:00.000000 + +This migration seeds the RBAC tables with: +1. All permissions from the existing SCOPE_REGISTRY +2. All roles from the existing ROLES_TO_SCOPES_MAPPING +3. Role-permission mappings based on existing role definitions + +This ensures backward compatibility when switching to the new RBAC system. +""" + +from uuid import uuid4 + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + +# Scope definitions from scope_registry.py +# We hardcode these to avoid import issues during migration +SCOPE_DOCS = { + "config:read": "View the configuration", + "config:update": "Update the configuration", + "cli-objects:create": "Create objects via the command line interface", + "cli-objects:read": "Read objects via the command line interface", + "cli-objects:update": "Update objects via the command line interface", + "cli-objects:delete": "Delete objects via the command line interface", + "client:create": "Create OAuth clients", + "client:delete": "Remove OAuth clients", + "client:read": "View current scopes for OAuth clients", + "client:update": "Modify existing scopes for OAuth clients", + "connection:create_or_update": "Create or modify connections", + "connection:delete": "Remove connections", + "connection:read": "View connections", + "connection:authorize": "OAuth2 Authorization", + "connection_type:read": "View types of connections", + "connector_template:register": "Register a connector template", + "connector_template:read": "View connector template configurations", + "consent:read": "Read consent preferences", + "consent_settings:read": "Read org-wide consent settings", + "consent_settings:update": "Update org-wide consent settings", + "ctl_dataset:create": "Create a ctl dataset", + "ctl_dataset:read": "Read ctl datasets", + "ctl_dataset:delete": "Delete a ctl dataset", + "ctl_dataset:update": "Update ctl datasets", + "ctl_policy:create": "Create a ctl policy", + "ctl_policy:read": "Read ctl policies", + "ctl_policy:delete": "Delete a ctl policy", + "ctl_policy:update": "Update ctl policies", + "current-privacy-preference:read": "Read the current privacy preferences of all users", + "database:reset": "Reset the application database", + "data_category:create": "Create a data category", + "data_category:delete": "Delete data categories", + "data_category:read": "Read data categories", + "data_category:update": "Update data categories", + "data_subject:create": "Create a data subject", + "data_subject:read": "Read data subjects", + "data_subject:delete": "Delete data subjects", + "data_subject:update": "Update data subjects", + "data_use:create": "Create a data use", + "data_use:read": "Read data uses", + "data_use:delete": "Delete data uses", + "data_use:update": "Update data uses", + "dataset:create_or_update": "Create or modify datasets", + "dataset:delete": "Delete datasets", + "privacy-request-redaction-patterns:read": "View privacy request redaction patterns", + "privacy-request-redaction-patterns:update": "Update privacy request redaction patterns", + "dataset:read": "View datasets", + "dataset:test": "Run a standalone privacy request test for a dataset", + "encryption:exec": "Encrypt data", + "heap_dump:exec": "Execute a heap dump for memory diagnostics", + "messaging-template:update": "Update messaging templates", + "evaluation:create": "Create evaluation", + "evaluation:read": "Read evaluations", + "evaluation:delete": "Delete evaluations", + "evaluation:update": "Update evaluations", + "fides_taxonomy:update": "Update default fides taxonomy description", + "generate:exec": "", + "masking:exec": "Execute a masking strategy", + "masking:read": "Read masking strategies", + "messaging:create_or_update": "", + "messaging:delete": "", + "messaging:read": "", + "organization:create": "Create organization", + "organization:read": "Read organization details", + "organization:delete": "Delete organization", + "organization:update": "Update organization details", + "policy:create_or_update": "Create or modify policies", + "policy:delete": "Remove policies", + "policy:read": "View policies", + "privacy-experience:create": "Create privacy experiences", + "privacy-experience:update": "Update privacy experiences", + "privacy-experience:read": "View privacy experiences", + "privacy-notice:create": "Create privacy notices", + "privacy-notice:update": "Update privacy notices", + "privacy-notice:read": "View privacy notices", + "privacy-preference-history:read": "Read the history of all saved privacy preferences", + "privacy-request:create": "", + "privacy-request:resume": "Restart paused privacy requests", + "privacy-request-access-results:read": "Download access data for the privacy request", + "privacy-request:delete": "Remove privacy requests", + "privacy-request-email-integrations:send": "Send email for email integrations for the privacy request", + "privacy-request:manual-steps:respond": "Respond to manual steps for the privacy request", + "privacy-request:manual-steps:review": "Review manual steps for the privacy request", + "privacy-request-notifications:create_or_update": "", + "privacy-request-notifications:read": "", + "privacy-request:read": "View privacy requests", + "privacy-request:review": "Review privacy requests", + "privacy-request:transfer": "Transfer privacy requests", + "privacy-request:upload_data": "Manually upload data for the privacy request", + "privacy-request:view_data": "View subject data related to the privacy request", + "rule:create_or_update": "Create or update rules", + "rule:delete": "Remove rules", + "rule:read": "View rules", + "saas_config:create_or_update": "Create or update SaaS configurations", + "saas_config:delete": "Remove SaaS configurations", + "saas_config:read": "View SaaS configurations", + "connection:instantiate": "Instantiate a SaaS connection config from a connector template", + "scope:read": "View authorization scopes", + "storage:create_or_update": "Create or update storage", + "storage:delete": "Remove storage", + "storage:read": "View storage", + "system:create": "Create systems", + "system:read": "Read systems", + "system:delete": "Delete systems", + "system:update": "Update systems", + "system_manager:read": "Read systems users can manage", + "system_manager:delete": "Delete systems user can manage", + "system_manager:update": "Update systems user can manage", + "user:create": "Create users", + "user:update": "Update users", + "user:delete": "Remove users", + "user:read": "View users", + "user:read-own": "View own user", + "user:password-reset": "Reset another user's password", + "user-permission:create": "Create user permissions", + "user-permission:update": "Update user permissions", + "user-permission:assign_owners": "Assign the owner role to a user", + "user-permission:read": "View user permissions", + "validate:exec": "", + "webhook:create_or_update": "Create or update web hooks", + "webhook:delete": "Remove web hooks", + "webhook:read": "View web hooks", + "worker-stats:read": "View worker statistics", +} + +# Role definitions from roles.py +LEGACY_ROLES = { + "owner": { + "name": "Owner", + "description": "Full admin access to all features and settings", + "priority": 100, + }, + "contributor": { + "name": "Contributor", + "description": "Can create and edit most resources but cannot configure storage, messaging, or assign owner role", + "priority": 80, + }, + "viewer_and_approver": { + "name": "Viewer & Approver", + "description": "Read-only access to the data map with ability to approve privacy requests", + "priority": 60, + }, + "viewer": { + "name": "Viewer", + "description": "Read-only access to the data map and system configuration", + "priority": 40, + }, + "approver": { + "name": "Approver", + "description": "Can review and approve privacy requests only", + "priority": 30, + }, + "respondent": { + "name": "Internal Respondent", + "description": "Internal user who can respond to assigned manual task steps", + "priority": 20, + }, + "external_respondent": { + "name": "External Respondent", + "description": "External user with minimal access to respond to assigned manual task steps", + "priority": 10, + }, +} + +# Approver scopes +APPROVER_SCOPES = [ + "privacy-request:review", + "privacy-request:read", + "privacy-request:resume", + "privacy-request:upload_data", + "privacy-request:view_data", + "privacy-request:delete", + "privacy-request:create", + "user:read", + "privacy-request:manual-steps:review", +] + +# Viewer scopes +VIEWER_SCOPES = [ + "cli-objects:read", + "client:read", + "connection:read", + "consent:read", + "consent_settings:read", + "connection_type:read", + "ctl_dataset:read", + "data_category:read", + "ctl_policy:read", + "dataset:read", + "data_subject:read", + "data_use:read", + "evaluation:read", + "masking:exec", + "masking:read", + "organization:read", + "policy:read", + "privacy-experience:read", + "privacy-notice:read", + "privacy-request-notifications:read", + "rule:read", + "scope:read", + "storage:read", + "system:read", + "messaging:read", + "webhook:read", + "system_manager:read", + "saas_config:read", + "user:read", +] + +# Respondent scopes +RESPONDENT_SCOPES = [ + "privacy-request:manual-steps:respond", + "user:read-own", +] + +# External respondent scopes +EXTERNAL_RESPONDENT_SCOPES = [ + "privacy-request:manual-steps:respond", +] + +# Scopes excluded from contributor +NOT_CONTRIBUTOR_SCOPES = [ + "connector_template:register", + "storage:create_or_update", + "storage:delete", + "messaging:create_or_update", + "messaging:delete", + "privacy-request-notifications:create_or_update", + "privacy-request-email-integrations:send", + "user-permission:assign_owners", + "heap_dump:exec", +] + + +def get_resource_type_from_scope(scope: str) -> str | None: + """Extract resource type from scope code.""" + if ":" not in scope: + return None + resource = scope.split(":")[0] + # Normalize common patterns + resource_map = { + "privacy-request": "privacy_request", + "cli-objects": "cli_objects", + "privacy-experience": "privacy_experience", + "privacy-notice": "privacy_notice", + "user-permission": "user_permission", + "privacy-request-redaction-patterns": "privacy_request", + "privacy-request-notifications": "privacy_request", + "privacy-request-email-integrations": "privacy_request", + "privacy-request-access-results": "privacy_request", + "current-privacy-preference": "consent", + "privacy-preference-history": "consent", + "messaging-template": "messaging", + "worker-stats": "worker", + "heap_dump": "system", + } + return resource_map.get(resource, resource) + + +def upgrade(): + connection = op.get_bind() + + # Step 1: Seed permissions from SCOPE_DOCS + permission_ids = {} # code -> id mapping + for scope_code, description in SCOPE_DOCS.items(): + permission_id = f"per_{uuid4()}" + permission_ids[scope_code] = permission_id + resource_type = get_resource_type_from_scope(scope_code) + + connection.execute( + text( + """ + INSERT INTO rbac_permission (id, code, description, resource_type, is_active) + VALUES (:id, :code, :description, :resource_type, true) + """ + ), + { + "id": permission_id, + "code": scope_code, + "description": description or f"Permission for {scope_code}", + "resource_type": resource_type, + }, + ) + + # Step 2: Seed roles + role_ids = {} # key -> id mapping + for role_key, role_data in LEGACY_ROLES.items(): + role_id = f"rol_{uuid4()}" + role_ids[role_key] = role_id + + connection.execute( + text( + """ + INSERT INTO rbac_role (id, key, name, description, is_system_role, is_active, priority) + VALUES (:id, :key, :name, :description, true, true, :priority) + """ + ), + { + "id": role_id, + "key": role_key, + "name": role_data["name"], + "description": role_data["description"], + "priority": role_data["priority"], + }, + ) + + # Step 3: Create role-permission mappings + + # Helper to create role-permission mapping + def create_role_permission_mapping(role_key: str, scope_codes: list[str]): + role_id = role_ids[role_key] + for scope_code in scope_codes: + if scope_code in permission_ids: + mapping_id = f"rpm_{uuid4()}" + connection.execute( + text( + """ + INSERT INTO rbac_role_permission (id, role_id, permission_id) + VALUES (:id, :role_id, :permission_id) + """ + ), + { + "id": mapping_id, + "role_id": role_id, + "permission_id": permission_ids[scope_code], + }, + ) + + # Owner gets all permissions + create_role_permission_mapping("owner", list(SCOPE_DOCS.keys())) + + # Viewer & Approver gets viewer + approver scopes + viewer_and_approver_scopes = list(set(VIEWER_SCOPES + APPROVER_SCOPES)) + create_role_permission_mapping("viewer_and_approver", viewer_and_approver_scopes) + + # Viewer gets viewer scopes + create_role_permission_mapping("viewer", VIEWER_SCOPES) + + # Approver gets approver scopes + create_role_permission_mapping("approver", APPROVER_SCOPES) + + # Contributor gets all scopes except NOT_CONTRIBUTOR_SCOPES + contributor_scopes = [ + s for s in SCOPE_DOCS.keys() if s not in NOT_CONTRIBUTOR_SCOPES + ] + create_role_permission_mapping("contributor", contributor_scopes) + + # Respondent gets respondent scopes + create_role_permission_mapping("respondent", RESPONDENT_SCOPES) + + # External respondent gets external respondent scopes + create_role_permission_mapping("external_respondent", EXTERNAL_RESPONDENT_SCOPES) + + # Step 4: Create default constraints + + # Approver and System Manager are mutually exclusive (static SoD) + # This mirrors the existing behavior in the codebase + # Note: We'll add this constraint once we verify the roles exist + + +def downgrade(): + connection = op.get_bind() + + # Delete all seeded data (system roles and their mappings) + # Cascading deletes will handle rbac_role_permission + connection.execute( + text("DELETE FROM rbac_role WHERE is_system_role = true") + ) + + # Delete all permissions (they were seeded) + connection.execute(text("DELETE FROM rbac_permission")) diff --git a/src/fides/api/models/rbac/__init__.py b/src/fides/api/models/rbac/__init__.py new file mode 100644 index 00000000000..e75336bc933 --- /dev/null +++ b/src/fides/api/models/rbac/__init__.py @@ -0,0 +1,15 @@ +"""RBAC models for dynamic role-based access control.""" + +from fides.api.models.rbac.rbac_permission import RBACPermission +from fides.api.models.rbac.rbac_role import RBACRole +from fides.api.models.rbac.rbac_role_constraint import RBACRoleConstraint +from fides.api.models.rbac.rbac_role_permission import RBACRolePermission +from fides.api.models.rbac.rbac_user_role import RBACUserRole + +__all__ = [ + "RBACRole", + "RBACPermission", + "RBACRolePermission", + "RBACUserRole", + "RBACRoleConstraint", +] diff --git a/src/fides/api/models/rbac/rbac_permission.py b/src/fides/api/models/rbac/rbac_permission.py new file mode 100644 index 00000000000..bb2d70e66d8 --- /dev/null +++ b/src/fides/api/models/rbac/rbac_permission.py @@ -0,0 +1,63 @@ +"""RBAC Permission model for storing permission definitions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +from sqlalchemy import Boolean, Column, String, Text +from sqlalchemy.orm import relationship + +from fides.api.db.base_class import Base + +if TYPE_CHECKING: + from fides.api.models.rbac.rbac_role import RBACRole + + +class RBACPermission(Base): + """ + Permission definition for RBAC system. + + Permissions are the atomic units of access control. Each permission represents + a specific action that can be performed on a resource type. + + Permissions are seeded from the existing SCOPE_REGISTRY and should not be + created manually in most cases. + """ + + __tablename__ = "rbac_permission" + + code = Column( + String(255), + nullable=False, + unique=True, + index=True, + comment="Unique permission code, e.g., 'system:read', 'privacy-request:create'", + ) + description = Column( + Text, + nullable=True, + comment="Human-readable description of what this permission allows", + ) + resource_type = Column( + String(100), + nullable=True, + index=True, + comment="Resource type this permission applies to, e.g., 'system', 'privacy_request'. NULL for global permissions.", + ) + is_active = Column( + Boolean, + nullable=False, + server_default="true", + comment="Whether this permission is currently active and can be assigned", + ) + + # Relationships + roles: List["RBACRole"] = relationship( + "RBACRole", + secondary="rbac_role_permission", + back_populates="permissions", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/fides/api/models/rbac/rbac_role.py b/src/fides/api/models/rbac/rbac_role.py new file mode 100644 index 00000000000..59d7b4d7ee0 --- /dev/null +++ b/src/fides/api/models/rbac/rbac_role.py @@ -0,0 +1,182 @@ +"""RBAC Role model for dynamic role definitions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Set + +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Session, relationship + +from fides.api.db.base_class import Base + +if TYPE_CHECKING: + from fides.api.models.rbac.rbac_permission import RBACPermission + from fides.api.models.rbac.rbac_user_role import RBACUserRole + + +class RBACRole(Base): + """ + Dynamic role definition for RBAC system. + + Roles group permissions together and can form hierarchies where child roles + inherit permissions from parent roles. System roles (is_system_role=True) + are the built-in roles migrated from the legacy hardcoded system and cannot + be deleted or have their core properties modified. + + Role hierarchy is implemented via parent_role_id. When checking permissions, + a role's effective permissions include both its directly assigned permissions + and all permissions inherited from ancestor roles. + """ + + __tablename__ = "rbac_role" + + name = Column( + String(255), + nullable=False, + unique=True, + comment="Human-readable display name for the role", + ) + key = Column( + String(255), + nullable=False, + unique=True, + index=True, + comment="Machine-readable key for the role, e.g., 'owner', 'custom_auditor'", + ) + description = Column( + Text, + nullable=True, + comment="Description of the role's purpose and access level", + ) + is_system_role = Column( + Boolean, + nullable=False, + server_default="false", + comment="True for built-in system roles that cannot be deleted", + ) + is_active = Column( + Boolean, + nullable=False, + server_default="true", + comment="Whether this role can be assigned to users", + ) + parent_role_id = Column( + String(255), + ForeignKey("rbac_role.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="Parent role ID for hierarchy. Child roles inherit parent permissions.", + ) + priority = Column( + Integer, + nullable=False, + server_default="0", + comment="Priority for conflict resolution. Higher values = more privileges.", + ) + + # Self-referential relationship for hierarchy + parent_role: Optional["RBACRole"] = relationship( + "RBACRole", + remote_side="RBACRole.id", + backref="child_roles", + foreign_keys=[parent_role_id], + ) + + # Permissions assigned directly to this role + permissions: List["RBACPermission"] = relationship( + "RBACPermission", + secondary="rbac_role_permission", + back_populates="roles", + lazy="selectin", + ) + + # User assignments for this role + user_assignments: List["RBACUserRole"] = relationship( + "RBACUserRole", + back_populates="role", + cascade="all, delete-orphan", + lazy="dynamic", + ) + + def __repr__(self) -> str: + return f"" + + def get_all_permissions(self, db: Session) -> List["RBACPermission"]: + """ + Get all permissions for this role, including inherited from parent roles. + + This method traverses the role hierarchy and collects all permissions + from this role and all ancestor roles. + + Args: + db: Database session (needed for lazy-loaded relationships) + + Returns: + List of unique RBACPermission objects + """ + seen_permission_ids: Set[str] = set() + all_permissions: List["RBACPermission"] = [] + + # Add direct permissions + for permission in self.permissions: + if permission.id not in seen_permission_ids and permission.is_active: + seen_permission_ids.add(permission.id) + all_permissions.append(permission) + + # Add inherited permissions from parent hierarchy + if self.parent_role: + for permission in self.parent_role.get_all_permissions(db): + if permission.id not in seen_permission_ids and permission.is_active: + seen_permission_ids.add(permission.id) + all_permissions.append(permission) + + return all_permissions + + def get_permission_codes(self, db: Session) -> List[str]: + """ + Get all permission codes for this role, including inherited. + + Args: + db: Database session + + Returns: + List of unique permission code strings + """ + return [p.code for p in self.get_all_permissions(db)] + + def get_direct_permission_codes(self) -> List[str]: + """ + Get only the directly assigned permission codes (not inherited). + + Returns: + List of permission code strings directly assigned to this role + """ + return [p.code for p in self.permissions if p.is_active] + + def get_inherited_permission_codes(self, db: Session) -> List[str]: + """ + Get only the inherited permission codes (from parent roles). + + Args: + db: Database session + + Returns: + List of permission codes inherited from parent roles + """ + direct_codes = set(self.get_direct_permission_codes()) + all_codes = set(self.get_permission_codes(db)) + return list(all_codes - direct_codes) + + def get_ancestor_roles(self) -> List["RBACRole"]: + """ + Get all ancestor roles in the hierarchy (parent, grandparent, etc.). + + Returns: + List of ancestor RBACRole objects, starting with immediate parent + """ + ancestors: List["RBACRole"] = [] + current = self.parent_role + while current: + ancestors.append(current) + current = current.parent_role + return ancestors diff --git a/src/fides/api/models/rbac/rbac_role_constraint.py b/src/fides/api/models/rbac/rbac_role_constraint.py new file mode 100644 index 00000000000..4f0e8ed2aa3 --- /dev/null +++ b/src/fides/api/models/rbac/rbac_role_constraint.py @@ -0,0 +1,144 @@ +"""RBAC Role Constraint model for separation of duties and cardinality constraints.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from fides.api.db.base_class import Base + +if TYPE_CHECKING: + from fides.api.models.rbac.rbac_role import RBACRole + + +class ConstraintType(str, Enum): + """Types of RBAC constraints.""" + + STATIC_SOD = "static_sod" + """ + Static Separation of Duties: Users cannot be assigned both roles simultaneously. + Checked at role assignment time. + """ + + DYNAMIC_SOD = "dynamic_sod" + """ + Dynamic Separation of Duties: Users cannot activate both roles in the same session. + Checked at runtime/permission evaluation time. + """ + + CARDINALITY = "cardinality" + """ + Cardinality constraint: Limits the number of users that can be assigned a role. + """ + + +class RBACRoleConstraint(Base): + """ + Separation of duties and cardinality constraints for RBAC. + + Constraints enforce security policies: + - Static SoD: Prevents users from holding conflicting roles + - Dynamic SoD: Prevents users from using conflicting roles in same session + - Cardinality: Limits how many users can have a role + + Examples: + - Static SoD: approver and contributor cannot be held by same user + - Cardinality: Only 3 users can be owners + """ + + __tablename__ = "rbac_role_constraint" + + name = Column( + String(255), + nullable=False, + comment="Human-readable name for this constraint", + ) + constraint_type = Column( + String(50), + nullable=False, + comment="Type of constraint: static_sod, dynamic_sod, or cardinality", + ) + role_id_1 = Column( + String(255), + ForeignKey("rbac_role.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="First role in the constraint (required for all types)", + ) + role_id_2 = Column( + String(255), + ForeignKey("rbac_role.id", ondelete="CASCADE"), + nullable=True, + index=True, + comment="Second role in the constraint (required for SoD, NULL for cardinality)", + ) + max_users = Column( + Integer, + nullable=True, + comment="Maximum number of users for cardinality constraint", + ) + description = Column( + Text, + nullable=True, + comment="Description of why this constraint exists", + ) + is_active = Column( + Boolean, + nullable=False, + server_default="true", + comment="Whether this constraint is currently enforced", + ) + + # Relationships + role_1: "RBACRole" = relationship( + "RBACRole", + foreign_keys=[role_id_1], + lazy="selectin", + ) + role_2: Optional["RBACRole"] = relationship( + "RBACRole", + foreign_keys=[role_id_2], + lazy="selectin", + ) + + def __repr__(self) -> str: + if self.constraint_type == ConstraintType.CARDINALITY: + return f"" + return f" '{self.role_id_2}')>" + + def is_sod_constraint(self) -> bool: + """Check if this is a separation of duties constraint.""" + return self.constraint_type in ( + ConstraintType.STATIC_SOD, + ConstraintType.DYNAMIC_SOD, + ) + + def is_cardinality_constraint(self) -> bool: + """Check if this is a cardinality constraint.""" + return self.constraint_type == ConstraintType.CARDINALITY + + def involves_role(self, role_id: str) -> bool: + """Check if a role is involved in this constraint.""" + return role_id in (self.role_id_1, self.role_id_2) + + def get_conflicting_role_id(self, role_id: str) -> Optional[str]: + """ + For SoD constraints, get the role that conflicts with the given role. + + Args: + role_id: The role to check + + Returns: + The conflicting role ID, or None if not a SoD constraint or role not involved + """ + if not self.is_sod_constraint(): + return None + + if role_id == self.role_id_1: + return self.role_id_2 + if role_id == self.role_id_2: + return self.role_id_1 + return None diff --git a/src/fides/api/models/rbac/rbac_role_permission.py b/src/fides/api/models/rbac/rbac_role_permission.py new file mode 100644 index 00000000000..214c1e1beb1 --- /dev/null +++ b/src/fides/api/models/rbac/rbac_role_permission.py @@ -0,0 +1,42 @@ +"""RBAC Role-Permission junction table.""" + +from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.sql import func + +from fides.api.db.base_class import Base + + +class RBACRolePermission(Base): + """ + Junction table for role-permission assignments. + + This is a many-to-many mapping between roles and permissions. + Each row represents a permission that is directly assigned to a role. + + Note: This table does not track inherited permissions. Inheritance is + computed at runtime via the role hierarchy. + """ + + __tablename__ = "rbac_role_permission" + + # Composite primary key: (role_id, permission_id) + role_id = Column( + String(255), + ForeignKey("rbac_role.id", ondelete="CASCADE"), + primary_key=True, + comment="FK to rbac_role", + ) + permission_id = Column( + String(255), + ForeignKey("rbac_permission.id", ondelete="CASCADE"), + primary_key=True, + comment="FK to rbac_permission", + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment="When this permission was assigned to the role", + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/fides/api/models/rbac/rbac_user_role.py b/src/fides/api/models/rbac/rbac_user_role.py new file mode 100644 index 00000000000..3f86ab00a7f --- /dev/null +++ b/src/fides/api/models/rbac/rbac_user_role.py @@ -0,0 +1,167 @@ +"""RBAC User-Role assignment model with resource scoping and temporal validity.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from fides.api.db.base_class import Base + +if TYPE_CHECKING: + from fides.api.models.fides_user import FidesUser + from fides.api.models.rbac.rbac_role import RBACRole + + +class RBACUserRole(Base): + """ + User to role assignment with optional resource scoping and temporal validity. + + This table supports: + - Global role assignments: resource_type and resource_id are NULL + - Resource-scoped assignments: permissions apply only to specific resources + - Temporal roles: valid_from/valid_until define when the assignment is active + + Examples: + - Global admin: role=owner, resource_type=NULL, resource_id=NULL + - System manager: role=system_manager, resource_type='system', resource_id='sys_123' + - Temporary access: valid_from=now, valid_until=now+30days + """ + + __tablename__ = "rbac_user_role" + + user_id = Column( + String(255), + ForeignKey("fidesuser.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="FK to fidesuser", + ) + role_id = Column( + String(255), + ForeignKey("rbac_role.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="FK to rbac_role", + ) + resource_type = Column( + String(100), + nullable=True, + comment="Resource type for scoped permissions, e.g., 'system'. NULL for global.", + ) + resource_id = Column( + String(255), + nullable=True, + comment="Specific resource ID for scoped permissions. NULL for global.", + ) + valid_from = Column( + DateTime(timezone=True), + server_default="now()", + nullable=True, + comment="When this assignment becomes active. NULL means immediately.", + ) + valid_until = Column( + DateTime(timezone=True), + nullable=True, + comment="When this assignment expires. NULL means never expires.", + ) + assigned_by = Column( + String(255), + ForeignKey("fidesuser.id", ondelete="SET NULL"), + nullable=True, + comment="User ID of who created this assignment", + ) + + # Relationships + user: "FidesUser" = relationship( + "FidesUser", + foreign_keys=[user_id], + backref="rbac_role_assignments", + ) + role: "RBACRole" = relationship( + "RBACRole", + back_populates="user_assignments", + lazy="selectin", + ) + assigner: Optional["FidesUser"] = relationship( + "FidesUser", + foreign_keys=[assigned_by], + ) + + # Ensure unique combination of user, role, resource_type, resource_id + __table_args__ = ( + UniqueConstraint( + "user_id", + "role_id", + "resource_type", + "resource_id", + name="uq_rbac_user_role_assignment", + ), + ) + + def __repr__(self) -> str: + scope = f"{self.resource_type}:{self.resource_id}" if self.resource_type else "global" + return f"" + + def is_valid(self) -> bool: + """ + Check if this role assignment is currently valid based on temporal constraints. + + Returns: + True if the assignment is currently active, False otherwise + """ + now = datetime.now(timezone.utc) + + # Check valid_from + if self.valid_from: + # Ensure comparison is timezone-aware + valid_from = self.valid_from + if valid_from.tzinfo is None: + valid_from = valid_from.replace(tzinfo=timezone.utc) + if valid_from > now: + return False + + # Check valid_until + if self.valid_until: + # Ensure comparison is timezone-aware + valid_until = self.valid_until + if valid_until.tzinfo is None: + valid_until = valid_until.replace(tzinfo=timezone.utc) + if valid_until < now: + return False + + return True + + def is_global(self) -> bool: + """Check if this is a global (non-resource-scoped) assignment.""" + return self.resource_type is None and self.resource_id is None + + def matches_resource( + self, resource_type: Optional[str], resource_id: Optional[str] + ) -> bool: + """ + Check if this assignment applies to a specific resource. + + Args: + resource_type: The type of resource being accessed + resource_id: The specific resource ID being accessed + + Returns: + True if this assignment grants access to the resource + """ + # Global assignments match everything + if self.is_global(): + return True + + # Resource type must match + if self.resource_type != resource_type: + return False + + # If assignment has specific resource_id, it must match + if self.resource_id is not None: + return self.resource_id == resource_id + + # Assignment applies to all resources of this type + return True diff --git a/src/fides/api/models/sql_models.py b/src/fides/api/models/sql_models.py index c9d8d10d183..1d96563679d 100644 --- a/src/fides/api/models/sql_models.py +++ b/src/fides/api/models/sql_models.py @@ -50,6 +50,13 @@ from fides.api.models.client import ClientDetail from fides.api.models.fides_user import FidesUser from fides.api.models.fides_user_permissions import FidesUserPermissions +from fides.api.models.rbac import ( + RBACPermission, + RBACRole, + RBACRoleConstraint, + RBACRolePermission, + RBACUserRole, +) from fides.api.models.tcf_purpose_overrides import TCFPurposeOverride from fides.api.util.taxonomy_utils import find_undeclared_categories from fides.config import get_config @@ -870,6 +877,11 @@ class SystemScans(Base): "policy": PolicyCtl, "system": System, "evaluation": Evaluation, + "rbac_role": RBACRole, + "rbac_permission": RBACPermission, + "rbac_role_permission": RBACRolePermission, + "rbac_user_role": RBACUserRole, + "rbac_role_constraint": RBACRoleConstraint, } From c3067db966500cee4811fbbcac3d3a12785249c7 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sat, 31 Jan 2026 16:36:47 -0800 Subject: [PATCH 02/20] Fix RBAC migration down_revision to chain after policy_conditions Co-Authored-By: Claude Opus 4.5 --- .../xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py index 9cb23940368..773b3a9615e 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py @@ -1,7 +1,7 @@ """Add RBAC tables for dynamic role-based access control Revision ID: a1b2c3d4e5f6 -Revises: 627c230d9917 +Revises: 6d5f70dd0ba5 Create Date: 2026-01-31 10:00:00.000000 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "a1b2c3d4e5f6" -down_revision = "627c230d9917" +down_revision = "6d5f70dd0ba5" branch_labels = None depends_on = None From f708a1ec7a36696bc09684e7895ec06df125225f Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sat, 31 Jan 2026 16:39:56 -0800 Subject: [PATCH 03/20] Fix RBAC migration revision IDs to avoid conflicts with existing migrations Changed revision IDs to unique values: - add_rbac_tables: a1b2c3d4e5f6 -> d9ee4ea46797 - seed_rbac_defaults: b2c3d4e5f6a7 -> f5f526cbc35a The original IDs conflicted with existing migrations: - xx_2025_11_10_1200_a1b2c3d4e5f6_add_test_datastore_to_connectiontype.py - xx_2025_11_12_1430_b2c3d4e5f6a7_add_default_identity_definitions.py Co-Authored-By: Claude Opus 4.5 --- ...=> xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py} | 4 ++-- ...xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py} | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/fides/api/alembic/migrations/versions/{xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py => xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py} (99%) rename src/fides/api/alembic/migrations/versions/{xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py => xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py} (99%) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py similarity index 99% rename from src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py rename to src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index 773b3a9615e..e2ce78b0481 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_a1b2c3d4e5f6_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -1,6 +1,6 @@ """Add RBAC tables for dynamic role-based access control -Revision ID: a1b2c3d4e5f6 +Revision ID: d9ee4ea46797 Revises: 6d5f70dd0ba5 Create Date: 2026-01-31 10:00:00.000000 @@ -10,7 +10,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision = "a1b2c3d4e5f6" +revision = "d9ee4ea46797" down_revision = "6d5f70dd0ba5" branch_labels = None depends_on = None diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py similarity index 99% rename from src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py rename to src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py index 2751af2f76e..94310614809 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_b2c3d4e5f6a7_seed_rbac_defaults.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py @@ -1,7 +1,7 @@ """Seed default RBAC roles and permissions from existing role definitions -Revision ID: b2c3d4e5f6a7 -Revises: a1b2c3d4e5f6 +Revision ID: f5f526cbc35a +Revises: d9ee4ea46797 Create Date: 2026-01-31 11:00:00.000000 This migration seeds the RBAC tables with: @@ -18,8 +18,8 @@ from sqlalchemy import text # revision identifiers, used by Alembic. -revision = "b2c3d4e5f6a7" -down_revision = "a1b2c3d4e5f6" +revision = "f5f526cbc35a" +down_revision = "d9ee4ea46797" branch_labels = None depends_on = None From bcd8eacb3e0173d2e51fe357ef68d5b497fe2a63 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sat, 31 Jan 2026 12:23:18 -0800 Subject: [PATCH 04/20] Add RBAC model integration tests This commit adds comprehensive tests for the RBAC models: - TestRBACPermission: Tests for creating and persisting permission records - TestRBACRole: Tests for custom roles, role hierarchy, and permission inheritance - TestRBACUserRole: Tests for role assignments, resource scoping, and temporal validity - TestRBACRoleConstraint: Tests for SoD and cardinality constraints Tests cover: - Basic CRUD operations - Parent-child role relationships - Permission inheritance through role hierarchy - Scoped role assignments (resource_type + resource_id) - Temporal role validity (valid_from, valid_until) - Separation of duties constraints - Cardinality constraints Co-Authored-By: Claude Opus 4.5 --- tests/api/models/test_rbac.py | 350 ++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 tests/api/models/test_rbac.py diff --git a/tests/api/models/test_rbac.py b/tests/api/models/test_rbac.py new file mode 100644 index 00000000000..81894f5b511 --- /dev/null +++ b/tests/api/models/test_rbac.py @@ -0,0 +1,350 @@ +"""Tests for the RBAC models.""" + +import pytest +from datetime import datetime, timedelta, timezone +from sqlalchemy.orm import Session + +from fides.api.models.fides_user import FidesUser +from fides.api.models.rbac import ( + RBACPermission, + RBACRole, + RBACRoleConstraint, + RBACRolePermission, + RBACUserRole, +) +from fides.api.models.rbac.rbac_role_constraint import ConstraintType + + +class TestRBACPermission: + """Tests for the RBACPermission model.""" + + def test_create_permission(self, db: Session): + """Test creating an RBACPermission record.""" + permission = RBACPermission.create( + db=db, + data={ + "code": "test:read", + "description": "Test read permission", + "resource_type": "test", + "is_active": True, + }, + check_name=False, + ) + db.commit() + + assert permission.code == "test:read" + assert permission.description == "Test read permission" + assert permission.resource_type == "test" + assert permission.is_active is True + + # Verify persistence + retrieved = ( + db.query(RBACPermission) + .filter(RBACPermission.code == "test:read") + .first() + ) + assert retrieved is not None + assert retrieved.code == "test:read" + + +class TestRBACRole: + """Tests for the RBACRole model.""" + + def test_create_custom_role(self, db: Session): + """Test creating a custom RBAC role.""" + role = RBACRole.create( + db=db, + data={ + "name": "Test Role", + "key": "test_role", + "description": "A test role for unit testing", + "priority": 10, + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + assert role.name == "Test Role" + assert role.key == "test_role" + assert role.description == "A test role for unit testing" + assert role.priority == 10 + assert role.is_system_role is False + assert role.is_active is True + + def test_create_role_with_parent(self, db: Session): + """Test creating a role with parent role hierarchy.""" + # Create parent role + parent_role = RBACRole.create( + db=db, + data={ + "name": "Parent Role", + "key": "parent_role", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Create child role + child_role = RBACRole.create( + db=db, + data={ + "name": "Child Role", + "key": "child_role", + "parent_role_id": parent_role.id, + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + assert child_role.parent_role_id == parent_role.id + assert child_role.parent_role.id == parent_role.id + assert parent_role.child_roles[0].id == child_role.id + + def test_role_permission_inheritance(self, db: Session): + """Test that child roles inherit permissions from parent roles.""" + # Create permission + permission = RBACPermission.create( + db=db, + data={ + "code": "inherited:permission", + "description": "Permission to be inherited", + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Create parent role with permission + parent_role = RBACRole.create( + db=db, + data={ + "name": "Inheriting Parent", + "key": "inheriting_parent", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + parent_role.permissions.append(permission) + db.commit() + + # Create child role + child_role = RBACRole.create( + db=db, + data={ + "name": "Inheriting Child", + "key": "inheriting_child", + "parent_role_id": parent_role.id, + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Test inheritance + all_permissions = child_role.get_all_permissions(db) + assert any(p.code == "inherited:permission" for p in all_permissions) + + # Test inherited permission codes + inherited_codes = child_role.get_inherited_permission_codes(db) + assert "inherited:permission" in inherited_codes + + +class TestRBACUserRole: + """Tests for the RBACUserRole model.""" + + def test_assign_role_to_user(self, db: Session, user: FidesUser): + """Test assigning a role to a user.""" + # Create role + role = RBACRole.create( + db=db, + data={ + "name": "User Test Role", + "key": "user_test_role", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Assign role to user + user_role = RBACUserRole.create( + db=db, + data={ + "user_id": user.id, + "role_id": role.id, + "assigned_by": user.id, + }, + check_name=False, + ) + db.commit() + + assert user_role.user_id == user.id + assert user_role.role_id == role.id + assert user_role.role.key == "user_test_role" + assert user_role.is_valid() is True + + def test_scoped_role_assignment(self, db: Session, user: FidesUser): + """Test assigning a role with resource scoping.""" + role = RBACRole.create( + db=db, + data={ + "name": "Scoped Role", + "key": "scoped_role", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Assign scoped role + user_role = RBACUserRole.create( + db=db, + data={ + "user_id": user.id, + "role_id": role.id, + "resource_type": "system", + "resource_id": "system_123", + }, + check_name=False, + ) + db.commit() + + assert user_role.resource_type == "system" + assert user_role.resource_id == "system_123" + assert user_role.is_global() is False + assert user_role.matches_resource("system", "system_123") is True + assert user_role.matches_resource("system", "system_456") is False + assert user_role.matches_resource("other", "system_123") is False + + def test_temporal_role_assignment(self, db: Session, user: FidesUser): + """Test temporal validity of role assignments.""" + role = RBACRole.create( + db=db, + data={ + "name": "Temporal Role", + "key": "temporal_role", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + now = datetime.now(timezone.utc) + + # Create future role (not yet valid) + future_role = RBACUserRole.create( + db=db, + data={ + "user_id": user.id, + "role_id": role.id, + "valid_from": now + timedelta(days=1), + }, + check_name=False, + ) + db.commit() + + assert future_role.is_valid() is False + + # Create expired role + expired_role = RBACUserRole.create( + db=db, + data={ + "user_id": user.id, + "role_id": role.id, + "valid_until": now - timedelta(days=1), + }, + check_name=False, + ) + db.commit() + + assert expired_role.is_valid() is False + + +class TestRBACRoleConstraint: + """Tests for the RBACRoleConstraint model.""" + + def test_create_sod_constraint(self, db: Session): + """Test creating a separation of duties constraint.""" + # Create two roles + role1 = RBACRole.create( + db=db, + data={ + "name": "SOD Role 1", + "key": "sod_role_1", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + role2 = RBACRole.create( + db=db, + data={ + "name": "SOD Role 2", + "key": "sod_role_2", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Create SOD constraint + constraint = RBACRoleConstraint.create( + db=db, + data={ + "name": "Test SOD Constraint", + "constraint_type": ConstraintType.STATIC_SOD, + "role_id_1": role1.id, + "role_id_2": role2.id, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + assert constraint.name == "Test SOD Constraint" + assert constraint.constraint_type == ConstraintType.STATIC_SOD + assert constraint.role_id_1 == role1.id + assert constraint.role_id_2 == role2.id + + def test_create_cardinality_constraint(self, db: Session): + """Test creating a cardinality constraint.""" + role = RBACRole.create( + db=db, + data={ + "name": "Cardinality Role", + "key": "cardinality_role", + "is_system_role": False, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + # Create cardinality constraint + constraint = RBACRoleConstraint.create( + db=db, + data={ + "name": "Max 5 Users", + "constraint_type": ConstraintType.CARDINALITY, + "role_id_1": role.id, + "max_users": 5, + "is_active": True, + }, + check_name=False, + ) + db.commit() + + assert constraint.constraint_type == ConstraintType.CARDINALITY + assert constraint.max_users == 5 From 20ba6e80aa6e72dd9ffd170e3b6e4351db2e6384 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sun, 1 Feb 2026 00:48:56 -0800 Subject: [PATCH 05/20] Fix cascade delete for RBAC user role assignments Add passive_deletes=True to the user relationship so SQLAlchemy defers to the database's ON DELETE CASCADE behavior instead of trying to set user_id=NULL (which causes IntegrityError). Co-Authored-By: Claude Opus 4.5 --- src/fides/api/models/rbac/rbac_user_role.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fides/api/models/rbac/rbac_user_role.py b/src/fides/api/models/rbac/rbac_user_role.py index 3f86ab00a7f..9511d7230b4 100644 --- a/src/fides/api/models/rbac/rbac_user_role.py +++ b/src/fides/api/models/rbac/rbac_user_role.py @@ -79,6 +79,7 @@ class RBACUserRole(Base): "FidesUser", foreign_keys=[user_id], backref="rbac_role_assignments", + passive_deletes=True, # Let database handle CASCADE delete ) role: "RBACRole" = relationship( "RBACRole", From e71f03141f55f47e55b3ef8e2b6b9a5aa0bb0112 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sun, 1 Feb 2026 15:22:19 -0800 Subject: [PATCH 06/20] feat: add RBAC management scopes to seed migration Add scopes required for RBAC management UI: - rbac_role:create/read/update/delete - rbac_permission:read - rbac_user_role:create/read/update/delete - rbac_constraint:create/read/update/delete - rbac:evaluate Also add migration for existing installations to add these scopes and assign them to the Owner role. Co-Authored-By: Claude Opus 4.5 --- ...31_1100_f5f526cbc35a_seed_rbac_defaults.py | 33 ++++ ...9f6555f12ad1_add_rbac_management_scopes.py | 145 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py index 94310614809..7337f5ef2f2 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py @@ -149,8 +149,41 @@ "webhook:delete": "Remove web hooks", "webhook:read": "View web hooks", "worker-stats:read": "View worker statistics", + # RBAC Management scopes - for managing the RBAC system itself + "rbac_role:create": "Create custom roles", + "rbac_role:read": "Read role definitions", + "rbac_role:update": "Update role definitions", + "rbac_role:delete": "Delete custom roles", + "rbac_permission:read": "Read permission definitions", + "rbac_user_role:create": "Assign roles to users", + "rbac_user_role:read": "Read user role assignments", + "rbac_user_role:update": "Update user role assignments", + "rbac_user_role:delete": "Remove roles from users", + "rbac_constraint:create": "Create role constraints", + "rbac_constraint:read": "Read role constraints", + "rbac_constraint:update": "Update role constraints", + "rbac_constraint:delete": "Delete role constraints", + "rbac:evaluate": "Evaluate user permissions", } +# RBAC management scopes - only Owner should have these +RBAC_MANAGEMENT_SCOPES = [ + "rbac_role:create", + "rbac_role:read", + "rbac_role:update", + "rbac_role:delete", + "rbac_permission:read", + "rbac_user_role:create", + "rbac_user_role:read", + "rbac_user_role:update", + "rbac_user_role:delete", + "rbac_constraint:create", + "rbac_constraint:read", + "rbac_constraint:update", + "rbac_constraint:delete", + "rbac:evaluate", +] + # Role definitions from roles.py LEGACY_ROLES = { "owner": { diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py new file mode 100644 index 00000000000..167a8e78a03 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py @@ -0,0 +1,145 @@ +"""Add RBAC management scopes to permission table and assign to Owner role + +Revision ID: 9f6555f12ad1 +Revises: f5f526cbc35a +Create Date: 2026-02-01 09:00:00.000000 + +This migration adds the RBAC management scopes (rbac_role:read, etc.) +to the rbac_permission table and assigns them to the Owner role. +These scopes are required for the RBAC management UI to function. +""" + +from uuid import uuid4 + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision = "9f6555f12ad1" +down_revision = "f5f526cbc35a" +branch_labels = None +depends_on = None + +# RBAC Management scopes - these allow managing the RBAC system itself +RBAC_MANAGEMENT_SCOPES = { + "rbac_role:create": "Create custom roles", + "rbac_role:read": "Read role definitions", + "rbac_role:update": "Update role definitions", + "rbac_role:delete": "Delete custom roles", + "rbac_permission:read": "Read permission definitions", + "rbac_user_role:create": "Assign roles to users", + "rbac_user_role:read": "Read user role assignments", + "rbac_user_role:update": "Update user role assignments", + "rbac_user_role:delete": "Remove roles from users", + "rbac_constraint:create": "Create role constraints", + "rbac_constraint:read": "Read role constraints", + "rbac_constraint:update": "Update role constraints", + "rbac_constraint:delete": "Delete role constraints", + "rbac:evaluate": "Evaluate user permissions", +} + + +def get_resource_type_from_scope(scope: str) -> str | None: + """Extract resource type from scope code.""" + if ":" not in scope: + return None + resource = scope.split(":")[0] + return resource + + +def upgrade(): + """Add RBAC management scopes and assign to Owner role.""" + conn = op.get_bind() + + # Get Owner role ID + result = conn.execute( + text("SELECT id FROM rbac_role WHERE key = 'owner'") + ).fetchone() + if not result: + # Owner role doesn't exist - skip (fresh install will have scopes from seed) + return + owner_role_id = result[0] + + # Add each RBAC management scope + for scope_code, description in RBAC_MANAGEMENT_SCOPES.items(): + # Check if permission already exists + existing = conn.execute( + text("SELECT id FROM rbac_permission WHERE code = :code"), + {"code": scope_code}, + ).fetchone() + + if existing: + permission_id = existing[0] + else: + # Create permission + permission_id = f"rba_{uuid4()}" + resource_type = get_resource_type_from_scope(scope_code) + conn.execute( + text( + """ + INSERT INTO rbac_permission (id, code, description, resource_type, is_active, created_at, updated_at) + VALUES (:id, :code, :description, :resource_type, true, now(), now()) + """ + ), + { + "id": permission_id, + "code": scope_code, + "description": description, + "resource_type": resource_type, + }, + ) + + # Check if role-permission mapping exists + existing_mapping = conn.execute( + text( + """ + SELECT id FROM rbac_role_permission + WHERE role_id = :role_id AND permission_id = :permission_id + """ + ), + {"role_id": owner_role_id, "permission_id": permission_id}, + ).fetchone() + + if not existing_mapping: + # Assign permission to Owner role + mapping_id = f"rba_{uuid4()}" + conn.execute( + text( + """ + INSERT INTO rbac_role_permission (id, role_id, permission_id, created_at, updated_at) + VALUES (:id, :role_id, :permission_id, now(), now()) + """ + ), + { + "id": mapping_id, + "role_id": owner_role_id, + "permission_id": permission_id, + }, + ) + + +def downgrade(): + """Remove RBAC management scopes.""" + conn = op.get_bind() + + for scope_code in RBAC_MANAGEMENT_SCOPES.keys(): + # Get permission ID + result = conn.execute( + text("SELECT id FROM rbac_permission WHERE code = :code"), + {"code": scope_code}, + ).fetchone() + + if result: + permission_id = result[0] + + # Remove role-permission mappings + conn.execute( + text("DELETE FROM rbac_role_permission WHERE permission_id = :id"), + {"id": permission_id}, + ) + + # Remove permission + conn.execute( + text("DELETE FROM rbac_permission WHERE id = :id"), + {"id": permission_id}, + ) From b1d7bd68a7bff0cc031e41a898e7838e6328c091 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Sun, 1 Feb 2026 20:08:53 -0800 Subject: [PATCH 07/20] Add migration to seed fidesplus scopes into RBAC tables This migration adds fidesplus-specific permissions (discovery_monitor, custom_field, taxonomy, etc.) to the rbac_permission table and assigns them to the appropriate system roles. This replaces the runtime seeding that was previously in fidesplus startup, following the pattern of keeping all DB changes in fides. Co-Authored-By: Claude Opus 4.5 --- ...1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py new file mode 100644 index 00000000000..09cb8efc2e2 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py @@ -0,0 +1,404 @@ +"""Seed fidesplus-specific scopes into RBAC tables + +Revision ID: a8b9c0d1e2f3 +Revises: 9f6555f12ad1 +Create Date: 2026-02-01 10:00:00.000000 + +This migration adds fidesplus-specific scopes (discovery_monitor, custom_field, etc.) +to the rbac_permission table and assigns them to the appropriate roles. + +These scopes are required for fidesplus features to work correctly when RBAC is enabled. +""" + +from uuid import uuid4 + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision = "a8b9c0d1e2f3" +down_revision = "9f6555f12ad1" +branch_labels = None +depends_on = None + + +# Fidesplus scope definitions (excluding RBAC scopes which are in previous migration) +FIDESPLUS_SCOPE_DOCS = { + "allow_list:create": "Create an allowlist", + "allow_list:delete": "Delete an allowlist", + "allow_list:read": "Read an allowlist", + "allow_list:update": "Update an allowlist", + "attachment:create": "Create an attachment", + "attachment:delete": "Delete an attachment", + "attachment:read": "Read an attachment", + "classify_instance:create": "Kicks off a classify instance", + "classify_instance:read": "Read classify instances", + "classify_instance:update": "Updates the state of a classify instance", + "comment:create": "Create a comment", + "comment:read": "Read a comment", + "custom_field:create": "Add a custom field for a resource", + "custom_field:delete": "Delete custom fields", + "custom_field:read": "Read custom fields", + "custom_field:update": "Update a custom field for a resource", + "custom_field_definition:create": "Add a custom field definition", + "custom_field_definition:delete": "Delete a custom field definition", + "custom_field_definition:read": "Read custom field definitions", + "custom_field_definition:update": "Update a custom field definition", + "custom_report:create": "Create a custom report", + "custom_report:read": "Read custom reports", + "custom_report:delete": "Delete a custom report", + "datamap:read": "Read systems on the datamap", + "digest_config:read": "Read digest configurations", + "digest_config:create": "Create digest configurations", + "digest_config:update": "Update digest configurations", + "digest_config:delete": "Delete digest configurations", + "discovery_monitor:read": "Read discovery monitors", + "discovery_monitor:update": "Update discovery monitors", + "monitor_steward:read": "Read monitor steward assignments", + "monitor_steward:update": "Update monitor steward assignments", + "monitor_steward:delete": "Delete monitor steward assignments", + "shared_monitor_config:create": "Create shared monitor configs", + "shared_monitor_config:read": "Read shared monitor configs", + "shared_monitor_config:update": "Update shared monitor configs", + "shared_monitor_config:delete": "Delete shared monitor configs", + "gvl:update": "Update the GVL vendor list", + "system_scan:create": "Fetch the results of a new system scan", + "system_scan:read": "Read a system scan", + "fides_cloud_config:read": "Read the Fides Cloud config", + "fides_cloud_config:update": "Update the Fides Cloud config", + "system_history:read": "Read a system's change history", + "custom_asset:update": "Update a custom asset", + "endpoint_cache:update": "Update endpoint caches", + "tcf_publisher_override:read": "Read global TCF purpose overrides", + "tcf_publisher_override:update": "Patch global TCF purpose overrides", + "location:read": "Read locations and regulations", + "location:update": "Update locations and regulations", + "language:read": "Read available languages", + "openid_provider:create": "Create OpenID providers", + "openid_provider:read": "Read OpenID providers", + "openid_provider:update": "Update OpenID providers", + "openid_provider:delete": "Delete OpenID providers", + "property:create": "Create properties", + "property:read": "Read properties", + "property:update": "Update properties", + "property:delete": "Delete properties", + "privacy_center_config:read": "Read the Privacy Center config", + "privacy_center_config:update": "Update the Privacy Center config", + "privacy_experience_cache:delete": "Delete all privacy experiences in the db cache", + "privacy_experience_cache:read": "Read the privacy experience cache", + "privacy_experience_cache:update": "Update (refresh) the privacy experience cache", + "privacy_preferences:create": "Submit a privacy preference update with pre-verified identities", + "respondent:create": "Create a respondent", + "consent_webhook:post": "Post to an integration's consent webhook", + "consent_webhook_token:create": "Create a token for consent webhook", + "system_group:create": "Create system groups", + "system_group:read": "Read system groups", + "system_group:update": "Update system groups", + "system_group:delete": "Delete system groups", + "taxonomy:create": "Create custom taxonomies and taxonomy elements", + "taxonomy:read": "Read custom taxonomies and taxonomy elements", + "taxonomy:update": "Update custom taxonomies and taxonomy elements", + "taxonomy:delete": "Delete custom taxonomies and taxonomy elements", + "tcf_configurations:create": "Create a TCF configuration", + "tcf_configurations:read": "Read TCF configurations", + "tcf_configurations:update": "Update a TCF configuration", + "tcf_configurations:delete": "Delete a TCF configuration", + "tcf_publisher_restrictions:create": "Create a TCF publisher restriction", + "tcf_publisher_restrictions:read": "Read TCF publisher restrictions", + "tcf_publisher_restrictions:update": "Update a TCF publisher restriction", + "tcf_publisher_restrictions:delete": "Delete a TCF publisher restriction", + "manual_field:read-all": "Read all manual task fields across assignees", + "manual_field:read-own": "Read only manual task fields assigned to the caller", + "identity_definition:create": "Create identity definitions", + "identity_definition:read": "Read identity definitions", + "identity_definition:delete": "Delete identity definitions", + "taxonomy_history:read": "Read taxonomy history (audit log entries)", + "datahub:sync": "Sync data with DataHub", +} + +# Role to fidesplus scopes mapping +ROLE_SCOPES = { + "owner": [ + "allow_list:create", "allow_list:delete", "allow_list:read", "allow_list:update", + "attachment:create", "attachment:delete", "attachment:read", + "classify_instance:create", "classify_instance:read", "classify_instance:update", + "comment:create", "comment:read", + "consent_webhook:post", "consent_webhook_token:create", + "custom_field:create", "custom_field:delete", "custom_field:read", "custom_field:update", + "custom_field_definition:create", "custom_field_definition:delete", "custom_field_definition:read", "custom_field_definition:update", + "custom_report:create", "custom_report:read", "custom_report:delete", + "datahub:sync", "datamap:read", + "digest_config:read", "digest_config:create", "digest_config:update", "digest_config:delete", + "discovery_monitor:read", "discovery_monitor:update", + "monitor_steward:read", "monitor_steward:update", "monitor_steward:delete", + "taxonomy_history:read", + "shared_monitor_config:create", "shared_monitor_config:read", "shared_monitor_config:update", "shared_monitor_config:delete", + "gvl:update", + "system_scan:create", "system_scan:read", + "fides_cloud_config:read", "fides_cloud_config:update", + "system_history:read", + "custom_asset:update", + "tcf_publisher_override:read", "tcf_publisher_override:update", + "endpoint_cache:update", + "location:update", "location:read", + "language:read", + "openid_provider:create", "openid_provider:read", "openid_provider:update", "openid_provider:delete", + "property:create", "property:read", "property:update", "property:delete", + "privacy_center_config:read", "privacy_center_config:update", + "privacy_experience_cache:delete", "privacy_experience_cache:read", "privacy_experience_cache:update", + "privacy_preferences:create", + "respondent:create", + "system_group:create", "system_group:read", "system_group:update", "system_group:delete", + "taxonomy:create", "taxonomy:read", "taxonomy:update", "taxonomy:delete", + "tcf_configurations:create", "tcf_configurations:read", "tcf_configurations:update", "tcf_configurations:delete", + "tcf_publisher_restrictions:create", "tcf_publisher_restrictions:read", "tcf_publisher_restrictions:update", "tcf_publisher_restrictions:delete", + "manual_field:read-all", + "identity_definition:create", "identity_definition:read", "identity_definition:delete", + ], + "contributor": [ + "allow_list:create", "allow_list:delete", "allow_list:read", "allow_list:update", + "attachment:create", "attachment:delete", "attachment:read", + "classify_instance:create", "classify_instance:read", "classify_instance:update", + "comment:create", "comment:read", + "consent_webhook:post", + "custom_field:create", "custom_field:delete", "custom_field:read", "custom_field:update", + "custom_field_definition:create", "custom_field_definition:delete", "custom_field_definition:read", "custom_field_definition:update", + "custom_report:create", "custom_report:read", "custom_report:delete", + "datahub:sync", "datamap:read", + "digest_config:read", "digest_config:create", "digest_config:update", "digest_config:delete", + "discovery_monitor:read", "discovery_monitor:update", + "monitor_steward:read", "monitor_steward:update", "monitor_steward:delete", + "shared_monitor_config:create", "shared_monitor_config:read", "shared_monitor_config:update", "shared_monitor_config:delete", + "gvl:update", + "system_scan:create", "system_scan:read", + "system_history:read", + "fides_cloud_config:read", + "custom_asset:update", + "tcf_publisher_override:read", "tcf_publisher_override:update", + "endpoint_cache:update", + "location:update", "location:read", + "language:read", + "property:create", "property:read", "property:update", "property:delete", + "privacy_center_config:read", "privacy_center_config:update", + "privacy_experience_cache:delete", "privacy_experience_cache:read", "privacy_experience_cache:update", + "privacy_preferences:create", + "system_group:create", "system_group:read", "system_group:update", "system_group:delete", + "taxonomy:create", "taxonomy:read", "taxonomy:update", "taxonomy:delete", + "tcf_configurations:create", "tcf_configurations:read", "tcf_configurations:update", "tcf_configurations:delete", + "tcf_publisher_restrictions:create", "tcf_publisher_restrictions:read", "tcf_publisher_restrictions:update", "tcf_publisher_restrictions:delete", + "manual_field:read-all", + "identity_definition:create", "identity_definition:read", "identity_definition:delete", + ], + "viewer_and_approver": [ + "allow_list:read", + "attachment:create", "attachment:delete", "attachment:read", + "classify_instance:read", + "comment:create", "comment:read", + "custom_field_definition:read", + "custom_field:read", + "datamap:read", + "discovery_monitor:read", + "monitor_steward:read", + "shared_monitor_config:read", + "system_scan:read", + "system_history:read", + "property:read", + "privacy_center_config:read", + "respondent:create", + "manual_field:read-all", + "system_group:read", + "taxonomy:read", + "identity_definition:read", + "taxonomy_history:read", + ], + "viewer": [ + "allow_list:read", + "classify_instance:read", + "custom_field_definition:read", + "custom_field:read", + "datamap:read", + "discovery_monitor:read", + "monitor_steward:read", + "shared_monitor_config:read", + "system_scan:read", + "system_history:read", + "property:read", + "privacy_center_config:read", + "manual_field:read-own", + "system_group:read", + "taxonomy:read", + "identity_definition:read", + "taxonomy_history:read", + ], + "approver": [ + "attachment:create", "attachment:delete", "attachment:read", + "comment:create", "comment:read", + "manual_field:read-all", + "privacy_center_config:read", + ], + "respondent": [ + "manual_field:read-own", + ], + "external_respondent": [ + "manual_field:read-own", + ], +} + + +def get_resource_type_from_scope(scope: str) -> str | None: + """Extract resource type from scope code.""" + if ":" not in scope: + return None + resource = scope.split(":")[0] + # Normalize common patterns + resource_map = { + "discovery_monitor": "discovery_monitor", + "custom_field": "custom_field", + "custom_field_definition": "custom_field", + "system_scan": "system", + "system_history": "system", + "system_group": "system", + "datamap": "system", + "allow_list": "classification", + "classify_instance": "classification", + "taxonomy": "taxonomy", + "taxonomy_history": "taxonomy", + "privacy_center_config": "consent", + "privacy_preferences": "consent", + "privacy_experience_cache": "consent", + "tcf_configurations": "consent", + "tcf_publisher_override": "consent", + "tcf_publisher_restrictions": "consent", + "gvl": "consent", + "property": "property", + "openid_provider": "authentication", + "fides_cloud_config": "config", + "digest_config": "notifications", + "comment": "collaboration", + "attachment": "collaboration", + "custom_report": "reporting", + "custom_asset": "asset", + "consent_webhook": "webhook", + "consent_webhook_token": "webhook", + "location": "location", + "language": "localization", + "identity_definition": "identity", + "manual_field": "privacy_request", + "respondent": "privacy_request", + "monitor_steward": "discovery_monitor", + "shared_monitor_config": "discovery_monitor", + "endpoint_cache": "cache", + "datahub": "integration", + } + return resource_map.get(resource, resource) + + +def upgrade(): + """Add fidesplus scopes and assign to roles.""" + conn = op.get_bind() + + # Create a mapping of scope code to permission ID + permission_ids = {} + + # Add each fidesplus scope + for scope_code, description in FIDESPLUS_SCOPE_DOCS.items(): + # Check if permission already exists + existing = conn.execute( + text("SELECT id FROM rbac_permission WHERE code = :code"), + {"code": scope_code}, + ).fetchone() + + if existing: + permission_ids[scope_code] = existing[0] + else: + # Create permission + permission_id = f"pls_{uuid4()}" + resource_type = get_resource_type_from_scope(scope_code) + conn.execute( + text( + """ + INSERT INTO rbac_permission (id, code, description, resource_type, is_active, created_at, updated_at) + VALUES (:id, :code, :description, :resource_type, true, now(), now()) + """ + ), + { + "id": permission_id, + "code": scope_code, + "description": description, + "resource_type": resource_type, + }, + ) + permission_ids[scope_code] = permission_id + + # Get role IDs + roles = conn.execute( + text("SELECT id, key FROM rbac_role") + ).fetchall() + role_ids = {r[1]: r[0] for r in roles} + + # Assign permissions to roles + for role_key, scopes in ROLE_SCOPES.items(): + role_id = role_ids.get(role_key) + if not role_id: + # Role doesn't exist - skip + continue + + for scope_code in scopes: + permission_id = permission_ids.get(scope_code) + if not permission_id: + continue + + # Check if mapping already exists + existing_mapping = conn.execute( + text( + """ + SELECT id FROM rbac_role_permission + WHERE role_id = :role_id AND permission_id = :permission_id + """ + ), + {"role_id": role_id, "permission_id": permission_id}, + ).fetchone() + + if not existing_mapping: + # Create mapping + mapping_id = f"rpm_{uuid4()}" + conn.execute( + text( + """ + INSERT INTO rbac_role_permission (id, role_id, permission_id, created_at, updated_at) + VALUES (:id, :role_id, :permission_id, now(), now()) + """ + ), + { + "id": mapping_id, + "role_id": role_id, + "permission_id": permission_id, + }, + ) + + +def downgrade(): + """Remove fidesplus scopes.""" + conn = op.get_bind() + + for scope_code in FIDESPLUS_SCOPE_DOCS.keys(): + # Get permission ID + result = conn.execute( + text("SELECT id FROM rbac_permission WHERE code = :code"), + {"code": scope_code}, + ).fetchone() + + if result: + permission_id = result[0] + + # Remove role-permission mappings + conn.execute( + text("DELETE FROM rbac_role_permission WHERE permission_id = :id"), + {"id": permission_id}, + ) + + # Remove permission + conn.execute( + text("DELETE FROM rbac_permission WHERE id = :id"), + {"id": permission_id}, + ) From 1529e43224067bec380d4764bc0209a16ad31459 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 17:07:19 -0800 Subject: [PATCH 08/20] fix: update RBAC migration downrev to latest main Updated down_revision from 6d5f70dd0ba5 to 627c230d9917 to align with the current head on main branch. Co-Authored-By: Claude Opus 4.5 --- .../versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index e2ce78b0481..9e425eb703d 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "d9ee4ea46797" -down_revision = "6d5f70dd0ba5" +down_revision = "627c230d9917" branch_labels = None depends_on = None From bdc9a62432b828b764b09fe6e2641b6bb35d9f19 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 17:07:56 -0800 Subject: [PATCH 09/20] Add changelog entry for RBAC migrations PR #7285 Co-Authored-By: Claude Opus 4.5 --- changelog/7285.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/7285.yaml diff --git a/changelog/7285.yaml b/changelog/7285.yaml new file mode 100644 index 00000000000..be86a78908a --- /dev/null +++ b/changelog/7285.yaml @@ -0,0 +1,4 @@ +type: Added +description: Database migrations and models for dynamic RBAC system (roles, permissions, user assignments, constraints) +pr: 7285 +labels: ["db-migration"] From 71005ba3593e195c1aff2e279141e96d61432f40 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 17:13:17 -0800 Subject: [PATCH 10/20] fix: sort imports in RBAC model tests Co-Authored-By: Claude Opus 4.5 --- tests/api/models/test_rbac.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/api/models/test_rbac.py b/tests/api/models/test_rbac.py index 81894f5b511..ce2dd3c962d 100644 --- a/tests/api/models/test_rbac.py +++ b/tests/api/models/test_rbac.py @@ -1,7 +1,8 @@ """Tests for the RBAC models.""" -import pytest from datetime import datetime, timedelta, timezone + +import pytest from sqlalchemy.orm import Session from fides.api.models.fides_user import FidesUser From 46c1aa4c57e9aef7cae0620530d9a39fa2fed87e Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 20:20:56 -0800 Subject: [PATCH 11/20] Fix ruff formatting in RBAC models Co-Authored-By: Claude Opus 4.5 --- src/fides/api/models/rbac/rbac_user_role.py | 6 +++++- tests/api/models/test_rbac.py | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/fides/api/models/rbac/rbac_user_role.py b/src/fides/api/models/rbac/rbac_user_role.py index 9511d7230b4..8d77a6bad10 100644 --- a/src/fides/api/models/rbac/rbac_user_role.py +++ b/src/fides/api/models/rbac/rbac_user_role.py @@ -103,7 +103,11 @@ class RBACUserRole(Base): ) def __repr__(self) -> str: - scope = f"{self.resource_type}:{self.resource_id}" if self.resource_type else "global" + scope = ( + f"{self.resource_type}:{self.resource_id}" + if self.resource_type + else "global" + ) return f"" def is_valid(self) -> bool: diff --git a/tests/api/models/test_rbac.py b/tests/api/models/test_rbac.py index ce2dd3c962d..6d3db5e00b3 100644 --- a/tests/api/models/test_rbac.py +++ b/tests/api/models/test_rbac.py @@ -40,9 +40,7 @@ def test_create_permission(self, db: Session): # Verify persistence retrieved = ( - db.query(RBACPermission) - .filter(RBACPermission.code == "test:read") - .first() + db.query(RBACPermission).filter(RBACPermission.code == "test:read").first() ) assert retrieved is not None assert retrieved.code == "test:read" From 88456261eda8e6b48b883a4d06a8cb1386884f30 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 20:23:14 -0800 Subject: [PATCH 12/20] Fix mypy errors in RBAC models Use RelationshipProperty[Type] for SQLAlchemy relationship type annotations to match the pattern used elsewhere in the fides codebase. Co-Authored-By: Claude Opus 4.5 --- src/fides/api/models/rbac/rbac_permission.py | 4 ++-- src/fides/api/models/rbac/rbac_role.py | 8 ++++---- src/fides/api/models/rbac/rbac_role_constraint.py | 6 +++--- src/fides/api/models/rbac/rbac_user_role.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/fides/api/models/rbac/rbac_permission.py b/src/fides/api/models/rbac/rbac_permission.py index bb2d70e66d8..0dadd833d14 100644 --- a/src/fides/api/models/rbac/rbac_permission.py +++ b/src/fides/api/models/rbac/rbac_permission.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, List from sqlalchemy import Boolean, Column, String, Text -from sqlalchemy.orm import relationship +from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -52,7 +52,7 @@ class RBACPermission(Base): ) # Relationships - roles: List["RBACRole"] = relationship( + roles: RelationshipProperty[List["RBACRole"]] = relationship( "RBACRole", secondary="rbac_role_permission", back_populates="permissions", diff --git a/src/fides/api/models/rbac/rbac_role.py b/src/fides/api/models/rbac/rbac_role.py index 59d7b4d7ee0..915d7c3f842 100644 --- a/src/fides/api/models/rbac/rbac_role.py +++ b/src/fides/api/models/rbac/rbac_role.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, List, Optional, Set from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Session, relationship +from sqlalchemy.orm import RelationshipProperty, Session, relationship from fides.api.db.base_class import Base @@ -75,7 +75,7 @@ class RBACRole(Base): ) # Self-referential relationship for hierarchy - parent_role: Optional["RBACRole"] = relationship( + parent_role: RelationshipProperty[Optional["RBACRole"]] = relationship( "RBACRole", remote_side="RBACRole.id", backref="child_roles", @@ -83,7 +83,7 @@ class RBACRole(Base): ) # Permissions assigned directly to this role - permissions: List["RBACPermission"] = relationship( + permissions: RelationshipProperty[List["RBACPermission"]] = relationship( "RBACPermission", secondary="rbac_role_permission", back_populates="roles", @@ -91,7 +91,7 @@ class RBACRole(Base): ) # User assignments for this role - user_assignments: List["RBACUserRole"] = relationship( + user_assignments: RelationshipProperty[List["RBACUserRole"]] = relationship( "RBACUserRole", back_populates="role", cascade="all, delete-orphan", diff --git a/src/fides/api/models/rbac/rbac_role_constraint.py b/src/fides/api/models/rbac/rbac_role_constraint.py index 4f0e8ed2aa3..996bb5a57e1 100644 --- a/src/fides/api/models/rbac/rbac_role_constraint.py +++ b/src/fides/api/models/rbac/rbac_role_constraint.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text -from sqlalchemy.orm import relationship +from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -93,12 +93,12 @@ class RBACRoleConstraint(Base): ) # Relationships - role_1: "RBACRole" = relationship( + role_1: RelationshipProperty["RBACRole"] = relationship( "RBACRole", foreign_keys=[role_id_1], lazy="selectin", ) - role_2: Optional["RBACRole"] = relationship( + role_2: RelationshipProperty[Optional["RBACRole"]] = relationship( "RBACRole", foreign_keys=[role_id_2], lazy="selectin", diff --git a/src/fides/api/models/rbac/rbac_user_role.py b/src/fides/api/models/rbac/rbac_user_role.py index 8d77a6bad10..7ef544e6388 100644 --- a/src/fides/api/models/rbac/rbac_user_role.py +++ b/src/fides/api/models/rbac/rbac_user_role.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint -from sqlalchemy.orm import relationship +from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -75,18 +75,18 @@ class RBACUserRole(Base): ) # Relationships - user: "FidesUser" = relationship( + user: RelationshipProperty["FidesUser"] = relationship( "FidesUser", foreign_keys=[user_id], backref="rbac_role_assignments", passive_deletes=True, # Let database handle CASCADE delete ) - role: "RBACRole" = relationship( + role: RelationshipProperty["RBACRole"] = relationship( "RBACRole", back_populates="user_assignments", lazy="selectin", ) - assigner: Optional["FidesUser"] = relationship( + assigner: RelationshipProperty[Optional["FidesUser"]] = relationship( "FidesUser", foreign_keys=[assigned_by], ) From 90eb4fa76cd54693ade6fbc00caa1563802e2ea1 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Mon, 2 Feb 2026 21:08:37 -0800 Subject: [PATCH 13/20] Fix rbac_role_permission schema to use composite PK Addresses Greptile review feedback: 1. Fix migration docstring to match actual down_revision (627c230d9917) 2. Change rbac_role_permission from id-based PK to composite PK: - Migration now creates table with (role_id, permission_id) as composite PK - Removed id column and updated_at column (not needed for junction table) - Updated all seed migrations to not insert id column 3. Update RBACRolePermission model to override Base columns: - Set id = None to remove inherited id column - Set updated_at = None to remove inherited updated_at column - Composite PK on (role_id, permission_id) matches migration Co-Authored-By: Claude Opus 4.5 --- ...01_31_1000_d9ee4ea46797_add_rbac_tables.py | 24 ++++--------------- ...31_1100_f5f526cbc35a_seed_rbac_defaults.py | 6 ++--- ...9f6555f12ad1_add_rbac_management_scopes.py | 8 +++---- ...1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py | 8 +++---- .../api/models/rbac/rbac_role_permission.py | 5 ++++ 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index 9e425eb703d..60876fae906 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -1,7 +1,7 @@ """Add RBAC tables for dynamic role-based access control Revision ID: d9ee4ea46797 -Revises: 6d5f70dd0ba5 +Revises: 627c230d9917 Create Date: 2026-01-31 10:00:00.000000 """ @@ -148,24 +148,17 @@ def upgrade(): unique=False, ) - # Create rbac_role_permission junction table + # Create rbac_role_permission junction table (composite PK) op.create_table( "rbac_role_permission", - sa.Column("id", sa.String(length=255), nullable=False), + sa.Column("role_id", sa.String(length=255), nullable=False), + sa.Column("permission_id", sa.String(length=255), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True, ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=True, - ), - sa.Column("role_id", sa.String(length=255), nullable=False), - sa.Column("permission_id", sa.String(length=255), nullable=False), sa.ForeignKeyConstraint( ["role_id"], ["rbac_role.id"], @@ -176,13 +169,7 @@ def upgrade(): ["rbac_permission.id"], ondelete="CASCADE", ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "role_id", "permission_id", name="uq_rbac_role_permission_mapping" - ), - ) - op.create_index( - op.f("ix_rbac_role_permission_id"), "rbac_role_permission", ["id"], unique=False + sa.PrimaryKeyConstraint("role_id", "permission_id"), ) op.create_index( op.f("ix_rbac_role_permission_role_id"), @@ -416,7 +403,6 @@ def downgrade(): op.drop_index( op.f("ix_rbac_role_permission_role_id"), table_name="rbac_role_permission" ) - op.drop_index(op.f("ix_rbac_role_permission_id"), table_name="rbac_role_permission") op.drop_table("rbac_role_permission") op.drop_index( diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py index 7337f5ef2f2..86ebaeb2573 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py @@ -373,16 +373,14 @@ def create_role_permission_mapping(role_key: str, scope_codes: list[str]): role_id = role_ids[role_key] for scope_code in scope_codes: if scope_code in permission_ids: - mapping_id = f"rpm_{uuid4()}" connection.execute( text( """ - INSERT INTO rbac_role_permission (id, role_id, permission_id) - VALUES (:id, :role_id, :permission_id) + INSERT INTO rbac_role_permission (role_id, permission_id) + VALUES (:role_id, :permission_id) """ ), { - "id": mapping_id, "role_id": role_id, "permission_id": permission_ids[scope_code], }, diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py index 167a8e78a03..288d7ccf2c7 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py @@ -93,7 +93,7 @@ def upgrade(): existing_mapping = conn.execute( text( """ - SELECT id FROM rbac_role_permission + SELECT 1 FROM rbac_role_permission WHERE role_id = :role_id AND permission_id = :permission_id """ ), @@ -102,16 +102,14 @@ def upgrade(): if not existing_mapping: # Assign permission to Owner role - mapping_id = f"rba_{uuid4()}" conn.execute( text( """ - INSERT INTO rbac_role_permission (id, role_id, permission_id, created_at, updated_at) - VALUES (:id, :role_id, :permission_id, now(), now()) + INSERT INTO rbac_role_permission (role_id, permission_id, created_at) + VALUES (:role_id, :permission_id, now()) """ ), { - "id": mapping_id, "role_id": owner_role_id, "permission_id": permission_id, }, diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py index 09cb8efc2e2..d538e173ecb 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py @@ -352,7 +352,7 @@ def upgrade(): existing_mapping = conn.execute( text( """ - SELECT id FROM rbac_role_permission + SELECT 1 FROM rbac_role_permission WHERE role_id = :role_id AND permission_id = :permission_id """ ), @@ -361,16 +361,14 @@ def upgrade(): if not existing_mapping: # Create mapping - mapping_id = f"rpm_{uuid4()}" conn.execute( text( """ - INSERT INTO rbac_role_permission (id, role_id, permission_id, created_at, updated_at) - VALUES (:id, :role_id, :permission_id, now(), now()) + INSERT INTO rbac_role_permission (role_id, permission_id, created_at) + VALUES (:role_id, :permission_id, now()) """ ), { - "id": mapping_id, "role_id": role_id, "permission_id": permission_id, }, diff --git a/src/fides/api/models/rbac/rbac_role_permission.py b/src/fides/api/models/rbac/rbac_role_permission.py index 214c1e1beb1..e3c57f0a3ea 100644 --- a/src/fides/api/models/rbac/rbac_role_permission.py +++ b/src/fides/api/models/rbac/rbac_role_permission.py @@ -19,6 +19,11 @@ class RBACRolePermission(Base): __tablename__ = "rbac_role_permission" + # Override Base.id - junction table uses composite PK instead + id = None # type: ignore[assignment] + # Override Base.updated_at - junction table doesn't need this + updated_at = None # type: ignore[assignment] + # Composite primary key: (role_id, permission_id) role_id = Column( String(255), From 58d46de9502054b67e1670da39fd96321c41ce26 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Fri, 6 Feb 2026 13:04:38 -0800 Subject: [PATCH 14/20] Fix RBAC: use @declared_attr for __tablename__ (mypy + SQLAlchemy), set down_revision to main head Co-authored-by: Cursor --- .../xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py | 4 ++-- src/fides/api/models/rbac/rbac_permission.py | 5 ++++- src/fides/api/models/rbac/rbac_role.py | 5 ++++- src/fides/api/models/rbac/rbac_role_constraint.py | 5 ++++- src/fides/api/models/rbac/rbac_role_permission.py | 5 ++++- src/fides/api/models/rbac/rbac_user_role.py | 5 ++++- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index 60876fae906..355118ca107 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -1,7 +1,7 @@ """Add RBAC tables for dynamic role-based access control Revision ID: d9ee4ea46797 -Revises: 627c230d9917 +Revises: a1b2c3d4e5f7 Create Date: 2026-01-31 10:00:00.000000 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "d9ee4ea46797" -down_revision = "627c230d9917" +down_revision = "a1b2c3d4e5f7" branch_labels = None depends_on = None diff --git a/src/fides/api/models/rbac/rbac_permission.py b/src/fides/api/models/rbac/rbac_permission.py index 0dadd833d14..c5e2277127a 100644 --- a/src/fides/api/models/rbac/rbac_permission.py +++ b/src/fides/api/models/rbac/rbac_permission.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, List from sqlalchemy import Boolean, Column, String, Text +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -24,7 +25,9 @@ class RBACPermission(Base): created manually in most cases. """ - __tablename__ = "rbac_permission" + @declared_attr + def __tablename__(cls) -> str: + return "rbac_permission" code = Column( String(255), diff --git a/src/fides/api/models/rbac/rbac_role.py b/src/fides/api/models/rbac/rbac_role.py index 915d7c3f842..e71926bd6c0 100644 --- a/src/fides/api/models/rbac/rbac_role.py +++ b/src/fides/api/models/rbac/rbac_role.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, List, Optional, Set from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import RelationshipProperty, Session, relationship from fides.api.db.base_class import Base @@ -28,7 +29,9 @@ class RBACRole(Base): and all permissions inherited from ancestor roles. """ - __tablename__ = "rbac_role" + @declared_attr + def __tablename__(cls) -> str: + return "rbac_role" name = Column( String(255), diff --git a/src/fides/api/models/rbac/rbac_role_constraint.py b/src/fides/api/models/rbac/rbac_role_constraint.py index 996bb5a57e1..42c1a0359a9 100644 --- a/src/fides/api/models/rbac/rbac_role_constraint.py +++ b/src/fides/api/models/rbac/rbac_role_constraint.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -49,7 +50,9 @@ class RBACRoleConstraint(Base): - Cardinality: Only 3 users can be owners """ - __tablename__ = "rbac_role_constraint" + @declared_attr + def __tablename__(cls) -> str: + return "rbac_role_constraint" name = Column( String(255), diff --git a/src/fides/api/models/rbac/rbac_role_permission.py b/src/fides/api/models/rbac/rbac_role_permission.py index e3c57f0a3ea..28f77371e4f 100644 --- a/src/fides/api/models/rbac/rbac_role_permission.py +++ b/src/fides/api/models/rbac/rbac_role_permission.py @@ -1,6 +1,7 @@ """RBAC Role-Permission junction table.""" from sqlalchemy import Column, DateTime, ForeignKey, String +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.sql import func from fides.api.db.base_class import Base @@ -17,7 +18,9 @@ class RBACRolePermission(Base): computed at runtime via the role hierarchy. """ - __tablename__ = "rbac_role_permission" + @declared_attr + def __tablename__(cls) -> str: + return "rbac_role_permission" # Override Base.id - junction table uses composite PK instead id = None # type: ignore[assignment] diff --git a/src/fides/api/models/rbac/rbac_user_role.py b/src/fides/api/models/rbac/rbac_user_role.py index 7ef544e6388..30a5495c839 100644 --- a/src/fides/api/models/rbac/rbac_user_role.py +++ b/src/fides/api/models/rbac/rbac_user_role.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import RelationshipProperty, relationship from fides.api.db.base_class import Base @@ -30,7 +31,9 @@ class RBACUserRole(Base): - Temporary access: valid_from=now, valid_until=now+30days """ - __tablename__ = "rbac_user_role" + @declared_attr + def __tablename__(cls) -> str: + return "rbac_user_role" user_id = Column( String(255), From aa2abfab667b3fdf7e8fdaa9b1115986f8744acd Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 12:00:43 -0800 Subject: [PATCH 15/20] fix: Update RBAC migration downrev and add missing scopes - Update first RBAC migration down_revision to f85bd4c08401 (current main head) - Add backfill:exec scope to base seed migration - Add chat_provider:read, chat_provider:update scopes to fidesplus seed - Add privacy_assessment:read/create/update/delete scopes to fidesplus seed - Assign new scopes to owner and contributor roles as appropriate Co-Authored-By: Claude Opus 4.5 --- .../xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py | 4 ++-- ...xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py | 1 + ...2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index 355118ca107..47b66507c1f 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -1,7 +1,7 @@ """Add RBAC tables for dynamic role-based access control Revision ID: d9ee4ea46797 -Revises: a1b2c3d4e5f7 +Revises: f85bd4c08401 Create Date: 2026-01-31 10:00:00.000000 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "d9ee4ea46797" -down_revision = "a1b2c3d4e5f7" +down_revision = "f85bd4c08401" branch_labels = None depends_on = None diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py index 86ebaeb2573..7f71c16cc11 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py @@ -149,6 +149,7 @@ "webhook:delete": "Remove web hooks", "webhook:read": "View web hooks", "worker-stats:read": "View worker statistics", + "backfill:exec": "Execute database backfill operations", # RBAC Management scopes - for managing the RBAC system itself "rbac_role:create": "Create custom roles", "rbac_role:read": "Read role definitions", diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py index d538e173ecb..c5e42ca3e4e 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py @@ -114,6 +114,12 @@ "identity_definition:delete": "Delete identity definitions", "taxonomy_history:read": "Read taxonomy history (audit log entries)", "datahub:sync": "Sync data with DataHub", + "chat_provider:read": "Read chat provider settings", + "chat_provider:update": "Update chat provider settings", + "privacy_assessment:read": "Read privacy assessments", + "privacy_assessment:create": "Create privacy assessments", + "privacy_assessment:update": "Update privacy assessments", + "privacy_assessment:delete": "Delete privacy assessments", } # Role to fidesplus scopes mapping @@ -154,6 +160,8 @@ "tcf_publisher_restrictions:create", "tcf_publisher_restrictions:read", "tcf_publisher_restrictions:update", "tcf_publisher_restrictions:delete", "manual_field:read-all", "identity_definition:create", "identity_definition:read", "identity_definition:delete", + "chat_provider:read", "chat_provider:update", + "privacy_assessment:read", "privacy_assessment:create", "privacy_assessment:update", "privacy_assessment:delete", ], "contributor": [ "allow_list:create", "allow_list:delete", "allow_list:read", "allow_list:update", @@ -188,6 +196,7 @@ "tcf_publisher_restrictions:create", "tcf_publisher_restrictions:read", "tcf_publisher_restrictions:update", "tcf_publisher_restrictions:delete", "manual_field:read-all", "identity_definition:create", "identity_definition:read", "identity_definition:delete", + "chat_provider:read", "chat_provider:update", ], "viewer_and_approver": [ "allow_list:read", From ed72f8415c22d1d675821ff308b1d9dc462ce107 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 15:09:59 -0800 Subject: [PATCH 16/20] Fix RBAC migration to match model definitions - Update column comments to match model docstrings exactly - Replace unique constraint + non-unique index with unique index for code and key columns (matching model unique=True, index=True) - Remove extraneous indexes not defined in models: - ix_rbac_role_permission_role_id/permission_id - ix_rbac_user_role_resource/validity - ix_rbac_role_constraint_type - Add column comments to junction table columns Co-Authored-By: Claude Opus 4.5 --- ...01_31_1000_d9ee4ea46797_add_rbac_tables.py | 106 +++++++----------- 1 file changed, 42 insertions(+), 64 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index 47b66507c1f..c10bd868cff 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -43,7 +43,7 @@ def upgrade(): "key", sa.String(length=255), nullable=False, - comment="Machine-readable key for the role", + comment="Machine-readable key for the role, e.g., 'owner', 'custom_auditor'", ), sa.Column( "description", @@ -69,14 +69,14 @@ def upgrade(): "parent_role_id", sa.String(length=255), nullable=True, - comment="Parent role ID for hierarchy", + comment="Parent role ID for hierarchy. Child roles inherit parent permissions.", ), sa.Column( "priority", sa.Integer(), server_default="0", nullable=False, - comment="Priority for conflict resolution", + comment="Priority for conflict resolution. Higher values = more privileges.", ), sa.ForeignKeyConstraint( ["parent_role_id"], @@ -85,10 +85,9 @@ def upgrade(): ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name", name="uq_rbac_role_name"), - sa.UniqueConstraint("key", name="uq_rbac_role_key"), ) op.create_index(op.f("ix_rbac_role_id"), "rbac_role", ["id"], unique=False) - op.create_index(op.f("ix_rbac_role_key"), "rbac_role", ["key"], unique=False) + op.create_index(op.f("ix_rbac_role_key"), "rbac_role", ["key"], unique=True) op.create_index( op.f("ix_rbac_role_parent_role_id"), "rbac_role", ["parent_role_id"], unique=False ) @@ -113,33 +112,32 @@ def upgrade(): "code", sa.String(length=255), nullable=False, - comment="Unique permission code", + comment="Unique permission code, e.g., 'system:read', 'privacy-request:create'", ), sa.Column( "description", sa.Text(), nullable=True, - comment="Human-readable description", + comment="Human-readable description of what this permission allows", ), sa.Column( "resource_type", sa.String(length=100), nullable=True, - comment="Resource type this permission applies to", + comment="Resource type this permission applies to, e.g., 'system', 'privacy_request'. NULL for global permissions.", ), sa.Column( "is_active", sa.Boolean(), server_default="true", nullable=False, - comment="Whether this permission is active", + comment="Whether this permission is currently active and can be assigned", ), sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("code", name="uq_rbac_permission_code"), ) op.create_index(op.f("ix_rbac_permission_id"), "rbac_permission", ["id"], unique=False) op.create_index( - op.f("ix_rbac_permission_code"), "rbac_permission", ["code"], unique=False + op.f("ix_rbac_permission_code"), "rbac_permission", ["code"], unique=True ) op.create_index( op.f("ix_rbac_permission_resource_type"), @@ -151,13 +149,24 @@ def upgrade(): # Create rbac_role_permission junction table (composite PK) op.create_table( "rbac_role_permission", - sa.Column("role_id", sa.String(length=255), nullable=False), - sa.Column("permission_id", sa.String(length=255), nullable=False), + sa.Column( + "role_id", + sa.String(length=255), + nullable=False, + comment="FK to rbac_role", + ), + sa.Column( + "permission_id", + sa.String(length=255), + nullable=False, + comment="FK to rbac_permission", + ), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True, + comment="When this permission was assigned to the role", ), sa.ForeignKeyConstraint( ["role_id"], @@ -171,18 +180,6 @@ def upgrade(): ), sa.PrimaryKeyConstraint("role_id", "permission_id"), ) - op.create_index( - op.f("ix_rbac_role_permission_role_id"), - "rbac_role_permission", - ["role_id"], - unique=False, - ) - op.create_index( - op.f("ix_rbac_role_permission_permission_id"), - "rbac_role_permission", - ["permission_id"], - unique=False, - ) # Create rbac_user_role table op.create_table( @@ -200,32 +197,42 @@ def upgrade(): server_default=sa.text("now()"), nullable=True, ), - sa.Column("user_id", sa.String(length=255), nullable=False), - sa.Column("role_id", sa.String(length=255), nullable=False), + sa.Column( + "user_id", + sa.String(length=255), + nullable=False, + comment="FK to fidesuser", + ), + sa.Column( + "role_id", + sa.String(length=255), + nullable=False, + comment="FK to rbac_role", + ), sa.Column( "resource_type", sa.String(length=100), nullable=True, - comment="Resource type for scoped permissions", + comment="Resource type for scoped permissions, e.g., 'system'. NULL for global.", ), sa.Column( "resource_id", sa.String(length=255), nullable=True, - comment="Specific resource ID for scoped permissions", + comment="Specific resource ID for scoped permissions. NULL for global.", ), sa.Column( "valid_from", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True, - comment="When this assignment becomes active", + comment="When this assignment becomes active. NULL means immediately.", ), sa.Column( "valid_until", sa.DateTime(timezone=True), nullable=True, - comment="When this assignment expires", + comment="When this assignment expires. NULL means never expires.", ), sa.Column( "assigned_by", @@ -266,18 +273,6 @@ def upgrade(): op.create_index( op.f("ix_rbac_user_role_role_id"), "rbac_user_role", ["role_id"], unique=False ) - op.create_index( - op.f("ix_rbac_user_role_resource"), - "rbac_user_role", - ["resource_type", "resource_id"], - unique=False, - ) - op.create_index( - op.f("ix_rbac_user_role_validity"), - "rbac_user_role", - ["valid_from", "valid_until"], - unique=False, - ) # Create rbac_role_constraint table op.create_table( @@ -311,19 +306,19 @@ def upgrade(): "role_id_1", sa.String(length=255), nullable=False, - comment="First role in the constraint", + comment="First role in the constraint (required for all types)", ), sa.Column( "role_id_2", sa.String(length=255), nullable=True, - comment="Second role (for SoD constraints)", + comment="Second role in the constraint (required for SoD, NULL for cardinality)", ), sa.Column( "max_users", sa.Integer(), nullable=True, - comment="Maximum users for cardinality constraint", + comment="Maximum number of users for cardinality constraint", ), sa.Column( "description", @@ -336,7 +331,7 @@ def upgrade(): sa.Boolean(), server_default="true", nullable=False, - comment="Whether this constraint is enforced", + comment="Whether this constraint is currently enforced", ), sa.ForeignKeyConstraint( ["role_id_1"], @@ -356,12 +351,6 @@ def upgrade(): ["id"], unique=False, ) - op.create_index( - op.f("ix_rbac_role_constraint_type"), - "rbac_role_constraint", - ["constraint_type"], - unique=False, - ) op.create_index( op.f("ix_rbac_role_constraint_role_id_1"), "rbac_role_constraint", @@ -384,25 +373,14 @@ def downgrade(): op.drop_index( op.f("ix_rbac_role_constraint_role_id_1"), table_name="rbac_role_constraint" ) - op.drop_index( - op.f("ix_rbac_role_constraint_type"), table_name="rbac_role_constraint" - ) op.drop_index(op.f("ix_rbac_role_constraint_id"), table_name="rbac_role_constraint") op.drop_table("rbac_role_constraint") - op.drop_index(op.f("ix_rbac_user_role_validity"), table_name="rbac_user_role") - op.drop_index(op.f("ix_rbac_user_role_resource"), table_name="rbac_user_role") op.drop_index(op.f("ix_rbac_user_role_role_id"), table_name="rbac_user_role") op.drop_index(op.f("ix_rbac_user_role_user_id"), table_name="rbac_user_role") op.drop_index(op.f("ix_rbac_user_role_id"), table_name="rbac_user_role") op.drop_table("rbac_user_role") - op.drop_index( - op.f("ix_rbac_role_permission_permission_id"), table_name="rbac_role_permission" - ) - op.drop_index( - op.f("ix_rbac_role_permission_role_id"), table_name="rbac_role_permission" - ) op.drop_table("rbac_role_permission") op.drop_index( From 07e49a35e9e98c46587c003f952a7e2039eb3949 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 15:39:30 -0800 Subject: [PATCH 17/20] Add data category annotations for RBAC tables Adds system.operations data category annotations to db_dataset.yml for the five new RBAC tables to satisfy the fides_db_scan CI check. Co-Authored-By: Claude Opus 4.5 --- .fides/db_dataset.yml | 95 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 8980dc01394..ad560c2094e 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -2069,6 +2069,101 @@ dataset: data_categories: [system.operations] - name: is_hash_migrated data_categories: [system.operations] + - name: rbac_permission + description: 'Permission definitions for RBAC system' + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: code + data_categories: [system.operations] + - name: description + data_categories: [system.operations] + - name: resource_type + data_categories: [system.operations] + - name: is_active + data_categories: [system.operations] + - name: rbac_role + description: 'Role definitions for RBAC system with hierarchy support' + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: name + data_categories: [system.operations] + - name: key + data_categories: [system.operations] + - name: description + data_categories: [system.operations] + - name: is_system_role + data_categories: [system.operations] + - name: is_active + data_categories: [system.operations] + - name: parent_role_id + data_categories: [system.operations] + - name: priority + data_categories: [system.operations] + - name: rbac_role_constraint + description: 'Separation of duties and cardinality constraints for RBAC roles' + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: name + data_categories: [system.operations] + - name: constraint_type + data_categories: [system.operations] + - name: role_id_1 + data_categories: [system.operations] + - name: role_id_2 + data_categories: [system.operations] + - name: max_users + data_categories: [system.operations] + - name: description + data_categories: [system.operations] + - name: is_active + data_categories: [system.operations] + - name: rbac_role_permission + description: 'Junction table mapping roles to permissions' + fields: + - name: role_id + data_categories: [system.operations] + - name: permission_id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: rbac_user_role + description: 'User role assignments with resource scoping and temporal validity' + fields: + - name: id + data_categories: [system.operations] + - name: created_at + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: user_id + data_categories: [system.operations] + - name: role_id + data_categories: [system.operations] + - name: resource_type + data_categories: [system.operations] + - name: resource_id + data_categories: [system.operations] + - name: valid_from + data_categories: [system.operations] + - name: valid_until + data_categories: [system.operations] + - name: assigned_by + data_categories: [system.operations] - name: rule data_categories: [] fields: From 78699094e7885b1ce733e8209ba1773543311d5e Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 16:49:19 -0800 Subject: [PATCH 18/20] Add RBAC scopes to SCOPE_REGISTRY and drift detection test - Add RBAC management scopes to scope_registry.py so they're part of the canonical SCOPE_REGISTRY (rbac_role:*, rbac_permission:read, rbac_user_role:*, rbac_constraint:*, rbac:evaluate) - Add TestRBACScopeRegistrySync test class that parses the RBAC seed migration and compares its SCOPE_DOCS against SCOPE_REGISTRY. This prevents drift where someone adds a scope without updating both locations, which could break the RBAC system. Co-Authored-By: Claude Opus 4.5 --- src/fides/common/api/scope_registry.py | 39 ++++++++++++++++ tests/api/models/test_rbac.py | 61 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/fides/common/api/scope_registry.py b/src/fides/common/api/scope_registry.py index e44bf588426..306cfad0831 100644 --- a/src/fides/common/api/scope_registry.py +++ b/src/fides/common/api/scope_registry.py @@ -79,6 +79,14 @@ HEAP_DUMP = "heap_dump" BACKFILL = "backfill" +# RBAC Management +RBAC = "rbac" +RBAC_ROLE = "rbac_role" +RBAC_PERMISSION = "rbac_permission" +RBAC_USER_ROLE = "rbac_user_role" +RBAC_CONSTRAINT = "rbac_constraint" +EVALUATE = "evaluate" + ASSIGN_OWNERS = "assign_owners" CLIENT_CREATE = f"{CLIENT}:{CREATE}" @@ -253,6 +261,22 @@ WORKER_STATS_READ = f"{WORKER_STATS}:{READ}" +# RBAC Management scopes +RBAC_ROLE_CREATE = f"{RBAC_ROLE}:{CREATE}" +RBAC_ROLE_READ = f"{RBAC_ROLE}:{READ}" +RBAC_ROLE_UPDATE = f"{RBAC_ROLE}:{UPDATE}" +RBAC_ROLE_DELETE = f"{RBAC_ROLE}:{DELETE}" +RBAC_PERMISSION_READ = f"{RBAC_PERMISSION}:{READ}" +RBAC_USER_ROLE_CREATE = f"{RBAC_USER_ROLE}:{CREATE}" +RBAC_USER_ROLE_READ = f"{RBAC_USER_ROLE}:{READ}" +RBAC_USER_ROLE_UPDATE = f"{RBAC_USER_ROLE}:{UPDATE}" +RBAC_USER_ROLE_DELETE = f"{RBAC_USER_ROLE}:{DELETE}" +RBAC_CONSTRAINT_CREATE = f"{RBAC_CONSTRAINT}:{CREATE}" +RBAC_CONSTRAINT_READ = f"{RBAC_CONSTRAINT}:{READ}" +RBAC_CONSTRAINT_UPDATE = f"{RBAC_CONSTRAINT}:{UPDATE}" +RBAC_CONSTRAINT_DELETE = f"{RBAC_CONSTRAINT}:{DELETE}" +RBAC_EVALUATE = f"{RBAC}:{EVALUATE}" + SCOPE_DOCS = { CONFIG_READ: "View the configuration", CONFIG_UPDATE: "Update the configuration", @@ -378,6 +402,21 @@ WEBHOOK_DELETE: "Remove web hooks", WEBHOOK_READ: "View web hooks", WORKER_STATS_READ: "View worker statistics", + # RBAC Management scopes + RBAC_ROLE_CREATE: "Create custom roles", + RBAC_ROLE_READ: "Read role definitions", + RBAC_ROLE_UPDATE: "Update role definitions", + RBAC_ROLE_DELETE: "Delete custom roles", + RBAC_PERMISSION_READ: "Read permission definitions", + RBAC_USER_ROLE_CREATE: "Assign roles to users", + RBAC_USER_ROLE_READ: "Read user role assignments", + RBAC_USER_ROLE_UPDATE: "Update user role assignments", + RBAC_USER_ROLE_DELETE: "Remove roles from users", + RBAC_CONSTRAINT_CREATE: "Create role constraints", + RBAC_CONSTRAINT_READ: "Read role constraints", + RBAC_CONSTRAINT_UPDATE: "Update role constraints", + RBAC_CONSTRAINT_DELETE: "Delete role constraints", + RBAC_EVALUATE: "Evaluate user permissions", } SCOPE_REGISTRY = list(SCOPE_DOCS.keys()) diff --git a/tests/api/models/test_rbac.py b/tests/api/models/test_rbac.py index 6d3db5e00b3..db3b92764d4 100644 --- a/tests/api/models/test_rbac.py +++ b/tests/api/models/test_rbac.py @@ -347,3 +347,64 @@ def test_create_cardinality_constraint(self, db: Session): assert constraint.constraint_type == ConstraintType.CARDINALITY assert constraint.max_users == 5 + + +class TestRBACScopeRegistrySync: + """Tests to ensure SCOPE_REGISTRY scopes exist in the RBAC migration. + + This prevents drift where scopes are added to SCOPE_REGISTRY but not to + the RBAC migration. Such drift would cause legacy scopes to not be + available in the database-driven RBAC system. + + Note: The reverse (RBAC-only scopes that don't exist in SCOPE_REGISTRY) + is intentionally allowed - new scopes can be added directly to RBAC + without needing to exist in the legacy system. + """ + + def test_all_scopes_in_registry_have_rbac_migration(self): + """Every scope in SCOPE_REGISTRY must exist in the RBAC seed migration. + + This test parses the migration file to extract hardcoded SCOPE_DOCS keys + and verifies all SCOPE_REGISTRY scopes are present. + """ + import ast + from pathlib import Path + + from fides.common.api.scope_registry import SCOPE_REGISTRY + + # Find the seed migration file + migrations_dir = Path(__file__).parent.parent.parent.parent / "src" / "fides" / "api" / "alembic" / "migrations" / "versions" + seed_migration = None + for f in migrations_dir.glob("*seed_rbac_defaults*.py"): + seed_migration = f + break + + assert seed_migration is not None, "Could not find RBAC seed migration file" + + # Parse the migration file to extract SCOPE_DOCS keys + migration_content = seed_migration.read_text() + + # Find the SCOPE_DOCS dictionary in the migration + tree = ast.parse(migration_content) + migration_scopes = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "SCOPE_DOCS": + if isinstance(node.value, ast.Dict): + for key in node.value.keys: + if isinstance(key, ast.Constant): + migration_scopes.add(key.value) + + assert len(migration_scopes) > 0, "Could not parse SCOPE_DOCS from migration" + + registry_scopes = set(SCOPE_REGISTRY) + + # Check for scopes in registry but not in migration + missing_from_migration = registry_scopes - migration_scopes + assert not missing_from_migration, ( + f"The following scopes exist in SCOPE_REGISTRY but are missing from the " + f"RBAC seed migration (seed_rbac_defaults.py). Add them to SCOPE_DOCS in " + f"the migration:\n{sorted(missing_from_migration)}" + ) From 371fac1b8f2a678c559f6bb3a6a75a80a214a910 Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 16:56:57 -0800 Subject: [PATCH 19/20] Remove RBAC scopes from SCOPE_REGISTRY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RBAC management scopes don't need to be in the legacy SCOPE_REGISTRY since: - RBAC management only works when RBAC is enabled - When RBAC is enabled, permissions are checked against the database - The drift detection test only checks one direction (registry → migration) The RBAC scopes remain in the migration's SCOPE_DOCS to be seeded into the database, where they'll be used when RBAC is enabled. Co-Authored-By: Claude Opus 4.5 --- src/fides/common/api/scope_registry.py | 39 -------------------------- tests/api/models/test_rbac.py | 10 ++++++- 2 files changed, 9 insertions(+), 40 deletions(-) diff --git a/src/fides/common/api/scope_registry.py b/src/fides/common/api/scope_registry.py index 306cfad0831..e44bf588426 100644 --- a/src/fides/common/api/scope_registry.py +++ b/src/fides/common/api/scope_registry.py @@ -79,14 +79,6 @@ HEAP_DUMP = "heap_dump" BACKFILL = "backfill" -# RBAC Management -RBAC = "rbac" -RBAC_ROLE = "rbac_role" -RBAC_PERMISSION = "rbac_permission" -RBAC_USER_ROLE = "rbac_user_role" -RBAC_CONSTRAINT = "rbac_constraint" -EVALUATE = "evaluate" - ASSIGN_OWNERS = "assign_owners" CLIENT_CREATE = f"{CLIENT}:{CREATE}" @@ -261,22 +253,6 @@ WORKER_STATS_READ = f"{WORKER_STATS}:{READ}" -# RBAC Management scopes -RBAC_ROLE_CREATE = f"{RBAC_ROLE}:{CREATE}" -RBAC_ROLE_READ = f"{RBAC_ROLE}:{READ}" -RBAC_ROLE_UPDATE = f"{RBAC_ROLE}:{UPDATE}" -RBAC_ROLE_DELETE = f"{RBAC_ROLE}:{DELETE}" -RBAC_PERMISSION_READ = f"{RBAC_PERMISSION}:{READ}" -RBAC_USER_ROLE_CREATE = f"{RBAC_USER_ROLE}:{CREATE}" -RBAC_USER_ROLE_READ = f"{RBAC_USER_ROLE}:{READ}" -RBAC_USER_ROLE_UPDATE = f"{RBAC_USER_ROLE}:{UPDATE}" -RBAC_USER_ROLE_DELETE = f"{RBAC_USER_ROLE}:{DELETE}" -RBAC_CONSTRAINT_CREATE = f"{RBAC_CONSTRAINT}:{CREATE}" -RBAC_CONSTRAINT_READ = f"{RBAC_CONSTRAINT}:{READ}" -RBAC_CONSTRAINT_UPDATE = f"{RBAC_CONSTRAINT}:{UPDATE}" -RBAC_CONSTRAINT_DELETE = f"{RBAC_CONSTRAINT}:{DELETE}" -RBAC_EVALUATE = f"{RBAC}:{EVALUATE}" - SCOPE_DOCS = { CONFIG_READ: "View the configuration", CONFIG_UPDATE: "Update the configuration", @@ -402,21 +378,6 @@ WEBHOOK_DELETE: "Remove web hooks", WEBHOOK_READ: "View web hooks", WORKER_STATS_READ: "View worker statistics", - # RBAC Management scopes - RBAC_ROLE_CREATE: "Create custom roles", - RBAC_ROLE_READ: "Read role definitions", - RBAC_ROLE_UPDATE: "Update role definitions", - RBAC_ROLE_DELETE: "Delete custom roles", - RBAC_PERMISSION_READ: "Read permission definitions", - RBAC_USER_ROLE_CREATE: "Assign roles to users", - RBAC_USER_ROLE_READ: "Read user role assignments", - RBAC_USER_ROLE_UPDATE: "Update user role assignments", - RBAC_USER_ROLE_DELETE: "Remove roles from users", - RBAC_CONSTRAINT_CREATE: "Create role constraints", - RBAC_CONSTRAINT_READ: "Read role constraints", - RBAC_CONSTRAINT_UPDATE: "Update role constraints", - RBAC_CONSTRAINT_DELETE: "Delete role constraints", - RBAC_EVALUATE: "Evaluate user permissions", } SCOPE_REGISTRY = list(SCOPE_DOCS.keys()) diff --git a/tests/api/models/test_rbac.py b/tests/api/models/test_rbac.py index db3b92764d4..d1679dfe82f 100644 --- a/tests/api/models/test_rbac.py +++ b/tests/api/models/test_rbac.py @@ -373,7 +373,15 @@ def test_all_scopes_in_registry_have_rbac_migration(self): from fides.common.api.scope_registry import SCOPE_REGISTRY # Find the seed migration file - migrations_dir = Path(__file__).parent.parent.parent.parent / "src" / "fides" / "api" / "alembic" / "migrations" / "versions" + migrations_dir = ( + Path(__file__).parent.parent.parent.parent + / "src" + / "fides" + / "api" + / "alembic" + / "migrations" + / "versions" + ) seed_migration = None for f in migrations_dir.glob("*seed_rbac_defaults*.py"): seed_migration = f From f360d39a408ac0796896cb0466ae1b56d3a884fe Mon Sep 17 00:00:00 2001 From: Thabo Fletcher Date: Wed, 11 Feb 2026 17:44:45 -0800 Subject: [PATCH 20/20] Fix migration head conflict by updating RBAC down_revision Update the first RBAC migration (d9ee4ea46797) to depend on d304f57aea6d (stagedresourceancestor distance) instead of f85bd4c08401. This resolves the "Multiple head revisions" error caused by both migrations pointing to the same parent after merging main. Co-Authored-By: Claude Opus 4.5 --- ...xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py index c10bd868cff..257497f3a1a 100644 --- a/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py +++ b/src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "d9ee4ea46797" -down_revision = "f85bd4c08401" +down_revision = "d304f57aea6d" branch_labels = None depends_on = None @@ -89,7 +89,10 @@ def upgrade(): op.create_index(op.f("ix_rbac_role_id"), "rbac_role", ["id"], unique=False) op.create_index(op.f("ix_rbac_role_key"), "rbac_role", ["key"], unique=True) op.create_index( - op.f("ix_rbac_role_parent_role_id"), "rbac_role", ["parent_role_id"], unique=False + op.f("ix_rbac_role_parent_role_id"), + "rbac_role", + ["parent_role_id"], + unique=False, ) # Create rbac_permission table @@ -135,7 +138,9 @@ def upgrade(): ), sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f("ix_rbac_permission_id"), "rbac_permission", ["id"], unique=False) + op.create_index( + op.f("ix_rbac_permission_id"), "rbac_permission", ["id"], unique=False + ) op.create_index( op.f("ix_rbac_permission_code"), "rbac_permission", ["code"], unique=True )