Skip to content

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 authenticated sub claim'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.Role entries for every active role (see TokenService.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-effort DEL against 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 the DEL itself fails, correctness is unaffected: the next lookup computes a key scoped to the new AuthVersion, which the stale entry never matches.
  • If Redis is unavailable entirely, PermissionAuthorizationService fails 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 AuthVersion as part of the same atomic transaction as the state/audit change (P1-6).
  • Any RBAC mutation that already increments AuthVersion for 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 - SuperAdminGrantRestrictedException otherwise, even for a caller who otherwise holds Rbac.UserRolesManage.
  • Permissions.Rbac.ManageSuperAdmin itself can never be individually granted to any role through GrantPermissionToRoleCommandHandler - 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's GrantsAllPermissions = true remains the only way this permission is ever effectively held - there is never a RolePermissions row 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:

  1. Revoking the SuperAdmin role from a user (RevokeRoleFromUserCommandHandler) - the conditional UPDATE only flips IsActive if, for a SuperAdmin grant specifically, at least one OTHER user currently holds an active SuperAdmin grant AND has an active account. Otherwise LastActiveSuperAdminException.
  2. 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:

  1. --bootstrap-superadmin CLI command (Program.RunBootstrapSuperAdminAsync) - reads SuperAdmin:Email/SuperAdmin:Password from configuration (environment variables, never hardcoded), refuses outright if ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT resolves to Production (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).
  2. Local UAT test account (superadmin@vayzomart.local / vayzo@2026) - seeded by DbSeeder.SeedLocalUatSuperAdminAsync, wired into the normal Development startup-seeding path (Program.cs, gated by builder.Environment.IsDevelopment() in code - never reachable via config alone - AND Database:SeedLocalUatSuperAdmin, default true). 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/UserRoleAudit immutability is an application-level guarantee only (no database WORM constraint or cryptographic tamper-evidence), matching this codebase's existing SellerKycAuditRecord/ProductComplianceAuditRecord precedent.
  • The legacy User.IsAdmin/IsSeller fields are not deprecated or removed - TokenService still falls back to deriving a single legacy role from them if a user somehow has zero RBAC UserRole rows (should not happen once the migration's backfill has run). Consolidating the two models is recommended future work, deliberately out of scope here.