Skip to content

RBAC DB migration#7285

Merged
thabofletcher merged 36 commits intomainfrom
rbac-migration
Mar 9, 2026
Merged

RBAC DB migration#7285
thabofletcher merged 36 commits intomainfrom
rbac-migration

Conversation

@thabofletcher
Copy link
Copy Markdown
Contributor

@thabofletcher thabofletcher commented Feb 1, 2026

Ticket: ENG-2696

Description Of Changes

Adds database migrations and SQLAlchemy models to support the new dynamic Role-Based Access Control (RBAC) system. This creates the foundational database schema that enables:

  • Dynamic Roles: Create, modify, and delete roles at runtime (vs. hardcoded roles)
  • Fine-grained Permissions: Map permissions to roles with granular control
  • Role Hierarchy: Parent-child role relationships for permission inheritance
  • Resource Scoping: Assign roles with optional resource-level restrictions
  • Temporal Validity: Time-bounded role assignments (valid_from/valid_until)
  • Separation of Duties: Constraints preventing conflicting role combinations

Important: These migrations only CREATE new tables - no existing tables are modified.

Code Changes

New Database Tables (5):

  • rbac_role - Dynamic role definitions with hierarchy support
  • rbac_permission - Permission definitions (seeded from SCOPE_REGISTRY)
  • rbac_role_permission - Role to permission mapping (many-to-many)
  • rbac_user_role - User role assignments with resource scoping + temporal validity
  • rbac_role_constraint - Separation of duties constraints

Migrations (3):

  1. d9ee4ea46797_add_rbac_tables.py - Creates all 5 RBAC tables with indexes
  2. f5f526cbc35a_seed_rbac_defaults.py - Seeds permissions from SCOPE_REGISTRY and creates system roles
  3. 9f6555f12ad1_add_rbac_management_scopes.py - Adds RBAC management scopes (idempotent)

SQLAlchemy Models:

  • src/fides/api/models/rbac/ - New model package with all RBAC entities
  • Updated sql_models.py to import RBAC models

Steps to Confirm

  1. Run migrations: alembic upgrade head
  2. Verify tables created: \dt rbac_* in psql
  3. Verify permissions seeded: SELECT COUNT(*) FROM rbac_permission; (should be ~135)
  4. Verify roles seeded: SELECT * FROM rbac_role; (should have 7 system roles)
  5. Test downgrade: alembic downgrade -1 (repeat 3x), verify tables removed

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Add a db-migration This indicates that a change includes a database migration label to the entry if your change includes a DB migration
    • Add a high-risk This issue suggests changes that have a high-probability of breaking existing code label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • All UX related changes have been reviewed by a designer
    • No UX review needed
  • Followup issues:
    • Followup issues created
    • No followup issues
  • Database migrations:
    • Ensure that your downrev is up to date with the latest revision on main
    • Ensure that your downgrade() migration is correct and works
      • If a downgrade migration is not possible for this change, please call this out in the PR description!
    • No migrations
  • Documentation:
    • Documentation complete, PR opened in fidesdocs
    • Documentation issue created in fidesdocs
    • If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
    • No documentation updates required

Summary by CodeRabbit

  • New Features
    • Comprehensive RBAC: hierarchical roles, permission inheritance, management/product scopes, resource-scoped and time-bound role assignments, Separation-of-Duties and cardinality constraints.
  • Chores
    • Database migrations and seed data to create RBAC tables, indexes, uniqueness rules, default roles, and permissions.
  • Tests
    • Added tests covering RBAC models, seeding, role assignments, constraints, and scope registry coverage.

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Feb 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
fides-plus-nightly Ignored Ignored Preview Mar 9, 2026 10:34pm
fides-privacy-center Ignored Ignored Mar 9, 2026 10:34pm

Request Review

thabofletcher added a commit that referenced this pull request Feb 3, 2026
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thabofletcher thabofletcher marked this pull request as ready for review February 3, 2026 01:08
@thabofletcher thabofletcher requested a review from a team as a code owner February 3, 2026 01:08
@thabofletcher thabofletcher requested review from erosselli and removed request for a team February 3, 2026 01:08
@thabofletcher thabofletcher added the db-migration This indicates that a change includes a database migration label Feb 3, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 3, 2026

Greptile Overview

Greptile Summary

This PR introduces the initial database schema and ORM models for a dynamic RBAC system, including Alembic migrations to create RBAC tables and seed permissions/roles, plus unit tests covering basic model behavior (roles, permissions, assignments, and constraints). The RBAC models are wired into the existing SQLAlchemy model registry via src/fides/api/models/sql_models.py.

Key issues to address before merging:

  • The first RBAC migration’s revision metadata appears inconsistent (Revises in the docstring does not match down_revision), which can create an unintended branch in the Alembic graph.
  • The ORM definition of rbac_role_permission does not match the table created by migrations (PK/id-column mismatch). This is likely to cause runtime failures when the ORM interacts with the real table.

After those are fixed, consider aligning server_default usage (SQL expressions vs strings) and making time-dependent tests deterministic.

Confidence Score: 2/5

  • Not safe to merge until migration graph and ORM/schema mismatches are resolved.
  • Score is low because there are two likely runtime-breaking issues: (1) Alembic down_revision mismatch can create multiple heads/incorrect ordering, and (2) rbac_role_permission ORM mapping does not match the table created/seeded by migrations. The remaining items are consistency/flakiness concerns.
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py and src/fides/api/models/rbac/rbac_role_permission.py (plus migrations that insert into rbac_role_permission).

Important Files Changed

Filename Overview
changelog/7285.yaml Adds a changelog entry for the RBAC DB migration work.
src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py Creates 5 RBAC tables; migration header Revises docstring does not match down_revision, and uses string server_default literals.
src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py Seeds RBAC permissions/roles and role-permission mappings; inserts into rbac_role_permission by id and defines a helper inside upgrade().
src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py Adds RBAC management scopes and assigns them to Owner; inserts into rbac_role_permission by id which conflicts with the ORM model PK definition.
src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py Seeds fidesplus scopes and assigns to roles; also inserts into rbac_role_permission by id.
src/fides/api/models/rbac/init.py Exports RBAC model classes for package import.
src/fides/api/models/rbac/rbac_permission.py Defines RBACPermission model with relationship to roles via rbac_role_permission.
src/fides/api/models/rbac/rbac_role.py Defines RBACRole model with hierarchy and permission aggregation helpers.
src/fides/api/models/rbac/rbac_role_constraint.py Defines RBACRoleConstraint model and helper methods for constraint type checks.
src/fides/api/models/rbac/rbac_role_permission.py Defines RBACRolePermission junction model with composite PK, but migrations create an id PK column (schema mismatch).
src/fides/api/models/rbac/rbac_user_role.py Defines RBACUserRole assignment model; valid_from uses server_default="now()" as a string rather than a SQL expression.
src/fides/api/models/sql_models.py Imports RBAC models and adds them to sql_model_map.
tests/api/models/test_rbac.py Adds unit tests for RBAC models; time-based assertions could be made deterministic with freezegun.

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

thabofletcher and others added 14 commits February 11, 2026 11:25
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ations

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Updated down_revision from 6d5f70dd0ba5 to 627c230d9917
to align with the current head on main branch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…et down_revision to main head

Co-authored-by: Cursor <cursoragent@cursor.com>
thabofletcher and others added 2 commits February 11, 2026 12:00
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
tests/api/models/test_rbac.py (1)

375-388: Consider using __file__ relative path more robustly.

The path construction traverses multiple parent directories with hardcoded depth. If the test file moves or the directory structure changes, this will break silently.

♻️ Suggested improvement
-        migrations_dir = (
-            Path(__file__).parent.parent.parent.parent
-            / "src"
-            / "fides"
-            / "api"
-            / "alembic"
-            / "migrations"
-            / "versions"
-        )
+        # Find repo root by looking for a known marker file
+        current = Path(__file__).resolve()
+        while current != current.parent:
+            if (current / "pyproject.toml").exists():
+                break
+            current = current.parent
+        migrations_dir = current / "src" / "fides" / "api" / "alembic" / "migrations" / "versions"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/api/models/test_rbac.py` around lines 375 - 388, The path building
using Path(__file__).parent.parent.parent.parent / "src" / "fides" / "api" /
"alembic" / "migrations" / "versions" is brittle; change the logic in this test
(symbols: migrations_dir, seed_migration) to resolve paths more robustly by
using Path(__file__).resolve() and walking parents or by locating the installed
package/module (e.g., import fides or use importlib.resources) to find the
"alembic/migrations/versions" directory dynamically; update migrations_dir
calculation to search upward for the repository root or the "src" or "fides"
directory instead of hardcoded parent chaining so the
glob("*seed_rbac_defaults*.py") still finds the file even if the test file or
layout moves.
src/fides/api/models/rbac/rbac_role.py (1)

97-102: lazy="dynamic" and List[...] type annotation do not match.

user_assignments is annotated as List["RBACUserRole"], but SQLAlchemy's dynamic loader returns an AppenderQuery (a Query subclass) instead of a list. Either switch to a non-dynamic loader or update the type annotation to AppenderQuery["RBACUserRole"] to match the actual runtime type.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_role.py` around lines 97 - 102, The type
annotation for user_assignments does not match the runtime type returned by
relationship(..., lazy="dynamic"); update the code so they agree: either remove
lazy="dynamic" on the relationship call to return a standard list-like
collection (keeping the annotation as
RelationshipProperty[List["RBACUserRole"]]) or change the annotation to
RelationshipProperty[AppenderQuery["RBACUserRole"]] (import AppenderQuery) to
reflect the dynamic loader; adjust the import and annotation next to
user_assignments and ensure RBACUserRole and RelationshipProperty references
remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`:
- Around line 304-350: The RBAC constraint table (rbac_role_constraint)
currently allows invalid rows; add CHECK constraints in the migration alongside
the existing columns to enforce invariants: (1) constrain "constraint_type" to
allowed values ('static_sod','dynamic_sod','cardinality'), (2) ensure
"role_id_1" != "role_id_2" (no identical roles), (3) require "role_id_2" IS NOT
NULL for SoD types and IS NULL for 'cardinality' rows, and (4) require
"max_users" IS NOT NULL AND "max_users" > 0 for 'cardinality' rows (and
optionally NULL for others). Implement these using sa.CheckConstraint entries
referenced by the column names "constraint_type", "role_id_1", "role_id_2", and
"max_users" in the migration that creates rbac_role_constraint.
- Around line 217-228: Replace the single UNIQUE constraint on rbac_user_role
with three PostgreSQL partial unique indexes and add a CHECK to prevent orphaned
resource IDs: create uq_rbac_user_role_global (unique on user_id, role_id WHERE
resource_type IS NULL AND resource_id IS NULL), uq_rbac_user_role_type_scoped
(unique on user_id, role_id, resource_type WHERE resource_type IS NOT NULL AND
resource_id IS NULL), and uq_rbac_user_role_fully_scoped (unique on user_id,
role_id, resource_type, resource_id WHERE resource_id IS NOT NULL), and add the
CHECK constraint resource_id IS NULL OR resource_type IS NOT NULL; update the
Alembic migration in the migration file that defines the rbac_user_role table
(the file in the diff) to create these indexes and the check instead of the
previous UNIQUE, and update the RBACUserRole model
(src/fides/api/models/rbac/rbac_user_role.py) to reflect the new DB constraints
(remove the overlapping UNIQUE definition and ensure the model allows NULLs on
resource_type/resource_id consistent with the partial indexes).

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py`:
- Around line 170-186: The contributor role currently ends up including RBAC
management scopes because RBAC_MANAGEMENT_SCOPES aren't excluded when building
contributor_scopes from SCOPE_DOCS; update the logic that constructs
contributor_scopes (the code that iterates SCOPE_DOCS and applies
NOT_CONTRIBUTOR_SCOPES) to also filter out any scope present in
RBAC_MANAGEMENT_SCOPES (or add RBAC_MANAGEMENT_SCOPES to NOT_CONTRIBUTOR_SCOPES)
so that contributor does not receive scopes listed in RBAC_MANAGEMENT_SCOPES;
locate the symbols contributor_scopes, SCOPE_DOCS, NOT_CONTRIBUTOR_SCOPES, and
RBAC_MANAGEMENT_SCOPES to apply this change.
- Around line 425-432: The downgrade currently issues broad deletes ("DELETE
FROM rbac_role WHERE is_system_role = true" and "DELETE FROM rbac_permission")
which remove any runtime/custom entries; narrow the deletes to only the rows
this migration seeded by filtering on the specific identifiers used in the
upgrade (e.g., delete from rbac_permission WHERE key IN (...) or WHERE name IN
(...) for the SCOPE_DOCS permission and any other seeds added here, and delete
from rbac_role WHERE name IN (...) or WHERE id IN (...) for the specific system
roles created), using the same unique keys/names inserted by this migration
(reference: rbac_permission, rbac_role, is_system_role, the SCOPE_DOCS seed) so
rollback only removes those seeded records.

In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 107-136: get_all_permissions (and similarly get_ancestor_roles)
can recurse infinitely if there's a cyclic parent_role chain; update
get_all_permissions to accept or create a visited set of role IDs, check and
skip/stop when encountering an already-seen role id to prevent recursion, and
ensure get_ancestor_roles uses the same visited-role guard; additionally, add
validation in the RBAC role save/update logic to detect and reject cycles by
walking parent_role_id pointers and failing the save if a cycle is found (refer
to methods get_all_permissions and get_ancestor_roles for where to add the
traversal/visited check and the save/update hook for cycle rejection).

In `@src/fides/api/models/rbac/rbac_user_role.py`:
- Around line 62-67: The Column definition for valid_from currently uses a plain
string server_default="now()", which SQLAlchemy treats as a literal; change it
to use a SQL expression like server_default=text("now()") (import text from
sqlalchemy or sqlalchemy.sql) so the model generates DEFAULT now() correctly;
update the valid_from Column in the rbac_user_role model to use
server_default=text("now()") and add the necessary import for text.

---

Nitpick comments:
In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 97-102: The type annotation for user_assignments does not match
the runtime type returned by relationship(..., lazy="dynamic"); update the code
so they agree: either remove lazy="dynamic" on the relationship call to return a
standard list-like collection (keeping the annotation as
RelationshipProperty[List["RBACUserRole"]]) or change the annotation to
RelationshipProperty[AppenderQuery["RBACUserRole"]] (import AppenderQuery) to
reflect the dynamic loader; adjust the import and annotation next to
user_assignments and ensure RBACUserRole and RelationshipProperty references
remain unchanged.

In `@tests/api/models/test_rbac.py`:
- Around line 375-388: The path building using
Path(__file__).parent.parent.parent.parent / "src" / "fides" / "api" / "alembic"
/ "migrations" / "versions" is brittle; change the logic in this test (symbols:
migrations_dir, seed_migration) to resolve paths more robustly by using
Path(__file__).resolve() and walking parents or by locating the installed
package/module (e.g., import fides or use importlib.resources) to find the
"alembic/migrations/versions" directory dynamically; update migrations_dir
calculation to search upward for the repository root or the "src" or "fides"
directory instead of hardcoded parent chaining so the
glob("*seed_rbac_defaults*.py") still finds the file even if the test file or
layout moves.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88004cb9-d6e1-494d-bb4b-d07b0230615f

📥 Commits

Reviewing files that changed from the base of the PR and between 4791199 and 68341a3.

📒 Files selected for processing (14)
  • .fides/db_dataset.yml
  • changelog/7285.yaml
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py
  • src/fides/api/alembic/migrations/versions/xx_2026_02_01_0900_9f6555f12ad1_add_rbac_management_scopes.py
  • src/fides/api/alembic/migrations/versions/xx_2026_02_01_1000_a8b9c0d1e2f3_seed_fidesplus_scopes.py
  • src/fides/api/models/rbac/__init__.py
  • src/fides/api/models/rbac/rbac_permission.py
  • src/fides/api/models/rbac/rbac_role.py
  • src/fides/api/models/rbac/rbac_role_constraint.py
  • src/fides/api/models/rbac/rbac_role_permission.py
  • src/fides/api/models/rbac/rbac_user_role.py
  • src/fides/api/models/sql_models.py
  • tests/api/models/test_rbac.py

Comment on lines +304 to +350
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 (required for all types)",
),
sa.Column(
"role_id_2",
sa.String(length=255),
nullable=True,
comment="Second role in the constraint (required for SoD, NULL for cardinality)",
),
sa.Column(
"max_users",
sa.Integer(),
nullable=True,
comment="Maximum number of 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 currently enforced",
),
sa.ForeignKeyConstraint(
["role_id_1"],
["rbac_role.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["role_id_2"],
["rbac_role.id"],
ondelete="CASCADE",
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject impossible RBAC constraint rows at the DB layer.

This table currently accepts invalid combinations: cardinality without max_users, SoD rows without role_id_2, negative limits, or even the same role on both sides. The helper methods in src/fides/api/models/rbac/rbac_role_constraint.py assume those invariants hold, so letting bad rows persist will turn into silent authorization drift later.

🧱 Example hardening
         sa.Column(
             "is_active",
             sa.Boolean(),
             server_default="true",
             nullable=False,
             comment="Whether this constraint is currently enforced",
         ),
+        sa.CheckConstraint(
+            "(constraint_type IN ('static_sod', 'dynamic_sod') AND role_id_2 IS NOT NULL AND max_users IS NULL) OR "
+            "(constraint_type = 'cardinality' AND role_id_2 IS NULL AND max_users IS NOT NULL AND max_users > 0)",
+            name="ck_rbac_role_constraint_shape",
+        ),
+        sa.CheckConstraint(
+            "role_id_2 IS NULL OR role_id_1 <> role_id_2",
+            name="ck_rbac_role_constraint_distinct_roles",
+        ),
         sa.ForeignKeyConstraint(
             ["role_id_1"],
             ["rbac_role.id"],
             ondelete="CASCADE",
         ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`
around lines 304 - 350, The RBAC constraint table (rbac_role_constraint)
currently allows invalid rows; add CHECK constraints in the migration alongside
the existing columns to enforce invariants: (1) constrain "constraint_type" to
allowed values ('static_sod','dynamic_sod','cardinality'), (2) ensure
"role_id_1" != "role_id_2" (no identical roles), (3) require "role_id_2" IS NOT
NULL for SoD types and IS NULL for 'cardinality' rows, and (4) require
"max_users" IS NOT NULL AND "max_users" > 0 for 'cardinality' rows (and
optionally NULL for others). Implement these using sa.CheckConstraint entries
referenced by the column names "constraint_type", "role_id_1", "role_id_2", and
"max_users" in the migration that creates rbac_role_constraint.

Renames RBACRoleConstraint to RBACConstraint and implements NIST RBAC set-based constraint model with roles relationship and threshold. Includes migration fix to chain after main branch head.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
src/fides/api/models/rbac/rbac_user_role.py (1)

68-72: ⚠️ Potential issue | 🟡 Minor

Use a SQL expression for valid_from's server default.

Line 70 uses a plain string, while the migration uses sa.text("now()"). In SQLAlchemy 1.4 that string is treated as a literal default, so metadata-created tables get DEFAULT 'now()' instead of DEFAULT now(). Keep the ORM definition aligned with the migration.

Suggested fix
-from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint
+from sqlalchemy import Column, DateTime, ForeignKey, String, UniqueConstraint, text
@@
     valid_from = Column(
         DateTime(timezone=True),
-        server_default="now()",
+        server_default=text("now()"),
         nullable=True,
         comment="When this assignment becomes active. NULL means immediately.",
     )
In SQLAlchemy 1.4, what DDL does `server_default="now()"` generate on a DateTime column, and what should be used instead to emit `DEFAULT now()`?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_user_role.py` around lines 68 - 72, The Column
valid_from is using a plain string for server_default which SQLAlchemy treats as
a literal and produces DEFAULT 'now()' instead of DEFAULT now(); update the ORM
Column definition for valid_from (in rbac_user_role.py) to use a SQL expression
object (e.g., sqlalchemy.text or sa.text("now()")) as the server_default so the
DDL emits DEFAULT now(); ensure the import for text is present where valid_from
is defined.
src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py (2)

217-228: ⚠️ Potential issue | 🟠 Major

Replace the scoped UNIQUE with partial unique indexes.

Line 264 still allows duplicate global rows (user_id, role_id, NULL, NULL) and duplicate type-scoped rows (user_id, role_id, resource_type, NULL) on PostgreSQL, because NULL values are distinct under a normal UNIQUE constraint. That breaks the single-assignment-per-scope assumption used by src/fides/api/models/rbac/rbac_user_role.py Lines 151-181. Add a scope-shape CHECK, create one partial unique index per scope, and mirror the same shape in the ORM model instead of the current UniqueConstraint.

Suggested migration shape
         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",
+        sa.CheckConstraint(
+            "resource_id IS NULL OR resource_type IS NOT NULL",
+            name="ck_rbac_user_role_resource_scope",
         ),
     )
+    op.create_index(
+        "uq_rbac_user_role_global",
+        "rbac_user_role",
+        ["user_id", "role_id"],
+        unique=True,
+        postgresql_where=sa.text("resource_type IS NULL AND resource_id IS NULL"),
+    )
+    op.create_index(
+        "uq_rbac_user_role_type_scoped",
+        "rbac_user_role",
+        ["user_id", "role_id", "resource_type"],
+        unique=True,
+        postgresql_where=sa.text("resource_type IS NOT NULL AND resource_id IS NULL"),
+    )
+    op.create_index(
+        "uq_rbac_user_role_fully_scoped",
+        "rbac_user_role",
+        ["user_id", "role_id", "resource_type", "resource_id"],
+        unique=True,
+        postgresql_where=sa.text("resource_id IS NOT NULL"),
+    )
In PostgreSQL, how do UNIQUE constraints treat NULL values, and what partial-unique-index pattern should be used to enforce uniqueness separately for global rows (resource_type/resource_id NULL), type-scoped rows (resource_id NULL), and fully scoped rows?

Also applies to: 263-270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`
around lines 217 - 228, The current UNIQUE constraint on the user-role
association does not prevent duplicates because PostgreSQL treats NULLs as
distinct; replace the single UniqueConstraint with a CHECK that enforces one of
three scope shapes (global: resource_type IS NULL AND resource_id IS NULL;
type-scoped: resource_type IS NOT NULL AND resource_id IS NULL; fully-scoped:
resource_type IS NOT NULL AND resource_id IS NOT NULL), then create three
partial UNIQUE indexes covering (user_id, role_id) for each scope condition (one
WHERE resource_type IS NULL AND resource_id IS NULL, one WHERE resource_id IS
NULL AND resource_type IS NOT NULL, and one WHERE resource_type IS NOT NULL AND
resource_id IS NOT NULL) so uniqueness is enforced per-scope; mirror this shape
in the ORM model (the class in rbac_user_role.py with the existing
UniqueConstraint) by removing the UniqueConstraint and documenting/validating
the same scope-check logic in the model (lines ~151-181) so DB and ORM enforce
the same rules.

307-336: ⚠️ Potential issue | 🟠 Major

Reject invalid RBAC constraint rows at the DB layer.

src/fides/api/models/rbac/rbac_constraint.py Lines 126-198 assumes constraint_type is one of the three supported values and threshold > 0. Right now a typo in constraint_type silently disables enforcement, and threshold <= 0 makes every SoD/cardinality check immediately fail. Add schema checks here so bad policy rows cannot persist.

Suggested hardening
         sa.Column(
             "is_active",
             sa.Boolean(),
             server_default="true",
             nullable=False,
             comment="Whether this constraint is currently enforced",
         ),
+        sa.CheckConstraint(
+            "constraint_type IN ('static_sod', 'dynamic_sod', 'cardinality')",
+            name="ck_rbac_constraint_type",
+        ),
+        sa.CheckConstraint(
+            "threshold > 0",
+            name="ck_rbac_constraint_threshold_positive",
+        ),
         sa.PrimaryKeyConstraint("id"),
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`
around lines 307 - 336, Add DB-level validation to the RBAC constraint table so
invalid rows cannot be persisted: in the Alembic migration that defines the RBAC
constraint table (the migration creating columns "constraint_type" and
"threshold"), add SQLAlchemy CheckConstraints that enforce constraint_type IN
('static_sod','dynamic_sod','cardinality') and threshold > 0 (e.g.,
sa.CheckConstraint("constraint_type IN
('static_sod','dynamic_sod','cardinality')", name="ck_rbac_constraint_type") and
sa.CheckConstraint("threshold > 0", name="ck_rbac_threshold_positive")),
ensuring names match existing table (the migration file and the model
src/fides/api/models/rbac/rbac_constraint.py) so application-level assumptions
cannot be violated by bad DB rows.
src/fides/api/models/rbac/rbac_role.py (1)

113-142: ⚠️ Potential issue | 🟠 Major

Guard hierarchy traversal against cyclic parents.

Lines 136-137 recurse into parent_role without a visited-set, and Lines 188-190 loop until None. A cyclic parent_role_id chain will therefore recurse forever in get_all_permissions() and loop forever in get_ancestor_roles(). Because roles are dynamic data, one bad row can take permission evaluation down.

Suggested traversal guard
     def get_all_permissions(self, db: Session) -> List["RBACPermission"]:
         """
         Get all permissions for this role, including inherited from parent roles.
@@
         """
         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):
+        current: Optional["RBACRole"] = self
+        visited_role_keys: Set[str] = set()
+        while current:
+            role_key = current.id or f"object:{id(current)}"
+            if role_key in visited_role_keys:
+                break
+            visited_role_keys.add(role_key)
+            for permission in current.permissions:
                 if permission.id not in seen_permission_ids and permission.is_active:
                     seen_permission_ids.add(permission.id)
                     all_permissions.append(permission)
+            current = current.parent_role
 
         return all_permissions
@@
     def get_ancestor_roles(self) -> List["RBACRole"]:
@@
         """
         ancestors: List["RBACRole"] = []
         current = self.parent_role
-        while current:
+        visited_role_keys: Set[str] = {self.id or f"object:{id(self)}"}
+        while current:
+            role_key = current.id or f"object:{id(current)}"
+            if role_key in visited_role_keys:
+                break
+            visited_role_keys.add(role_key)
             ancestors.append(current)
             current = current.parent_role
         return ancestors

Also applies to: 179-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_role.py` around lines 113 - 142,
get_all_permissions (and similarly get_ancestor_roles) can recurse/loop forever
on cyclic parent_role/parent_role_id chains; add a visited guard: track visited
role IDs (e.g., seen_role_ids) and check before traversing into self.parent_role
or adding parents in the loop, returning/stop traversal when a role ID is
already seen. Update get_all_permissions to accept or create a local visited set
(or use an iterative traversal over parent_role following parent_role_id) and
ensure you check parent_role.id in seen_role_ids before recursing and add it
when first visited; apply the same visited-set logic to get_ancestor_roles to
break cycles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`:
- Around line 217-228: The current UNIQUE constraint on the user-role
association does not prevent duplicates because PostgreSQL treats NULLs as
distinct; replace the single UniqueConstraint with a CHECK that enforces one of
three scope shapes (global: resource_type IS NULL AND resource_id IS NULL;
type-scoped: resource_type IS NOT NULL AND resource_id IS NULL; fully-scoped:
resource_type IS NOT NULL AND resource_id IS NOT NULL), then create three
partial UNIQUE indexes covering (user_id, role_id) for each scope condition (one
WHERE resource_type IS NULL AND resource_id IS NULL, one WHERE resource_id IS
NULL AND resource_type IS NOT NULL, and one WHERE resource_type IS NOT NULL AND
resource_id IS NOT NULL) so uniqueness is enforced per-scope; mirror this shape
in the ORM model (the class in rbac_user_role.py with the existing
UniqueConstraint) by removing the UniqueConstraint and documenting/validating
the same scope-check logic in the model (lines ~151-181) so DB and ORM enforce
the same rules.
- Around line 307-336: Add DB-level validation to the RBAC constraint table so
invalid rows cannot be persisted: in the Alembic migration that defines the RBAC
constraint table (the migration creating columns "constraint_type" and
"threshold"), add SQLAlchemy CheckConstraints that enforce constraint_type IN
('static_sod','dynamic_sod','cardinality') and threshold > 0 (e.g.,
sa.CheckConstraint("constraint_type IN
('static_sod','dynamic_sod','cardinality')", name="ck_rbac_constraint_type") and
sa.CheckConstraint("threshold > 0", name="ck_rbac_threshold_positive")),
ensuring names match existing table (the migration file and the model
src/fides/api/models/rbac/rbac_constraint.py) so application-level assumptions
cannot be violated by bad DB rows.

In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 113-142: get_all_permissions (and similarly get_ancestor_roles)
can recurse/loop forever on cyclic parent_role/parent_role_id chains; add a
visited guard: track visited role IDs (e.g., seen_role_ids) and check before
traversing into self.parent_role or adding parents in the loop, returning/stop
traversal when a role ID is already seen. Update get_all_permissions to accept
or create a local visited set (or use an iterative traversal over parent_role
following parent_role_id) and ensure you check parent_role.id in seen_role_ids
before recursing and add it when first visited; apply the same visited-set logic
to get_ancestor_roles to break cycles.

In `@src/fides/api/models/rbac/rbac_user_role.py`:
- Around line 68-72: The Column valid_from is using a plain string for
server_default which SQLAlchemy treats as a literal and produces DEFAULT 'now()'
instead of DEFAULT now(); update the ORM Column definition for valid_from (in
rbac_user_role.py) to use a SQL expression object (e.g., sqlalchemy.text or
sa.text("now()")) as the server_default so the DDL emits DEFAULT now(); ensure
the import for text is present where valid_from is defined.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8856add-7ed3-4622-b7d9-3a888c35ba33

📥 Commits

Reviewing files that changed from the base of the PR and between 68341a3 and 0835e7a.

📒 Files selected for processing (11)
  • .fides/db_dataset.yml
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py
  • src/fides/api/models/rbac/__init__.py
  • src/fides/api/models/rbac/rbac_constraint.py
  • src/fides/api/models/rbac/rbac_constraint_role.py
  • src/fides/api/models/rbac/rbac_permission.py
  • src/fides/api/models/rbac/rbac_role.py
  • src/fides/api/models/rbac/rbac_role_permission.py
  • src/fides/api/models/rbac/rbac_user_role.py
  • src/fides/api/models/sql_models.py
  • tests/api/models/test_rbac.py
✅ Files skipped from review due to trivial changes (1)
  • tests/api/models/test_rbac.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/fides/api/models/rbac/init.py
  • src/fides/api/models/sql_models.py
  • src/fides/api/models/rbac/rbac_permission.py

@thabofletcher
Copy link
Copy Markdown
Contributor Author

Addressing review comments:

No longer applies:

  • Migration revision mismatch (comment 2756685866) - Fixed, down_revision now chains after main branch head (bf12f05ef8eb)
  • rbac_role_permission PK mismatch (comment 2756685907) - Fixed, model uses composite PK with id = None
  • CHECK constraints for rbac_role_constraint (comment 2907016771) - Model redesigned in PR Proposal for a more flexible RBAC model #7367 to NIST RBAC set-based model (roles relationship + threshold instead of pairwise role_id_1/role_id_2)
  • Drift detection test (comment 2796132863) - Already implemented in commit 7869909. See TestRBACScopeRegistrySync in tests/api/models/test_rbac.py

Addressing remaining issues:

  • Contributor scope escalation - will fix
  • UNIQUE constraint NULL handling - will fix
  • Downgrade scope - will fix
  • Cycle detection in hierarchy - will fix

Security fix:
- Exclude RBAC_MANAGEMENT_SCOPES from contributor role to prevent
  privilege escalation (contributors could previously manage roles)

Data integrity fixes:
- Replace single UNIQUE constraint on rbac_user_role with three partial
  unique indexes to correctly handle NULL scope values in PostgreSQL
- Add CHECK constraint to prevent orphaned resource_id without resource_type

Migration safety:
- Scope downgrade to only delete seeded permissions, preserving any
  custom permissions created after the migration

Robustness:
- Add cycle detection to get_all_permissions() and get_ancestor_roles()
  to prevent infinite recursion/loops from cyclic parent_role_id chains

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py (1)

307-368: Consider adding CHECK constraints for constraint validation.

The NIST set-based model is well-designed, but the table currently accepts any constraint_type string and any threshold value (including 0 or negative). Adding validation would prevent invalid constraint data:

🛡️ Suggested hardening
         sa.Column(
             "is_active",
             sa.Boolean(),
             server_default="true",
             nullable=False,
             comment="Whether this constraint is currently enforced",
         ),
         sa.PrimaryKeyConstraint("id"),
+        sa.CheckConstraint(
+            "constraint_type IN ('static_sod', 'dynamic_sod', 'cardinality')",
+            name="ck_rbac_constraint_type",
+        ),
+        sa.CheckConstraint(
+            "threshold > 0",
+            name="ck_rbac_constraint_threshold_positive",
+        ),
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`
around lines 307 - 368, The rbac_constraint table currently allows arbitrary
constraint_type strings and invalid threshold values; add CHECK constraints when
creating the table (in the op.create_table call for "rbac_constraint") to
restrict constraint_type to the allowed set (e.g., 'static_sod', 'dynamic_sod',
'cardinality') and enforce threshold > 0 (and optionally threshold >= 1 for
cardinality-specific logic), and ensure the is_active default uses a proper SQL
boolean expression; modify the op.create_table invocation that defines columns
"constraint_type", "threshold", and "is_active" to include the appropriate
sa.CheckConstraint(s) referencing those column names so invalid rows are
rejected at the DB level.
src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py (1)

333-346: Consider adding explicit timestamps for consistency with other migrations.

The INSERT statement omits created_at and updated_at columns, relying on server defaults. While this works, the related seed_fidesplus_scopes.py migration (lines 324-337) explicitly includes created_at, updated_at with now(), now(). For consistency and explicit behavior, consider adding these columns.

♻️ Optional consistency improvement
         connection.execute(
             text(
                 """
-                INSERT INTO rbac_permission (id, code, description, resource_type, is_active)
-                VALUES (:id, :code, :description, :resource_type, true)
+                INSERT INTO rbac_permission (id, code, description, resource_type, is_active, created_at, updated_at)
+                VALUES (:id, :code, :description, :resource_type, true, now(), now())
             """
             ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py`
around lines 333 - 346, The INSERT into rbac_permission done in the
connection.execute block omits created_at/updated_at; update that INSERT (the
SQL string used in connection.execute) to explicitly include created_at and
updated_at columns and set their values to now(), now() (matching the pattern
used in seed_fidesplus_scopes.py), ensuring the same parameter dict
(permission_id, scope_code, description, resource_type) remains unchanged;
locate the SQL text in the migration file where rbac_permission is inserted and
add these columns to make timestamp behavior explicit and consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`:
- Around line 1-16: The module docstring header has a mismatch: the "Revises:
f85bd4c08401" entry does not match the actual down_revision variable value used
by Alembic (down_revision = "bf12f05ef8eb"); update the docstring so the
"Revises:" line matches the down_revision value "bf12f05ef8eb" (keep the
existing Revision ID and other metadata unchanged) to avoid confusion when
inspecting the migration file.

In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 102-108: Remove the single UniqueConstraint defined in
RBACUserRole.__table_args__ (the constraint on user_id, role_id, resource_type,
resource_id) because it conflicts with the migration's three PostgreSQL partial
unique indexes; edit the RBACUserRole class to delete that UniqueConstraint
entry from __table_args__ and rely solely on the migration-created partial
unique indexes for global, type-scoped, and fully-scoped uniqueness enforcement.

---

Nitpick comments:
In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`:
- Around line 307-368: The rbac_constraint table currently allows arbitrary
constraint_type strings and invalid threshold values; add CHECK constraints when
creating the table (in the op.create_table call for "rbac_constraint") to
restrict constraint_type to the allowed set (e.g., 'static_sod', 'dynamic_sod',
'cardinality') and enforce threshold > 0 (and optionally threshold >= 1 for
cardinality-specific logic), and ensure the is_active default uses a proper SQL
boolean expression; modify the op.create_table invocation that defines columns
"constraint_type", "threshold", and "is_active" to include the appropriate
sa.CheckConstraint(s) referencing those column names so invalid rows are
rejected at the DB level.

In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py`:
- Around line 333-346: The INSERT into rbac_permission done in the
connection.execute block omits created_at/updated_at; update that INSERT (the
SQL string used in connection.execute) to explicitly include created_at and
updated_at columns and set their values to now(), now() (matching the pattern
used in seed_fidesplus_scopes.py), ensuring the same parameter dict
(permission_id, scope_code, description, resource_type) remains unchanged;
locate the SQL text in the migration file where rbac_permission is inserted and
add these columns to make timestamp behavior explicit and consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49fcc31c-a6a6-4c5a-8618-2014eefa2e51

📥 Commits

Reviewing files that changed from the base of the PR and between 0835e7a and e5eb0cd.

📒 Files selected for processing (3)
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1100_f5f526cbc35a_seed_rbac_defaults.py
  • src/fides/api/models/rbac/rbac_role.py

- Update migration docstring Revises: to match actual down_revision
- Remove UniqueConstraint from RBACUserRole model since uniqueness is
  now enforced by partial indexes in the migration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thabofletcher
Copy link
Copy Markdown
Contributor Author

@coderabbitai please summarize any changes that are applicable and have not been addressed

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 9, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me check the current state of unresolved review comments on this PR.


🏁 Script executed:

#!/bin/bash
# Get all review comments from coderabbitai on PR `#7285`
gh api repos/ethyca/fides/pulls/7285/comments \
  --paginate \
  --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, path: .path, body: .body[:300], line: .line, created_at: .created_at}' \
  2>/dev/null | head -300

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Try fetching reviews on PR `#7285`
gh pr view 7285 --repo ethyca/fides --json reviews,comments 2>/dev/null | jq '.reviews[] | {state, author: .author.login, body: .body[:200]}' 2>/dev/null | head -100

echo "---REVIEW THREADS---"
gh api "repos/ethyca/fides/pulls/7285/reviews" --paginate 2>/dev/null | jq '.[] | select(.user.login == "coderabbitai") | {id, state, body: .body[:400]}' 2>/dev/null | head -200

Length of output: 1942


🏁 Script executed:

#!/bin/bash
# Get all review comments with more body content
gh api "repos/ethyca/fides/pulls/7285/comments" --paginate 2>/dev/null \
  | jq '.[] | select(.user.login == "coderabbitai") | {id, path, line, body: .body}' 2>/dev/null

echo "=== PR ISSUE COMMENTS ==="
gh api "repos/ethyca/fides/issues/7285/comments" --paginate 2>/dev/null \
  | jq '.[] | select(.user.login == "coderabbitai") | {id, body: .body[:2000]}' 2>/dev/null

Length of output: 25


🏁 Script executed:

#!/bin/bash
# Get full CodeRabbit review bodies
gh api "repos/ethyca/fides/pulls/7285/reviews" --paginate 2>/dev/null \
  | jq '.[] | select(.user.login == "coderabbitai") | {id, state, body}' 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Try fetching with different auth approach - list all comments on the PR
gh pr view https://github.com/ethyca/fides/pull/7285 --comments 2>/dev/null | head -500

Length of output: 47545


🏁 Script executed:

#!/bin/bash
# Get the most recent CodeRabbit review with full body
gh api "repos/ethyca/fides/pulls/7285/reviews" --paginate 2>/dev/null \
  | python3 -c "
import sys, json
data = json.load(sys.stdin)
crab_reviews = [r for r in data if r.get('user',{}).get('login') == 'coderabbitai']
for r in crab_reviews:
    print('=== REVIEW ID:', r['id'], '===')
    print('STATE:', r['state'])
    print('BODY:')
    print(r['body'])
    print()
" 2>/dev/null

Length of output: 0


@thabofletcher Here's a summary of review items that are still applicable and not yet addressed, based on the original CodeRabbit review comments cross-referenced with the commit messages:


⚠️ Actionable Issues (1 remaining)

1. valid_from uses a plain string server_default instead of a SQL expression

  • File: src/fides/api/models/rbac/rbac_user_role.py (~Line 62–67)
  • Issue: server_default="now()" is treated by SQLAlchemy as a literal string, not a SQL expression. This was flagged in the first review and re-raised as a duplicate in the follow-up review.
  • Fix: Use server_default=text("now()") (import text from sqlalchemy):
from sqlalchemy import text

valid_from: Column = Column(
    DateTime(timezone=True),
    server_default=text("now()"),
    nullable=True,
)

🧹 Nitpicks (2 remaining)

2. Brittle path construction in sync test

  • File: tests/api/models/test_rbac.py (~Lines 375–388)
  • Issue: Path(__file__).parent.parent.parent.parent / "src" / ... breaks if the file moves.
  • Fix: Walk parents looking for pyproject.toml or use importlib.resources.

3. lazy="dynamic" vs List[...] type annotation mismatch

  • File: src/fides/api/models/rbac/rbac_role.py (~Lines 97–102)
  • Issue: user_assignments is annotated as List["RBACUserRole"], but lazy="dynamic" returns an AppenderQuery at runtime.
  • Fix: Either remove lazy="dynamic" and keep the List annotation, or update the annotation to AppenderQuery["RBACUserRole"].

❌ Pre-merge Check Failing

4. Docstring coverage: 67.24% (threshold: 80.00%)

  • Several functions across the new RBAC model files are missing docstrings. You can use the "Generate docstrings" option in the Finishing Touches section of my review comment to auto-generate them.

✅ Already Addressed (per commit messages)

Item Status
Contributor scope escalation (RBAC_MANAGEMENT_SCOPES in contributor) ✅ Fixed
UNIQUE constraint NULL handling (3 partial indexes) ✅ Fixed
Downgrade scope (preserve custom permissions) ✅ Fixed
Cycle detection in get_all_permissions / get_ancestor_roles ✅ Fixed
rbac_role_constraint CHECK constraints ✅ No longer applicable (redesigned in #7367)
Migration revision mismatch ✅ Fixed
rbac_role_permission PK mismatch ✅ Fixed
Drift detection test ✅ Already implemented

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/fides/api/models/rbac/rbac_user_role.py (1)

68-72: ⚠️ Potential issue | 🟠 Major

Use a SQL expression for valid_from's server default.

This still diverges from the migration, which uses sa.text("now()"). With SQLAlchemy, the plain string here is treated as a literal, so metadata-created tables get DEFAULT 'now()' instead of executing now().

Suggested change
-from sqlalchemy import Column, DateTime, ForeignKey, String
+from sqlalchemy import Column, DateTime, ForeignKey, String, text
@@
     valid_from = Column(
         DateTime(timezone=True),
-        server_default="now()",
+        server_default=text("now()"),
         nullable=True,
         comment="When this assignment becomes active. NULL means immediately.",
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_user_role.py` around lines 68 - 72, The Column
definition for valid_from uses a plain string server_default ("now()") which
becomes a literal default instead of a SQL expression; update the valid_from
Column (in rbac_user_role.py) to use a SQL expression like sa.text("now()") or
sqlalchemy.text("now()") for server_default and add the corresponding import (sa
or text) so the metadata-created tables get DEFAULT now() rather than DEFAULT
'now()'.
🧹 Nitpick comments (1)
src/fides/api/models/rbac/rbac_user_role.py (1)

58-67: Mirror the scope CHECK in the ORM metadata.

The migration now rejects resource_id without resource_type, but the model metadata still allows that impossible shape. If this table is ever created from metadata instead of Alembic, is_global()/matches_resource() can end up evaluating states the schema is supposed to forbid.

Suggested change
-from sqlalchemy import Column, DateTime, ForeignKey, String
+from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, String
@@
 class RBACUserRole(Base):
+    __table_args__ = (
+        CheckConstraint(
+            "resource_id IS NULL OR resource_type IS NOT NULL",
+            name="ck_rbac_user_role_resource_scope",
+        ),
+    )
+
     """
     User to role assignment with optional resource scoping and temporal validity.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_user_role.py` around lines 58 - 67, The model
allows resource_id/resource_type combinations the DB migration forbids; add a
table-level CheckConstraint in the RBACUserRole model (e.g., in __table_args__)
that mirrors the migration rule so resource_id cannot be set without
resource_type and vice-versa (use a constraint like "(resource_type IS NULL AND
resource_id IS NULL) OR (resource_type IS NOT NULL AND resource_id IS NOT
NULL)"). This keeps the ORM metadata consistent with the migration and prevents
impossible states observed by is_global() and matches_resource().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py`:
- Around line 68-85: Add a schema-level CHECK that prevents a role from being
its own parent: modify the table definition that creates the rbac_role columns
(the parent_role_id column and its FK) to include a CheckConstraint such as
CHECK (parent_role_id IS NULL OR parent_role_id <> id) with a clear name (e.g.,
rbac_role_no_self_parent) so the database rejects rows where parent_role_id
equals the row's id; update the migration that defines parent_role_id and the FK
(the same migration that adds parent_role_id and the ForeignKeyConstraint) to
add this CheckConstraint on creation.

---

Duplicate comments:
In `@src/fides/api/models/rbac/rbac_user_role.py`:
- Around line 68-72: The Column definition for valid_from uses a plain string
server_default ("now()") which becomes a literal default instead of a SQL
expression; update the valid_from Column (in rbac_user_role.py) to use a SQL
expression like sa.text("now()") or sqlalchemy.text("now()") for server_default
and add the corresponding import (sa or text) so the metadata-created tables get
DEFAULT now() rather than DEFAULT 'now()'.

---

Nitpick comments:
In `@src/fides/api/models/rbac/rbac_user_role.py`:
- Around line 58-67: The model allows resource_id/resource_type combinations the
DB migration forbids; add a table-level CheckConstraint in the RBACUserRole
model (e.g., in __table_args__) that mirrors the migration rule so resource_id
cannot be set without resource_type and vice-versa (use a constraint like
"(resource_type IS NULL AND resource_id IS NULL) OR (resource_type IS NOT NULL
AND resource_id IS NOT NULL)"). This keeps the ORM metadata consistent with the
migration and prevents impossible states observed by is_global() and
matches_resource().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49758f11-d324-496b-b83a-a883a852dda3

📥 Commits

Reviewing files that changed from the base of the PR and between e5eb0cd and 0b15aaa.

📒 Files selected for processing (2)
  • src/fides/api/alembic/migrations/versions/xx_2026_01_31_1000_d9ee4ea46797_add_rbac_tables.py
  • src/fides/api/models/rbac/rbac_user_role.py

- Use text("now()") instead of string "now()" for valid_from server_default
  to ensure proper SQL expression generation
- Change user_assignments lazy="dynamic" to lazy="selectin" to match
  List type annotation (dynamic returns AppenderQuery, not List)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/fides/api/models/rbac/rbac_role.py (1)

86-92: ⚠️ Potential issue | 🟠 Major

Reject cyclic parent chains on write, not just on read.

The visited-set guards stop infinite recursion, but they still allow A -> B -> A to be persisted. After that, get_all_permissions() and get_ancestor_roles() quietly return truncated results instead of a valid hierarchy. Add save/update validation on parent_role_id (or a before_flush hook) so invalid role graphs fail fast.

Also applies to: 113-157, 194-213

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fides/api/models/rbac/rbac_role.py` around lines 86 - 92, RBACRole
currently allows cyclic parent chains to be persisted (e.g., A->B->A) which
breaks get_all_permissions and get_ancestor_roles; add write-time validation
that walks the parent chain and rejects any assignment that would create a
cycle. Implement this by adding either a SQLAlchemy before_flush/session event
handler or a `@validates`("parent_role_id") validator on RBACRole that, for each
role being added/updated (inspect session.new/session.dirty or the validated
instance), follows parent_role/parent_role_id up the chain and raises an
exception (ValueError/IntegrityError) if the candidate parent is already a
descendant of the instance; reference the RBACRole class, the parent_role and
parent_role_id fields, and ensure the same logic is applied where parent_role is
set or updated so invalid graphs fail fast on save.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 130-137: The cycle-detection uses self.id which is None for
transient instances causing false positives; update both get_all_permissions()
and get_ancestor_roles() to use a stable identity key that falls back to object
identity when the PK is unpersisted (e.g. use key = self.id if self.id is not
None else id(self) and check/add that key to _visited_role_ids instead of
self.id) so in-memory hierarchies don't get incorrectly treated as cycles while
preserving persisted-ID behavior; ensure you update checks and additions around
the _visited_role_ids usage accordingly.

---

Duplicate comments:
In `@src/fides/api/models/rbac/rbac_role.py`:
- Around line 86-92: RBACRole currently allows cyclic parent chains to be
persisted (e.g., A->B->A) which breaks get_all_permissions and
get_ancestor_roles; add write-time validation that walks the parent chain and
rejects any assignment that would create a cycle. Implement this by adding
either a SQLAlchemy before_flush/session event handler or a
`@validates`("parent_role_id") validator on RBACRole that, for each role being
added/updated (inspect session.new/session.dirty or the validated instance),
follows parent_role/parent_role_id up the chain and raises an exception
(ValueError/IntegrityError) if the candidate parent is already a descendant of
the instance; reference the RBACRole class, the parent_role and parent_role_id
fields, and ensure the same logic is applied where parent_role is set or updated
so invalid graphs fail fast on save.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 21e53ce8-ba55-46d3-b7e7-297e1fda55d0

📥 Commits

Reviewing files that changed from the base of the PR and between 0b15aaa and 7ebaca1.

📒 Files selected for processing (2)
  • src/fides/api/models/rbac/rbac_role.py
  • src/fides/api/models/rbac/rbac_user_role.py

thabofletcher and others added 2 commits March 9, 2026 13:12
- Add CHECK constraint to prevent self-parent roles in migration
- Fix cycle detection for transient SQLAlchemy instances (use id(self) fallback)
- Add docstrings to all RBAC model methods for coverage threshold
- Add type annotations to migration functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thabofletcher
Copy link
Copy Markdown
Contributor Author

@coderabbitai please check the latest set of changes and make sure all your concerns are addressed. I have chose not to address the "brittle path" - I will adress that if it becomes a rel concern (flaky or failing test, etc)

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 9, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== rbac_user_role.py ==="
cat src/fides/api/models/rbac/rbac_user_role.py

echo ""
echo "=== rbac_role.py ==="
cat src/fides/api/models/rbac/rbac_role.py

echo ""
echo "=== rbac_permission.py ==="
cat src/fides/api/models/rbac/rbac_permission.py

echo ""
echo "=== rbac_constraint.py ==="
cat src/fides/api/models/rbac/rbac_constraint.py

echo ""
echo "=== rbac_role_permission.py ==="
cat src/fides/api/models/rbac/rbac_role_permission.py

echo ""
echo "=== rbac_constraint_role.py ==="
cat src/fides/api/models/rbac/rbac_constraint_role.py

Length of output: 27734


@thabofletcher — I've reviewed the latest state of all the RBAC model files. Here's the verdict:

Concern Status
valid_from uses text("now()") instead of string literal ✅ Fixed — text is imported from sqlalchemy and used correctly
lazy="dynamic" vs List[...] annotation mismatch in rbac_role.py ✅ Fixed — lazy="selectin" is now used, consistent with the List["RBACUserRole"] annotation
Docstring coverage below 80% threshold ✅ Fixed — All classes and methods across every RBAC model file now have complete docstrings
Brittle path in test_rbac.py Acknowledged — deferred per your decision

All applicable concerns have been addressed. The PR looks good from a code review perspective. 🎉

(゚ー゚)

@thabofletcher thabofletcher enabled auto-merge March 9, 2026 20:52
thabofletcher and others added 3 commits March 9, 2026 15:16
- Add partial unique indexes to RBACUserRole model's __table_args__ to
  match migration definitions, fixing Alembic autogenerate mismatch
- Fix test_temporal_role_assignment to use separate roles, avoiding
  unique constraint violation on global scope assignments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thabofletcher thabofletcher added this pull request to the merge queue Mar 9, 2026
Merged via the queue into main with commit 8113ce4 Mar 9, 2026
57 of 58 checks passed
@thabofletcher thabofletcher deleted the rbac-migration branch March 9, 2026 23:10
mfbrown pushed a commit that referenced this pull request Mar 12, 2026
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Adrian Galvan <adrian@ethyca.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

db-migration This indicates that a change includes a database migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants