|
| 1 | +# Fix for Issue #4482: RBAC Race Condition |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +This fix addresses the potential race condition in RBAC role and user_role seeding when multiple replicas/workers bootstrap the database concurrently. The fix implements defense-in-depth with both database-level and application-level protection. |
| 6 | + |
| 7 | +## Changes Made |
| 8 | + |
| 9 | +### 1. Database Migration (`d21698ae4a19_add_rbac_unique_constraints_race_fix.py`) |
| 10 | + |
| 11 | +**Purpose**: Add database-level unique constraints to prevent duplicate active roles and role assignments. |
| 12 | + |
| 13 | +**Key Features**: |
| 14 | +- ✅ **Supports both PostgreSQL and SQLite**: Uses dialect-specific SQL for boolean values and partial indexes |
| 15 | +- ✅ **Idempotent**: Checks for existing indexes before creating them |
| 16 | +- ✅ **Safe for existing data**: Deduplicates any existing duplicate active rows before adding constraints |
| 17 | +- ✅ **Audit-friendly**: Soft-deletes duplicates (sets `is_active=false`) instead of hard-deleting them |
| 18 | + |
| 19 | +**What it does**: |
| 20 | + |
| 21 | +1. **Deduplication (STEP 1 & 2)**: |
| 22 | + - Finds duplicate active `roles` with same `(name, scope)` - keeps oldest by `created_at` |
| 23 | + - Finds duplicate active `user_roles` with same `(user_email, role_id, scope, scope_id)` - keeps oldest by `granted_at` |
| 24 | + - Soft-deletes newer duplicates by setting `is_active=false` |
| 25 | + - Preserves audit history - duplicates remain in DB for forensics |
| 26 | + |
| 27 | +2. **Unique Constraints (STEP 3)**: |
| 28 | + - Creates partial unique index: `uq_roles_name_scope_active` on `roles(name, scope) WHERE is_active = true` |
| 29 | + - Creates partial unique index: `uq_user_roles_email_role_scope_null_active` on `user_roles(user_email, role_id, scope) WHERE scope_id IS NULL AND is_active = true` |
| 30 | + - Creates partial unique index: `uq_user_roles_email_role_scope_id_active` on `user_roles(user_email, role_id, scope, scope_id) WHERE scope_id IS NOT NULL AND is_active = true` |
| 31 | + |
| 32 | +**Why partial indexes?**: |
| 33 | +- Only active rows (`is_active = true`) need uniqueness constraint |
| 34 | +- Allows multiple inactive/historical rows with same values (for audit purposes) |
| 35 | +- Split `user_roles` indexes handle nullable `scope_id` (PostgreSQL/SQLite treat NULL as distinct in unique indexes) |
| 36 | + |
| 37 | +**Downgrade**: |
| 38 | +- Drops the three unique indexes |
| 39 | +- Does NOT reactivate soft-deleted duplicates (preserves audit trail) |
| 40 | + |
| 41 | +### 2. Application-Level Changes (`mcpgateway/services/role_service.py`) |
| 42 | + |
| 43 | +**Purpose**: Handle IntegrityError gracefully when database constraints prevent duplicates. |
| 44 | + |
| 45 | +**Changes**: |
| 46 | + |
| 47 | +1. **Import IntegrityError**: |
| 48 | + ```python |
| 49 | + from sqlalchemy.exc import IntegrityError |
| 50 | + ``` |
| 51 | + |
| 52 | +2. **Updated `create_role()` method**: |
| 53 | + - Wraps insert in `db.begin_nested()` (savepoint) |
| 54 | + - On `IntegrityError`: rolls back savepoint, refetches existing role, returns it |
| 55 | + - No error to caller - seamlessly returns the winner's row |
| 56 | + - Logs info-level message about concurrent creation |
| 57 | + |
| 58 | +3. **Updated `assign_role_to_user()` method**: |
| 59 | + - Same savepoint + refetch pattern |
| 60 | + - On `IntegrityError`: rolls back, refetches existing assignment, returns it |
| 61 | + - Transparent to callers |
| 62 | + |
| 63 | +**Benefits**: |
| 64 | +- No breaking changes - methods still return the role/assignment |
| 65 | +- Prevents `MultipleResultsFound` errors that would cause 500 responses |
| 66 | +- Logs provide visibility into concurrent operations |
| 67 | +- If refetch fails (shouldn't happen), raises descriptive error |
| 68 | + |
| 69 | +## Security Considerations |
| 70 | + |
| 71 | +### ✅ No Security Issues Introduced |
| 72 | + |
| 73 | +1. **No authentication bypass**: Changes are pure data integrity - don't affect auth flows |
| 74 | +2. **No privilege escalation**: Doesn't modify role permissions or RBAC logic |
| 75 | +3. **No data exposure**: Deduplication only affects active rows, preserves audit history |
| 76 | +4. **No SQL injection**: Uses parameterized queries and SQLAlchemy ORM |
| 77 | +5. **Defense in depth**: Database constraints are ultimate authority, application handles gracefully |
| 78 | + |
| 79 | +### ✅ Backwards Compatible |
| 80 | + |
| 81 | +1. **Existing functionality preserved**: |
| 82 | + - All existing role/assignment operations work identically |
| 83 | + - Bootstrap flows unchanged (just more robust) |
| 84 | + - No API changes, no schema-breaking changes |
| 85 | + |
| 86 | +2. **Safe migration**: |
| 87 | + - Idempotent - can run multiple times safely |
| 88 | + - Works on fresh DBs (skips if tables don't exist) |
| 89 | + - Works on populated DBs (deduplicates first) |
| 90 | + - Downgrade available (though not recommended in production) |
| 91 | + |
| 92 | +## Testing Recommendations |
| 93 | + |
| 94 | +Before committing, run: |
| 95 | + |
| 96 | +```bash |
| 97 | +# 1. Check code quality |
| 98 | +make ruff |
| 99 | +make pylint |
| 100 | +make mypy |
| 101 | + |
| 102 | +# 2. Run unit tests |
| 103 | +make test |
| 104 | + |
| 105 | +# 3. Test migration on SQLite (default .env) |
| 106 | +.venv/bin/alembic -c mcpgateway/alembic.ini upgrade head |
| 107 | +.venv/bin/alembic -c mcpgateway/alembic.ini downgrade -1 |
| 108 | +.venv/bin/alembic -c mcpgateway/alembic.ini upgrade head |
| 109 | + |
| 110 | +# 4. Test migration on PostgreSQL (if available) |
| 111 | +# Set DATABASE_URL=postgresql://... |
| 112 | +# Repeat alembic commands above |
| 113 | + |
| 114 | +# 5. Integration tests (if using --with-integration) |
| 115 | +make test-integration |
| 116 | +``` |
| 117 | + |
| 118 | +## Files Modified |
| 119 | + |
| 120 | +1. **New file**: `mcpgateway/alembic/versions/d21698ae4a19_add_rbac_unique_constraints_race_fix.py` |
| 121 | + - Migration script (320 lines) |
| 122 | + |
| 123 | +2. **Modified**: `mcpgateway/services/role_service.py` |
| 124 | + - Added IntegrityError import |
| 125 | + - Updated `create_role()` with savepoint + refetch pattern (+19 lines) |
| 126 | + - Updated `assign_role_to_user()` with savepoint + refetch pattern (+19 lines) |
| 127 | + |
| 128 | +## Commit Message Template |
| 129 | + |
| 130 | +``` |
| 131 | +fix(rbac): add unique constraints to prevent role/user_role seeding race (#4482) |
| 132 | +
|
| 133 | +Fixes issue #4482 - RBAC role/user_role seeder race when fast-path skips |
| 134 | +advisory lock. |
| 135 | +
|
| 136 | +This fix implements defense-in-depth to prevent duplicate active roles |
| 137 | +and user role assignments when multiple replicas/workers bootstrap the |
| 138 | +database concurrently. |
| 139 | +
|
| 140 | +Changes: |
| 141 | +- Add database-level partial unique indexes on roles and user_roles tables |
| 142 | +- Update RoleService to handle IntegrityError gracefully with savepoint pattern |
| 143 | +- Deduplicate any existing duplicate active rows in migration (soft-delete) |
| 144 | +- Support both PostgreSQL and SQLite databases |
| 145 | +
|
| 146 | +The database constraints are the ultimate authority on uniqueness, while |
| 147 | +the application-level handling ensures no errors propagate to callers. |
| 148 | +
|
| 149 | +Migration: d21698ae4a19 (idempotent, safe for existing data) |
| 150 | +
|
| 151 | +Signed-off-by: [Your Name] <[your-email]> |
| 152 | +``` |
| 153 | + |
| 154 | +## Related Issues |
| 155 | + |
| 156 | +- Issue #4482: RBAC role/user_role seeder race when fast-path skips advisory lock |
| 157 | +- PR #4444: fix(bootstrap): improve startup reliability for multi-replica deploys (not yet merged) |
| 158 | +- PR #4480: Draft fix mentioned in issue #4482 (this is an independent implementation) |
| 159 | + |
| 160 | +## Notes for Reviewers |
| 161 | + |
| 162 | +1. **This fix is safe to merge NOW** - it works with current code (advisory locks in place) |
| 163 | +2. **Makes PR #4444 safe** - when the fast-path lands, these constraints prevent the race |
| 164 | +3. **Two-layer defense**: DB constraints (authority) + app handling (graceful recovery) |
| 165 | +4. **No performance impact**: Partial indexes only on active rows, minimal overhead |
| 166 | +5. **Audit-friendly**: Soft-deletes preserve history, no data loss |
0 commit comments