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
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,32 +946,33 @@ await signInManager.RevokeOtherSessionsForCurrentUserAsync(httpContext);
Session listing is ordered by `CreatedAt` descending (newest first). Sensitive fields like IP address and user agent are only populated if they were enabled during session creation. Token hashes are never exposed through these APIs. In the PostgreSQL store, last-seen writes are ignored once a session is revoked or expired, so a concurrent sign-out or expiry cannot be undone by validation telemetry.

#### Admin User Browsing
Use `IUserAdministrationService` for read-only admin and operations tooling that needs to browse users without querying provider tables directly:
Use `IUserAdministrationReader` for read-only admin and operations tooling that needs to browse users without querying provider tables directly:

```csharp
var users = await userAdministration.SearchUsersAsync(
adminReadActor,
new SearchUsersRequest
{
Actor = adminReadActor,
Tenant = new TenantContext(tenantId), // or TenantContext.Global, or IncludeAllTenants = true
Query = "alex@example.com",
Limit = 50
});

var detail = await userAdministration.GetUserDetailAsync(
new UserAdministrationDetailRequest(userId, new TenantContext(tenantId), Actor: adminReadActor));
adminReadActor,
new UserAdministrationDetailRequest(userId, new TenantContext(tenantId)));
```

Search, lookup, and detail requests require `AccountSecurityActorContext` with the authenticated actor, actor tenant, current active session, matching `AuditContext`, and an Ashlar-issued fresh MFA proof for the `administration-read` purpose. They also require an explicit tenant/global/all-tenant scope and host `IAccountSecurityOperationAuthorizer` approval; all-tenant requests carry a distinct authorization decision. Successes and failures are durably audited and fail closed when audit persistence fails. User admin detail includes safe projections only.
Search and detail calls require a separate `AccountSecurityActorContext` with the authenticated actor, actor tenant, current active session, matching `AuditContext`, and an Ashlar-issued fresh MFA proof for the `administration-read` purpose. Requests require an explicit tenant/global/all-tenant scope, and calls require host `IAccountSecurityOperationAuthorizer` approval; all-tenant requests carry a distinct authorization decision. Successes and failures are durably audited and fail closed when audit persistence fails. User admin detail includes safe projections only.

#### Admin Session Browsing
Use `IAuthenticationSessionAdministrationService` for read-only admin and operations tooling that needs to browse sessions across users and tenants without querying provider tables directly:
Use `IAuthenticationSessionAdministrationReader` for read-only admin and operations tooling that needs to browse sessions across users and tenants without querying provider tables directly:

```csharp
var result = await sessionAdministration.SearchAuthenticationSessionsAsync(
adminReadActor,
new SearchAuthenticationSessionsRequest
{
Actor = adminReadActor,
Tenant = new TenantContext(tenantId), // or TenantContext.Global, or IncludeAllTenants = true
UserId = userId,
Active = true,
Expand All @@ -987,19 +988,20 @@ if (result.Succeeded)
}

var session = await sessionAdministration.GetAuthenticationSessionAsync(
new AuthenticationSessionAdministrationLookupRequest(sessionId, new TenantContext(tenantId), Actor: adminReadActor));
adminReadActor,
new AuthenticationSessionAdministrationLookupRequest(sessionId, new TenantContext(tenantId)));
```

Search and single-session requests require the shared actor-bound admin-read context and an explicit tenant scope, `TenantContext.Global`, or `IncludeAllTenants = true`. The single-session lookup returns the same safe projection shape as search. Raw session tokens and token hashes are never returned, and session metadata is not included in the admin read model.
Search and single-session calls require the separate actor-bound admin-read context; their requests require an explicit tenant scope, `TenantContext.Global`, or `IncludeAllTenants = true`. The single-session lookup returns the same safe projection shape as search. Raw session tokens and token hashes are never returned, and session metadata is not included in the admin read model.

#### Admin Credential Inventory
Use `ICredentialAdministrationService` for read-only admin and operations tooling that needs to browse credential inventory across users or tenants without querying provider tables directly:
Use `ICredentialAdministrationReader` for read-only admin and operations tooling that needs to browse credential inventory across users or tenants without querying provider tables directly:

```csharp
var result = await credentialAdministration.SearchCredentialsAsync(
adminReadActor,
new SearchCredentialsRequest
{
Actor = adminReadActor,
Tenant = new TenantContext(tenantId), // or TenantContext.Global, or IncludeAllTenants = true
UserId = userId,
Provider = AuthenticationProviderKey.Passkey,
Expand All @@ -1016,7 +1018,7 @@ if (result.Succeeded)
}
```

Call `GetCredentialAsync(new CredentialAdministrationLookupRequest(credentialId, new TenantContext(tenantId), Actor: adminReadActor))` for the same safe projection shape for a single credential. Single-credential requests also require `TenantContext.Global` or `IncludeAllTenants = true` when appropriate. Raw credential values, provider keys, metadata, password hashes, token hashes, passkey payloads, recovery codes, OAuth/OIDC subject identifiers, provider-specific raw identifiers, and other secrets are never returned.
Call `GetCredentialAsync(adminReadActor, new CredentialAdministrationLookupRequest(credentialId, new TenantContext(tenantId)))` for the same safe projection shape for a single credential. Single-credential requests also require `TenantContext.Global` or `IncludeAllTenants = true` when appropriate. Raw credential values, provider keys, metadata, password hashes, token hashes, passkey payloads, recovery codes, OAuth/OIDC subject identifiers, provider-specific raw identifiers, and other secrets are never returned.

#### Admin Account Recovery Options
Use `IAccountRecoveryAdministrationService` when admin tooling needs a display-safe preview of account recovery actions before presenting destructive controls:
Expand Down
14 changes: 7 additions & 7 deletions samples/Ashlar.Sample.AspNetCore/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private static void MapAdminUserEndpoints(IEndpointRouteBuilder app)

private static async Task<IResult> ListAdminUsersAsync(
IAuthorizationEvaluator auth,
IUserAdministrationService users,
IUserAdministrationReader users,
StepUpAuthenticationService stepUp,
HttpContext httpContext,
CancellationToken cancellationToken)
Expand All @@ -134,9 +134,9 @@ private static async Task<IResult> ListAdminUsersAsync(

var proof = httpContext.CreateFreshMfaProof(stepUp, AdminReadRequirement, AccountSecurityActorContext.AdministrationReadProofPurpose);
if (!proof.TryGetValue(out var freshProof)) return Results.Forbid();
var result = await users.SearchUsersAsync(new SearchUsersRequest
var actor = new AccountSecurityActorContext(actorUserId, actorTenant, sessionId, freshProof, httpContext.ToAuditContext());
var result = await users.SearchUsersAsync(actor, new SearchUsersRequest
{
Actor = new AccountSecurityActorContext(actorUserId, actorTenant, sessionId, freshProof, httpContext.ToAuditContext()),
Tenant = tenant,
Limit = 100
}, cancellationToken);
Expand All @@ -147,7 +147,7 @@ private static async Task<IResult> ListAdminUsersAsync(

private static async Task<IResult> GetAdminUserSecurityAsync(
Guid userId,
IUserAdministrationService users,
IUserAdministrationReader users,
StepUpAuthenticationService stepUp,
IAuthorizationEvaluator auth,
HttpContext httpContext,
Expand All @@ -161,9 +161,9 @@ private static async Task<IResult> GetAdminUserSecurityAsync(

var proof = httpContext.CreateFreshMfaProof(stepUp, AdminReadRequirement, AccountSecurityActorContext.AdministrationReadProofPurpose);
if (!proof.TryGetValue(out var freshProof)) return Results.Forbid();
var result = await users.GetUserDetailAsync(new UserAdministrationDetailRequest(
userId, tenant, RecentSecurityEventWindow: TimeSpan.FromDays(30),
Actor: new AccountSecurityActorContext(actorUserId, actorTenant, sessionId, freshProof, httpContext.ToAuditContext())), cancellationToken);
var actor = new AccountSecurityActorContext(actorUserId, actorTenant, sessionId, freshProof, httpContext.ToAuditContext());
var result = await users.GetUserDetailAsync(actor,
new UserAdministrationDetailRequest(userId, tenant, RecentSecurityEventWindow: TimeSpan.FromDays(30)), cancellationToken);
return ToAccountSecurityPostureResult(result);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,21 +195,21 @@ public static IServiceCollection AddAshlarIdentity(
services.TryAddScoped(provider => new AccountLockoutServiceDependencies(
provider.GetService<TimeProvider>(),
provider.GetService<ISecurityEventSink>()));
services.TryAddScoped<IUserAdministrationService>(provider => new UserAdministrationService(
services.TryAddScoped<IUserAdministrationReader>(provider => new UserAdministrationReader(
provider.GetRequiredAshlarProviderService<IUserAdministrationRepository>(),
provider.GetRequiredService<IAccountSecurityPostureReader>(),
provider.GetRequiredAshlarProviderService<IAuthenticationSessionRepository>(),
provider.GetRequiredService<IAccountSecurityOperationAuthorizer>(),
provider.GetRequiredAshlarProviderService<IPersistentSecurityEventSink>(),
provider.GetService<TimeProvider>()));
services.TryAddScoped<ICredentialAdministrationService>(provider => new CredentialAdministrationService(
services.TryAddScoped<ICredentialAdministrationReader>(provider => new CredentialAdministrationReader(
provider.GetRequiredAshlarProviderService<ICredentialAdministrationRepository>(),
provider.GetRequiredAshlarProviderService<IAuthenticationSessionRepository>(),
provider.GetRequiredService<IAccountSecurityOperationAuthorizer>(),
provider.GetRequiredAshlarProviderService<IPersistentSecurityEventSink>(),
provider.GetService<TimeProvider>()));
services.TryAddScoped<IAccountRecoveryAdministrationService>(provider => new AccountRecoveryAdministrationService(
provider.GetRequiredService<IUserAdministrationService>(),
provider.GetRequiredService<IUserAdministrationReader>(),
provider.GetRequiredAshlarProviderService<IRememberedMfaDeviceRepository>(),
provider.GetService<TimeProvider>()));
services.TryAddScoped(provider => new AccountLockoutAdministrationServiceDependencies(
Expand All @@ -233,7 +233,7 @@ public static IServiceCollection AddAshlarIdentity(
provider.GetRequiredService<IAccountSecurityOperationAuthorizer>(),
provider.GetRequiredAshlarProviderService<IPersistentSecurityEventSink>(),
provider.GetService<TimeProvider>()));
services.TryAddScoped<IAuthenticationSessionAdministrationService>(provider => new AuthenticationSessionAdministrationService(
services.TryAddScoped<IAuthenticationSessionAdministrationReader>(provider => new AuthenticationSessionAdministrationReader(
provider.GetRequiredAshlarProviderService<IAuthenticationSessionAdministrationRepository>(),
provider.GetRequiredAshlarProviderService<IAuthenticationSessionRepository>(),
provider.GetRequiredService<IAccountSecurityOperationAuthorizer>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services;
/// <remarks>
/// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements.
/// </remarks>
public interface IAuthenticationSessionAdministrationService
public interface IAuthenticationSessionAdministrationReader
{
/// <summary>
/// Searches authentication sessions using provider-neutral display fields.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope, filters, and paging options for the search.</param>
/// <param name="cancellationToken">A token that can cancel the search.</param>
/// <returns>Provider-neutral session summaries. Raw bearer tokens are never returned.</returns>
Task<Result<AuthenticationSessionSearchResult>> SearchAuthenticationSessionsAsync(SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default);
Task<Result<AuthenticationSessionSearchResult>> SearchAuthenticationSessionsAsync(AccountSecurityActorContext actor, SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Gets an authentication session by id.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope and session identifier for the lookup.</param>
/// <param name="cancellationToken">A token that can cancel the lookup.</param>
/// <returns>The same provider-neutral session projection used by search when found. Raw bearer tokens are never returned.</returns>
Task<Result<AuthenticationSessionAdministrationSummary>> GetAuthenticationSessionAsync(AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default);
Task<Result<AuthenticationSessionAdministrationSummary>> GetAuthenticationSessionAsync(AccountSecurityActorContext actor, AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services;
/// <remarks>
/// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements.
/// </remarks>
public interface ICredentialAdministrationService
public interface ICredentialAdministrationReader
{
/// <summary>
/// Searches credentials using provider-neutral display fields.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope, filters, and paging options for the search.</param>
/// <param name="cancellationToken">A token that can cancel the search.</param>
/// <returns>Provider-neutral credential summaries. Credential secrets are never returned.</returns>
Task<Result<CredentialSearchResult>> SearchCredentialsAsync(SearchCredentialsRequest request, CancellationToken cancellationToken = default);
Task<Result<CredentialSearchResult>> SearchCredentialsAsync(AccountSecurityActorContext actor, SearchCredentialsRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a safe credential projection by credential id.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope and credential identifier for the lookup.</param>
/// <param name="cancellationToken">A token that can cancel the lookup.</param>
/// <returns>The same provider-neutral credential projection used by search when found, without credential secret values.</returns>
Task<Result<CredentialAdministrationSummary>> GetCredentialAsync(CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default);
Task<Result<CredentialAdministrationSummary>> GetCredentialAsync(AccountSecurityActorContext actor, CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services;
/// <remarks>
/// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements.
/// </remarks>
public interface IUserAdministrationService
public interface IUserAdministrationReader
{
/// <summary>
/// Searches users for administrator and operations interfaces.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope, filters, and paging options for the search.</param>
/// <param name="cancellationToken">A token that can cancel the search.</param>
/// <returns>Provider-neutral user summaries for administrative display.</returns>
Task<Result<UserSearchResult>> SearchUsersAsync(SearchUsersRequest request, CancellationToken cancellationToken = default);
Task<Result<UserSearchResult>> SearchUsersAsync(AccountSecurityActorContext actor, SearchUsersRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Gets safe user detail with the existing security posture summary.
/// </summary>
/// <param name="actor">Authenticated actor, active session, fresh proof, and audit metadata.</param>
/// <param name="request">Tenant scope and user identifier for the lookup.</param>
/// <param name="cancellationToken">A token that can cancel the lookup.</param>
/// <returns>Safe user detail and security posture information.</returns>
Task<Result<UserAdministrationDetail>> GetUserDetailAsync(UserAdministrationDetailRequest request, CancellationToken cancellationToken = default);
Task<Result<UserAdministrationDetail>> GetUserDetailAsync(AccountSecurityActorContext actor, UserAdministrationDetailRequest request, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
namespace Ashlar.Identity.Features.Administration;

internal sealed class AccountRecoveryAdministrationService(
IUserAdministrationService userAdministrationService,
IUserAdministrationReader userAdministrationReader,
IRememberedMfaDeviceRepository rememberedMfaDeviceRepository,
TimeProvider? timeProvider = null)
: IAccountRecoveryAdministrationService
{
internal const string LastPrimarySignInMethodWarningCode = "last_primary_sign_in_method";

private readonly IUserAdministrationService _userAdministrationService = userAdministrationService ?? throw new ArgumentNullException(nameof(userAdministrationService));
private readonly IUserAdministrationReader _userAdministrationReader = userAdministrationReader ?? throw new ArgumentNullException(nameof(userAdministrationReader));
private readonly IRememberedMfaDeviceRepository _rememberedMfaDeviceRepository = rememberedMfaDeviceRepository ?? throw new ArgumentNullException(nameof(rememberedMfaDeviceRepository));
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;

Expand All @@ -25,13 +25,13 @@ public async Task<Result<AccountRecoveryOptions>> GetAccountRecoveryOptionsAsync
return validationFailure;
}

var detailResult = await _userAdministrationService.GetUserDetailAsync(
var detailResult = await _userAdministrationReader.GetUserDetailAsync(
request.Actor!,
new UserAdministrationDetailRequest(
request.UserId,
request.Tenant,
request.IncludeAllTenants,
request.RecentSecurityEventWindow,
request.Actor),
request.RecentSecurityEventWindow),
cancellationToken);
if (!detailResult.TryGetValue(out var detail))
{
Expand Down
Loading
Loading