| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 5 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Heimdall.Shared.Services; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="DeletePersonCommand" /> (UC-09): locates the person (AF-09a), refuses a |
| | | 16 | | /// self-deletion (AF-09d), enforces the per-actor rule (AF-09c), serves an already-deleted person |
| | | 17 | | /// as an idempotent no-op (AF-09b), and otherwise sets <c>IsDeleted = true</c> — provided no scope |
| | | 18 | | /// would be left without an owner (AF-09e, NFR-12). Nothing cascades: the person's join rows, |
| | | 19 | | /// tokens, and owned applications are untouched. All failures are returned as errors on the |
| | | 20 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 21 | | /// </summary> |
| | 20 | 22 | | public class DeletePersonCommandHandler( |
| | 20 | 23 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 20 | 24 | | IAsyncRepository<Person, long> personWriter, |
| | 20 | 25 | | IScopeOwnershipChecker scopeOwnership) |
| | | 26 | | : ICommandHandlerAsync<DeletePersonCommand, DeletePersonCommandOutput> |
| | | 27 | | { |
| | | 28 | | public async Task<DataOutput<DeletePersonCommandOutput?>> HandleAsync(DeletePersonCommand command, CancellationToken |
| | | 29 | | { |
| | 20 | 30 | | var output = DataOutput<DeletePersonCommandOutput?>.New; |
| | | 31 | | |
| | | 32 | | // AF-09a. The lookup deliberately omits an !IsDeleted filter (unlike UC-08) so an |
| | | 33 | | // already-deleted person is found and served idempotently by AF-09b below. Both scope |
| | | 34 | | // navigations are needed: ScopeMembership by the authorization rule, ScopeOwnerships by the |
| | | 35 | | // last-owner guard. |
| | 20 | 36 | | var person = await personReader.Query() |
| | 20 | 37 | | .Include(x => x.ScopeMembership) |
| | 20 | 38 | | .Include(x => x.ScopeOwnerships) |
| | 20 | 39 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 40 | | |
| | 20 | 41 | | if (person is null) |
| | | 42 | | { |
| | 2 | 43 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | // AF-09d: nobody deletes their own record, System Admin included, so one call cannot lock an |
| | | 47 | | // administrator out. Checked before authorization, which a System Admin would otherwise pass. |
| | 18 | 48 | | if (command.ActingPersonId == person.PublicId) |
| | | 49 | | { |
| | 2 | 50 | | return output.WithError(PersonMessages.CannotDeleteSelf); |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | // UC-09 step 2 (AF-09c). Runs before AF-09b so an already-deleted person cannot be used to |
| | | 54 | | // probe for the existence of persons outside the actor's scopes. |
| | 16 | 55 | | if (!await MayDeleteAsync(command, person)) |
| | | 56 | | { |
| | 4 | 57 | | return output.WithError(PersonMessages.NotAuthorizedToDeletePerson); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | // AF-09b: already deleted, so there is nothing to write. Checked before the last-owner guard — |
| | | 61 | | // such an owner is already out of the scope, and re-running the guard would turn a required |
| | | 62 | | // idempotent success into a conflict. |
| | 12 | 63 | | if (person.IsDeleted) |
| | | 64 | | { |
| | 3 | 65 | | return output |
| | 3 | 66 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = true }) |
| | 3 | 67 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | // AF-09e (NFR-12): a soft-deleted owner can no longer authenticate, so deleting the last one |
| | | 71 | | // would leave the scope effectively ownerless. |
| | 9 | 72 | | if (await WouldStripLastOwnerAsync(person)) |
| | | 73 | | { |
| | 3 | 74 | | return output.WithError(PersonMessages.ScopeWouldLoseLastOwner); |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // UC-09 step 3: flip the flag and stamp UpdatedAt (no DB trigger maintains it). |
| | | 78 | | // |
| | | 79 | | // DeletedAt and DeletionKind are stamped alongside it for NFR-20: the window that ends in |
| | | 80 | | // anonymisation is measured from the deletion, and UpdatedAt cannot serve as that mark |
| | | 81 | | // because any later write moves it and would silently restart the window. The kind is |
| | | 82 | | // Administrative because this is an administrator's decision — no data subject asked — and |
| | | 83 | | // that carries the longer of the schedule's two deadlines. UC-42 (#91) is what will write |
| | | 84 | | // SubjectRequested. |
| | 6 | 85 | | var deletedAt = DateTime.UtcNow; |
| | | 86 | | |
| | 6 | 87 | | person.IsDeleted = true; |
| | 6 | 88 | | person.DeletedAt = deletedAt; |
| | 6 | 89 | | person.DeletionKind = (int)DeletionKinds.Administrative; |
| | 6 | 90 | | person.UpdatedAt = deletedAt; |
| | | 91 | | |
| | 6 | 92 | | var update = await personWriter.UpdateAsync(person); |
| | | 93 | | |
| | 6 | 94 | | if (!update.Success) |
| | | 95 | | { |
| | 0 | 96 | | return output.WithErrors(update.Errors); |
| | | 97 | | } |
| | | 98 | | |
| | | 99 | | // UC-09 step 4. |
| | 6 | 100 | | return output |
| | 6 | 101 | | .WithData(new DeletePersonCommandOutput { Id = person.PublicId, AlreadyDeleted = false }) |
| | 6 | 102 | | .WithMessage(PersonMessages.PersonDeletedSuccessfully); |
| | 20 | 103 | | } |
| | | 104 | | |
| | | 105 | | /// <summary> |
| | | 106 | | /// UC-09 step 2. A System Admin may delete any person; a Scope Admin may delete only a |
| | | 107 | | /// <c>User</c> belonging to a scope they own. Everything else is denied. |
| | | 108 | | /// </summary> |
| | | 109 | | private async Task<bool> MayDeleteAsync(DeletePersonCommand command, Person person) |
| | | 110 | | { |
| | 16 | 111 | | if (command.ActingRole == (int)Roles.SystemAdmin) |
| | | 112 | | { |
| | 10 | 113 | | return true; |
| | | 114 | | } |
| | | 115 | | |
| | 6 | 116 | | if (command.ActingRole != (int)Roles.ScopeAdmin || |
| | 6 | 117 | | person.RoleId != (long)Roles.User || |
| | 6 | 118 | | person.ScopeMembership is null) |
| | | 119 | | { |
| | 2 | 120 | | return false; |
| | | 121 | | } |
| | | 122 | | |
| | 4 | 123 | | return await scopeOwnership.ActorMayManageScopeAsync( |
| | 4 | 124 | | command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId); |
| | 16 | 125 | | } |
| | | 126 | | |
| | | 127 | | /// <summary> |
| | | 128 | | /// NFR-12. Gathers the scopes somebody *other* than this person owns and reports whether any |
| | | 129 | | /// scope this person owns is missing from them — the same guard |
| | | 130 | | /// <see cref="UpdatePersonCommandHandler" /> applies to its role change. Persons already |
| | | 131 | | /// logically deleted are excluded, since they no longer keep a scope owned. |
| | | 132 | | /// </summary> |
| | | 133 | | private async Task<bool> WouldStripLastOwnerAsync(Person person) |
| | | 134 | | { |
| | | 135 | | // Whatever the role: a SystemAdmin may own a scope too (FR-SC-08). |
| | 9 | 136 | | if (person.ScopeOwnerships.Count == 0) |
| | | 137 | | { |
| | 4 | 138 | | return false; |
| | | 139 | | } |
| | | 140 | | |
| | 10 | 141 | | var ownedScopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList(); |
| | | 142 | | |
| | 5 | 143 | | var coOwnedScopeIds = await personReader.Query() |
| | 5 | 144 | | .Where(other => other.Id != person.Id && !other.IsDeleted) |
| | 5 | 145 | | .SelectMany(other => other.ScopeOwnerships.Select(ownership => ownership.ScopeId)) |
| | 5 | 146 | | .Distinct() |
| | 5 | 147 | | .ToListAsync(); |
| | | 148 | | |
| | 10 | 149 | | return ownedScopeIds.Any(scopeId => !coOwnedScopeIds.Contains(scopeId)); |
| | 9 | 150 | | } |
| | | 151 | | } |