diff --git a/README.md b/README.md index 196ba5a8..db386311 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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, @@ -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: diff --git a/samples/Ashlar.Sample.AspNetCore/Endpoints/AdminEndpoints.cs b/samples/Ashlar.Sample.AspNetCore/Endpoints/AdminEndpoints.cs index 66dd6817..e030443d 100644 --- a/samples/Ashlar.Sample.AspNetCore/Endpoints/AdminEndpoints.cs +++ b/samples/Ashlar.Sample.AspNetCore/Endpoints/AdminEndpoints.cs @@ -121,7 +121,7 @@ private static void MapAdminUserEndpoints(IEndpointRouteBuilder app) private static async Task ListAdminUsersAsync( IAuthorizationEvaluator auth, - IUserAdministrationService users, + IUserAdministrationReader users, StepUpAuthenticationService stepUp, HttpContext httpContext, CancellationToken cancellationToken) @@ -134,9 +134,9 @@ private static async Task 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); @@ -147,7 +147,7 @@ private static async Task ListAdminUsersAsync( private static async Task GetAdminUserSecurityAsync( Guid userId, - IUserAdministrationService users, + IUserAdministrationReader users, StepUpAuthenticationService stepUp, IAuthorizationEvaluator auth, HttpContext httpContext, @@ -161,9 +161,9 @@ private static async Task 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); } diff --git a/src/Ashlar/DependencyInjection/AshlarServiceCollectionExtensions.Identity.cs b/src/Ashlar/DependencyInjection/AshlarServiceCollectionExtensions.Identity.cs index 2a846fe4..841a5a5f 100644 --- a/src/Ashlar/DependencyInjection/AshlarServiceCollectionExtensions.Identity.cs +++ b/src/Ashlar/DependencyInjection/AshlarServiceCollectionExtensions.Identity.cs @@ -195,21 +195,21 @@ public static IServiceCollection AddAshlarIdentity( services.TryAddScoped(provider => new AccountLockoutServiceDependencies( provider.GetService(), provider.GetService())); - services.TryAddScoped(provider => new UserAdministrationService( + services.TryAddScoped(provider => new UserAdministrationReader( provider.GetRequiredAshlarProviderService(), provider.GetRequiredService(), provider.GetRequiredAshlarProviderService(), provider.GetRequiredService(), provider.GetRequiredAshlarProviderService(), provider.GetService())); - services.TryAddScoped(provider => new CredentialAdministrationService( + services.TryAddScoped(provider => new CredentialAdministrationReader( provider.GetRequiredAshlarProviderService(), provider.GetRequiredAshlarProviderService(), provider.GetRequiredService(), provider.GetRequiredAshlarProviderService(), provider.GetService())); services.TryAddScoped(provider => new AccountRecoveryAdministrationService( - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredAshlarProviderService(), provider.GetService())); services.TryAddScoped(provider => new AccountLockoutAdministrationServiceDependencies( @@ -233,7 +233,7 @@ public static IServiceCollection AddAshlarIdentity( provider.GetRequiredService(), provider.GetRequiredAshlarProviderService(), provider.GetService())); - services.TryAddScoped(provider => new AuthenticationSessionAdministrationService( + services.TryAddScoped(provider => new AuthenticationSessionAdministrationReader( provider.GetRequiredAshlarProviderService(), provider.GetRequiredAshlarProviderService(), provider.GetRequiredService(), diff --git a/src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationService.cs b/src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationReader.cs similarity index 67% rename from src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationService.cs rename to src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationReader.cs index 7ac7f159..65ff646d 100644 --- a/src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationService.cs +++ b/src/Ashlar/Identity/Abstractions/Services/IAuthenticationSessionAdministrationReader.cs @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services; /// /// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements. /// -public interface IAuthenticationSessionAdministrationService +public interface IAuthenticationSessionAdministrationReader { /// /// Searches authentication sessions using provider-neutral display fields. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope, filters, and paging options for the search. /// A token that can cancel the search. /// Provider-neutral session summaries. Raw bearer tokens are never returned. - Task> SearchAuthenticationSessionsAsync(SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default); + Task> SearchAuthenticationSessionsAsync(AccountSecurityActorContext actor, SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default); /// /// Gets an authentication session by id. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope and session identifier for the lookup. /// A token that can cancel the lookup. /// The same provider-neutral session projection used by search when found. Raw bearer tokens are never returned. - Task> GetAuthenticationSessionAsync(AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default); + Task> GetAuthenticationSessionAsync(AccountSecurityActorContext actor, AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationService.cs b/src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationReader.cs similarity index 70% rename from src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationService.cs rename to src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationReader.cs index fbff5e42..84a28e1e 100644 --- a/src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationService.cs +++ b/src/Ashlar/Identity/Abstractions/Services/ICredentialAdministrationReader.cs @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services; /// /// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements. /// -public interface ICredentialAdministrationService +public interface ICredentialAdministrationReader { /// /// Searches credentials using provider-neutral display fields. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope, filters, and paging options for the search. /// A token that can cancel the search. /// Provider-neutral credential summaries. Credential secrets are never returned. - Task> SearchCredentialsAsync(SearchCredentialsRequest request, CancellationToken cancellationToken = default); + Task> SearchCredentialsAsync(AccountSecurityActorContext actor, SearchCredentialsRequest request, CancellationToken cancellationToken = default); /// /// Gets a safe credential projection by credential id. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope and credential identifier for the lookup. /// A token that can cancel the lookup. /// The same provider-neutral credential projection used by search when found, without credential secret values. - Task> GetCredentialAsync(CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default); + Task> GetCredentialAsync(AccountSecurityActorContext actor, CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Ashlar/Identity/Abstractions/Services/IUserAdministrationService.cs b/src/Ashlar/Identity/Abstractions/Services/IUserAdministrationReader.cs similarity index 63% rename from src/Ashlar/Identity/Abstractions/Services/IUserAdministrationService.cs rename to src/Ashlar/Identity/Abstractions/Services/IUserAdministrationReader.cs index cd2a2dce..ccf1fdb4 100644 --- a/src/Ashlar/Identity/Abstractions/Services/IUserAdministrationService.cs +++ b/src/Ashlar/Identity/Abstractions/Services/IUserAdministrationReader.cs @@ -6,21 +6,23 @@ namespace Ashlar.Identity.Abstractions.Services; /// /// Every operation enforces actor, active-session proof, scope, host authorization, and durable audit requirements. /// -public interface IUserAdministrationService +public interface IUserAdministrationReader { /// /// Searches users for administrator and operations interfaces. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope, filters, and paging options for the search. /// A token that can cancel the search. /// Provider-neutral user summaries for administrative display. - Task> SearchUsersAsync(SearchUsersRequest request, CancellationToken cancellationToken = default); + Task> SearchUsersAsync(AccountSecurityActorContext actor, SearchUsersRequest request, CancellationToken cancellationToken = default); /// /// Gets safe user detail with the existing security posture summary. /// + /// Authenticated actor, active session, fresh proof, and audit metadata. /// Tenant scope and user identifier for the lookup. /// A token that can cancel the lookup. /// Safe user detail and security posture information. - Task> GetUserDetailAsync(UserAdministrationDetailRequest request, CancellationToken cancellationToken = default); + Task> GetUserDetailAsync(AccountSecurityActorContext actor, UserAdministrationDetailRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Ashlar/Identity/Features/Administration/AccountRecoveryAdministrationService.cs b/src/Ashlar/Identity/Features/Administration/AccountRecoveryAdministrationService.cs index 71a18dc1..2ccbf0aa 100644 --- a/src/Ashlar/Identity/Features/Administration/AccountRecoveryAdministrationService.cs +++ b/src/Ashlar/Identity/Features/Administration/AccountRecoveryAdministrationService.cs @@ -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; @@ -25,13 +25,13 @@ public async Task> 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)) { diff --git a/src/Ashlar/Identity/Features/Administration/CredentialAdministrationService.cs b/src/Ashlar/Identity/Features/Administration/CredentialAdministrationReader.cs similarity index 76% rename from src/Ashlar/Identity/Features/Administration/CredentialAdministrationService.cs rename to src/Ashlar/Identity/Features/Administration/CredentialAdministrationReader.cs index 86d0664d..ce4d0bc2 100644 --- a/src/Ashlar/Identity/Features/Administration/CredentialAdministrationService.cs +++ b/src/Ashlar/Identity/Features/Administration/CredentialAdministrationReader.cs @@ -13,13 +13,13 @@ namespace Ashlar.Identity.Features.Administration; /// /// Every operation enforces actor-bound active-session proof, scope, host authorization, and durable audit requirements. /// -public sealed class CredentialAdministrationService( +internal sealed class CredentialAdministrationReader( ICredentialAdministrationRepository repository, IAuthenticationSessionRepository sessions, IAccountSecurityOperationAuthorizer authorizer, IPersistentSecurityEventSink auditSink, TimeProvider? timeProvider = null) - : ICredentialAdministrationService + : ICredentialAdministrationReader { internal const int MaximumLimit = 100; @@ -28,8 +28,9 @@ public sealed class CredentialAdministrationService( private readonly AccountSecurityOperationBoundary _boundary = new(sessions, authorizer, auditSink, timeProvider ?? TimeProvider.System); /// - public async Task> SearchCredentialsAsync(SearchCredentialsRequest request, CancellationToken cancellationToken = default) + public async Task> SearchCredentialsAsync(AccountSecurityActorContext actor, SearchCredentialsRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateSearchRequest(request, out var validationFailure)) @@ -47,12 +48,12 @@ public async Task> SearchCredentialsAsync(SearchC return Result.Failure(AshlarFailureCodes.ValidationError, "Limit must be greater than zero."); } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, request.UserId ?? Guid.Empty, AccountSecurityOperation.SearchCredentials, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); var limit = Math.Min(request.Limit, MaximumLimit); - var repositoryRequest = request with { Actor = null, Limit = limit + 1 }; + var repositoryRequest = request with { Limit = limit + 1 }; List credentials; try { @@ -60,16 +61,16 @@ public async Task> SearchCredentialsAsync(SearchC } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); throw; } if (credentials.Any(credential => !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, credential.TenantId, request.UserId, credential.UserId))) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); throw new InvalidOperationException("The credential administration provider returned a result outside the authorized scope."); } - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchCredentials); var hasMore = credentials.Count > limit; var page = credentials.Take(limit).ToList().AsReadOnly(); @@ -77,8 +78,9 @@ public async Task> SearchCredentialsAsync(SearchC } /// - public async Task> GetCredentialAsync(CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default) + public async Task> GetCredentialAsync(AccountSecurityActorContext actor, CredentialAdministrationLookupRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateLookupRequest(request, out var validationFailure)) @@ -86,31 +88,31 @@ public async Task> GetCredentialAsync(Cr return validationFailure; } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, Guid.Empty, AccountSecurityOperation.ReadCredential, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); CredentialAdministrationSummary? credential; try { - credential = await _repository.GetCredentialAsync(request with { Actor = null }, _timeProvider.GetUtcNow(), cancellationToken); + credential = await _repository.GetCredentialAsync(request, _timeProvider.GetUtcNow(), cancellationToken); } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadCredential); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadCredential); throw; } if (credential is null || credential.CredentialId != request.CredentialId || !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, credential.TenantId)) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadCredential); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadCredential); credential = null; } - else if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + else if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, credential.UserId, AccountSecurityOperation.ReadCredential, cancellationToken) is not null) credential = null; else - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadCredential); return credential == null ? Result.Failure(AshlarFailureCodes.CredentialNotFound, "Credential was not found.") diff --git a/src/Ashlar/Identity/Features/Administration/UserAdministrationService.cs b/src/Ashlar/Identity/Features/Administration/UserAdministrationReader.cs similarity index 71% rename from src/Ashlar/Identity/Features/Administration/UserAdministrationService.cs rename to src/Ashlar/Identity/Features/Administration/UserAdministrationReader.cs index 75598221..8fb1437f 100644 --- a/src/Ashlar/Identity/Features/Administration/UserAdministrationService.cs +++ b/src/Ashlar/Identity/Features/Administration/UserAdministrationReader.cs @@ -2,9 +2,9 @@ namespace Ashlar.Identity.Features.Administration; -internal sealed class UserAdministrationService(IUserAdministrationRepository repository, IAccountSecurityPostureReader accountSecurityService, +internal sealed class UserAdministrationReader(IUserAdministrationRepository repository, IAccountSecurityPostureReader accountSecurityService, IAuthenticationSessionRepository sessions, IAccountSecurityOperationAuthorizer authorizer, IPersistentSecurityEventSink auditSink, - TimeProvider? timeProvider = null) : IUserAdministrationService + TimeProvider? timeProvider = null) : IUserAdministrationReader { internal const int MaximumLimit = 100; @@ -12,37 +12,38 @@ internal sealed class UserAdministrationService(IUserAdministrationRepository re private readonly IAccountSecurityPostureReader _accountSecurityService = accountSecurityService ?? throw new ArgumentNullException(nameof(accountSecurityService)); private readonly AccountSecurityOperationBoundary _boundary = new(sessions, authorizer, auditSink, timeProvider ?? TimeProvider.System); - public async Task> SearchUsersAsync(SearchUsersRequest request, CancellationToken cancellationToken = default) + public async Task> SearchUsersAsync(AccountSecurityActorContext actor, SearchUsersRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateSearchRequest(request, out var validationFailure)) { - await AuditValidationFailureAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + await AuditValidationFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers, cancellationToken); return validationFailure; } if (request.Offset < 0) { - await AuditValidationFailureAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + await AuditValidationFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers, cancellationToken); return Result.Failure(AshlarFailureCodes.ValidationError, "Offset cannot be negative."); } if (request.Limit < 1) { - await AuditValidationFailureAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + await AuditValidationFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers, cancellationToken); return Result.Failure(AshlarFailureCodes.ValidationError, "Limit must be greater than zero."); } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, Guid.Empty, AccountSecurityOperation.SearchUsers, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); var limit = Math.Min(request.Limit, MaximumLimit); - var repositoryRequest = request with { Actor = null, Limit = limit + 1 }; + var repositoryRequest = request with { Limit = limit + 1 }; List users; try { @@ -50,51 +51,52 @@ await AuditValidationFailureAsync(request.Actor, request.Tenant, request.Include } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); throw; } if (users.Any(user => !IsValidSummary(user) || !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, user.TenantId))) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); throw new InvalidOperationException("The user administration provider returned a result outside the authorized scope."); } - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchUsers); var hasMore = users.Count > limit; var page = users.Take(limit).ToList().AsReadOnly(); return Result.Success(new UserSearchResult(page, limit, request.Offset, hasMore)); } - public async Task> GetUserDetailAsync(UserAdministrationDetailRequest request, CancellationToken cancellationToken = default) + public async Task> GetUserDetailAsync(AccountSecurityActorContext actor, UserAdministrationDetailRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateLookupRequest(request, out var validationFailure)) { - await AuditValidationFailureAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + await AuditValidationFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser, cancellationToken); return validationFailure; } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, request.UserId, AccountSecurityOperation.ReadUser, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); UserSummary? user; try { - user = await _repository.GetUserSummaryAsync(request with { Actor = null }, cancellationToken); + user = await _repository.GetUserSummaryAsync(request, cancellationToken); } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); throw; } if (user == null || !IsValidSummary(user) || user.UserId != request.UserId || !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, user.TenantId)) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); return Result.Failure(AshlarFailureCodes.UserNotFound); } @@ -106,15 +108,15 @@ await AuditValidationFailureAsync(request.Actor, request.Tenant, request.Include } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); throw; } if (!posture.Succeeded || posture.Value == null || posture.Value.UserId != user.UserId) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); return Result.Failure(posture.FailureDetails ?? new AshlarFailure(AshlarFailureCodes.UserNotFound)); } - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadUser); return Result.Success(new UserAdministrationDetail(user, posture.Value)); } @@ -137,7 +139,7 @@ private static bool IsValidSummary(UserSummary user) => user.UserId != Guid.Empty && Enum.IsDefined(user.AccountState) && user.CanSignIn == user.AccountState.CanSignIn(); - private async Task AuditValidationFailureAsync(AccountSecurityActorContext? actor, TenantContext? tenant, + private async Task AuditValidationFailureAsync(AccountSecurityActorContext actor, TenantContext? tenant, bool includeAllTenants, AccountSecurityOperation operation, CancellationToken cancellationToken) { await _boundary.RecordValidatedFailureAsync(actor, tenant, includeAllTenants, operation, cancellationToken); diff --git a/src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationService.cs b/src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationReader.cs similarity index 76% rename from src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationService.cs rename to src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationReader.cs index 072e997d..6f937d65 100644 --- a/src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationService.cs +++ b/src/Ashlar/Identity/Features/Sessions/AuthenticationSessionAdministrationReader.cs @@ -13,13 +13,13 @@ namespace Ashlar.Identity.Features.Sessions; /// /// Every operation enforces actor-bound active-session proof, scope, host authorization, and durable audit requirements. /// -public sealed class AuthenticationSessionAdministrationService( +internal sealed class AuthenticationSessionAdministrationReader( IAuthenticationSessionAdministrationRepository repository, IAuthenticationSessionRepository sessions, IAccountSecurityOperationAuthorizer authorizer, IPersistentSecurityEventSink auditSink, TimeProvider? timeProvider = null) - : IAuthenticationSessionAdministrationService + : IAuthenticationSessionAdministrationReader { internal const int MaximumLimit = 100; @@ -28,8 +28,9 @@ public sealed class AuthenticationSessionAdministrationService( private readonly AccountSecurityOperationBoundary _boundary = new(sessions, authorizer, auditSink, timeProvider ?? TimeProvider.System); /// - public async Task> SearchAuthenticationSessionsAsync(SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default) + public async Task> SearchAuthenticationSessionsAsync(AccountSecurityActorContext actor, SearchAuthenticationSessionsRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateSearchRequest(request, out var validationFailure)) @@ -47,12 +48,12 @@ public async Task> SearchAuthenticatio return Result.Failure(AshlarFailureCodes.ValidationError, "Limit must be greater than zero."); } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, request.UserId ?? Guid.Empty, AccountSecurityOperation.SearchAuthenticationSessions, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); var limit = Math.Min(request.Limit, MaximumLimit); - var repositoryRequest = request with { Actor = null, Limit = limit + 1 }; + var repositoryRequest = request with { Limit = limit + 1 }; var now = _timeProvider.GetUtcNow(); List results; try @@ -61,16 +62,16 @@ public async Task> SearchAuthenticatio } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); throw; } if (results.Any(session => !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, session.TenantId, request.UserId, session.UserId))) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); throw new InvalidOperationException("The authentication-session administration provider returned a result outside the authorized scope."); } - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.SearchAuthenticationSessions); var hasMore = results.Count > limit; var page = results.Take(limit).ToList().AsReadOnly(); @@ -78,8 +79,9 @@ public async Task> SearchAuthenticatio } /// - public async Task> GetAuthenticationSessionAsync(AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default) + public async Task> GetAuthenticationSessionAsync(AccountSecurityActorContext actor, AuthenticationSessionAdministrationLookupRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(actor); ArgumentNullException.ThrowIfNull(request); if (!TryValidateLookupRequest(request, out var validationFailure)) @@ -87,31 +89,31 @@ public async Task> GetAuthent return validationFailure; } - if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, Guid.Empty, AccountSecurityOperation.ReadAuthenticationSession, cancellationToken) is { } authorizationFailure) return Result.Failure(authorizationFailure); AuthenticationSessionAdministrationSummary? session; try { - session = await _repository.GetAuthenticationSessionAsync(request with { Actor = null }, _timeProvider.GetUtcNow(), cancellationToken); + session = await _repository.GetAuthenticationSessionAsync(request, _timeProvider.GetUtcNow(), cancellationToken); } catch { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadAuthenticationSession); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadAuthenticationSession); throw; } if (session is null || session.Id != request.SessionId || !AdministrationScopeValidation.IncludesResult(request.Tenant, request.IncludeAllTenants, session.TenantId)) { - await _boundary.RecordFailureAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadAuthenticationSession); + await _boundary.RecordFailureAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadAuthenticationSession); session = null; } - else if (await _boundary.AuthorizeAsync(request.Actor, request.Tenant, request.IncludeAllTenants, + else if (await _boundary.AuthorizeAsync(actor, request.Tenant, request.IncludeAllTenants, session.UserId, AccountSecurityOperation.ReadAuthenticationSession, cancellationToken) is not null) session = null; else - await _boundary.RecordSuccessAsync(request.Actor!, request.Tenant, request.IncludeAllTenants, + await _boundary.RecordSuccessAsync(actor, request.Tenant, request.IncludeAllTenants, AccountSecurityOperation.ReadAuthenticationSession); return session == null ? Result.Failure(AshlarFailureCodes.SessionNotFound, "Session was not found.") diff --git a/src/Ashlar/Identity/Models/Administration/AuthenticationSessionAdministrationModels.cs b/src/Ashlar/Identity/Models/Administration/AuthenticationSessionAdministrationModels.cs index 8cffaf10..a5662523 100644 --- a/src/Ashlar/Identity/Models/Administration/AuthenticationSessionAdministrationModels.cs +++ b/src/Ashlar/Identity/Models/Administration/AuthenticationSessionAdministrationModels.cs @@ -5,9 +5,6 @@ namespace Ashlar.Identity.Models.Administration; /// public sealed record SearchAuthenticationSessionsRequest { - /// Authenticated actor, active session, fresh proof, and audit metadata. - public AccountSecurityActorContext? Actor { get; init; } - /// Tenant scope to search. Use for global users; leave only when is enabled. public TenantContext? Tenant { get; init; } @@ -117,12 +114,10 @@ public sealed record AuthenticationSessionSearchResult( /// Session to load. /// Requested scope. Use for global users; leave only when is enabled. /// Whether to allow lookup across all tenancy scopes. Cannot be combined with . -/// Authenticated actor, active session, fresh proof, and audit metadata. public sealed record AuthenticationSessionAdministrationLookupRequest( Guid SessionId, TenantContext? Tenant = null, - bool IncludeAllTenants = false, - AccountSecurityActorContext? Actor = null) + bool IncludeAllTenants = false) { /// /// Throws when the authentication session lookup request is not safe to execute. diff --git a/src/Ashlar/Identity/Models/Administration/CredentialAdministrationModels.cs b/src/Ashlar/Identity/Models/Administration/CredentialAdministrationModels.cs index b15ab6a4..08239ff1 100644 --- a/src/Ashlar/Identity/Models/Administration/CredentialAdministrationModels.cs +++ b/src/Ashlar/Identity/Models/Administration/CredentialAdministrationModels.cs @@ -5,9 +5,6 @@ namespace Ashlar.Identity.Models.Administration; /// public sealed record SearchCredentialsRequest { - /// Authenticated actor, active session, fresh proof, and audit metadata. - public AccountSecurityActorContext? Actor { get; init; } - /// Tenant scope to search. Use for global users; leave only when is enabled. public TenantContext? Tenant { get; init; } @@ -121,12 +118,10 @@ public sealed record CredentialSearchResult( /// Credential to load. /// Requested scope. Use for global users; leave only when is enabled. /// Whether to allow lookup across all tenancy scopes. Cannot be combined with . -/// Authenticated actor, active session, fresh proof, and audit metadata. public sealed record CredentialAdministrationLookupRequest( Guid CredentialId, TenantContext? Tenant = null, - bool IncludeAllTenants = false, - AccountSecurityActorContext? Actor = null) + bool IncludeAllTenants = false) { /// /// Throws when the credential lookup request is not safe to execute. diff --git a/src/Ashlar/Identity/Models/Administration/UserAdministrationModels.cs b/src/Ashlar/Identity/Models/Administration/UserAdministrationModels.cs index 16b5ef88..5be7fb91 100644 --- a/src/Ashlar/Identity/Models/Administration/UserAdministrationModels.cs +++ b/src/Ashlar/Identity/Models/Administration/UserAdministrationModels.cs @@ -5,9 +5,6 @@ namespace Ashlar.Identity.Models.Administration; /// public sealed record SearchUsersRequest { - /// Authenticated actor, active session, fresh proof, and audit metadata. - public AccountSecurityActorContext? Actor { get; init; } - /// Optional query matched against normalized email addresses and display names. public string? Query { get; init; } @@ -92,13 +89,11 @@ public sealed record UserAdministrationDetail( /// Requested scope. Use for global users; leave only when is enabled. /// Whether to allow lookup across all tenancy scopes. Cannot be combined with . /// Optional positive recent security event window for the embedded account-security posture; it must fit within the UTC timestamp range. -/// Authenticated actor, active session, fresh proof, and audit metadata. public sealed record UserAdministrationDetailRequest( Guid UserId, TenantContext? Tenant = null, bool IncludeAllTenants = false, - TimeSpan? RecentSecurityEventWindow = null, - AccountSecurityActorContext? Actor = null) + TimeSpan? RecentSecurityEventWindow = null) { /// /// Throws when the user lookup request is not safe to execute. diff --git a/src/Ashlar/Operational/Configuration/AshlarCoreConfigurationCheck.cs b/src/Ashlar/Operational/Configuration/AshlarCoreConfigurationCheck.cs index 4de6792a..3d8a251e 100644 --- a/src/Ashlar/Operational/Configuration/AshlarCoreConfigurationCheck.cs +++ b/src/Ashlar/Operational/Configuration/AshlarCoreConfigurationCheck.cs @@ -38,7 +38,7 @@ public async ValueTask> CheckAsync( typeof(IIdentityService), typeof(ICredentialService), typeof(IAccountSecurityService), - typeof(IUserAdministrationService), + typeof(IUserAdministrationReader), typeof(IInvitationService), typeof(IBootstrapService), typeof(IEmailVerificationService), @@ -57,7 +57,7 @@ public async ValueTask> CheckAsync( "Credential persistence", typeof(ICredentialService), typeof(IAccountSecurityService), - typeof(IUserAdministrationService), + typeof(IUserAdministrationReader), typeof(IEmailVerificationService), typeof(IEmailChangeService), typeof(IEmailCodeSignInService), @@ -85,7 +85,7 @@ public async ValueTask> CheckAsync( "Session persistence", typeof(IAuthenticationSessionService), typeof(IAccountSecurityService), - typeof(IUserAdministrationService), + typeof(IUserAdministrationReader), typeof(IEmailChangeService)); AddMissingServiceIssue( @@ -95,7 +95,7 @@ public async ValueTask> CheckAsync( "User administration persistence is not configured.", "Register an IUserAdministrationRepository implementation, usually from an Ashlar persistence provider.", "User administration", - typeof(IUserAdministrationService)); + typeof(IUserAdministrationReader)); AddMissingServiceIssue( serviceProvider, @@ -104,7 +104,7 @@ public async ValueTask> CheckAsync( "Credential administration persistence is not configured.", "Register an ICredentialAdministrationRepository implementation, usually from an Ashlar persistence provider.", "Credential administration", - typeof(ICredentialAdministrationService)); + typeof(ICredentialAdministrationReader)); AddMissingServiceIssue( serviceProvider, @@ -122,7 +122,7 @@ public async ValueTask> CheckAsync( "Authentication session administration persistence is not configured.", "Register an IAuthenticationSessionAdministrationRepository implementation, usually from an Ashlar persistence provider.", "Session administration", - typeof(IAuthenticationSessionAdministrationService)); + typeof(IAuthenticationSessionAdministrationReader)); AddMissingProviderServiceIssue( serviceProvider, diff --git a/src/Ashlar/README.md b/src/Ashlar/README.md index 84381423..49e87f63 100644 --- a/src/Ashlar/README.md +++ b/src/Ashlar/README.md @@ -63,11 +63,11 @@ Use `PrimaryCredentials` for sign-in methods, `AdditionalVerificationFactors` fo ## Admin User Reads -`IUserAdministrationService` provides reusable read-only operations for admin and operations UIs. Every request requires `AccountSecurityActorContext`, an explicit tenant/global/all-tenant scope, an active-session-bound fresh MFA proof for `administration-read`, matching audit identity, and host authorizer approval. Reads are durably audited and fail closed when audit persistence fails. Provider repositories remain provider-facing and results are safe projections. +`IUserAdministrationReader` provides reusable read-only operations for admin and operations UIs. Every call requires `AccountSecurityActorContext` separately from its query request, an explicit tenant/global/all-tenant scope, an active-session-bound fresh MFA proof for `administration-read`, matching audit identity, and host authorizer approval. Reads are durably audited and fail closed when audit persistence fails. Provider repositories remain provider-facing and results are safe projections. ## Admin Session Reads -`IAuthenticationSessionAdministrationService` uses the same actor-bound `administration-read` proof, scope, host authorization, and durable audit boundary as other administration reads. +`IAuthenticationSessionAdministrationReader` uses the same actor-bound `administration-read` proof, scope, host authorization, and durable audit boundary as other administration reads. Provider packages implement `IAuthenticationSessionAdministrationRepository`; raw session tokens, token hashes, and session metadata are not exposed. diff --git a/tests/Ashlar.Tests/DependencyInjection/AshlarCompositionTests.cs b/tests/Ashlar.Tests/DependencyInjection/AshlarCompositionTests.cs index 182dcabb..186e542b 100644 --- a/tests/Ashlar.Tests/DependencyInjection/AshlarCompositionTests.cs +++ b/tests/Ashlar.Tests/DependencyInjection/AshlarCompositionTests.cs @@ -347,14 +347,15 @@ public void CoreIdentityCompositionBuildsWithStrictValidationAndRequiredTestDoub var services = new ServiceCollection(); services.AddAshlarProviderScoped(_ => Mock.Of()); services.AddAshlarProviderScoped(_ => Mock.Of()); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); + services.AddAshlarProviderScoped(_ => Mock.Of()); + services.AddAshlarProviderScoped(_ => Mock.Of()); services.AddAshlarProviderScoped(_ => Mock.Of()); - services.AddSingleton(Mock.Of()); + services.AddAshlarProviderScoped(_ => Mock.Of()); services.AddSingleton(Mock.Of()); services.AddSingleton(secretProtector); services.AddSingleton(emailSender); services.AddSingleton(rateLimiter); + services.AddSingleton(Mock.Of()); services .AddAshlarIdentity() .AddDurableAuditForTests() @@ -367,6 +368,9 @@ public void CoreIdentityCompositionBuildsWithStrictValidationAndRequiredTestDoub typeof(IIdentityService), typeof(IAuthenticationPipeline), typeof(ICredentialService), + typeof(IUserAdministrationReader), + typeof(ICredentialAdministrationReader), + typeof(IAuthenticationSessionAdministrationReader), typeof(IdentityInfrastructureContext), typeof(AshlarDurableTransactionProvider), typeof(IAuthenticationRateLimiterDiagnostics), @@ -378,6 +382,9 @@ public void CoreIdentityCompositionBuildsWithStrictValidationAndRequiredTestDoub Assert.That(scope.ServiceProvider.GetRequiredService(), Is.SameAs(secretProtector)); Assert.That(scope.ServiceProvider.GetRequiredService(), Is.SameAs(emailSender)); Assert.That(scope.ServiceProvider.GetRequiredService(), Is.SameAs(rateLimiter)); + Assert.That(scope.ServiceProvider.GetRequiredService(), Is.TypeOf()); + Assert.That(scope.ServiceProvider.GetRequiredService(), Is.TypeOf()); + Assert.That(scope.ServiceProvider.GetRequiredService(), Is.TypeOf()); Assert.That(scope.ServiceProvider.GetRequiredService(), Is.TypeOf()); Assert.That(Microsoft.Extensions.DependencyInjection.AshlarProviderServiceCollection.GetRequiredAshlarProviderService(scope.ServiceProvider), Is.Not.Null); Assert.That(scope.ServiceProvider.GetRequiredService(), Is.TypeOf()); diff --git a/tests/Ashlar.Tests/DependencyInjection/AshlarServiceCollectionExtensionsTests.cs b/tests/Ashlar.Tests/DependencyInjection/AshlarServiceCollectionExtensionsTests.cs index 646a4688..b42f3c02 100644 --- a/tests/Ashlar.Tests/DependencyInjection/AshlarServiceCollectionExtensionsTests.cs +++ b/tests/Ashlar.Tests/DependencyInjection/AshlarServiceCollectionExtensionsTests.cs @@ -137,7 +137,9 @@ public void AddAshlarIdentityRegistersCoreServicesWithExpectedLifetimes() AssertDescriptor(services, ServiceLifetime.Scoped); AssertDescriptor(services, ServiceLifetime.Scoped); AssertDescriptor(services, ServiceLifetime.Scoped); - AssertDescriptor(services, ServiceLifetime.Scoped); + AssertDescriptor(services, ServiceLifetime.Scoped); + AssertDescriptor(services, ServiceLifetime.Scoped); + AssertDescriptor(services, ServiceLifetime.Scoped); AssertDescriptor(services, ServiceLifetime.Scoped); AssertDescriptor(services, ServiceLifetime.Scoped); Assert.That(services.Any(d => d.ServiceType == typeof(IAccountSecurityOperationAuthorizer)), Is.False); diff --git a/tests/Ashlar.Tests/Identity/Features/Administration/AccountRecoveryAdministrationServiceTests.cs b/tests/Ashlar.Tests/Identity/Features/Administration/AccountRecoveryAdministrationServiceTests.cs index cfb52343..59a9480d 100644 --- a/tests/Ashlar.Tests/Identity/Features/Administration/AccountRecoveryAdministrationServiceTests.cs +++ b/tests/Ashlar.Tests/Identity/Features/Administration/AccountRecoveryAdministrationServiceTests.cs @@ -8,7 +8,7 @@ internal sealed class AccountRecoveryAdministrationServiceTests [Test] public void ConstructorRejectsNullDependency() { - var users = new RecordingUserAdministrationService(Result.Failure(AshlarFailureCodes.UserNotFound)); + var users = new RecordingUserAdministrationReader(Result.Failure(AshlarFailureCodes.UserNotFound)); var devices = Mock.Of(); using (Assert.EnterMultipleScope()) @@ -105,7 +105,7 @@ public async Task GetAccountRecoveryOptionsAsyncExposesAccountStateAndSignInAvai public async Task GetAccountRecoveryOptionsAsyncPreservesGlobalTenantScope() { var userId = Guid.NewGuid(); - var userAdministration = new RecordingUserAdministrationService(Result.Success(CreateDetail(userId))); + var userAdministration = new RecordingUserAdministrationReader(Result.Success(CreateDetail(userId))); var service = CreateService(userAdministration: userAdministration); var eventWindow = TimeSpan.FromDays(3); var actor = CreateActor(); @@ -117,7 +117,7 @@ public async Task GetAccountRecoveryOptionsAsyncPreservesGlobalTenantScope() Assert.That(userAdministration.LastRequest?.Tenant, Is.EqualTo(TenantContext.Global)); Assert.That(userAdministration.LastRequest?.IncludeAllTenants, Is.False); Assert.That(userAdministration.LastRequest?.RecentSecurityEventWindow, Is.EqualTo(eventWindow)); - Assert.That(userAdministration.LastRequest?.Actor, Is.SameAs(actor)); + Assert.That(userAdministration.LastActor, Is.SameAs(actor)); } } @@ -135,7 +135,7 @@ private static AccountSecurityActorContext CreateActor() public async Task GetAccountRecoveryOptionsAsyncPreservesIncludeAllTenantsScope() { var userId = Guid.NewGuid(); - var userAdministration = new RecordingUserAdministrationService(Result.Success(CreateDetail(userId, Guid.NewGuid()))); + var userAdministration = new RecordingUserAdministrationReader(Result.Success(CreateDetail(userId, Guid.NewGuid()))); var service = CreateService(userAdministration: userAdministration); await service.GetAccountRecoveryOptionsAsync(new AccountRecoveryOptionsRequest(userId, IncludeAllTenants: true)); @@ -421,11 +421,11 @@ public async Task GetAccountRecoveryOptionsAsyncDoesNotWarnForUnavailablePrimary private static AccountRecoveryAdministrationService CreateService( Result? detailResult = null, - RecordingUserAdministrationService? userAdministration = null, + RecordingUserAdministrationReader? userAdministration = null, IRememberedMfaDeviceRepository? rememberedMfaDeviceRepository = null) { return new AccountRecoveryAdministrationService( - userAdministration ?? new RecordingUserAdministrationService(detailResult ?? Result.Failure(AshlarFailureCodes.UserNotFound)), + userAdministration ?? new RecordingUserAdministrationReader(detailResult ?? Result.Failure(AshlarFailureCodes.UserNotFound)), rememberedMfaDeviceRepository ?? Mock.Of()); } @@ -488,17 +488,19 @@ private static CredentialPostureItem CreateCredential( status); } - private sealed class RecordingUserAdministrationService(Result detailResult) : IUserAdministrationService + private sealed class RecordingUserAdministrationReader(Result detailResult) : IUserAdministrationReader { + public AccountSecurityActorContext? LastActor { get; private set; } public UserAdministrationDetailRequest? LastRequest { get; private set; } - public Task> SearchUsersAsync(SearchUsersRequest request, CancellationToken cancellationToken = default) + public Task> SearchUsersAsync(AccountSecurityActorContext actor, SearchUsersRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } - public Task> GetUserDetailAsync(UserAdministrationDetailRequest request, CancellationToken cancellationToken = default) + public Task> GetUserDetailAsync(AccountSecurityActorContext actor, UserAdministrationDetailRequest request, CancellationToken cancellationToken = default) { + LastActor = actor; LastRequest = request; return Task.FromResult(detailResult); } diff --git a/tests/Ashlar.Tests/Identity/Features/Administration/AccountSecurityOperationBoundaryTests.cs b/tests/Ashlar.Tests/Identity/Features/Administration/AccountSecurityOperationBoundaryTests.cs index a96981c0..acd1b8d6 100644 --- a/tests/Ashlar.Tests/Identity/Features/Administration/AccountSecurityOperationBoundaryTests.cs +++ b/tests/Ashlar.Tests/Identity/Features/Administration/AccountSecurityOperationBoundaryTests.cs @@ -47,26 +47,31 @@ public void ActorContextRequiresAudit() } [Test] - public async Task EveryAdminReadRejectsMissingActor() + public void SplitAdministrationReadersRejectNullActorBeforeProviderAccess() { - AssertAllFailed(await InvokeAllAsync(null, new AllowByScopeAuthorizer(), Mock.Of())); - var boundary = new AdminReadTestBoundary(Now); - var credentials = await new CredentialAdministrationService(Mock.Of(), boundary.Sessions, - boundary.Authorizer, boundary.Sink, boundary.TimeProvider).GetCredentialAsync( - new CredentialAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global)); - var sessions = await new AuthenticationSessionAdministrationService(Mock.Of(), boundary.Sessions, - boundary.Authorizer, boundary.Sink, boundary.TimeProvider).GetAuthenticationSessionAsync( - new AuthenticationSessionAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global)); - var events = await new SecurityEventAdministrationService(Mock.Of(), boundary.Sessions, - boundary.Authorizer, boundary.Sink, boundary.TimeProvider).GetSecurityEventAsync( - new SecurityEventAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global)); + var users = new Mock(); + var credentials = new Mock(); + var sessions = new Mock(); + var userReader = new UserAdministrationReader(users.Object, new PostureReader(), boundary.Sessions, + boundary.Authorizer, boundary.Sink, boundary.TimeProvider); + var credentialReader = new CredentialAdministrationReader(credentials.Object, boundary.Sessions, + boundary.Authorizer, boundary.Sink, boundary.TimeProvider); + var sessionReader = new AuthenticationSessionAdministrationReader(sessions.Object, boundary.Sessions, + boundary.Authorizer, boundary.Sink, boundary.TimeProvider); + using (Assert.EnterMultipleScope()) { - Assert.That(credentials.FailureCode, Is.EqualTo(AshlarFailureCodes.ValidationError)); - Assert.That(sessions.FailureCode, Is.EqualTo(AshlarFailureCodes.ValidationError)); - Assert.That(events.FailureCode, Is.EqualTo(AshlarFailureCodes.ValidationError)); + Assert.ThrowsAsync(() => userReader.SearchUsersAsync(null!, new() { IncludeAllTenants = true })); + Assert.ThrowsAsync(() => userReader.GetUserDetailAsync(null!, new(Guid.NewGuid(), TenantContext.Global))); + Assert.ThrowsAsync(() => credentialReader.SearchCredentialsAsync(null!, new() { IncludeAllTenants = true })); + Assert.ThrowsAsync(() => credentialReader.GetCredentialAsync(null!, new(Guid.NewGuid(), TenantContext.Global))); + Assert.ThrowsAsync(() => sessionReader.SearchAuthenticationSessionsAsync(null!, new() { IncludeAllTenants = true })); + Assert.ThrowsAsync(() => sessionReader.GetAuthenticationSessionAsync(null!, new(Guid.NewGuid(), TenantContext.Global))); } + users.VerifyNoOtherCalls(); + credentials.VerifyNoOtherCalls(); + sessions.VerifyNoOtherCalls(); } [Test] @@ -86,9 +91,9 @@ public async Task EveryAdminReadRejectsMismatchedAuditActorAndInvalidProof() AssertAllFailed(await InvokeAllAsync(missingAuditActor, new AllowByScopeAuthorizer(), boundary.Sessions)); AssertAllFailed(await InvokeAllAsync(invalidProof, new AllowByScopeAuthorizer(), boundary.Sessions)); - var service = new CredentialAdministrationService(Mock.Of(), + var service = new CredentialAdministrationReader(Mock.Of(), boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider); - await service.SearchCredentialsAsync(new SearchCredentialsRequest { Actor = mismatched, Tenant = TenantContext.Global }); + await service.SearchCredentialsAsync(mismatched, new SearchCredentialsRequest { Tenant = TenantContext.Global }); Assert.That(boundary.Sink.Events.Single().ActorUserId, Is.EqualTo(boundary.Actor.ActorUserId)); } @@ -146,14 +151,14 @@ public async Task AdminReadsDurablyAuditNormalizedOutcomesAndFailClosed() var repository = new Mock(); repository.Setup(value => value.SearchCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync([]); - var service = new CredentialAdministrationService(repository.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider); - var request = new SearchCredentialsRequest { Actor = boundary.Actor, Tenant = TenantContext.Global }; + var service = new CredentialAdministrationReader(repository.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider); + var request = new SearchCredentialsRequest { Tenant = TenantContext.Global }; - var result = await service.SearchCredentialsAsync(request); + var result = await service.SearchCredentialsAsync(boundary.Actor, request); var deniedBoundary = new AdminReadTestBoundary(Now, authorized: false); - var denied = await new CredentialAdministrationService(repository.Object, deniedBoundary.Sessions, deniedBoundary.Authorizer, - deniedBoundary.Sink, deniedBoundary.TimeProvider).SearchCredentialsAsync(request with { Actor = deniedBoundary.Actor }); - var throwing = new CredentialAdministrationService(repository.Object, boundary.Sessions, boundary.Authorizer, + var denied = await new CredentialAdministrationReader(repository.Object, deniedBoundary.Sessions, deniedBoundary.Authorizer, + deniedBoundary.Sink, deniedBoundary.TimeProvider).SearchCredentialsAsync(deniedBoundary.Actor, request); + var throwing = new CredentialAdministrationReader(repository.Object, boundary.Sessions, boundary.Authorizer, new ThrowingSink(), boundary.TimeProvider); using (Assert.EnterMultipleScope()) @@ -167,7 +172,7 @@ public async Task AdminReadsDurablyAuditNormalizedOutcomesAndFailClosed() Assert.That(deniedBoundary.Sink.Events.Single().Outcome, Is.EqualTo(SecurityEventOutcomes.Failure)); Assert.That(deniedBoundary.Sink.Events.Single().FailureReason, Is.EqualTo(AshlarFailureCodes.AuthorizationDeniedValue)); } - Assert.ThrowsAsync(async () => await throwing.SearchCredentialsAsync(request)); + Assert.ThrowsAsync(async () => await throwing.SearchCredentialsAsync(boundary.Actor, request)); } [Test] @@ -181,29 +186,28 @@ public void BoundaryFailuresAndAllTenantScopeAreDurablyAudited() sessions.Setup(value => value.GetSessionAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("session unavailable")); var proofSink = new AdminReadTestBoundary.RecordingSink(); - var proofFailure = new CredentialAdministrationService(repository.Object, sessions.Object, boundary.Authorizer, + var proofFailure = new CredentialAdministrationReader(repository.Object, sessions.Object, boundary.Authorizer, proofSink, boundary.TimeProvider); var authorizationSink = new AdminReadTestBoundary.RecordingSink(); - var authorizationFailure = new CredentialAdministrationService(repository.Object, boundary.Sessions, + var authorizationFailure = new CredentialAdministrationReader(repository.Object, boundary.Sessions, new ThrowingAuthorizer(), authorizationSink, boundary.TimeProvider); var allTenantsSink = new AdminReadTestBoundary.RecordingSink(); - var allTenants = new CredentialAdministrationService(repository.Object, boundary.Sessions, boundary.Authorizer, + var allTenants = new CredentialAdministrationReader(repository.Object, boundary.Sessions, boundary.Authorizer, allTenantsSink, boundary.TimeProvider); using (Assert.EnterMultipleScope()) { - Assert.ThrowsAsync(async () => await proofFailure.SearchCredentialsAsync( - new SearchCredentialsRequest { Actor = boundary.Actor, Tenant = TenantContext.Global })); - Assert.ThrowsAsync(async () => await authorizationFailure.SearchCredentialsAsync( - new SearchCredentialsRequest { Actor = boundary.Actor, Tenant = TenantContext.Global })); + Assert.ThrowsAsync(async () => await proofFailure.SearchCredentialsAsync(boundary.Actor, + new SearchCredentialsRequest { Tenant = TenantContext.Global })); + Assert.ThrowsAsync(async () => await authorizationFailure.SearchCredentialsAsync(boundary.Actor, + new SearchCredentialsRequest { Tenant = TenantContext.Global })); Assert.That(proofSink.Events.Single().Outcome, Is.EqualTo(SecurityEventOutcomes.Failure)); Assert.That(proofSink.Events.Single().ActorUserId, Is.Null); Assert.That(proofSink.Events.Single().SessionId, Is.Null); Assert.That(authorizationSink.Events.Single().Outcome, Is.EqualTo(SecurityEventOutcomes.Failure)); Assert.That(authorizationSink.Events.Single().ActorUserId, Is.EqualTo(boundary.Actor.ActorUserId)); - Assert.That((allTenants.SearchCredentialsAsync(new SearchCredentialsRequest + Assert.That((allTenants.SearchCredentialsAsync(boundary.Actor, new SearchCredentialsRequest { - Actor = boundary.Actor, IncludeAllTenants = true }).GetAwaiter().GetResult()).Succeeded, Is.True); Assert.That(allTenantsSink.Events.Single().Properties!["scope"], Is.EqualTo("all-tenants")); @@ -232,10 +236,10 @@ public async Task ItemLookupsReauthorizeTheResolvedOwnerWithoutRevealingDeniedTa .ReturnsAsync(new SecurityEventSummary(eventId, "test", Now, otherUser, null, null, null, null, null, null, null, SecurityEventOutcomes.Success, null, null)); - var credential = await new CredentialAdministrationService(credentials.Object, boundary.Sessions, authorizer, boundary.Sink, boundary.TimeProvider) - .GetCredentialAsync(new CredentialAdministrationLookupRequest(credentialId, TenantContext.Global, Actor: boundary.Actor)); - var session = await new AuthenticationSessionAdministrationService(sessions.Object, boundary.Sessions, authorizer, boundary.Sink, boundary.TimeProvider) - .GetAuthenticationSessionAsync(new AuthenticationSessionAdministrationLookupRequest(sessionId, TenantContext.Global, Actor: boundary.Actor)); + var credential = await new CredentialAdministrationReader(credentials.Object, boundary.Sessions, authorizer, boundary.Sink, boundary.TimeProvider) + .GetCredentialAsync(boundary.Actor, new CredentialAdministrationLookupRequest(credentialId, TenantContext.Global)); + var session = await new AuthenticationSessionAdministrationReader(sessions.Object, boundary.Sessions, authorizer, boundary.Sink, boundary.TimeProvider) + .GetAuthenticationSessionAsync(boundary.Actor, new AuthenticationSessionAdministrationLookupRequest(sessionId, TenantContext.Global)); var securityEvent = await new SecurityEventAdministrationService(events.Object, boundary.Sessions, authorizer, boundary.Sink, boundary.TimeProvider) .GetSecurityEventAsync(new SecurityEventAdministrationDetailRequest(eventId, TenantContext.Global, Actor: boundary.Actor)); @@ -255,14 +259,14 @@ public async Task ItemLookupsRejectInitialAuthorizationAndUseSystemClockByDefaul { var boundary = new AdminReadTestBoundary(DateTimeOffset.UtcNow); var denied = new DenyAuthorizer(); - var credential = await new CredentialAdministrationService(Mock.Of(), boundary.Sessions, denied, boundary.Sink) - .GetCredentialAsync(new CredentialAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor)); - var session = await new AuthenticationSessionAdministrationService(Mock.Of(), boundary.Sessions, denied, boundary.Sink) - .GetAuthenticationSessionAsync(new AuthenticationSessionAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor)); + var credential = await new CredentialAdministrationReader(Mock.Of(), boundary.Sessions, denied, boundary.Sink) + .GetCredentialAsync(boundary.Actor, new CredentialAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global)); + var session = await new AuthenticationSessionAdministrationReader(Mock.Of(), boundary.Sessions, denied, boundary.Sink) + .GetAuthenticationSessionAsync(boundary.Actor, new AuthenticationSessionAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global)); var securityEvent = await new SecurityEventAdministrationService(Mock.Of(), boundary.Sessions, denied, boundary.Sink) .GetSecurityEventAsync(new SecurityEventAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor)); - var user = await new UserAdministrationService(Mock.Of(), new PostureReader(), boundary.Sessions, denied, boundary.Sink) - .GetUserDetailAsync(new UserAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor)); + var user = await new UserAdministrationReader(Mock.Of(), new PostureReader(), boundary.Sessions, denied, boundary.Sink) + .GetUserDetailAsync(boundary.Actor, new UserAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global)); using (Assert.EnterMultipleScope()) { @@ -294,16 +298,16 @@ public void ItemLookupProviderFailuresAreDurablyAudited() using (Assert.EnterMultipleScope()) { - Assert.ThrowsAsync(async () => await new UserAdministrationService(users.Object, new PostureReader(), boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) - .GetUserDetailAsync(new UserAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor))); - Assert.ThrowsAsync(async () => await new CredentialAdministrationService(credentials.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) - .GetCredentialAsync(new CredentialAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor))); - Assert.ThrowsAsync(async () => await new AuthenticationSessionAdministrationService(sessions.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) - .GetAuthenticationSessionAsync(new AuthenticationSessionAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor))); + Assert.ThrowsAsync(async () => await new UserAdministrationReader(users.Object, new PostureReader(), boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) + .GetUserDetailAsync(boundary.Actor, new UserAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global))); + Assert.ThrowsAsync(async () => await new CredentialAdministrationReader(credentials.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) + .GetCredentialAsync(boundary.Actor, new CredentialAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global))); + Assert.ThrowsAsync(async () => await new AuthenticationSessionAdministrationReader(sessions.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) + .GetAuthenticationSessionAsync(boundary.Actor, new AuthenticationSessionAdministrationLookupRequest(Guid.NewGuid(), TenantContext.Global))); Assert.ThrowsAsync(async () => await new SecurityEventAdministrationService(events.Object, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) .GetSecurityEventAsync(new SecurityEventAdministrationDetailRequest(Guid.NewGuid(), TenantContext.Global, Actor: boundary.Actor))); - Assert.ThrowsAsync(async () => await new UserAdministrationService(postureUsers.Object, posture, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) - .GetUserDetailAsync(new UserAdministrationDetailRequest(userId, TenantContext.Global, Actor: boundary.Actor))); + Assert.ThrowsAsync(async () => await new UserAdministrationReader(postureUsers.Object, posture, boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider) + .GetUserDetailAsync(boundary.Actor, new UserAdministrationDetailRequest(userId, TenantContext.Global))); Assert.That(boundary.Sink.Events, Has.Count.EqualTo(5)); Assert.That(boundary.Sink.Events, Is.All.Matches(audit => audit.Outcome == SecurityEventOutcomes.Failure)); @@ -331,16 +335,16 @@ public void ItemLookupProviderFailuresAreDurablyAudited() var eventRepository = new Mock(); eventRepository.Setup(repository => repository.SearchSecurityEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([]); - var users = new UserAdministrationService(userRepository.Object, new PostureReader(), sessions, authorizer, sink, clock); - var credentials = new CredentialAdministrationService(credentialRepository.Object, sessions, authorizer, sink, clock); - var authenticationSessions = new AuthenticationSessionAdministrationService(sessionRepository.Object, sessions, authorizer, sink, clock); + var users = new UserAdministrationReader(userRepository.Object, new PostureReader(), sessions, authorizer, sink, clock); + var credentials = new CredentialAdministrationReader(credentialRepository.Object, sessions, authorizer, sink, clock); + var authenticationSessions = new AuthenticationSessionAdministrationReader(sessionRepository.Object, sessions, authorizer, sink, clock); var events = new SecurityEventAdministrationService(eventRepository.Object, sessions, authorizer, sink, clock); return [ - (await users.SearchUsersAsync(new SearchUsersRequest { Actor = actor, Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, - (await credentials.SearchCredentialsAsync(new SearchCredentialsRequest { Actor = actor, Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, - (await authenticationSessions.SearchAuthenticationSessionsAsync(new SearchAuthenticationSessionsRequest { Actor = actor, Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, + (await users.SearchUsersAsync(actor!, new SearchUsersRequest { Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, + (await credentials.SearchCredentialsAsync(actor!, new SearchCredentialsRequest { Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, + (await authenticationSessions.SearchAuthenticationSessionsAsync(actor!, new SearchAuthenticationSessionsRequest { Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode, (await events.SearchSecurityEventsAsync(new SearchSecurityEventsRequest { Actor = actor, Tenant = tenant, IncludeAllTenants = includeAllTenants })).FailureCode ]; } diff --git a/tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationServiceTests.cs b/tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationReaderTests.cs similarity index 96% rename from tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationServiceTests.cs rename to tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationReaderTests.cs index 766e037a..a908a4b1 100644 --- a/tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationServiceTests.cs +++ b/tests/Ashlar.Tests/Identity/Features/Administration/CredentialAdministrationReaderTests.cs @@ -2,14 +2,14 @@ namespace Ashlar.Tests.Identity.Features.Administration; using Ashlar.Tests.Support; -internal sealed class CredentialAdministrationServiceTests +internal sealed class CredentialAdministrationReaderTests { private static readonly DateTimeOffset Now = new(2026, 6, 2, 12, 0, 0, TimeSpan.Zero); [Test] public void ConstructorRejectsNullRepository() { - Assert.Throws(() => new CredentialAdministrationService(null!, null!, null!, null!)); + Assert.Throws(() => new CredentialAdministrationReader(null!, null!, null!, null!)); } [Test] @@ -311,20 +311,20 @@ public void SearchCredentialsAsyncRejectsOutOfScopePaginationSentinel() new SearchCredentialsRequest { Tenant = tenant, UserId = userId, Limit = 1 })); } - private static AuthorizedCredentialAdministrationService CreateService(RecordingCredentialAdministrationRepository? repository = null, TimeProvider? timeProvider = null) + private static AuthorizedCredentialAdministrationReader CreateService(RecordingCredentialAdministrationRepository? repository = null, TimeProvider? timeProvider = null) { var boundary = new AdminReadTestBoundary(timeProvider?.GetUtcNow() ?? Now); - return new AuthorizedCredentialAdministrationService(new CredentialAdministrationService( + return new AuthorizedCredentialAdministrationReader(new CredentialAdministrationReader( repository ?? new RecordingCredentialAdministrationRepository(), boundary.Sessions, boundary.Authorizer, boundary.Sink, timeProvider ?? new StaticTimeProvider(Now)), boundary.Actor); } - private sealed class AuthorizedCredentialAdministrationService(CredentialAdministrationService service, AccountSecurityActorContext actor) + private sealed class AuthorizedCredentialAdministrationReader(CredentialAdministrationReader reader, AccountSecurityActorContext actor) { public Task> SearchCredentialsAsync(SearchCredentialsRequest request) => - service.SearchCredentialsAsync(request is null ? null! : request with { Actor = actor }); + reader.SearchCredentialsAsync(actor, request); public Task> GetCredentialAsync(CredentialAdministrationLookupRequest request) => - service.GetCredentialAsync(request with { Actor = actor }); + reader.GetCredentialAsync(actor, request); } private static CredentialAdministrationSummary CreateSummary(DateTimeOffset? expiresAt = null, bool isAvailable = true) diff --git a/tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationServiceTests.cs b/tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationReaderTests.cs similarity index 95% rename from tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationServiceTests.cs rename to tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationReaderTests.cs index 13ffad05..7536e438 100644 --- a/tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationServiceTests.cs +++ b/tests/Ashlar.Tests/Identity/Features/Administration/UserAdministrationReaderTests.cs @@ -3,7 +3,7 @@ namespace Ashlar.Tests.Identity.Features.Administration; using Ashlar.Auditing; using Ashlar.Tests.Support; -internal sealed class UserAdministrationServiceTests +internal sealed class UserAdministrationReaderTests { [Test] public void SearchUsersAsyncRejectsNullRequest() @@ -18,8 +18,8 @@ public void ConstructorRejectsNullDependencies() { using (Assert.EnterMultipleScope()) { - Assert.Throws(() => new UserAdministrationService(null!, new RecordingAccountSecurityService(), null!, null!, null!)); - Assert.Throws(() => new UserAdministrationService(new RecordingUserAdministrationRepository(), null!, null!, null!, null!)); + Assert.Throws(() => new UserAdministrationReader(null!, new RecordingAccountSecurityService(), null!, null!, null!)); + Assert.Throws(() => new UserAdministrationReader(new RecordingUserAdministrationRepository(), null!, null!, null!, null!)); } } @@ -56,13 +56,12 @@ public async Task SearchUsersAsyncRejectsNegativeOffset() public async Task SearchUsersAsyncDurablyAuditsValidatedActorRequestRejection() { var boundary = new AdminReadTestBoundary(DateTimeOffset.UtcNow); - var service = new UserAdministrationService(new RecordingUserAdministrationRepository(), + var service = new UserAdministrationReader(new RecordingUserAdministrationRepository(), new RecordingAccountSecurityService(), boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider); - var result = await service.SearchUsersAsync(new SearchUsersRequest + var result = await service.SearchUsersAsync(boundary.Actor, new SearchUsersRequest { - Actor = boundary.Actor, Tenant = TenantContext.Global, Limit = 0 }); @@ -421,23 +420,23 @@ public async Task GetUserDetailAsyncRejectsInvalidProviderProjection() Assert.That(result.FailureCode, Is.EqualTo(AshlarFailureCodes.UserNotFound)); } - private static AuthorizedUserAdministrationService CreateService( + private static AuthorizedUserAdministrationReader CreateService( RecordingUserAdministrationRepository? repository = null, RecordingAccountSecurityService? accountSecurityService = null) { var boundary = new AdminReadTestBoundary(DateTimeOffset.UtcNow); - return new AuthorizedUserAdministrationService(new UserAdministrationService( + return new AuthorizedUserAdministrationReader(new UserAdministrationReader( repository ?? new RecordingUserAdministrationRepository(), accountSecurityService ?? new RecordingAccountSecurityService(), boundary.Sessions, boundary.Authorizer, boundary.Sink, boundary.TimeProvider), boundary.Actor); } - private sealed class AuthorizedUserAdministrationService(UserAdministrationService service, AccountSecurityActorContext actor) + private sealed class AuthorizedUserAdministrationReader(UserAdministrationReader reader, AccountSecurityActorContext actor) { public Task> SearchUsersAsync(SearchUsersRequest request) => - service.SearchUsersAsync(request is null ? null! : request with { Actor = actor }); + reader.SearchUsersAsync(actor, request); public Task> GetUserDetailAsync(UserAdministrationDetailRequest request) => - service.GetUserDetailAsync(request with { Actor = actor }); + reader.GetUserDetailAsync(actor, request); } private static UserSummary CreateSummary(string email, Guid? tenantId = null) diff --git a/tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationServiceTests.cs b/tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationReaderTests.cs similarity index 95% rename from tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationServiceTests.cs rename to tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationReaderTests.cs index f7952f49..1b0b19d2 100644 --- a/tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationServiceTests.cs +++ b/tests/Ashlar.Tests/Identity/Features/Sessions/AuthenticationSessionAdministrationReaderTests.cs @@ -2,14 +2,14 @@ namespace Ashlar.Tests.Identity.Features.Sessions; using Ashlar.Tests.Support; -internal sealed class AuthenticationSessionAdministrationServiceTests +internal sealed class AuthenticationSessionAdministrationReaderTests { private static readonly DateTimeOffset Now = new(2026, 6, 2, 12, 0, 0, TimeSpan.Zero); [Test] public void ConstructorRejectsNullRepository() { - Assert.Throws(() => new AuthenticationSessionAdministrationService(null!, null!, null!, null!)); + Assert.Throws(() => new AuthenticationSessionAdministrationReader(null!, null!, null!, null!)); } [Test] @@ -259,20 +259,20 @@ public void SearchAuthenticationSessionsAsyncRejectsOutOfScopePaginationSentinel new SearchAuthenticationSessionsRequest { Tenant = tenant, UserId = userId, Limit = 1 })); } - private static AuthorizedSessionAdministrationService CreateService(RecordingAuthenticationSessionAdministrationRepository? repository = null, TimeProvider? timeProvider = null) + private static AuthorizedSessionAdministrationReader CreateService(RecordingAuthenticationSessionAdministrationRepository? repository = null, TimeProvider? timeProvider = null) { var boundary = new AdminReadTestBoundary(timeProvider?.GetUtcNow() ?? Now); - return new AuthorizedSessionAdministrationService(new AuthenticationSessionAdministrationService( + return new AuthorizedSessionAdministrationReader(new AuthenticationSessionAdministrationReader( repository ?? new RecordingAuthenticationSessionAdministrationRepository(), boundary.Sessions, boundary.Authorizer, boundary.Sink, timeProvider ?? new StaticTimeProvider(Now)), boundary.Actor); } - private sealed class AuthorizedSessionAdministrationService(AuthenticationSessionAdministrationService service, AccountSecurityActorContext actor) + private sealed class AuthorizedSessionAdministrationReader(AuthenticationSessionAdministrationReader reader, AccountSecurityActorContext actor) { public Task> SearchAuthenticationSessionsAsync(SearchAuthenticationSessionsRequest request) => - service.SearchAuthenticationSessionsAsync(request is null ? null! : request with { Actor = actor }); + reader.SearchAuthenticationSessionsAsync(actor, request); public Task> GetAuthenticationSessionAsync(AuthenticationSessionAdministrationLookupRequest request) => - service.GetAuthenticationSessionAsync(request with { Actor = actor }); + reader.GetAuthenticationSessionAsync(actor, request); } private static AuthenticationSessionAdministrationSummary CreateSummary()