| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 8 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 9 | | using ArturRios.Heimdall.Shared.Services; |
| | | 10 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 11 | | using ArturRios.Output; |
| | | 12 | | using Microsoft.EntityFrameworkCore; |
| | | 13 | | |
| | | 14 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Handles <see cref="DeletePersonCommand" /> (UC-09): locates the person (AF-09a), refuses a |
| | | 18 | | /// self-deletion (AF-09d), enforces the per-actor rule (AF-09c), serves an already-deleted person |
| | | 19 | | /// as an idempotent no-op (AF-09b), and otherwise sets <c>IsDeleted = true</c> — provided no scope |
| | | 20 | | /// would be left without an owner (AF-09e, NFR-12) and the system not without an active System Admin |
| | | 21 | | /// (AF-09f, NFR-32). Nothing cascades: the person's join rows, |
| | | 22 | | /// tokens, and owned applications are untouched. All failures are returned as errors on the |
| | | 23 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 24 | | /// </summary> |
| | | 25 | | /// <remarks> |
| | | 26 | | /// <b>Under the locks of the person's scopes and of the System Admin set</b> (#202, #203). The guards |
| | | 27 | | /// and the deletion run in one transaction holding the rows of the scopes the person owns, and then |
| | | 28 | | /// the System Admin set (<see cref="IInvariantLocks" />), and decide on the person as read under them |
| | | 29 | | /// (<see cref="LockedPerson" />), as every writer that can take an owner or a System Admin out does. Deleting one o |
| | | 30 | | /// removed or deleted therefore runs one after the other, and the second is refused, where both used |
| | | 31 | | /// to see the other still there. |
| | | 32 | | /// </remarks> |
| | 37 | 33 | | public class DeletePersonCommandHandler( |
| | 37 | 34 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 37 | 35 | | IScopeOwnershipChecker scopeOwnership, |
| | 37 | 36 | | IAtomicWrites atomicWrites, |
| | 37 | 37 | | IWriteTransactions transactions, |
| | 37 | 38 | | IInvariantLocks locks) |
| | | 39 | | : ICommandHandlerAsync<DeletePersonCommand, DeletePersonCommandOutput> |
| | | 40 | | { |
| | | 41 | | public async Task<DataOutput<DeletePersonCommandOutput?>> HandleAsync(DeletePersonCommand command, CancellationToken |
| | | 42 | | { |
| | 37 | 43 | | var output = DataOutput<DeletePersonCommandOutput?>.New; |
| | | 44 | | |
| | | 45 | | // AF-09a. The lookup deliberately omits an !IsDeleted filter (unlike UC-08) so an |
| | | 46 | | // already-deleted person is found and served idempotently by AF-09b below. ScopeMembership is |
| | | 47 | | // needed by the authorization rule; the ownerships the last-owner guard needs are read under its |
| | | 48 | | // locks. |
| | 37 | 49 | | var person = await personReader.Query() |
| | 37 | 50 | | .Include(x => x.ScopeMembership) |
| | 37 | 51 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 52 | | |
| | 37 | 53 | | if (person is null) |
| | | 54 | | { |
| | 2 | 55 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | // AF-09d: nobody deletes their own record, System Admin included, so one call cannot lock an |
| | | 59 | | // administrator out. Checked before authorization, which a System Admin would otherwise pass. |
| | 35 | 60 | | if (command.ActingPersonId == person.PublicId) |
| | | 61 | | { |
| | 3 | 62 | | return output.WithError(PersonMessages.CannotDeleteSelf); |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | // UC-09 step 2 (AF-09c). Runs before AF-09b so an already-deleted person cannot be used to |
| | | 66 | | // probe for the existence of persons outside the actor's scopes. |
| | 32 | 67 | | if (!await MayDeleteAsync(command, person)) |
| | | 68 | | { |
| | 4 | 69 | | return output.WithError(PersonMessages.NotAuthorizedToDeletePerson); |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | // AF-09b: already deleted, so there is nothing to write. Checked before the last-owner guard — |
| | | 73 | | // such an owner is already out of the scope, and re-running the guard would turn a required |
| | | 74 | | // idempotent success into a conflict. |
| | 28 | 75 | | if (person.IsDeleted) |
| | | 76 | | { |
| | 3 | 77 | | return output |
| | 3 | 78 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true }) |
| | 3 | 79 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | // Steps 3-4 under the locks of the person's scopes (#202): leaving this block before the commit |
| | | 83 | | // rolls back. |
| | 25 | 84 | | await using var transaction = await transactions.BeginAsync(); |
| | | 85 | | |
| | | 86 | | // The person as they stand under the locks (#202, #203): the guards decide on this, not on the |
| | | 87 | | // copy read above. |
| | 25 | 88 | | var locked = await LockedPerson.LockAsync(locks, personReader, person.Id); |
| | | 89 | | |
| | 25 | 90 | | if (locked is null) |
| | | 91 | | { |
| | 0 | 92 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 93 | | } |
| | | 94 | | |
| | | 95 | | // AF-09b again: a deletion that committed since the read above leaves nothing to do, and the |
| | | 96 | | // guards below would only turn the idempotent answer into a conflict. |
| | 25 | 97 | | if (locked.IsDeleted) |
| | | 98 | | { |
| | 1 | 99 | | return output |
| | 1 | 100 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true }) |
| | 1 | 101 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | // AF-09e (NFR-12): a soft-deleted owner can no longer authenticate, so deleting the last one |
| | | 105 | | // would leave the scope effectively ownerless. Asked under the locks, so it is still true when |
| | | 106 | | // the deletion below is written. |
| | 24 | 107 | | if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, locked.OwnedScopeIds, personReader)) |
| | | 108 | | { |
| | 6 | 109 | | return output.WithError(PersonMessages.ScopeWouldLoseLastOwner); |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | // AF-09f (NFR-32): nor may it leave the system without an active System Admin. AF-09d already |
| | | 113 | | // keeps an administrator from deleting themselves; this is the other one, deleted by someone |
| | | 114 | | // who — restricted, or deleting them at the same moment — would leave none. |
| | 18 | 115 | | if (locked.IsLiveSystemAdmin && await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader)) |
| | | 116 | | { |
| | 4 | 117 | | return output.WithError(PersonMessages.WouldRemoveLastSystemAdmin); |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | // UC-09 step 3: flip the flag and stamp UpdatedAt (no DB trigger maintains it). |
| | | 121 | | // |
| | | 122 | | // DeletedAt and DeletionKind are stamped alongside it for NFR-20: the window that ends in |
| | | 123 | | // anonymisation is measured from the deletion, and UpdatedAt cannot serve as that mark |
| | | 124 | | // because any later write moves it and would silently restart the window. The kind is |
| | | 125 | | // Administrative because this is an administrator's decision — no data subject asked — and |
| | | 126 | | // that carries the longer of the schedule's two deadlines; UC-42 writes SubjectRequested. |
| | | 127 | | // |
| | | 128 | | // A targeted write of those four columns alone (#237), and only while the person is live: |
| | | 129 | | // a deletion that landed since the read stands as it was, and this one answers as AF-09b |
| | | 130 | | // does, since the outcome the caller asked for is already the case. |
| | 14 | 131 | | var deleted = await atomicWrites.TryMarkDeletedAsync(person, DeletionKinds.Administrative, DateTime.UtcNow); |
| | | 132 | | |
| | 14 | 133 | | if (!deleted) |
| | | 134 | | { |
| | 1 | 135 | | return output |
| | 1 | 136 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true }) |
| | 1 | 137 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | | 138 | | } |
| | | 139 | | |
| | 13 | 140 | | await transaction.CommitAsync(); |
| | | 141 | | |
| | | 142 | | // UC-09 step 4. |
| | 13 | 143 | | return output |
| | 13 | 144 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = false }) |
| | 13 | 145 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | 37 | 146 | | } |
| | | 147 | | |
| | | 148 | | /// <summary> |
| | | 149 | | /// UC-09 step 2. A System Admin may delete any person; a Scope Admin may delete only a |
| | | 150 | | /// <c>User</c> belonging to a scope they own. Everything else is denied. |
| | | 151 | | /// </summary> |
| | | 152 | | private async Task<bool> MayDeleteAsync(DeletePersonCommand command, Person person) |
| | | 153 | | { |
| | 32 | 154 | | if (command.ActingRole == (int)Roles.SystemAdmin) |
| | | 155 | | { |
| | 26 | 156 | | return true; |
| | | 157 | | } |
| | | 158 | | |
| | 6 | 159 | | if (command.ActingRole != (int)Roles.ScopeAdmin || |
| | 6 | 160 | | person.RoleId != (long)Roles.User || |
| | 6 | 161 | | person.ScopeMembership is null) |
| | | 162 | | { |
| | 2 | 163 | | return false; |
| | | 164 | | } |
| | | 165 | | |
| | 4 | 166 | | return await scopeOwnership.ActorMayManageScopeAsync( |
| | 4 | 167 | | command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId); |
| | 32 | 168 | | } |
| | | 169 | | |
| | | 170 | | } |