| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Auditing; |
| | | 3 | | using ArturRios.Heimdall.Command.Input; |
| | | 4 | | using ArturRios.Heimdall.Command.Output; |
| | | 5 | | using ArturRios.Heimdall.Command.Services; |
| | | 6 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 7 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 8 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 9 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 10 | | using ArturRios.Heimdall.Shared.Services; |
| | | 11 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 12 | | using ArturRios.Output; |
| | | 13 | | using Microsoft.EntityFrameworkCore; |
| | | 14 | | using Microsoft.Extensions.Logging; |
| | | 15 | | |
| | | 16 | | namespace 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> |
| | 43 | 28 | | public class AddScopeOwnerCommandHandler( |
| | 43 | 29 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 43 | 30 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 43 | 31 | | IScopeOwnershipChecker scopeOwnership, |
| | 43 | 32 | | IAtomicWrites atomicWrites, |
| | 43 | 33 | | IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader, |
| | 43 | 34 | | IScopeOwnerInvitations invitations, |
| | 43 | 35 | | ScopeInvitationOptions invitationOptions, |
| | 43 | 36 | | AdminEmailOptions adminEmail, |
| | 43 | 37 | | IScopeInvitationNoticeSender notices, |
| | 43 | 38 | | IAuditLogWriter auditLog, |
| | 43 | 39 | | ILogger<AddScopeOwnerCommandHandler> logger) |
| | | 40 | | : ICommandHandlerAsync<AddScopeOwnerCommand, AddScopeOwnerCommandOutput> |
| | | 41 | | { |
| | | 42 | | public async Task<DataOutput<AddScopeOwnerCommandOutput?>> HandleAsync(AddScopeOwnerCommand command, CancellationTok |
| | | 43 | | { |
| | 44 | 44 | | 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. |
| | 44 | 48 | | var scope = await scopeReader.Query() |
| | 44 | 49 | | .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted); |
| | | 50 | | |
| | 44 | 51 | | if (scope is null) |
| | | 52 | | { |
| | 5 | 53 | | 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. |
| | 39 | 59 | | if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id)) |
| | | 60 | | { |
| | 3 | 61 | | 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. |
| | 36 | 70 | | var person = await personReader.Query() |
| | 36 | 71 | | .Include(x => x.ScopeOwnerships) |
| | 36 | 72 | | .FirstOrDefaultAsync(x => |
| | 36 | 73 | | x.PublicId == command.PersonId && !x.IsDeleted && |
| | 36 | 74 | | (x.RoleId == (long)Roles.ScopeAdmin || x.RoleId == (long)Roles.SystemAdmin)); |
| | | 75 | | |
| | 36 | 76 | | if (person is null) |
| | | 77 | | { |
| | 9 | 78 | | 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. |
| | 31 | 83 | | if (person.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id)) |
| | | 84 | | { |
| | 3 | 85 | | 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. |
| | 24 | 92 | | if (command.ActingRole != (int)Roles.SystemAdmin) |
| | | 93 | | { |
| | 14 | 94 | | 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. |
| | 10 | 102 | | 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. |
| | 2 | 106 | | var ownsNow = await personReader.Query() |
| | 2 | 107 | | .AnyAsync(x => x.Id == person.Id && x.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id)); |
| | | 108 | | |
| | 2 | 109 | | return ownsNow |
| | 2 | 110 | | ? AlreadyOwner(output, scope, person) |
| | 2 | 111 | | : output.WithError(PersonMessages.PersonNotValidScopeAdmin); |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | // UC-21 step 5. |
| | 8 | 115 | | return output |
| | 8 | 116 | | .WithData(new AddScopeOwnerCommandOutput |
| | 8 | 117 | | { |
| | 8 | 118 | | ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = false |
| | 8 | 119 | | }) |
| | 8 | 120 | | .WithMessage(PersonMessages.ScopeOwnerAddedSuccessfully); |
| | 44 | 121 | | } |
| | | 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 | | { |
| | 14 | 136 | | var now = DateTime.UtcNow; |
| | | 137 | | |
| | 14 | 138 | | var inviter = await personReader.Query() |
| | 14 | 139 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId, cancellationToken); |
| | | 140 | | |
| | 14 | 141 | | var opening = await invitations.OpenAsync(new ScopeOwnerInvitation |
| | 14 | 142 | | { |
| | 14 | 143 | | PublicId = Guid.NewGuid(), |
| | 14 | 144 | | ScopeId = scope.Id, |
| | 14 | 145 | | InviteePersonId = person.Id, |
| | 14 | 146 | | InvitedByPersonId = inviter?.Id, |
| | 14 | 147 | | CreatedAt = now, |
| | 14 | 148 | | ExpiresAt = now + invitationOptions.Lifetime |
| | 14 | 149 | | }, now); |
| | | 150 | | |
| | 14 | 151 | | var invitation = opening.Invitation; |
| | | 152 | | |
| | 14 | 153 | | if (opening.Created) |
| | | 154 | | { |
| | 12 | 155 | | 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. |
| | 14 | 162 | | if (opening.Created && person.ProcessingRestrictedAt is null && |
| | 14 | 163 | | !await InvitedRecentlyAsync(person.Id, invitation.Id, now, cancellationToken)) |
| | | 164 | | { |
| | 10 | 165 | | await notices.SendInvitationAsync( |
| | 10 | 166 | | person.Email, inviter?.Name ?? "An administrator", scope.Name, invitation.ExpiresAt); |
| | | 167 | | } |
| | | 168 | | |
| | 14 | 169 | | return output |
| | 14 | 170 | | .WithData(new AddScopeOwnerCommandOutput |
| | 14 | 171 | | { |
| | 14 | 172 | | ScopeId = scope.PublicId, |
| | 14 | 173 | | PersonId = person.PublicId, |
| | 14 | 174 | | AlreadyOwner = false, |
| | 14 | 175 | | Invited = true, |
| | 14 | 176 | | InvitationId = invitation.PublicId, |
| | 14 | 177 | | InvitationExpiresAt = invitation.ExpiresAt |
| | 14 | 178 | | }) |
| | 14 | 179 | | .WithMessage(ScopeInvitationMessages.InvitationSent); |
| | 14 | 180 | | } |
| | | 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 | | { |
| | 11 | 190 | | var since = now - adminEmail.Cooldown; |
| | | 191 | | |
| | 11 | 192 | | return invitationReader.Query() |
| | 11 | 193 | | .AnyAsync(x => x.InviteePersonId == inviteeId && x.Id != invitationId && x.CreatedAt > since, |
| | 11 | 194 | | 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 | | { |
| | 12 | 205 | | await auditLog.WriteAsync(ScopeOwnerInvitationAuditActions.Created, invitation.PublicId, true, null); |
| | 11 | 206 | | } |
| | 1 | 207 | | catch (Exception exception) |
| | | 208 | | { |
| | 1 | 209 | | logger.LogWarning( |
| | 1 | 210 | | exception, "Failed to audit the creation of invitation {InvitationId}", invitation.PublicId); |
| | 1 | 211 | | } |
| | 12 | 212 | | } |
| | | 213 | | |
| | | 214 | | private static DataOutput<AddScopeOwnerCommandOutput?> AlreadyOwner( |
| | | 215 | | DataOutput<AddScopeOwnerCommandOutput?> output, Scope scope, Person person) => |
| | 3 | 216 | | output |
| | 3 | 217 | | .WithData(new AddScopeOwnerCommandOutput |
| | 3 | 218 | | { |
| | 3 | 219 | | ScopeId = scope.PublicId, PersonId = person.PublicId, AlreadyOwner = true |
| | 3 | 220 | | }) |
| | 3 | 221 | | .WithMessage(PersonMessages.AlreadyScopeOwner); |
| | | 222 | | } |