Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -98,9 +108,11 @@ guarantees above. This is your deployment checklist.
forms rely on the host wiring `AddAntiforgery()` / `UseAntiforgery()` and
`MapRazorComponents<App>()` 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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/AndreGoepel.Marten.Identity/Initialization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ var scheme in new[]
.AddDefaultTokenProviders();

services.AddScoped<ICurrentUserService, CurrentUserService>();
services.AddScoped<IIdentityAuthorizer, IdentityAuthorizer>();
services.AddScoped<UserStore<User>>();
services.AddScoped<RoleStore<Role>>();
services.AddSingleton<LoginTokenProtector>();
Expand Down
24 changes: 24 additions & 0 deletions src/AndreGoepel.Marten.Identity/Roles/RoleStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@ namespace AndreGoepel.Marten.Identity.Roles;
public class RoleStore<TRole>(
IDocumentSession session,
ICurrentUserService currentUserService,
IIdentityAuthorizer authorizer,
ILogger<RoleStore<TRole>> logger
) : IQueryableRoleStore<TRole>
where TRole : Role
{
public IQueryable<TRole> Roles => session.Query<TRole>();

// 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);
Expand All @@ -24,6 +36,9 @@ public async Task<IdentityResult> 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." }
Expand Down Expand Up @@ -57,6 +72,9 @@ public async Task<IdentityResult> UpdateAsync(TRole role, CancellationToken canc
{
try
{
if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken))
return NotAuthorized();

if (role.Name == null)
{
return IdentityResult.Failed(
Expand Down Expand Up @@ -93,6 +111,9 @@ public async Task<IdentityResult> DeleteAsync(TRole role, CancellationToken canc
{
try
{
if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken))
return NotAuthorized();

if (!role.Deletable)
{
return IdentityResult.Failed(
Expand Down Expand Up @@ -124,6 +145,9 @@ public async Task<IdentityResult> RestoreAsync(
{
try
{
if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken))
return NotAuthorized();

session.Events.Append(
role.StreamId,
new RoleRestored(role.RoleId, await currentUserService.GetCurrentUserIdAsync())
Expand Down
39 changes: 39 additions & 0 deletions src/AndreGoepel.Marten.Identity/Services/IIdentityAuthorizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace AndreGoepel.Marten.Identity.Services;

/// <summary>
/// Domain-layer authorization for privileged identity store operations (#69/#41).
/// <para>
/// The stores are a reusable persistence layer; the page-level <c>[Authorize]</c>
/// 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.
/// </para>
/// <para>
/// It also provides an explicit escape hatch — <see cref="BeginSystemScope"/> — for
/// trusted server-side code (seeding, background provisioning, an alternative first-run
/// bootstrap) that legitimately acts without an authenticated administrator.
/// </para>
/// </summary>
public interface IIdentityAuthorizer
{
/// <summary>
/// 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.
/// </summary>
IDisposable BeginSystemScope();

/// <summary>True while executing inside a <see cref="BeginSystemScope"/>.</summary>
bool IsSystemScope { get; }

/// <summary>
/// True when the operation may act with administrator authority: inside a system
/// scope, or when the current user (from <see cref="ICurrentUserService"/>) holds the
/// non-deleted Administrator role. Fails closed — returns <c>false</c> — when there is
/// no identified current user.
/// </summary>
Task<bool> IsCurrentUserAdministratorAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace AndreGoepel.Marten.Identity.Services;

/// <summary>
/// Thrown when a privileged identity store operation that returns no result — the
/// role-assignment methods (<c>IUserRoleStore</c>) — is invoked without the required
/// authorization (#69/#41). Operations that return
/// <see cref="Microsoft.AspNetCore.Identity.IdentityResult" /> surface the same condition
/// as a failed result instead.
/// </summary>
public sealed class IdentityAuthorizationException(string message) : Exception(message);
72 changes: 72 additions & 0 deletions src/AndreGoepel.Marten.Identity/Services/IdentityAuthorizer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <inheritdoc />
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<bool> 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<bool> 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<Role>()
.FirstOrDefaultAsync(
r => r.NormalizedName == NormalizedAdministrator && !r.Deleted,
cancellationToken
);
if (adminRole is null)
return false;

return await querySession
.Query<UserRoleAssignment>()
.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;
}
}
}
36 changes: 36 additions & 0 deletions src/AndreGoepel.Marten.Identity/Users/UserStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class UserStore<TUser>(
IQuerySession querySession,
IDataProtectionProvider dataProtectionProvider,
ICurrentUserService currentUserService,
IIdentityAuthorizer authorizer,
IOptions<IdentityOptions> identityOptions,
ILogger<UserStore<TUser>> logger
)
Expand All @@ -37,6 +38,11 @@ ILogger<UserStore<TUser>> logger
{
private const string UserDataProtectionPurpose = "UserDataProtection";

private static IdentityResult NotAuthorized(string description) =>
IdentityResult.Failed(
new IdentityError { Code = "NotAuthorized", Description = description }
);

public IQueryable<TUser> Users => querySession.Query<TUser>();

public Task<string> GetUserIdAsync(TUser user, CancellationToken cancellationToken) =>
Expand Down Expand Up @@ -253,6 +259,15 @@ public async Task<IdentityResult> 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(
Expand Down Expand Up @@ -287,6 +302,11 @@ public async Task<IdentityResult> 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(
Expand Down Expand Up @@ -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<Role>()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ private RoleStore<Role> Build()
.GetCurrentUserIdAsync(Arg.Any<CancellationToken>())
.Returns(UserId.New());

// Persistence tests run with authorization satisfied; the authz guard itself is
// covered separately (#69).
var authorizer = Substitute.For<IIdentityAuthorizer>();
authorizer.IsCurrentUserAdministratorAsync(Arg.Any<CancellationToken>()).Returns(true);

var session = fixture.Store.LightweightSession();
return new RoleStore<Role>(
session,
currentUserService,
authorizer,
NullLogger<RoleStore<Role>>.Instance
);
}
Expand Down
Loading