Skip to main content
Version: Latest

SaaS Admin Authorization and MenuClaims

Phase 5G (2026-07-17), extended by Phase 6/6A.1's own controllers. Verifies and hardens the real HTTP authorization path for every SaaS admin controller introduced by this initiative (BusinessActivities, AccountingChartTemplates, InventoryAccountCategoryTemplates, CostCenterTemplates, TenantStarterKits, and later ProductCatalogTemplates, ProductCatalogPrerequisiteTemplates) — a verification-and-hardening phase, not a redesign.

Host wiring — v1/MultiTenancy inside Shumoul.Api

Every controller in this guide lives in the Shumoul.Framework.MultiTenancy.Api package, but that package never runs standalone in production — Shumoul.Api (BackEnd) hosts it. Shumoul.Api/Program.cs calls AddInfrustracture(), which calls services.AddMultiTenancy(config), which calls services.AddMultiTenancyControllers()AddMvc().AddApplicationPart(Assembly.Load(... "Shumoul.Framework.MultiTenancy.Api")) — the same AddApplicationPart pattern used elsewhere in the platform (e.g. E-Invoicing, Delivery Integration). This mounts every controller in that assembly, including all of this initiative's admin controllers, as live, routable endpoints inside Shumoul.Api, under the v1/MultiTenancy Swagger group ([ApiExplorerSettings(GroupName = "MultiTenancy")]).

A documented, corrected finding: an earlier verification pass (referenced in this initiative's phase history as "Phase 5F") concluded that no host could produce a genuinely-authorized call to any SaaS admin controller, reasoning that Shumoul.Api's Program.cs never called AddMultiTenancy(). Direct re-inspection found this specific claim incorrect — it does call it, transitively, and the permission-service DI conflict that would have actually broken authorization (OnboardingCurrentUserPermissionsService shadowing the real CurrentUserPermissionsService) had already been found and fixed in an unrelated, earlier session (TryAddScoped vs AddScoped registration order). The earlier document was corrected with a pointer note; its original text was left intact as historical record rather than deleted. Two genuine gaps were found and fixed in the same phase that made this correction — see below.

MustHavePermission is preserved unweakened on every action across every controller. [AllowAnonymous] appears exactly once in this whole initiative — BusinessActivitiesController.GetActive (needed so the unauthenticated onboarding survey UI can list activities before a tenant login exists) — and once more on the internal apply-bridge endpoints (a different, header-key-based trust model, see Architecture). No SaaS admin CRUD/Apply action is ever anonymous.

TenantAccessGuardBehavior — target-tenant isolation

Permission scoping only answers "who may call this endpoint." Every Preview/Apply action across this initiative accepts an explicit target TenantId, separate from the caller's own ambient tenant — nothing previously checked whether the caller was actually allowed to target that specific tenant. This was a defense-in-depth gap (only root-tenant admins can reach these actions at all today, so not an observed live bypass), closed by a new MediatR pipeline behavior:

internal class TenantAccessGuardBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : ITenantScopedRequest
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var callerIsRoot = _settings.IsRootTenant(_currentTenant.Id);
var targetsOwnTenant = string.Equals(_currentTenant.Id, request.TenantId, StringComparison.OrdinalIgnoreCase);
if (!callerIsRoot && !targetsOwnTenant)
throw new TenantAccessForbiddenException(request.TenantId);
return await next();
}
}
CallerTargetResult
Root tenant, permission grantedAny tenantAllowed
Root tenant, permission grantedOwn tenantAllowed
Non-root tenant admin, permission grantedOwn tenantAllowed
Non-root tenant admin, permission grantedDifferent tenantForbidden (403)
Any caller without permission[MustHavePermission] evaluated first, blocks before this check runs
Unauthenticated401, unchanged

Applies only to requests implementing the new ITenantScopedRequest { string TenantId { get; } } marker — registered as an open generic, zero impact on any other MediatR request. Every Preview/Apply request in this initiative (accounting accounts/mappings, inventory account category, cost centers, Tenant Starter Kit, and — by the same pattern, added when each shipped — product catalog prerequisites/categories/items) implements this marker.

Structurally, every permission in this guide lives under TenancyPermissions.* (not TenantPermissions.*), and ApplicationDbSeeder.SeedAdminUserAsync only ever seeds TenancyPermissions.* for the root tenant — every other tenant's Admin role is seeded from TenantPermissions.*/ PermissionConstants.* only. So only a root-tenant-authenticated user can ever be granted any permission in this guide at all — the "SuperAdmin only" requirement is satisfied using existing constructs, with no new roles or seeder changes needed. (This also means the "non-root caller, own tenant" row in the table above is currently unreachable by any real caller — pure defense-in-depth, documented as a known limitation.)

MenuClaims.jsonShumoul.Infrastructure/MultiTenancy/Seeders/JsonFiles/MenuClaims.json — is a 4-level nested hierarchy: AppSystem[] → Menus[] → Controllers[] → Actions[], each level carrying Name/FName/Key (Systems/Menus/Controllers also carry a packages tier string). The seeder (MenuClaimSeeder) walks these four nested loops and builds one flat MenuClaim row per leaf action, concatenating controller.Key + "." + action.Key" as the claim key.

Every permission constant added by this initiative — under both Shumoul.Domain.Constants.PermissionConstants (BackEnd) and Shumoul.Framework.MultiTenancy.Api.Constants.TenancyPermissions (MultiTenancyApi) — must have a matching MenuClaims.json entry. TenancyPermissions.TenantStarterKits, .ProductCatalogTemplates, and .ProductCatalogPrerequisiteTemplates all have real entries in the JSON file (confirmed present, titled in Arabic/English — e.g. "حزمة تجهيز المشترك" / "Tenant Starter Kits").

Known model limitation: MenuClaimSeeder's AppController model is a flat shape — { Name, FName, Key, Actions: List<AppAction> } — it does not support a nested sub-controller array. Any future permission group that would naturally want a nested-controller shape must instead be modeled as a sibling top-level Controller entry (or a new Menu) until that model is extended; avoid inventing an unsupported nested pattern in new MenuClaims.json rows.

Apply permissions are deliberately kept separate from Edit permissions throughout this initiative (e.g. AccountingChartTemplates.ApplyMappings is its own permission, not folded into .Edit) — applying a template to a tenant is a materially more sensitive action than editing the SaaS-side template catalog, and is gated accordingly. Legacy/orphaned MenuClaims.json rows are reported by tooling but never automatically deleted — removal is always a deliberate, separate decision.