| | | 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.Persistence; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Heimdall.Shared.Services; |
| | | 9 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 10 | | using ArturRios.Output; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="RemoveScopeOwnerCommand" /> (UC-22, FR-SC-08/FR-SC-10): verifies the target |
| | | 17 | | /// scope exists and is active (AF-22a), enforces scope ownership for a Scope Admin actor (AF-22c), |
| | | 18 | | /// verifies the named person actually holds a <c>SCOPE_OWNER</c> row for that scope (AF-22a), |
| | | 19 | | /// refuses to strip the scope of its last live owner (AF-22b, NFR-12), then removes the join row. |
| | | 20 | | /// A System Admin actor bypasses the ownership check. All failures are returned as errors on the |
| | | 21 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 22 | | /// </summary> |
| | | 23 | | /// <remarks> |
| | | 24 | | /// <b>Under the scope's lock</b> (#202). The guard and the removal run in one transaction that holds |
| | | 25 | | /// the scope's row (<see cref="IInvariantLocks" />), and so does every other writer that can take an |
| | | 26 | | /// owner out of it. Two requests removing the scope's last two owners therefore run one after the |
| | | 27 | | /// other: the second reads the first's removal and is refused, where both used to see the other |
| | | 28 | | /// owner still there and both remove. |
| | | 29 | | /// </remarks> |
| | 34 | 30 | | public class RemoveScopeOwnerCommandHandler( |
| | 34 | 31 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 34 | 32 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 34 | 33 | | IScopeOwnershipChecker scopeOwnership, |
| | 34 | 34 | | IAtomicWrites atomicWrites, |
| | 34 | 35 | | IWriteTransactions transactions, |
| | 34 | 36 | | IInvariantLocks locks) |
| | | 37 | | : ICommandHandlerAsync<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput> |
| | | 38 | | { |
| | | 39 | | public async Task<DataOutput<RemoveScopeOwnerCommandOutput?>> HandleAsync(RemoveScopeOwnerCommand command, Cancellat |
| | | 40 | | { |
| | 34 | 41 | | var output = DataOutput<RemoveScopeOwnerCommandOutput?>.New; |
| | | 42 | | |
| | | 43 | | // AF-22a (UC-22 step 2): the target scope must exist. The !IsDeleted filter answers alike for a |
| | | 44 | | // logically deleted one — rewriting the ownership of a scope withdrawn from service is not |
| | | 45 | | // something UC-22 promises, and every scope-scoped handler treats it as absent. |
| | 34 | 46 | | var scope = await scopeReader.Query() |
| | 34 | 47 | | .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted); |
| | | 48 | | |
| | 34 | 49 | | if (scope is null) |
| | | 50 | | { |
| | 4 | 51 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | // AF-22c: a Scope Admin actor may only act on a scope they own; a System Admin bypasses. |
| | | 55 | | // Checked before the person is looked up so a caller who fails here learns nothing about which |
| | | 56 | | // person ids exist or who owns the scope. |
| | 30 | 57 | | if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id)) |
| | | 58 | | { |
| | 3 | 59 | | return output.WithError(PersonMessages.NotScopeOwner); |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | // UC-22 steps 2-4 under the scope's lock (#202): leaving this block before the commit rolls back. |
| | 27 | 63 | | await using var transaction = await transactions.BeginAsync(); |
| | 27 | 64 | | await locks.LockAsync([scope.Id]); |
| | | 65 | | |
| | | 66 | | // Read again under the lock: a deletion of the scope that committed since the read above answers |
| | | 67 | | // AF-22a as it would have a moment earlier. |
| | 27 | 68 | | if (!await scopeReader.Query().AnyAsync(x => x.Id == scope.Id && !x.IsDeleted)) |
| | | 69 | | { |
| | 1 | 70 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 71 | | } |
| | | 72 | | |
| | | 73 | | // AF-22a (UC-22 step 2): the person must hold an ownership row for this scope. Neither |
| | | 74 | | // !IsDeleted nor a role filter applies — what matters is only whether the SCOPE_OWNER row |
| | | 75 | | // exists. A logically deleted ScopeAdmin keeps their ownership rows (UC-09 cascades nothing), |
| | | 76 | | // and clearing such a stale row is exactly what this endpoint is for. |
| | 26 | 77 | | var person = await personReader.Query() |
| | 26 | 78 | | .Include(x => x.ScopeOwnerships) |
| | 26 | 79 | | .FirstOrDefaultAsync(x => x.PublicId == command.PersonId); |
| | | 80 | | |
| | 49 | 81 | | var ownership = person?.ScopeOwnerships.FirstOrDefault(x => x.ScopeId == scope.Id); |
| | | 82 | | |
| | 26 | 83 | | if (person is null || ownership is null) |
| | | 84 | | { |
| | 5 | 85 | | return output.WithError(PersonMessages.PersonNotScopeOwner); |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | // AF-22b (UC-22 step 3, NFR-12): somebody else must be left owning the scope. Only an active |
| | | 89 | | // co-owner counts (LastScopeOwnerGuard): one logically deleted, restricted or awaiting erasure |
| | | 90 | | // cannot administer the scope (#211), so without that exclusion an ownerless scope could be |
| | | 91 | | // created by removing the only active owner while an inactive one remained on the row. |
| | 21 | 92 | | if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, [scope.Id], personReader)) |
| | | 93 | | { |
| | 8 | 94 | | return output.WithError(PersonMessages.ScopeWouldLoseLastOwner); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | // UC-22 step 4: delete 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 | | // and this endpoint reads deleted persons too, whose name anonymisation may have replaced |
| | | 100 | | // meanwhile. The write re-checks AF-22b itself; under the lock no owner-removing writer can have |
| | | 101 | | // run since the check above, but a co-owner restricting their own processing (UC-44) takes no |
| | | 102 | | // lock, and the write sees one that committed. FR-PE-11 is deliberately not guarded here: SRD §8 |
| | | 103 | | // names UC-22 as an operation that may leave a ScopeAdmin owning no scope. |
| | 13 | 104 | | if (!await atomicWrites.TryRemoveScopeOwnerAsync(person, scope.Id)) |
| | | 105 | | { |
| | | 106 | | // Refused: either another request removed the row meanwhile, which is AF-22a's outcome, or |
| | | 107 | | // the last live co-owner went, which is AF-22b's. The row tells which. |
| | 2 | 108 | | var ownsStill = await personReader.Query() |
| | 2 | 109 | | .AnyAsync(x => x.Id == person.Id && x.ScopeOwnerships.Any(owned => owned.ScopeId == scope.Id)); |
| | | 110 | | |
| | 2 | 111 | | return output.WithError(ownsStill |
| | 2 | 112 | | ? PersonMessages.ScopeWouldLoseLastOwner |
| | 2 | 113 | | : PersonMessages.PersonNotScopeOwner); |
| | | 114 | | } |
| | | 115 | | |
| | 11 | 116 | | await transaction.CommitAsync(); |
| | | 117 | | |
| | | 118 | | // UC-22 step 5. |
| | 11 | 119 | | return output |
| | 11 | 120 | | .WithData(new RemoveScopeOwnerCommandOutput { ScopeId = scope.PublicId, PersonId = person.PublicId }) |
| | 11 | 121 | | .WithMessage(PersonMessages.ScopeOwnerRemovedSuccessfully); |
| | 34 | 122 | | } |
| | | 123 | | } |