Skip to content

feat: enforce domain-layer authorization on privileged store operations (#69)#75

Merged
andregoepel merged 1 commit into
mainfrom
fix/domain-layer-authorization
Jul 4, 2026
Merged

feat: enforce domain-layer authorization on privileged store operations (#69)#75
andregoepel merged 1 commit into
mainfrom
fix/domain-layer-authorization

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Fixes #69 (the domain-layer half of #41).

Problem

UserStore / RoleStore performed privileged mutations (assign role, delete, restore, role management) with no authorization of their own — the only gate was the page-level [Authorize]. A single forgotten attribute, a permissive host FallbackPolicy, or any caller reaching a store directly became a privilege-escalation path (self-assign Administrator → full takeover). This is #41's tracked root cause, previously closed without a domain-layer fix.

Approach

New IIdentityAuthorizer provides a defense-in-depth check independent of the UI:

  • DB-authoritative admin check — queries the live UserRoleAssignment / Role projection (not claims; per [architecture] Default-deny authorization + defense-in-depth in the domain layer #41 "the acting identity from claims proves identity, never authority"), and fails closed for an unidentified (Guid.Empty) caller.
  • Guards on the operations that are never legitimately anonymous:
    • UserStore.AddToRoleAsync / RemoveFromRoleAsync → require Administrator (throw IdentityAuthorizationException; these return Task). This is the headline escalation vector.
    • UserStore.DeleteAsync → require Administrator or account ownership.
    • UserStore.RestoreAsync and all RoleStore mutations → require Administrator (return IdentityResult.Failed / NotAuthorized).
  • BeginSystemScope() — an explicit, ambient (AsyncLocal) escape hatch so trusted server-side code (seeding, background provisioning, alternative bootstrap) can bypass the checks deliberately.

Why CreateAsync / UpdateAsync are not guarded

ASP.NET Identity legitimately drives them through anonymous flows — registration, password reset, email confirmation, failed-login lockout — with no authenticated principal. Their real authorization is the reset/confirmation token at the UI layer; a role/ownership check in the domain layer would break those flows. This is documented in THREAT-MODEL.md, and the stale-UpdateAsync-overwrite concern remains tracked in #70.

Why bootstrap doesn't deadlock

First-run root creation runs through CreateAsync, which appends the RoleAssigned / RoleCreated events directly (not via the guarded methods), so the first admin is created without needing a pre-existing admin.

Tests

  • New UserStoreAuthorizationTests (9 cases): non-admin and anonymous actors are denied (role ops throw; delete/restore return NotAuthorized); admin and self are allowed; BeginSystemScope() bypasses.
  • Existing persistence/invariant tests run under a permissive authorizer (they don't test authz), so RemoveFromRole_RootUserAdministrator_IsRefused still reaches the root invariant.
  • Full suite: 187 unit + 68 Blazor + 71 integration. Build 0 warnings; CSharpier clean.

Out of scope (documented)

Page-level default-deny (FallbackPolicy / AuthorizeRouteView) remains a host obligation — this PR is the domain-layer defense-in-depth half.

The stores performed privileged mutations with no authorization of their own —
the only gate was the page-level [Authorize]. A forgotten attribute, a permissive
host fallback policy, or any caller reaching the store directly was a
privilege-escalation path (self-assign Administrator → takeover). This is #41's
tracked root cause.

Add defense-in-depth authorization in the domain layer via a new IIdentityAuthorizer:

- DB-authoritative admin check (queries the live projection, not claims), failing
  closed for an unidentified caller.
- Guard the operations that are never legitimately anonymous:
  * UserStore.AddToRoleAsync / RemoveFromRoleAsync — require Administrator (throw
    IdentityAuthorizationException).
  * UserStore.DeleteAsync — require Administrator or account ownership.
  * UserStore.RestoreAsync and all RoleStore mutations — require Administrator
    (return IdentityResult.Failed "NotAuthorized").
- BeginSystemScope() — an explicit, ambient escape hatch so trusted server-side
  code (seeding, bootstrap) can bypass the checks.

CreateAsync and UpdateAsync are intentionally left unguarded: ASP.NET Identity
drives them through anonymous flows (registration, password reset, email
confirmation, lockout), where the authorization is the token at the UI layer.
First-run root creation appends the admin role via direct events (not the guarded
methods), so bootstrap needs no pre-existing admin.

Adds UserStoreAuthorizationTests (deny non-admin/anonymous, allow admin/self,
system-scope bypass); existing persistence tests run under a permissive authorizer.
Updates THREAT-MODEL.md.
@andregoepel
andregoepel merged commit 546b34e into main Jul 4, 2026
3 checks passed
@andregoepel
andregoepel deleted the fix/domain-layer-authorization branch July 6, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] Domain layer performs no authorization (UI-only enforcement) — #41 still unaddressed

1 participant