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:
- Reflects the two current permission sources exactly like the seeder already does:
typeof(PermissionConstants).GetNestedClassesStaticStringValues()+typeof(TenancyPermissions).GetNestedClassesStaticStringValues(), deduplicated. - Queries
ApplicationDbContext.Rolesfor the role-name allowlist (see below). - For each matched role, loads its currently-granted
RolePermissionsrows (ClaimType == AppClaims.Permission) and calls the pure planner. - Inserts exactly the missing rows (
ApplicationDbContext.RoleClaims.Add(...), oneSaveChangesAsync). - 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
| Rule | How it's enforced |
|---|---|
| Only the platform/root tenant is ever affected | Structural: 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 permissions | Same structural guarantee — this service has no code path that ever touches a customer tenant's database |
| Role allowlist is a minimum, explicit, documented set | AllowedRootRoleNames = { 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-only | ComputeMissingPermissions is a one-directional set difference; the service only ever calls .Add(...), never .Remove(...) or .Update(...) |
| No deletes, no overwrites | Confirmed 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 touched | A 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 result | Zero 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):
| Check | Result |
|---|---|
| Migration status | Unchanged — no new migration added or required; this is a data-only fix |
| Root Admin role permission count, before any backfill this pass | 1790 (after the earlier manual 45-row fix from the live-apply pass) |
| First real service run — roles scanned | 1 (Admin) |
| First real service run — current valid permissions considered | 2465 (PermissionConstants + TenancyPermissions, deduplicated) |
| First real service run — inserted | 1136 |
| First real service run — already granted | 1329 |
| Root Admin role permission count, after first run | 2926 |
| Second run (idempotency check) — inserted | 0 |
| Second run — already granted | 2465 |
| Root Admin role permission count, after second run | 2926 (unchanged) |
| Customer tenant role check | Structurally 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.Adminonly) matches the only role the historical seeder ever grantedTenancyPermissionsto. 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
RolePermissionsrows noted above. - This fix does not change how permissions are seeded for customer tenants at all (
TenantPermissionsviaSeedRolesAsync's non-root branch, still gated by the same one-timeSeedsHistorymechanism) — that remains exactly as it was; extending the same backfill approach to customer tenants was deliberately out of scope for this task.
