Dynamic RBAC¶
Production-grade, permission-based Role-Based Access Control, additive on top of the pre-existing
User.IsAdmin/User.IsSeller single-role model. Introduces Role, Permission, UserRole,
RolePermission, RoleAudit, UserRoleAudit, and a dedicated SuperAdmin tier with mandatory
MFA and structural last-holder protection.
Why dynamic¶
Before this tranche, authorization was [Authorize(Roles = "Admin")]-style: every guarded action
named a hard-coded role STRING, and a user's role was a single derived value
(IsAdmin ? "Admin" : IsSeller ? "Seller" : "Customer") baked into the JWT at login. Adding a new
role that should also be allowed into an existing action meant hunting down and editing every
[Authorize(Roles=...)] attribute that should now also include it, and a user could hold at most
one role.
This tranche makes authorization permission-based: a Role is a named, data-driven collection of
Permissions (Users, Roles, Permissions, UserRoles, RolePermissions - see "Schema" below),
and guarded actions check a PERMISSION KEY ([RequirePermission(Permissions.Seller.OrdersRead)]),
never a role name. Adding a new role that should be allowed into an action is now a data change
(grant it the permission), not a code change. Users may hold multiple roles simultaneously.
Required roles¶
Customer, Seller, CustomerSupport, Logistics, Finance, Compliance, CatalogModerator,
Admin, SuperAdmin - see ECommerce.Domain.Rbac.RoleNames. These are the only roles the
AddDynamicRbac migration seeds; the model itself is generic enough to support an admin-created
custom role in the future (Role.IsSystemRole = false), but no such creation flow beyond
CreateRoleCommand exists in the UI yet.
Schema¶
| Table | Purpose |
|---|---|
Roles |
A named role. GrantsAllPermissions (SuperAdmin only) resolves the full live permission catalog dynamically instead of a fixed set - see "SuperAdmin" below. Version is an optimistic concurrency token, incremented by every domain mutation method (UpdateDescription/Activate/Deactivate). |
Permissions |
A single named capability, Key (e.g. seller.orders.read) is what code checks - see ECommerce.Domain.Rbac.Permissions. |
UserRoles |
Current-state grant of a Role to a User. Revocation sets IsActive = false (never deletes the row) so "who granted/revoked this, when" survives for support/audit purposes even though UserRoleAudit is the actual immutable trail. Version is a concurrency token. |
RolePermissions |
Current-state grant of a Permission to a Role. Never created for a GrantsAllPermissions role. |
RoleAudit |
Append-only: every Role mutation (created, description updated, activated/deactivated, permission granted/revoked). Actor, target RoleId, timestamp (CreatedAt), before/after JSON. |
UserRoleAudit |
Append-only: every grant/revoke of a Role to/from a User. Actor, target UserId+RoleId, timestamp, before/after JSON. |
Plus three additive columns on the pre-existing Users table: IsActive (account-level
enable/disable, distinct from a role revocation - see "Last-SuperAdmin protection"), MfaEnabled,
MfaSecretEncrypted (AES-256-GCM ciphertext, never plaintext).
Both audit tables are immutable at the application level only (no Update/Delete method
exists on either entity, mirroring SellerKycAuditRecord's/ProductComplianceAuditRecord's own
established precedent in this codebase) - there is no database-level WORM/append-only constraint.
Permission model¶
Permission keys are area.action, lowercase, dot-separated - see ECommerce.Domain.Rbac.Permissions
for the full catalog (41 permissions across Customer/Seller/CustomerSupport/Logistics/Finance/
Compliance/CatalogModerator/Admin/Rbac) and ECommerce.Domain.Rbac.RbacSeedData for the
role -> permission-key mapping the AddDynamicRbac migration seeds from. RbacCatalogTests.cs
asserts the declared constants, the catalog, and the seed mapping can never silently drift apart.
Admin holds broad admin.* permissions plus RBAC administration (rbac.roles.manage,
rbac.permissions.manage, rbac.user_roles.manage, rbac.audit.read) but deliberately never
rbac.superadmin.manage - the one permission that gates granting/revoking the SuperAdmin role
itself. This is the literal data behind "Admin cannot grant SuperAdmin" / "only SuperAdmin may
grant/revoke SuperAdmin".
IPermissionAuthorizationService (PermissionAuthorizationService) is the single authoritative
resolver:
HasPermissionAsync(userId, key)/GetEffectivePermissionsAsync(userId)- unions permission keys across every ACTIVE role grant whose Role is itself active. Never trusts a client-supplied role/permission claim - always re-resolves from the database/cache by the authenticatedsubclaim's user id only.- Fail closed: any exception during resolution (DB outage, cache outage) is caught internally and treated as "zero permissions" - never as "allow". A user with no active roles also resolves to zero permissions, never an error.
- Client-supplied trust: the JWT DOES carry
ClaimTypes.Roleentries for every active role (seeTokenService.GenerateJwtAndRawRefreshTokenAsync) so pre-existing[Authorize(Roles="X")]attributes keep working unchanged - but this is coarse, token-lifetime-scoped authorization only. The AUTHORITATIVE, revocation-sensitive check is always permission-based and always re-resolved server-side per request, never satisfied from those claims.
[RequirePermission(key)] (ECommerce.API.Filters.RequirePermissionAttribute) is an
IAsyncAuthorizationFilter (not the standard ASP.NET Core named-policy mechanism, which expects a
fixed pre-registered policy set) that resolves the caller's user id from the authenticated
principal and calls IPermissionAuthorizationService.HasPermissionAsync - 401 if unauthenticated,
403 if denied or if resolution throws for any reason.
Permission cache & multi-replica invalidation¶
Effective permissions are cached per user and per User.AuthVersion
(rbac:perms:{userId}:{authVersion}, ICacheService - Redis in any environment with
ConnectionStrings:Redis configured, AddDistributedMemoryCache fallback locally) with a
5-minute TTL backstop. This AuthVersion scoping is the actual security boundary (P1-3): the
user's current IsActive/AuthVersion is read from the database on EVERY call - cache hit or
miss - and only a cache entry whose key matches the CURRENT AuthVersion is ever served. Any
AuthVersion-incrementing mutation (role grant/revoke, role permission grant/revoke, role/user
activation toggle, MFA enroll/replace/disable, production bootstrap) therefore makes every
previously cached entry for that user unreachable immediately, without depending on a cache
DEL succeeding:
InvalidateUserAsync(userId)/InvalidateRoleAsync(roleId)still issue a best-effortDELagainst the (now-orphaned) old key, purely to reclaim memory sooner - this is an optimization, not the security boundary. If Redis is unreachable when these run, or theDELitself fails, correctness is unaffected: the next lookup computes a key scoped to the newAuthVersion, which the stale entry never matches.- If Redis is unavailable entirely,
PermissionAuthorizationServicefails closed (returns no permissions) rather than serving a stale positive - see its own doc comment.
Because the cache is a shared distributed store (Redis), invalidation from one app replica is
immediately visible to every other replica reading the same key - RbacAuthorizationAndCacheTests
and MySqlRealRedisIntegrationTests.MultiReplica_GrantRevokeDeactivate_PropagatesAcrossRealRedis_FailsClosedOnOutage
prove this with two separate PermissionAuthorizationService instances sharing one backing store.
AuthVersion as an authentication boundary¶
User.AuthVersion (an integer, defaulting to 1) is issued as the auth_ver claim on every access
token (TokenService's GenerateJwtAndRawRefreshTokenAsync) and is an ENFORCED part of
authentication, not merely a cache-invalidation hint: Program.cs's JwtBearerEvents
.OnTokenValidated runs on every authenticated request and rejects the token unless the user still
exists, IsActive == true, and the token's auth_ver claim equals the user's CURRENT
AuthVersion - a missing or malformed auth_ver claim is rejected the same way. This means an
already-issued access token stops working the moment AuthVersion is incremented, without waiting
for the token's natural expiry:
- Account deactivation/reactivation (
User.Deactivate/Activate). - MFA enroll, replace, or disable (see "MFA" above) - each increments
AuthVersionas part of the same atomic transaction as the state/audit change (P1-6). - Any RBAC mutation that already increments
AuthVersionfor the affected user(s) - role grant/ revoke, role permission grant/revoke, role/user activation toggle (see "Permission cache" above). - Production SuperAdmin bootstrap.
This check happens independently of, and before, the permission-cache lookup described above - a cached positive permission result can never substitute for it.
SuperAdmin¶
SuperAdmin.GrantsAllPermissions = true - PermissionAuthorizationService resolves the FULL live
Permissions table for any such role every time, never a fixed snapshot, so a new permission added
after SuperAdmin was created is automatically included with no migration or explicit grant.
MFA enforcement: AssignRoleToUserCommandHandler refuses to grant the SuperAdmin role to any
user whose MfaEnabled is not already true (SuperAdminMfaRequiredException). An active
SuperAdmin may never disable their own MFA (DisableMfaCommandHandler checks their live SuperAdmin
grant and throws SuperAdminMfaCannotBeDisabledException). MFA itself is real RFC 6238 TOTP
(MfaService - any standard authenticator app), the shared secret encrypted at rest with
AES-256-GCM (IMfaSecretProtector, key from Mfa:EncryptionKey runtime config, never source -
Program.ValidateRuntimeConfiguration fails startup fast if it is missing/invalid/a placeholder
outside Development).
Login for an MFA-enabled user is a two-step exchange: POST /api/auth/login with the correct
password returns { requiresMfa: true, challengeToken } (MfaChallengeRequiredException) instead
of tokens; POST /api/auth/mfa/verify with that token + a valid TOTP code issues the real JWT/
refresh token pair. The challenge is a single-use, 5-minute cache entry keyed by a random token -
never the user id directly, never guessable from the login response alone beyond the token itself.
Self-escalation / privilege-escalation prevention:
- An actor can never grant a role to their OWN account (
SelfRoleEscalationException), regardless of which permissions they hold - self-revocation is unrestricted (de-escalation is not the risk this guards against). - Only a caller holding
Permissions.Rbac.ManageSuperAdmin(SuperAdmin only, by construction - see "Permission model" above) may grant OR revoke the SuperAdmin role -SuperAdminGrantRestrictedExceptionotherwise, even for a caller who otherwise holdsRbac.UserRolesManage. Permissions.Rbac.ManageSuperAdminitself can never be individually granted to any role throughGrantPermissionToRoleCommandHandler- the check is unconditional on the permission key, not on whether the caller already holds it, so an existing SuperAdmin is explicitly forbidden from delegating this capability onto an ordinary/custom/cloned role too (doing so would manufacture a second, uncontrolled path to full SuperAdmin authority, bypassing the MFA-required/last-active-SuperAdmin invariants that only the SuperAdmin ROLE grant path enforces).RoleNames.SuperAdmin'sGrantsAllPermissions = trueremains the only way this permission is ever effectively held - there is never aRolePermissionsrow for it.
Non-bypass: SuperAdmin is a coarser, MORE broadly permitted principal, never a code path that skips other subsystems' own integrity checks. Nothing in this tranche special-cases SuperAdmin in Razorpay signature/webhook verification, payment/refund idempotency, checkout fencing, inventory concurrency, seller KYC, statutory compliance, audit integrity, secret handling, or transaction/ concurrency safeguards - those all continue to run unconditionally regardless of the caller's role.
Last-active-SuperAdmin protection¶
The platform must always have at least one active, usable SuperAdmin. Two independent atomic
guards, both real single-statement conditional UPDATEs (never a separate
check-then-write, which a concurrent second actor could always race) - see
IRbacRepository.TryRevokeUserRoleAsync/TryDeactivateUserAsync's own doc comments:
- Revoking the SuperAdmin role from a user (
RevokeRoleFromUserCommandHandler) - the conditional UPDATE only flipsIsActiveif, for a SuperAdmin grant specifically, at least one OTHER user currently holds an active SuperAdmin grant AND has an active account. OtherwiseLastActiveSuperAdminException. - Deactivating a user's account (
SetUserActiveCommandHandler) - same reasoning, scoped to "does this account currently hold an active SuperAdmin grant, and if so is there another active holder".
Both conditions are evaluated by the database AT THE INSTANT the UPDATE statement runs (a
correlated EXISTS/derived-table subquery in the same statement, not an earlier separate read),
closing the TOCTOU window two concurrent revocations of DIFFERENT SuperAdmins would otherwise open
- MySqlRbacConcurrencyTests.RaceE/RaceF prove this against real MySQL/InnoDB: two genuinely
concurrent revoke/deactivate attempts against two different SuperAdmins can never both succeed,
and the platform is never left with zero active SuperAdmins.
Portability note: the count subquery deliberately reads from the SAME table the outer
statement updates (UserRoles/Users), wrapped in an extra derived-table SELECT - real MySQL/
InnoDB refuses "You can't specify target table for update in a FROM clause" for a bare subquery
against the table being updated (even aliased); wrapping it in a materialized derived table is
MySQL's own documented workaround, and is equally valid, unremarkable SQL on SQLite (used in unit
tests). The UPDATE target table itself is also never aliased (UPDATE UserRoles SET ..., never
UPDATE UserRoles ur SET ...) - SQLite's UPDATE grammar does not accept a table alias on the
statement being updated the way MySQL does, mirroring TryActivateSellerProductAsync's identical
convention elsewhere in this codebase.
Concurrency¶
Role.Version/UserRole.Version are genuine EF Core optimistic-concurrency tokens
(.IsConcurrencyToken() on a plain int, portable across MySQL/SQLite). Every domain mutation
method that changes a concurrency-relevant field explicitly increments Version itself
(UpdateDescription/Activate/Deactivate/Revoke) - an IsConcurrencyToken() column that is
never actually changed by application code provides NO real protection, since EF's own conflict
detection compares the ORIGINAL value it read against the CURRENT value at write time; if that
column's value never changes, two writers editing different OTHER columns can never be detected as
conflicting. MySqlRbacConcurrencyTests.ConcurrentRoleDescriptionUpdates_RealInnoDb_... and the
equivalent SQLite test both prove a genuine DbUpdateConcurrencyException on the second writer.
Migration strategy¶
AddDynamicRbac is additive only: 6 new tables, 3 new columns on Users
(IsActive - defaults to true via an explicit HasDefaultValue(true) in UserConfiguration,
critical for not silently deactivating every existing account the moment this migration runs;
MfaEnabled, MfaSecretEncrypted), never a drop/rename. It seeds the fixed role/permission
catalog from RbacSeedData/Permissions.Catalog (so the seed data and the code that checks
against it can never drift apart), then safely backfills every EXISTING user into exactly the role
their pre-existing IsAdmin/IsSeller flags already implied - the SAME precedence
TokenService's legacy single-role JWT claim used to derive
(IsAdmin ? Admin : IsSeller ? Seller : Customer) - so no existing account's effective access
changes as a result of this migration. It does NOT grant SuperAdmin to any existing Admin -
MySqlAddDynamicRbacMigrationTests.IncrementalUpgrade_... asserts this explicitly against real
MySQL, alongside the correct per-user role backfill.
Local SuperAdmin bootstrap¶
The very first SuperAdmin is never created automatically by the migration. Two separate, explicit mechanisms, both restricted to non-Production environments:
--bootstrap-superadminCLI command (Program.RunBootstrapSuperAdminAsync) - readsSuperAdmin:Email/SuperAdmin:Passwordfrom configuration (environment variables, never hardcoded), refuses outright ifASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENTresolves toProduction(no override flag exists - "restricted to controlled local/staging setup" is enforced literally, not merely defaulted), creates the account if it does not already exist, generates a fresh TOTP secret, enables MFA immediately, grants SuperAdmin, and prints the provisioning URI/secret to the console EXACTLY ONCE (never logged, never persisted anywhere but the encrypted column).- Local UAT test account (
superadmin@vayzomart.local/vayzo@2026) - seeded byDbSeeder.SeedLocalUatSuperAdminAsync, wired into the normal Development startup-seeding path (Program.cs, gated bybuilder.Environment.IsDevelopment()in code - never reachable via config alone - ANDDatabase:SeedLocalUatSuperAdmin, defaulttrue). Uses a fixed, documented TOTP secret (DbSeeder.LocalUatSuperAdminMfaSecret) so a UAT tester/script can compute the current 6-digit code deterministically with any standard TOTP tool, without reading application logs. Idempotent - does nothing if the account already exists.
Known limitations (not addressed here, documented)¶
- No admin UI for role/permission management exists yet -
RbacController's REST endpoints are the only surface today. - Custom (non-system) role creation (
CreateRoleCommand) is implemented but not exercised by any onboarding flow - the 9 required roles are the only ones any real user currently holds. RoleAudit/UserRoleAuditimmutability is an application-level guarantee only (no database WORM constraint or cryptographic tamper-evidence), matching this codebase's existingSellerKycAuditRecord/ProductComplianceAuditRecordprecedent.- The legacy
User.IsAdmin/IsSellerfields are not deprecated or removed -TokenServicestill falls back to deriving a single legacy role from them if a user somehow has zero RBACUserRolerows (should not happen once the migration's backfill has run). Consolidating the two models is recommended future work, deliberately out of scope here.