< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.ActorLivenessFilter
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/ActorLivenessFilter.cs
Line coverage
100%
Covered lines: 35
Uncovered lines: 0
Coverable lines: 35
Total lines: 179
Line coverage: 100%
Branch coverage
93%
Covered branches: 15
Total branches: 16
Branch coverage: 93.7%
Method coverage
100%
Covered methods: 3
Fully covered methods: 3
Total methods: 3
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
OnAuthorizationAsync()100%44100%
RefusalReasonAsync()91.66%1212100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/ActorLivenessFilter.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Output;
 5using ArturRios.Util.Http;
 6using ArturRios.Util.WebApi.Security.Extensions;
 7using Microsoft.AspNetCore.Mvc;
 8using Microsoft.AspNetCore.Mvc.Filters;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.WebApi.Security;
 12
 13/// <summary>
 14///     Global authorization filter rejecting a request whose bearer token names an identity that no
 15///     longer exists or has been logically deleted (FR-AU-05, FR-GO-12), whose role claim no longer
 16///     matches that identity's role (Threat Model TH-08), or — for a person — whose security stamp is
 17///     missing or no longer the person's (FR-AU-11, TH-36), with <c>401 Unauthorized</c>.
 18/// </summary>
 19/// <remarks>
 20///     <para>
 21///         Authentication runs in <c>ClaimsOnly</c> mode (<c>Startup.ConfigureSecurity</c>): the
 22///         caller is rebuilt from the token's claims and no data store is consulted, which is what
 23///         keeps an ordinary request free of a per-request lookup. The cost is that a token outlives
 24///         the state it describes — both the identity it names and the role it claims, for the whole
 25///         of its lifetime, an hour by default — and nothing in the pipeline noticed.
 26///     </para>
 27///     <para>
 28///         Several handlers already compensated, and that is precisely why this filter exists:
 29///         <c>ScopeOwnershipChecker</c> and <c>GetPersonByIdQueryHandler</c> excluded a deleted actor
 30///         explicitly, while the checks that grant on the role claim alone — every System Admin
 31///         bypass, and every "the actor is acting on themselves" branch — did not. The protection was
 32///         therefore present for a Scope Admin and absent for a System Admin, who can do strictly
 33///         more. Enforcing it once here makes the rule uniform instead of a per-handler decision that
 34///         each new endpoint has to remember, and the handlers' own guards stay as defence in depth.
 35///     </para>
 36///     <para>
 37///         Both identity tables are consulted because both mint tokens through the same claims: a
 38///         password identity is a <see cref="Person" /> (UC-11), a Google-authenticated one is a
 39///         <see cref="GoogleUser" /> (UC-25 step 8), and the token itself does not say which. The
 40///         lookups run against unique indexes on <c>public_id</c>, and the second only when the first
 41///         misses — so an authenticated request costs one indexed read, and a Google User's two.
 42///     </para>
 43///     <para>
 44///         One exception: an action marked <see cref="DataSubjectRightAttribute" /> — the endpoints
 45///         through which a subject exports, erases, restricts, or lifts their own restriction — also
 46///         admits an identity that is restricted, or logically deleted but not yet anonymised, and
 47///         leaves the use case's own eligibility flows to answer. See that attribute for why.
 48///     </para>
 49///     <para>
 50///         A request carrying no bearer token resolves to <c>null</c> and is left alone, exactly as
 51///         <see cref="MfaPendingGuardFilter" /> leaves it: this filter narrows an identity the
 52///         pipeline already attached and never requires authentication of its own, so
 53///         <c>[AllowAnonymous]</c> endpoints are unaffected.
 54///     </para>
 55/// </remarks>
 141156public class ActorLivenessFilter(
 141157    IAsyncReadOnlyRepository<Person, long> personReader,
 141158    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader) : IAsyncAuthorizationFilter
 59{
 60    /// <summary>
 61    ///     The single refusal, deliberately identical for "no such identity" and "deleted": a caller
 62    ///     holding a token the API will not honour learns only that, never which of the two it was.
 63    /// </summary>
 64    public const string ActorNotLive = "The identity this token names no longer exists or has been deleted.";
 65
 66    /// <summary>
 67    ///     The refusal for a token whose role claim no longer matches the identity's role. Separate
 68    ///     from <see cref="ActorNotLive" /> on purpose: that message is deliberately vague because
 69    ///     the two cases it covers are both questions about whether an account exists, which a
 70    ///     caller has no business learning. A role change is not an existence question — it is the
 71    ///     caller's own account, they can establish it by signing in again, and telling them to is
 72    ///     more useful than telling them nothing.
 73    /// </summary>
 74    public const string ActorRoleChanged = "This token's role is out of date. Sign in again.";
 75
 76    /// <summary>
 77    ///     The refusal for a person token whose security stamp is missing or is no longer the
 78    ///     person's (FR-AU-11): a password reset, an email change or a second-factor change since it
 79    ///     was issued revoked it, or it predates the stamp. Worded like <see cref="ActorRoleChanged" />
 80    ///     and for the same reason: the token is the caller's own, and signing in again is the remedy.
 81    /// </summary>
 82    public const string ActorTokenRevoked = "This token has been revoked. Sign in again.";
 83
 84    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
 85    {
 134286        var user = context.HttpContext.GetUser<IdentityUser>();
 87
 134288        if (user is null)
 89        {
 48390            return;
 91        }
 92
 85993        var subjectRight = context.ActionDescriptor.EndpointMetadata.OfType<DataSubjectRightAttribute>().Any();
 94
 85995        var error = await RefusalReasonAsync(user, subjectRight);
 96
 85997        if (error is null)
 98        {
 81999            return;
 100        }
 101
 40102        var output = ProcessOutput.New.WithError(error);
 103
 40104        context.Result = new ObjectResult(output) { StatusCode = HttpStatusCodes.Unauthorized };
 1342105    }
 106
 107    /// <summary>
 108    ///     Why this actor may not act, or <c>null</c> when it may.
 109    /// </summary>
 110    /// <remarks>
 111    ///     <para>
 112    ///         The role is compared as well as the identity, and the reason is TH-08 in the Threat
 113    ///         Model: without this, a role change took effect only when the token expired — an hour
 114    ///         by default — because the role travels in a claim and nothing re-read it. Deletion was
 115    ///         enforced within one request and demotion was not enforced at all.
 116    ///     </para>
 117    ///     <para>
 118    ///         That gap was worse than a stale privilege, because the window was long enough to make
 119    ///         itself permanent: a role change is authorised from the acting role claim, and the one
 120    ///         transition <c>UpdatePersonCommandHandler</c> supports is a promotion to System Admin,
 121    ///         so a demoted System Admin could promote themselves back with a write that outlives
 122    ///         every token involved.
 123    ///     </para>
 124    ///     <para>
 125    ///         It costs nothing: the row was already being read to check liveness, so this reads the
 126    ///         role from that same row instead of asking whether it exists.
 127    ///     </para>
 128    ///     <para>
 129    ///         The security stamp is read from the same row for the same reason, and checked first
 130    ///         (FR-AU-11): a token a credential change has revoked is refused whatever else it claims.
 131    ///         A credential change is when a person's earlier sessions should end, and the stamp is
 132    ///         what makes them end then rather than when each token expires (TH-36). A token with
 133    ///         no stamp is refused too: every token issued before the stamp existed lacks one, and no
 134    ///         person token issued since does. A Google User's token carries none and is not asked
 135    ///         for one, since nothing here can change its credential.
 136    ///     </para>
 137    /// </remarks>
 138    private async Task<string?> RefusalReasonAsync(IdentityUser actor, bool subjectRight)
 139    {
 859140        var actorPublicId = actor.Id;
 859141        var actorRole = actor.RoleId;
 142
 143        // On a subject-rights action a restricted or suspended identity still counts, an anonymised
 144        // one never does: once NFR-20 has run there is no subject left to serve.
 859145        var stored = await personReader.Query()
 859146            .Where(person => person.PublicId == actorPublicId &&
 859147                             (subjectRight
 859148                                 ? person.AnonymisedAt == null
 859149                                 : !person.IsDeleted && person.ProcessingRestrictedAt == null))
 859150            .Select(person => new { person.RoleId, person.SecurityStamp })
 859151            .FirstOrDefaultAsync();
 152
 859153        if (stored is not null)
 154        {
 824155            if (actor.SecurityStamp != stored.SecurityStamp)
 156            {
 16157                return ActorTokenRevoked;
 158            }
 159
 808160            return stored.RoleId == actorRole ? null : ActorRoleChanged;
 161        }
 162
 35163        var googleUserIsLive = await googleUserReader.Query()
 35164            .AnyAsync(googleUser => googleUser.PublicId == actorPublicId &&
 35165                                    (subjectRight
 35166                                        ? googleUser.AnonymisedAt == null
 35167                                        : !googleUser.IsDeleted && googleUser.ProcessingRestrictedAt == null));
 168
 35169        if (!googleUserIsLive)
 170        {
 20171            return ActorNotLive;
 172        }
 173
 174        // A Google User has no stored role — authentication is delegated to Google — and UC-25 mints
 175        // its token with Roles.User every time. So there is no drift to detect here, only the
 176        // assertion that a token naming a Google identity claims nothing more than that role.
 15177        return actorRole == (int)Roles.User ? null : ActorRoleChanged;
 859178    }
 179}