fix(rbac): add unique constraints for role/user_role race (#4482) #4636
fix(rbac): add unique constraints for role/user_role race (#4482) #4636rakdutta wants to merge 22 commits into
Conversation
0082c7a to
4635561
Compare
a824bd7 to
9d3e8f8
Compare
|
@gandhipratik203 can you please review this PR. |
aed6af1 to
a0499ac
Compare
0615571 to
e6f47d3
Compare
|
Thanks for the previous changes. Two smaller follow-ups I noticed:
|
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for tackling this — the two-layer defense (DB partial unique indexes + savepoint/retry) is the right shape for this bug, and the migration correctly dedupes before constraining. I ran the migration SQL and the concurrency-handling path independently (including a real multi-threaded SQLite stress test) and the core mechanism holds up well.
Blocking
- Lint gate fails:
pylintreports 6 newW1203(logging-fstring-interpolation) violations inrole_service.pyat lines 239, 245, 253, 696, 705, 713 — the new log calls use f-strings where the rest of the file (and this PR's own unmodified lines) use lazy%sformatting, e.g.logger.info(f"Created role: {role.name}...")vs. the pre-existinglogger.info("Created role: %s...", role.name, ...). This tripsmake pylint, part of the requiredmake ruff bandit interrogate pylint verifygate. Straightforward fix — revert those calls to lazy formatting.
Suggestions (non-blocking)
- Migration has no dedicated test.
d21698ae4a19...pyis the largest new file here (285 lines, dialect-branching SQL + idempotency checks) but isn't exercised by any test. The repo has a clear convention for this (e.g.tests/unit/mcpgateway/db/test_token_uniqueness_migration.py) — importing the module and runningupgrade()/downgrade()against a scratch schema. Worth adding, given this file has the most novel logic in the PR. - Race tests are all single-threaded mocks. The 8 new "concurrent" tests simulate the race via
patch.object+ call counters rather than real concurrent threads/connections against the actual constraint. A single true multi-threaded (or multiprocess) test hammeringcreate_role/assign_role_to_useragainst a real SQLite/Postgres unique index would give much stronger confidence this stays fixed under refactors. - CHANGELOG.md isn't updated — this fixes a real production 500 under concurrent bootstrap, which seems worth a line given the repo tracks fixes of similar severity there.
- Minor: the PR description's "Solution" section doesn't mention that expired-but-active assignments are now auto soft-deleted and replaced on re-grant (previously blocked with
ValueError). Small but real behavior change worth calling out explicitly.
Nothing security-sensitive stood out — checked the new ValueError messages that embed raw IntegrityError text and confirmed they're routed through safe_error_detail() in rbac.py, so no leak to non-debug callers.
cd0abb7 to
1a092a3
Compare
|
@msureshkumar88 @gandhipratik203 — all review comments addressed, ready for re-review. Blocking fix (msureshkumar88)
Review comments (gandhipratik203)
Non-blocking suggestions (msureshkumar88)
Also fixed a |
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for addressing all the prior feedback — the remap-before-dedupe ordering, the unexpired-preference dedupe, the W1203 lint fixes, and the new migration test suite all check out. Ran the full test surface locally (migration tests, duplicate-handling integration tests, race-condition tests, role_service unit tests) and everything is green, and pylint on role_service.py is 10.00/10.
One thing to clean up before merge:
Required
CHANGELOG.md re-adds two entries that are already documented under the released [1.0.5] section.
The [Unreleased] section this PR adds to (commit e0f7f751a) includes:
### Added—POST /v1/tools/generate-schemas-from-openapi(#5142)### Security— cross-environment JWT fix (GHSA-vgf8-3685-66j9)
Both are already fully documented under ## [1.0.5] - 2026-07-07 further down the same file (lines ~51 and ~74) — that release shipped 10 days ago. Re-adding them to [Unreleased] will misleadingly re-announce already-shipped features as still-unreleased on the next release cut, and they're unrelated to the RBAC fix this PR is about. Likely a stale-branch/rebase artifact.
Please trim [Unreleased] down to just the RBAC ### Fixed entry this PR actually adds.
Non-blocking notes (no action required)
assign_role_to_user'sIntegrityErrorrefetch path: if the refetched "winner" row is itself already expired (both racers create near-simultaneously-expiring assignments), the code soft-deletes it and raisesValueErrorrather than retrying — surfaces as an HTTP 400 for a caller who did nothing wrong. Very low likelihood, just flagging for awareness.- The new PostgreSQL-specific dedupe/remap SQL in the migration (
UPDATE ... FROM ... JOIN ...with window functions) has no test coverage — all 22 new tests run against SQLite only. Matches existing repo convention (other migrations aren't PG-tested either), so not a PR-specific gap, but worth knowing since it's a data-mutating migration. AsyncMockimported but unused in two test functions intest_role_service_duplicate_handling.py.
Nothing else blocking — the core fix (partial unique indexes + savepoint/retry) is solid and well-tested.
|
Thanks @rakdutta, the pr is good to go. We have all the needed approvals. There is a merge conflict. Once it's rebased, we are good to merge. |
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Remove review-only documentation that should not be in the main codebase. Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
… service Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Add partial unique indexes to Role and UserRole ORM models via __table_args__ to ensure test databases created with create_all() have the same constraints as production databases created through Alembic migrations. This fixes integration test coverage by allowing IntegrityError race condition handling tests to trigger real constraint violations. Changes: - Role: Add uq_roles_name_scope_active index (one active role per name+scope) - UserRole: Add two indexes for NULL and non-NULL scope_id cases - Simplify test mocks to use natural IntegrityError from database Coverage: 100% on role_service.py changed lines (previously 62%) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…tion tests Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
…handling Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
… heads - Changes down_revision from 9fb98535724d to w7x8y9z0a1b2 - Ensures proper migration chain after rebase with main Signed-off-by: Rakhi Dutta <rakhidutta@users.noreply.github.com>
…e deterministic deduplication Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
…nments, fix W1203 logging, add migration tests Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
… import, remove blank line Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
…mbic heads Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
… remove stale Authors Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
db9c3e5
540124d to
db9c3e5
Compare
|
@gandhipratik203 rebased |
|
Thanks @rakdutta. The PR is in good shape now. @msureshkumar88, @Lang-Akshay We can prioratise this pr for the merge once the merge queue opens up after the release. |
closes #4482 - Adds database-level unique constraints to prevent duplicate RBAC roles and role assignments during concurrent bootstrap.
Problem
Multiple workers can create duplicate system roles (
platform_admin, etc.) and role assignments when bootstrapping concurrently, causingMultipleResultsFoundexceptions → 500 errors.Solution
Two-layer defense:
roles(name, scope)anduser_roles(split for NULL/NOT NULLscope_id)IntegrityError, refetches existing row, returns transparentlyBehavior Change: Re-granting an expired assignment no longer raises an error
Previously,
assign_role_to_user()raisedValueError("User already has this role assignment")even when the existing assignment had already expired, because the active-assignment check did not distinguish expired rows from valid ones.New behavior: if an active assignment exists but
is_expired()returnsTrue, it is automatically soft-deleted (is_active = False) in-place before the new grant is created. This allows the re-grant to proceed cleanly without any manual revocation step.A concurrent-race variant is also handled: if two processes race to soft-delete the expired row simultaneously, the process that loses the race refetches the current state and returns the winner's fresh assignment rather than raising.
Impact: callers that previously caught
ValueErroron re-grant of an expired role will no longer see that error — the call now succeeds and returns the newUserRolerow. Any code that relied on that error to detect "assignment already exists" should instead check the returned object'sexpires_atfield or uselist_user_roles().Changes
d21698ae4a19— Deduplicates existing duplicate active rows (remappinguser_roles.role_idto the kept role before deactivating duplicates, and preferring unexpired/most-recently-granted assignments), then adds partial unique indexes (PostgreSQL + SQLite)role_service.py— Updatedcreate_role()andassign_role_to_user()with savepoint +IntegrityErrorretry handling; fixed W1203 logging violations (lazy%sformatting throughout)tests/unit/mcpgateway/db/test_rbac_unique_constraints_migration.pycovering dedup logic, role_id remapping, expiry-preference ordering, index idempotency, and downgrade### Fixedentry added under[Unreleased]Related