using DodoSSH.Contracts; using DodoSSH.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.EntityFrameworkCore; namespace DodoSSH.Api.Authorization; /// /// Requires that the caller has published an identity key. /// /// /// Not a confidentiality boundary — vault access is decided by ownership and grants, and an /// unenrolled user holds neither. What this prevents is a client with no key material writing /// ciphertext nobody can ever decrypt, and reading vault contents it has no way to open. Both /// present to a user as data corruption, so the server refuses early and says why. /// internal sealed class EnrolledRequirement : IAuthorizationRequirement; /// Checks enrollment state against the database. /// /// Scoped, not singleton, because it uses the request's . Registering it /// with the default singleton lifetime would capture one context for the process. /// internal sealed class EnrolledHandler( ICurrentUserContext currentUser, DodoDbContext database, IHttpContextAccessor accessor) : AuthorizationHandler { /// protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, EnrolledRequirement requirement) { ArgumentNullException.ThrowIfNull(context); // Anonymous callers are already refused by the policy's authentication requirement, which // produces a 401. Leaving this one unmet as well would turn that into a 403. if (context.User.Identity?.IsAuthenticated != true) { return; } var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None; var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false); var enrolled = await database.UserKeys .AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken) .ConfigureAwait(false); if (enrolled) { context.Succeed(requirement); } } } /// /// Turns an unmet into a ProblemDetails response carrying /// . /// /// /// A bare 403 has an empty body, so a client cannot tell "you have not enrolled" — which it can /// fix on its own — from "you may not touch this vault", which it cannot. Every other failure falls /// through to the framework's default handling unchanged. /// internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler { private readonly AuthorizationMiddlewareResultHandler defaultHandler = new(); /// public async Task HandleAsync( RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(authorizeResult); var enrollmentMissing = authorizeResult.Forbidden && authorizeResult.AuthorizationFailure is { } failure && failure.FailedRequirements.OfType().Any(); if (!enrollmentMissing) { await defaultHandler.HandleAsync(next, context, policy, authorizeResult).ConfigureAwait(false); return; } await TypedResults.Problem( detail: "Publish an identity key at POST /api/v1/me/enrollment before using vaults.", statusCode: StatusCodes.Status403Forbidden, type: ProblemCodes.TypeBaseUri + ProblemCodes.EnrollmentRequired, extensions: new Dictionary(StringComparer.Ordinal) { ["code"] = ProblemCodes.EnrollmentRequired, }) .ExecuteAsync(context) .ConfigureAwait(false); } }