From 576ba64d02e65c68a3327db1b26f3839bad806a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 22:59:01 +0800 Subject: [PATCH] feat: enforce domain-layer authorization on privileged store ops (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- THREAT-MODEL.md | 28 ++- .../Initialization.cs | 1 + .../Roles/RoleStore.cs | 24 ++ .../Services/IIdentityAuthorizer.cs | 39 ++++ .../IdentityAuthorizationException.cs | 10 + .../Services/IdentityAuthorizer.cs | 72 ++++++ .../Users/UserStore.cs | 36 +++ .../Roles/RoleStoreTests.cs | 6 + .../Users/UserStoreAuthorizationTests.cs | 213 ++++++++++++++++++ .../Users/UserStoreTestHelpers.cs | 26 ++- .../Roles/RoleStore.Tests.cs | 11 +- 11 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 src/AndreGoepel.Marten.Identity/Services/IIdentityAuthorizer.cs create mode 100644 src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizationException.cs create mode 100644 src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizer.cs create mode 100644 tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreAuthorizationTests.cs diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index c47670b..30dac08 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -76,6 +76,16 @@ These are enforced by the library itself — you do not need to wire them up. blocked by middleware, so it cannot be used to mint a second root administrator. - **Authorization on built-in pages.** The administration pages require the `Administrator` role; every `/Account/Manage/*` page requires authentication. +- **Domain-layer authorization (defense in depth).** The privileged store operations + re-check authorization themselves, independent of the page `[Authorize]` guards + (#41/#69): assigning/removing a role, restoring a user, and all role management + require the acting user to hold the `Administrator` role (verified against the live + projection, not claims); deleting a user requires administrator authority **or** + account ownership. These fail closed for an unidentified caller. Trusted server-side + code (seeding, bootstrap) can opt out via `IIdentityAuthorizer.BeginSystemScope()`. + *Not* guarded: `CreateAsync` and `UpdateAsync`, which ASP.NET Identity legitimately + drives through **anonymous** flows (registration, password reset, email confirmation, + lockout) — their authorization is the reset/confirmation **token** at the UI layer. - **PII erasure.** Past the retention period the cleanup job erases personal data from the event stream via Marten event-data masking (no raw SQL). - **Parameterized data access** throughout — no SQL injection surface in the library. @@ -98,9 +108,11 @@ guarantees above. This is your deployment checklist. forms rely on the host wiring `AddAntiforgery()` / `UseAntiforgery()` and `MapRazorComponents()` with antiforgery active. - [ ] **Set a default-deny authorization posture for *your* routes.** The library - guards its own pages, but a `FallbackPolicy` requiring an authenticated user + guards its own pages **and** re-checks authorization in the privileged store + operations (#41/#69), but a `FallbackPolicy` requiring an authenticated user (and an `AuthorizeRouteView`) ensures pages you add are not anonymous by - accident. (A library-level default-deny in the domain layer is tracked in #41.) + accident. The domain-layer guard is defense in depth, not a substitute for + guarding your own routes. - [ ] **Put rate limiting / anti-automation in front of login.** Per-account lockout is built in, but **IP-based / global** throttling is not — add ASP.NET Core rate limiting (or an edge WAF) on the login and password-reset paths to blunt @@ -129,10 +141,14 @@ Stated honestly so you can make an informed risk decision. token to work around the Blazor-Server constraint that an interactive circuit cannot set cookies. Removing the client from the auth control flow entirely is the structural fix, tracked in **#40**. -- **The domain layer trusts its callers.** Store methods (`UserStore`/`RoleStore`) - perform privileged operations without re-checking authorization; protection - currently relies on the page-level `[Authorize]` attributes. Default-deny + - defense-in-depth in the domain layer is tracked in **#41**. +- **Domain-layer authorization is partial by necessity.** The privileged store + operations now re-check authorization in the domain layer (#41/#69) — role + assignment/removal, restore, and role management require the `Administrator` role; + delete requires administrator authority or ownership. But `CreateAsync` and + `UpdateAsync` **cannot** be guarded there: ASP.NET Identity drives them through + anonymous flows (registration, password reset, email confirmation, lockout), so + their authorization remains the reset/confirmation **token** and the UI-layer guard. + A stale full-state `UpdateAsync` overwrite is tracked separately in **#70**. - **First-run setup is an unauthenticated bootstrap** by design — see the host obligation above. The actual `/Setup` page is host-provided and outside this library's control. diff --git a/src/AndreGoepel.Marten.Identity/Initialization.cs b/src/AndreGoepel.Marten.Identity/Initialization.cs index 14ad17b..8614d64 100644 --- a/src/AndreGoepel.Marten.Identity/Initialization.cs +++ b/src/AndreGoepel.Marten.Identity/Initialization.cs @@ -72,6 +72,7 @@ var scheme in new[] .AddDefaultTokenProviders(); services.AddScoped(); + services.AddScoped(); services.AddScoped>(); services.AddScoped>(); services.AddSingleton(); diff --git a/src/AndreGoepel.Marten.Identity/Roles/RoleStore.cs b/src/AndreGoepel.Marten.Identity/Roles/RoleStore.cs index abef5c3..47f9e1b 100644 --- a/src/AndreGoepel.Marten.Identity/Roles/RoleStore.cs +++ b/src/AndreGoepel.Marten.Identity/Roles/RoleStore.cs @@ -9,12 +9,24 @@ namespace AndreGoepel.Marten.Identity.Roles; public class RoleStore( IDocumentSession session, ICurrentUserService currentUserService, + IIdentityAuthorizer authorizer, ILogger> logger ) : IQueryableRoleStore where TRole : Role { public IQueryable Roles => session.Query(); + // Defence in depth (#69/#41): role management is an administrator-only operation, + // enforced here independently of any UI [Authorize] guard. + private static IdentityResult NotAuthorized() => + IdentityResult.Failed( + new IdentityError + { + Code = "NotAuthorized", + Description = "Managing roles requires administrator authority.", + } + ); + public void Dispose() { GC.SuppressFinalize(this); @@ -24,6 +36,9 @@ public async Task CreateAsync(TRole role, CancellationToken canc { try { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized(); + if (role.Name == null) return IdentityResult.Failed( new IdentityError() { Description = "Role name cannot be null." } @@ -57,6 +72,9 @@ public async Task UpdateAsync(TRole role, CancellationToken canc { try { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized(); + if (role.Name == null) { return IdentityResult.Failed( @@ -93,6 +111,9 @@ public async Task DeleteAsync(TRole role, CancellationToken canc { try { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized(); + if (!role.Deletable) { return IdentityResult.Failed( @@ -124,6 +145,9 @@ public async Task RestoreAsync( { try { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized(); + session.Events.Append( role.StreamId, new RoleRestored(role.RoleId, await currentUserService.GetCurrentUserIdAsync()) diff --git a/src/AndreGoepel.Marten.Identity/Services/IIdentityAuthorizer.cs b/src/AndreGoepel.Marten.Identity/Services/IIdentityAuthorizer.cs new file mode 100644 index 0000000..817ad48 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Services/IIdentityAuthorizer.cs @@ -0,0 +1,39 @@ +namespace AndreGoepel.Marten.Identity.Services; + +/// +/// Domain-layer authorization for privileged identity store operations (#69/#41). +/// +/// The stores are a reusable persistence layer; the page-level [Authorize] +/// attributes are the primary guard, but they are a single point of failure — a +/// forgotten attribute, a permissive host fallback policy, or any caller reaching a +/// store directly becomes a privilege-escalation path. This service is the +/// defence-in-depth backstop: privileged operations re-check authorization here, +/// independent of the UI. +/// +/// +/// It also provides an explicit escape hatch — — for +/// trusted server-side code (seeding, background provisioning, an alternative first-run +/// bootstrap) that legitimately acts without an authenticated administrator. +/// +/// +public interface IIdentityAuthorizer +{ + /// + /// Enters a trusted scope in which the domain authorization checks are bypassed. The + /// scope is ambient (it flows across awaits within the same execution context) and + /// ends when the returned handle is disposed. Use only from trusted server-side code + /// you control — never in response to untrusted input. + /// + IDisposable BeginSystemScope(); + + /// True while executing inside a . + bool IsSystemScope { get; } + + /// + /// True when the operation may act with administrator authority: inside a system + /// scope, or when the current user (from ) holds the + /// non-deleted Administrator role. Fails closed — returns false — when there is + /// no identified current user. + /// + Task IsCurrentUserAdministratorAsync(CancellationToken cancellationToken = default); +} diff --git a/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizationException.cs b/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizationException.cs new file mode 100644 index 0000000..ee87d4a --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizationException.cs @@ -0,0 +1,10 @@ +namespace AndreGoepel.Marten.Identity.Services; + +/// +/// Thrown when a privileged identity store operation that returns no result — the +/// role-assignment methods (IUserRoleStore) — is invoked without the required +/// authorization (#69/#41). Operations that return +/// surface the same condition +/// as a failed result instead. +/// +public sealed class IdentityAuthorizationException(string message) : Exception(message); diff --git a/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizer.cs b/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizer.cs new file mode 100644 index 0000000..303467a --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizer.cs @@ -0,0 +1,72 @@ +using AndreGoepel.Marten.Identity.Roles; +using AndreGoepel.Marten.Identity.UserRoles; +using Marten; +using RoleNames = AndreGoepel.Marten.Identity.Roles.Roles; + +namespace AndreGoepel.Marten.Identity.Services; + +/// +public sealed class IdentityAuthorizer( + ICurrentUserService currentUserService, + IQuerySession querySession +) : IIdentityAuthorizer +{ + private static readonly string NormalizedAdministrator = + RoleNames.Administrator.ToUpperInvariant(); + + // Ambient across the async flow; each execution context carries its own value, so a + // system scope on one request never bleeds into another. + private static readonly AsyncLocal SystemScopeFlag = new(); + + public bool IsSystemScope => SystemScopeFlag.Value; + + public IDisposable BeginSystemScope() + { + var previous = SystemScopeFlag.Value; + SystemScopeFlag.Value = true; + return new Scope(() => SystemScopeFlag.Value = previous); + } + + public async Task IsCurrentUserAdministratorAsync( + CancellationToken cancellationToken = default + ) + { + if (IsSystemScope) + return true; + + var actor = await currentUserService.GetCurrentUserIdAsync(cancellationToken); + + // Fail closed: an unidentified caller is never treated as an administrator. + if (actor.Value == Guid.Empty) + return false; + + // DB-authoritative: check the live projection, not claims (#41 — the acting + // identity from claims proves identity, never authority). + var adminRole = await querySession + .Query() + .FirstOrDefaultAsync( + r => r.NormalizedName == NormalizedAdministrator && !r.Deleted, + cancellationToken + ); + if (adminRole is null) + return false; + + return await querySession + .Query() + .AnyAsync( + a => a.UserGuid == actor.Value && a.RoleGuid == adminRole.RoleId, + cancellationToken + ); + } + + private sealed class Scope(Action onDispose) : IDisposable + { + private Action? _onDispose = onDispose; + + public void Dispose() + { + _onDispose?.Invoke(); + _onDispose = null; + } + } +} diff --git a/src/AndreGoepel.Marten.Identity/Users/UserStore.cs b/src/AndreGoepel.Marten.Identity/Users/UserStore.cs index bd6aaad..8165582 100644 --- a/src/AndreGoepel.Marten.Identity/Users/UserStore.cs +++ b/src/AndreGoepel.Marten.Identity/Users/UserStore.cs @@ -19,6 +19,7 @@ public class UserStore( IQuerySession querySession, IDataProtectionProvider dataProtectionProvider, ICurrentUserService currentUserService, + IIdentityAuthorizer authorizer, IOptions identityOptions, ILogger> logger ) @@ -37,6 +38,11 @@ ILogger> logger { private const string UserDataProtectionPurpose = "UserDataProtection"; + private static IdentityResult NotAuthorized(string description) => + IdentityResult.Failed( + new IdentityError { Code = "NotAuthorized", Description = description } + ); + public IQueryable Users => querySession.Query(); public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken) => @@ -253,6 +259,15 @@ public async Task DeleteAsync(TUser user, CancellationToken canc { try { + // Defence in depth (#69/#41): deleting an account requires administrator + // authority or account ownership, independent of any UI [Authorize] guard. + var actor = await currentUserService.GetCurrentUserIdAsync(cancellationToken); + var isSelf = actor.Value != Guid.Empty && actor.Value == user.StreamId; + if (!isSelf && !await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized( + "Deleting a user requires administrator authority or account ownership." + ); + if (!user.Deletable) { return IdentityResult.Failed( @@ -287,6 +302,11 @@ public async Task RestoreAsync( { try { + // Defence in depth (#69/#41): restoring a soft-deleted account is an + // administrator-only operation. + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return NotAuthorized("Restoring a user requires administrator authority."); + var userId = UserId.Parse(user.Id); var stream = await querySession.Events.FetchStreamAsync( @@ -676,6 +696,15 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Role name cannot be null or empty.", nameof(roleName)); + // Defence in depth (#69/#41): assigning a role is an administrator-only operation, + // independent of any UI [Authorize] guard. This is the primary escalation vector + // (self-assigning Administrator), so it is refused for any non-admin caller — + // including one reaching the store directly. + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + throw new IdentityAuthorizationException( + "Assigning a role requires administrator authority." + ); + var role = await querySession .Query() @@ -708,6 +737,13 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Role name cannot be null or empty.", nameof(roleName)); + // Defence in depth (#69/#41): removing a role is an administrator-only operation, + // independent of any UI [Authorize] guard. + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + throw new IdentityAuthorizationException( + "Removing a role requires administrator authority." + ); + // Domain-layer invariant (defence in depth, independent of any UI [Authorize]): // the root user is the un-removable administrator anchor created during setup. // Stripping its Administrator role would orphan all admin access, so refuse it diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Roles/RoleStoreTests.cs b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Roles/RoleStoreTests.cs index 03127f1..b579fe5 100644 --- a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Roles/RoleStoreTests.cs +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Roles/RoleStoreTests.cs @@ -160,10 +160,16 @@ private RoleStore Build() .GetCurrentUserIdAsync(Arg.Any()) .Returns(UserId.New()); + // Persistence tests run with authorization satisfied; the authz guard itself is + // covered separately (#69). + var authorizer = Substitute.For(); + authorizer.IsCurrentUserAdministratorAsync(Arg.Any()).Returns(true); + var session = fixture.Store.LightweightSession(); return new RoleStore( session, currentUserService, + authorizer, NullLogger>.Instance ); } diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreAuthorizationTests.cs b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreAuthorizationTests.cs new file mode 100644 index 0000000..ff1a61b --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreAuthorizationTests.cs @@ -0,0 +1,213 @@ +using AndreGoepel.Marten.Identity.IntegrationTests.Infrastructure; +using AndreGoepel.Marten.Identity.Roles; +using AndreGoepel.Marten.Identity.Roles.Events; +using AndreGoepel.Marten.Identity.Services; +using AndreGoepel.Marten.Identity.Users; +using Marten; + +namespace AndreGoepel.Marten.Identity.IntegrationTests.Users; + +/// +/// Defence-in-depth authorization enforced inside the store, independent of any UI +/// [Authorize] guard (#69/#41). The guarded operations — role assignment, delete, +/// restore — must refuse a caller that is neither an administrator nor (for delete) the +/// account owner, and a trusted must +/// bypass the checks for seeding/bootstrap. +/// +[Collection(IntegrationCollection.Name)] +public class UserStoreAuthorizationTests(MartenFixture fixture) : IAsyncLifetime +{ + private CancellationToken Ct => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() => await fixture.ResetAsync(Ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + // --- Role assignment (throws IdentityAuthorizationException) --- + + [Fact] + public async Task AddToRoleAsync_NonAdminActor_Throws() + { + var target = await SeedUserAsync("target@example.com"); + await SeedRoleAsync("Member"); + var (store, _) = BuildEnforcing(UserId.New()); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + await Assert.ThrowsAsync(() => + store.AddToRoleAsync(user!, "MEMBER", Ct) + ); + } + + [Fact] + public async Task AddToRoleAsync_AnonymousActor_Throws() + { + var target = await SeedUserAsync("target@example.com"); + await SeedRoleAsync("Member"); + var (store, _) = BuildEnforcing(default); // Guid.Empty — fails closed + var user = await store.FindByIdAsync(target.ToString(), Ct); + + await Assert.ThrowsAsync(() => + store.AddToRoleAsync(user!, "MEMBER", Ct) + ); + } + + [Fact] + public async Task AddToRoleAsync_AdminActor_Succeeds() + { + var admin = await SeedAdminAsync(); + var target = await SeedUserAsync("target@example.com"); + await SeedRoleAsync("Member"); + var (store, _) = BuildEnforcing(admin); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + await store.AddToRoleAsync(user!, "MEMBER", Ct); + + var after = await store.FindByIdAsync(target.ToString(), Ct); + Assert.Contains("Member", await store.GetRolesAsync(after!, Ct)); + } + + [Fact] + public async Task RemoveFromRoleAsync_NonAdminActor_Throws() + { + var target = await SeedUserAsync("target@example.com"); + await SeedRoleAsync("Member"); + await AssignRoleAsSystemAsync(target, "MEMBER"); + var (store, _) = BuildEnforcing(UserId.New()); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + await Assert.ThrowsAsync(() => + store.RemoveFromRoleAsync(user!, "MEMBER", Ct) + ); + } + + // --- Delete (self-or-admin; returns IdentityResult) --- + + [Fact] + public async Task DeleteAsync_NonOwnerNonAdmin_ReturnsNotAuthorized() + { + var target = await SeedUserAsync("victim@example.com"); + var (store, _) = BuildEnforcing(UserId.New()); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + var result = await store.DeleteAsync(user!, Ct); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "NotAuthorized"); + var persisted = await store.FindByIdAsync(target.ToString(), Ct); + Assert.False(persisted!.Deleted); + } + + [Fact] + public async Task DeleteAsync_Owner_Succeeds() + { + var target = await SeedUserAsync("self@example.com"); + var (store, _) = BuildEnforcing(target); // actor == target + var user = await store.FindByIdAsync(target.ToString(), Ct); + + var result = await store.DeleteAsync(user!, Ct); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task DeleteAsync_AdminActor_Succeeds() + { + var admin = await SeedAdminAsync(); + var target = await SeedUserAsync("victim@example.com"); + var (store, _) = BuildEnforcing(admin); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + var result = await store.DeleteAsync(user!, Ct); + + Assert.True(result.Succeeded); + } + + // --- Restore (admin only) --- + + [Fact] + public async Task RestoreAsync_NonAdminActor_ReturnsNotAuthorized() + { + var admin = await SeedAdminAsync(); + var target = await SeedUserAsync("victim@example.com"); + var (adminStore, _) = BuildEnforcing(admin); + var toDelete = await adminStore.FindByIdAsync(target.ToString(), Ct); + await adminStore.DeleteAsync(toDelete!, Ct); + + var (store, _) = BuildEnforcing(UserId.New()); + var user = await store.FindByIdAsync(target.ToString(), Ct); + + var result = await store.RestoreAsync(user!, Ct); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "NotAuthorized"); + } + + // --- System scope bypasses the checks --- + + [Fact] + public async Task SystemScope_BypassesAuthorization() + { + var target = await SeedUserAsync("target@example.com"); + await SeedRoleAsync("Member"); + var (store, authorizer) = BuildEnforcing(UserId.New()); // non-admin actor + var user = await store.FindByIdAsync(target.ToString(), Ct); + + using (authorizer.BeginSystemScope()) + { + await store.AddToRoleAsync(user!, "MEMBER", Ct); + } + + var after = await store.FindByIdAsync(target.ToString(), Ct); + Assert.Contains("Member", await store.GetRolesAsync(after!, Ct)); + } + + // --- Helpers --- + + private (UserStore Store, IIdentityAuthorizer Authorizer) BuildEnforcing(UserId actor) + { + var authorizer = new IdentityAuthorizer( + UserStoreTestHelpers.CurrentUserServiceFor(actor), + fixture.Store.QuerySession() + ); + var store = UserStoreTestHelpers.BuildStore( + fixture.Store, + currentUser: actor, + authorizer: authorizer + ); + return (store, authorizer); + } + + private async Task SeedAdminAsync() + { + // Root creation auto-assigns Administrator via direct event append (not the + // guarded AddToRoleAsync), so it needs no existing admin. + var store = UserStoreTestHelpers.BuildStore(fixture.Store); + var root = UserStoreTestHelpers.NewUser("root@example.com"); + root.RootUser = true; + await store.CreateAsync(root, Ct); + return root.UserId; + } + + private async Task SeedUserAsync(string email) + { + var store = UserStoreTestHelpers.BuildStore(fixture.Store); + var user = UserStoreTestHelpers.NewUser(email); + await store.CreateAsync(user, Ct); + return user.UserId; + } + + private async Task SeedRoleAsync(string name) + { + await using var session = fixture.Store.LightweightSession(); + var roleId = RoleId.New(); + session.Events.Append(roleId.Value, new RoleCreated(roleId, name, UserId.New())); + await session.SaveChangesAsync(Ct); + } + + private async Task AssignRoleAsSystemAsync(UserId target, string normalizedRoleName) + { + var store = UserStoreTestHelpers.BuildStore(fixture.Store); // permissive + var user = await store.FindByIdAsync(target.ToString(), Ct); + await store.AddToRoleAsync(user!, normalizedRoleName, Ct); + } +} diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreTestHelpers.cs b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreTestHelpers.cs index 1d8b6c8..f4a93f9 100644 --- a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreTestHelpers.cs +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserStoreTestHelpers.cs @@ -14,24 +14,40 @@ internal static class UserStoreTestHelpers public static UserStore BuildStore( IDocumentStore store, UserId? currentUser = null, - IdentityOptions? identityOptions = null + IdentityOptions? identityOptions = null, + IIdentityAuthorizer? authorizer = null ) { - var currentUserService = Substitute.For(); - currentUserService - .GetCurrentUserIdAsync(Arg.Any()) - .Returns(currentUser ?? UserId.New()); + var currentUserService = CurrentUserServiceFor(currentUser ?? UserId.New()); return new UserStore( store, store.QuerySession(), DataProtectionProvider.Create("Tests"), currentUserService, + // Persistence/invariant tests default to a permissive authorizer; authz tests + // pass a real IdentityAuthorizer to exercise the DB-authoritative check (#69). + authorizer ?? PermissiveAuthorizer(), Options.Create(identityOptions ?? new IdentityOptions()), NullLogger>.Instance ); } + public static ICurrentUserService CurrentUserServiceFor(UserId currentUser) + { + var service = Substitute.For(); + service.GetCurrentUserIdAsync(Arg.Any()).Returns(currentUser); + return service; + } + + public static IIdentityAuthorizer PermissiveAuthorizer() + { + var authorizer = Substitute.For(); + authorizer.IsSystemScope.Returns(true); + authorizer.IsCurrentUserAdministratorAsync(Arg.Any()).Returns(true); + return authorizer; + } + public static User NewUser(string email = "alice@example.com") => new() { diff --git a/tests/AndreGoepel.Marten.Identity.Tests/Roles/RoleStore.Tests.cs b/tests/AndreGoepel.Marten.Identity.Tests/Roles/RoleStore.Tests.cs index 917ca6f..cefed00 100644 --- a/tests/AndreGoepel.Marten.Identity.Tests/Roles/RoleStore.Tests.cs +++ b/tests/AndreGoepel.Marten.Identity.Tests/Roles/RoleStore.Tests.cs @@ -47,7 +47,16 @@ private static Harness Build() var logger = Substitute.For>>(); - return new Harness(new RoleStore(session, currentUser, logger), session, appended); + // Persistence tests run with authorization satisfied; the authz guard itself is + // covered separately (#69). + var authorizer = Substitute.For(); + authorizer.IsCurrentUserAdministratorAsync(Arg.Any()).Returns(true); + + return new Harness( + new RoleStore(session, currentUser, authorizer, logger), + session, + appended + ); } #endregion