Skip to main content
Version: Latest

Permission Role Backfill

A structural fix, added 2026-07-19, for the real permission gap the Staging Live Apply Verification found: the root tenant's Admin role had zero grants for the 6 SaaS-admin permission groups this whole initiative added. That pass closed the immediate instance with a one-off SQL insert; this page documents the permanent, structural fix that replaces it and prevents the same class of gap for any future permission addition — not just this initiative's own permissions.

Problem discovered

ApplicationDbSeeder.SeedRolesAsync assigns every current PermissionConstants/TenancyPermissions value to the root tenant's Admin role via the already-idempotent AssignPermissionsToRoleAsync (insert- only, skips anything already granted). The catch: SeedRolesAsync is itself wrapped in a named, SeedsHistory-gated, run-once-per-tenant step ("Roles"). Once that step has succeeded for a tenant, it never runs again — so any permission group added to the codebase after that first run was silently never granted, no matter how many times the app restarted afterward.

Root cause — deeper than expected

Investigating this surfaced a second, more fundamental fact: ApplicationDbSeeder.SeedDatabaseAsync (and therefore SeedRolesAsync) has no invocation path for the root tenant at all in the current codebase. DatabaseInitializer.InitializeApplicationDbForTenantAsync — the only caller of ApplicationDbSeeder.SeedDatabaseAsync — explicitly returns immediately for the root tenant (if (_tenancySettings.IsRootTenant(tenant.Id)) return;), and the root tenant is excluded from the per-tenant provisioning loop entirely (Where(x => x.Id != _tenancySettings.Identifier...)). Root's own ApplicationDbContext schema only ever gets migrations applied at startup (MigrateRootTenantApplicationDbAsync) — never seeding. The root Admin role's original permission grants were established once, long ago, through a process no longer reachable from Program.cs today. Live verification against the real dev database found this role's permission grants had drifted far beyond just the 6 initiative groups — see "Manual verification results" below.

Implementation

Two new files, both in the BackEnd repo (Shumoul.Saas.Api) — no MultiTenancyApi changes were needed, since TenancyPermissions is consumed as a plain, already-published constants class:

Shumoul.Application/Utilities/RootAdminPermissionBackfillPlanner.cs — pure, DB-free decision logic. ApplicationDbContext cannot run against an isolated in-memory/SQLite provider (it always forces SQL Server, a known, pre-existing constraint in this codebase), so the actual insert-vs-skip decision is extracted here to stay fully unit-testable, matching the same convention already used elsewhere for the same constraint (e.g. TenantEntitlementProvisioningServiceTests).

  • ComputeMissingPermissions(validPermissions, grantedPermissions) — a plain set difference. Returns exactly the valid values absent from granted; a granted value no longer in the valid set (an obsolete grant) is never returned and never touched.
  • IsInBackfillScope(tenantId, rootTenantIdentifier, roleName, allowedRootRoleNames) — documents the scope policy as pure, testable logic (root tenant AND allow-listed role name, both required), available for any future caller that needs a runtime scope check.

Shumoul.Infrastructure/Persistence/Initialization/RootAdminPermissionBackfillService.cs — the thin EF-touching orchestration:

  1. Reflects the two current permission sources exactly like the seeder already does: typeof(PermissionConstants).GetNestedClassesStaticStringValues() + typeof(TenancyPermissions).GetNestedClassesStaticStringValues(), deduplicated.
  2. Queries ApplicationDbContext.Roles for the role-name allowlist (see below).
  3. For each matched role, loads its currently-granted RolePermissions rows (ClaimType == AppClaims.Permission) and calls the pure planner.
  4. Inserts exactly the missing rows (ApplicationDbContext.RoleClaims.Add(...), one SaveChangesAsync).
  5. Logs one safe summary line — roles scanned, permissions considered, inserted count, already-granted count. No secrets, no permission values themselves in the log.

Where it runs — no one-time gate. Wired into DatabaseInitializer.InitializeDatabasesAsync (Shumoul.Infrastructure/Persistence/Initialization/DatabaseInitializer.cs), immediately after MigrateRootTenantApplicationDbAsync, on every application startup — deliberately not wrapped in the SeedsHistory/ExecuteSeedIfNeededAsync pattern that caused the original gap. A newly-added permission group is picked up automatically the next time the process starts; no code change is required per future group.

A second real bug found and fixed while wiring this in

The first working version of RootAdminPermissionBackfillService silently no-opped: it carried a runtime ITenantInfo-based root-tenant check that, in the bare startup scope InitializeDatabasesAsync runs in (no HTTP request in flight), never resolves to the root tenant's identifier — the check quietly returned without ever running (logged at Debug level, invisible at the app's Information threshold). Removing that redundant check surfaced a second issue: any query against ApplicationDbContext that goes through a tenant-scoped global filter (unlike MigrateRootTenantApplicationDbAsync, which only calls _dbContext.Database migration APIs and never touches a query filter at all) throws a NullReferenceException in this same bare scope — the exact class of bug already documented in ApplicationDbContext.OnModelCreating's OAuthClient/OAuthGrant exclusion comment ("a query filter that references TenantInfo.Id → NRE when TenantInfo is null"). The fix mirrors exactly what DatabaseInitializer.InitializeApplicationDbForTenantAsync already does for a customer tenant: set the ambient IMultiTenantContextAccessor.MultiTenantContext to root, then resolve a fresh scope and construct the backfill service from that scope — a service (and its ApplicationDbContext) resolved before the ambient context is set never picks it up, which is why the fix does not reuse a constructor- injected instance.

Safety policy

RuleHow it's enforced
Only the platform/root tenant is ever affectedStructural: the only caller in the whole codebase is DatabaseInitializer.InitializeDatabasesAsync's root-only section, run once against root's own ApplicationDbContext, before the per-tenant customer loop even begins
Customer tenant roles never receive platform permissionsSame structural guarantee — this service has no code path that ever touches a customer tenant's database
Role allowlist is a minimum, explicit, documented setAllowedRootRoleNames = { RoleConstants.Admin } — the only role ApplicationDbSeeder.SeedRolesAsync itself has ever granted TenancyPermissions to. Not expanded to "Owner" or any other role without the same seeder evidence — extending this list is a security-relevant decision, not a routine change
Insert-missing-onlyComputeMissingPermissions is a one-directional set difference; the service only ever calls .Add(...), never .Remove(...) or .Update(...)
No deletes, no overwritesConfirmed by code (no delete/update call exists anywhere in the service) and by live verification (pre-existing grants and pre-existing duplicate rows — see below — were left completely untouched)
Obsolete/orphaned permissions are never touchedA granted value no longer present in current PermissionConstants/TenancyPermissions is simply invisible to the planner — never flagged, never removed
No exception breaks startup for an empty/missing resultZero valid permissions found, or zero allow-listed roles found, both log a Warning and return — never throw. Only a genuine, unexpected database error propagates

Tests

Shumoul.Application.Tests/ServicesTests/RootAdminPermissionBackfillPlannerTests.cs — 12 tests, all DB-free, covering: inserts exactly the missing values; idempotency (nothing to insert once granted matches valid); never returns obsolete granted values not in the current valid set; detects a brand-new future permission immediately (no new seed marker needed — simulates the exact "add a permission group later" scenario this whole fix exists for); empty valid/granted set handling; no duplicate output when the valid source itself has duplicate values; scope check true only for root tenant + allow-listed role, false for a customer tenant even with a matching role name, false for a root-tenant role not on the allowlist, false when the allowlist doesn't contain the role at all, false for a null/empty tenant id.

Shumoul.Application.Tests/ServicesTests/DatabaseInitializerConnectionTests.cs — unchanged, still passing (this class's constructor signature was not touched by the final design; an earlier revision briefly added a parameter here and reverted it once the tenant-context bug was found and fixed instead).

MenuClaims/permission-discovery regression tests (MenuClaimsPermissionSyncTests, PermissionSeedingIncludesMultiTenancyPermissionsTests) — unaffected, still passing; this fix reads the same reflection sources they already assert against but adds no new permission constants of its own.

Manual verification results (real dev database)

Run against the same dev database used throughout this initiative (SHUMOUL-DEV-MNG), via a locally-run Shumoul.Api process (the same safe, ephemeral-environment-variable technique from the Staging Live Apply Verification — no secrets committed, no production URL involved):

CheckResult
Migration statusUnchanged — no new migration added or required; this is a data-only fix
Root Admin role permission count, before any backfill this pass1790 (after the earlier manual 45-row fix from the live-apply pass)
First real service run — roles scanned1 (Admin)
First real service run — current valid permissions considered2465 (PermissionConstants + TenancyPermissions, deduplicated)
First real service run — inserted1136
First real service run — already granted1329
Root Admin role permission count, after first run2926
Second run (idempotency check) — inserted0
Second run — already granted2465
Root Admin role permission count, after second run2926 (unchanged)
Customer tenant role checkStructurally impossible to reach — this service never queries a customer tenant's database at all; tenant 555001's own dbo.RolePermissions (a completely separate physical database) was not queried or touched by this change

A genuinely bigger fix than originally scoped. The 1136 inserted permissions are far more than just the 6 initiative groups (45 permissions) fixed manually in the prior pass — they cover the entire historical permission surface added to PermissionConstants since whenever the root Admin role was originally seeded (a process no longer reachable in the current codebase, per the root cause above). This is the correct, intended behavior of a real backfill mechanism, not scope creep: the task's own goal was "detect missing permissions for the root/platform admin role(s) ... whenever new permission groups ... are added in the future" — this fix satisfies that goal for every past and future permission addition, not only the 6 groups that prompted it.

A pre-existing, unrelated finding — not touched. 31 permission values were found with exactly 2 rows each for the Admin role, both timestamped 2025-03-18 — long before this fix (and before the live-apply verification pass). This is a pre-existing duplication from whenever the role was originally seeded, not something this service introduced (confirmed: it only ever adds a value once, via the same HashSet.Contains check that already treats either duplicate as "already granted"). Left exactly as-is, per "no deletes" — removing them is a separate, deliberate cleanup decision this task's scope does not cover.

Known limitations

  • The role allowlist (RoleConstants.Admin only) matches the only role the historical seeder ever granted TenancyPermissions to. If a future initiative introduces a second true platform-admin role (e.g. a narrower "SuperAdmin" or "Owner" variant that should also receive these permissions), the allowlist must be extended deliberately — this is called out explicitly in the service's own doc comment as a security-relevant decision, not something to change silently.
  • This fix does not attempt to clean up the pre-existing duplicate RolePermissions rows noted above.
  • This fix does not change how permissions are seeded for customer tenants at all (TenantPermissions via SeedRolesAsync's non-root branch, still gated by the same one-time SeedsHistory mechanism) — that remains exactly as it was; extending the same backfill approach to customer tenants was deliberately out of scope for this task.