< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.AddScopeOwnerCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AddScopeOwnerCommandHandler.cs
Line coverage
100%
Covered lines: 91
Uncovered lines: 0
Coverable lines: 91
Total lines: 222
Line coverage: 100%
Branch coverage
84%
Covered branches: 22
Total branches: 26
Branch coverage: 84.6%
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.85%1414100%
InviteAsync()78.57%1414100%
InvitedRecentlyAsync(...)100%11100%
AuditCreatedAsync()100%11100%
AlreadyOwner(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AddScopeOwnerCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Auditing;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Domain.Persistence;
 9using ArturRios.Heimdall.Shared.Messages;
 10using ArturRios.Heimdall.Shared.Services;
 11using ArturRios.Mediator.Command.Interfaces;
 12using ArturRios.Output;
 13using Microsoft.EntityFrameworkCore;
 14using Microsoft.Extensions.Logging;
 15
 16namespace ArturRios.Heimdall.Command.Handlers;
 17
 18/// <summary>
 19///     Handles <see cref="AddScopeOwnerCommand" /> (UC-21, FR-SC-08/FR-SC-09): verifies the target
 20///     scope exists and is active (AF-21a), enforces scope ownership for a Scope Admin actor
 21///     (AF-21c), verifies the named person is an existing, non-deleted <c>ScopeAdmin</c> or
 22///     <c>SystemAdmin</c> (AF-21b), then links them to the scope with a <c>SCOPE_OWNER</c> row —
 23///     serving a person who already owns it as an idempotent no-op (AF-21d). A Scope Admin actor gets
 24///     an invitation instead (FR-SC-14, Threat Model TH-41): the person owns nothing until they accept
 25///     it (UC-60). A System Admin actor bypasses the ownership check. All failures are returned as
 26///     errors on the <see cref="DataOutput{T}" /> rather than thrown.
 27/// </summary>
 4328public class AddScopeOwnerCommandHandler(
 4329    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 4330    IAsyncReadOnlyRepository<Person, long> personReader,
 4331    IScopeOwnershipChecker scopeOwnership,
 4332    IAtomicWrites atomicWrites,
 4333    IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader,
 4334    IScopeOwnerInvitations invitations,
 4335    ScopeInvitationOptions invitationOptions,
 4336    AdminEmailOptions adminEmail,
 4337    IScopeInvitationNoticeSender notices,
 4338    IAuditLogWriter auditLog,
 4339    ILogger<AddScopeOwnerCommandHandler> logger)
 40    : ICommandHandlerAsync<AddScopeOwnerCommand, AddScopeOwnerCommandOutput>
 41{
 42    public async Task<DataOutput<AddScopeOwnerCommandOutput?>> HandleAsync(AddScopeOwnerCommand command, CancellationTok
 43    {
 4444        var output = DataOutput<AddScopeOwnerCommandOutput?>.New;
 45
 46        // AF-21a (UC-21 step 2): the target scope must exist and not be logically deleted — the
 47        // alternative flow names both conditions as one outcome, so the filter answers for both.
 4448        var scope = await scopeReader.Query()
 4449            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 50
 4451        if (scope is null)
 52        {
 553            return output.WithError(PersonMessages.ScopeNotFound);
 54        }
 55
 56        // AF-21c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 57        // Checked before the person is looked up so a caller who fails here learns nothing about
 58        // which person ids exist or what roles they hold.
 3959        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 60        {
 361            return output.WithError(PersonMessages.NotScopeOwner);
 62        }
 63
 64        // AF-21b (UC-21 step 3): the person must exist, not be logically deleted, and hold an
 65        // administrator role — ScopeAdmin or SystemAdmin (FR-SC-08). A deleted person can no longer
 66        // authenticate, so an ownership granted to them could never be exercised. All three
 67        // conditions share one answer, so the endpoint cannot be used to tell an unknown id from a
 68        // User. The ownership rows are included because adding to that collection is how the join
 69        // row gets written below.
 3670        var person = await personReader.Query()
 3671            .Include(x => x.ScopeOwnerships)
 3672            .FirstOrDefaultAsync(x =>
 3673                x.PublicId == command.PersonId && !x.IsDeleted &&
 3674                (x.RoleId == (long)Roles.ScopeAdmin || x.RoleId == (long)Roles.SystemAdmin));
 75
 3676        if (person is null)
 77        {
 978            return output.WithError(PersonMessages.PersonNotValidScopeAdmin);
 79        }
 80
 81        // AF-21d: already an owner, so there is nothing to write — UC-21 step 4 calls the insert a
 82        // no-op in that case.
 3183        if (person.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id))
 84        {
 385            return AlreadyOwner(output, scope, person);
 86        }
 87
 88        // TH-41 (FR-SC-14): a Scope Admin no longer makes another administrator an owner on their own. The
 89        // person is invited and owns nothing until they accept, signed in as themselves (UC-60). A System
 90        // Admin administers every scope already, and UC-06 onboarding relies on their immediate add, so
 91        // their path below is unchanged.
 2492        if (command.ActingRole != (int)Roles.SystemAdmin)
 93        {
 1494            return await InviteAsync(output, command, scope, person, cancellationToken);
 95        }
 96
 97        // UC-21 step 4: insert the SCOPE_OWNER row, by a targeted write of that row alone (#237) rather
 98        // than a save of the person aggregate, which would write the person's profile back as read.
 99        // Made only while the person is still a live administrator, so a deletion or a role change
 100        // that landed since the check above is not overruled. FR-PE-11 needs no guard here: the person
 101        // is already an administrator and this only ever increases the scopes they own.
 10102        if (!await atomicWrites.TryAddScopeOwnerAsync(person, scope.Id))
 103        {
 104            // Refused: either another request made them an owner meanwhile, which is AF-21d's outcome,
 105            // or they stopped being a live administrator, which is AF-21b's. The row tells which.
 2106            var ownsNow = await personReader.Query()
 2107                .AnyAsync(x => x.Id == person.Id && x.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id));
 108
 2109            return ownsNow
 2110                ? AlreadyOwner(output, scope, person)
 2111                : output.WithError(PersonMessages.PersonNotValidScopeAdmin);
 112        }
 113
 114        // UC-21 step 5.
 8115        return output
 8116            .WithData(new AddScopeOwnerCommandOutput
 8117            {
 8118                ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = false
 8119            })
 8120            .WithMessage(PersonMessages.ScopeOwnerAddedSuccessfully);
 44121    }
 122
 123    /// <summary>
 124    ///     Opens an invitation for <paramref name="person" />, or finds the one already open, audits a new one,
 125    ///     and mails the invitee a notice when it is new — unless the administrator-mail rules hold it back.
 126    /// </summary>
 127    /// <remarks>
 128    ///     <see cref="IScopeOwnerInvitations.OpenAsync" /> saves the unit of work, so nothing on this path
 129    ///     changes a tracked entity before it: the scope, the person and the inviter are read for decisions
 130    ///     only, and the invitation is the one entity it adds.
 131    /// </remarks>
 132    private async Task<DataOutput<AddScopeOwnerCommandOutput?>> InviteAsync(
 133        DataOutput<AddScopeOwnerCommandOutput?> output, AddScopeOwnerCommand command, Scope scope, Person person,
 134        CancellationToken cancellationToken)
 135    {
 14136        var now = DateTime.UtcNow;
 137
 14138        var inviter = await personReader.Query()
 14139            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId, cancellationToken);
 140
 14141        var opening = await invitations.OpenAsync(new ScopeOwnerInvitation
 14142        {
 14143            PublicId = Guid.NewGuid(),
 14144            ScopeId = scope.Id,
 14145            InviteePersonId = person.Id,
 14146            InvitedByPersonId = inviter?.Id,
 14147            CreatedAt = now,
 14148            ExpiresAt = now + invitationOptions.Lifetime
 14149        }, now);
 150
 14151        var invitation = opening.Invitation;
 152
 14153        if (opening.Created)
 154        {
 12155            await AuditCreatedAsync(invitation);
 156        }
 157
 158        // The administrator-mail rules hold the notice back, never the invitation. A repeat while one is
 159        // open mails nothing (the idempotent re-invite). A restricted person is mailed nothing (AF-51c,
 160        // NFR-24); their acceptance would void it anyway. And a new one mails nothing when the invitee was
 161        // invited within the cooldown: withdrawing and re-inviting must not become a way to fill their inbox.
 14162        if (opening.Created && person.ProcessingRestrictedAt is null &&
 14163            !await InvitedRecentlyAsync(person.Id, invitation.Id, now, cancellationToken))
 164        {
 10165            await notices.SendInvitationAsync(
 10166                person.Email, inviter?.Name ?? "An administrator", scope.Name, invitation.ExpiresAt);
 167        }
 168
 14169        return output
 14170            .WithData(new AddScopeOwnerCommandOutput
 14171            {
 14172                ScopeId = scope.PublicId,
 14173                PersonId = person.PublicId,
 14174                AlreadyOwner = false,
 14175                Invited = true,
 14176                InvitationId = invitation.PublicId,
 14177                InvitationExpiresAt = invitation.ExpiresAt
 14178            })
 14179            .WithMessage(ScopeInvitationMessages.InvitationSent);
 14180    }
 181
 182    /// <summary>
 183    ///     Whether another invitation to the same person was created within
 184    ///     <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c> — the administrator-mail rule, derived from the
 185    ///     invitation rows as <see cref="AdminEmailOptions" /> derives it from token rows.
 186    /// </summary>
 187    private Task<bool> InvitedRecentlyAsync(
 188        long inviteeId, long invitationId, DateTime now, CancellationToken cancellationToken)
 189    {
 11190        var since = now - adminEmail.Cooldown;
 191
 11192        return invitationReader.Query()
 11193            .AnyAsync(x => x.InviteePersonId == inviteeId && x.Id != invitationId && x.CreatedAt > since,
 11194                cancellationToken);
 195    }
 196
 197    /// <summary>
 198    ///     Records the new invitation (NFR-09) with the invitation as target, which the decorator's entry for
 199    ///     this command cannot carry. Like the decorator, a failed write is logged and the answer stands.
 200    /// </summary>
 201    private async Task AuditCreatedAsync(ScopeOwnerInvitation invitation)
 202    {
 203        try
 204        {
 12205            await auditLog.WriteAsync(ScopeOwnerInvitationAuditActions.Created, invitation.PublicId, true, null);
 11206        }
 1207        catch (Exception exception)
 208        {
 1209            logger.LogWarning(
 1210                exception, "Failed to audit the creation of invitation {InvitationId}", invitation.PublicId);
 1211        }
 12212    }
 213
 214    private static DataOutput<AddScopeOwnerCommandOutput?> AlreadyOwner(
 215        DataOutput<AddScopeOwnerCommandOutput?> output, Scope scope, Person person) =>
 3216        output
 3217            .WithData(new AddScopeOwnerCommandOutput
 3218            {
 3219                ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = true
 3220            })
 3221            .WithMessage(PersonMessages.AlreadyScopeOwner);
 222}