| | | 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.Domain.Persistence; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 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="HardDeleteScopeCommand" /> (UC-05): locates the scope (AF-05a), then |
| | | 16 | | /// permanently deletes its Users (via <c>SCOPE_USER</c>), Google Users, and applications, and |
| | | 17 | | /// finally the scope itself — whose <c>ON DELETE CASCADE</c> foreign keys remove the |
| | | 18 | | /// <c>SCOPE_OWNER</c>/<c>SCOPE_USER</c> join rows and any remaining scope permissions. Owner |
| | | 19 | | /// person records (<c>ScopeAdmin</c>s) are never removed. The response reports the totals of |
| | | 20 | | /// the scope's members and permissions, counted regardless of their individual deletion state. |
| | | 21 | | /// A failure is returned as errors on the <see cref="DataOutput{T}" /> or, from a set-based |
| | | 22 | | /// statement, thrown; either way nothing is kept. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// <para> |
| | | 26 | | /// Before anything is removed, the <c>PublicId</c> of every member person and Google User about |
| | | 27 | | /// to go is tombstoned (<see cref="ErasedSubject" />, NFR-21, #195), one per row, in one write. |
| | | 28 | | /// Audit entries name their target by that bare id, so without the tombstone nothing would say, |
| | | 29 | | /// once the rows are gone, that those entries name people whose targets must be cleared. The |
| | | 30 | | /// scope's own id is not tombstoned: a scope is not a person, and its entries stay as recorded. |
| | | 31 | | /// A retry writes them again without error. |
| | | 32 | | /// </para> |
| | | 33 | | /// <para> |
| | | 34 | | /// <b>All or nothing</b> (#212). The tombstones, the members and the scope are removed in one |
| | | 35 | | /// <see cref="IWriteTransactions" /> transaction, committed only once the scope is gone, so a |
| | | 36 | | /// failure at any step leaves the scope with every member it had, and no tombstone for a live |
| | | 37 | | /// row. |
| | | 38 | | /// </para> |
| | | 39 | | /// <para> |
| | | 40 | | /// <b>Only the ids</b> (#204). The members are never loaded: their ids and public ids are |
| | | 41 | | /// projected, which is all the tombstones and the deletes need, and |
| | | 42 | | /// <see cref="IScopeCascades.DeleteMembersAsync" /> removes them by id a bounded batch per statement, |
| | | 43 | | /// inside the same transaction, so a large tenant neither fills the request's memory with whole |
| | | 44 | | /// rows — password hashes included — nor sends one statement per member. |
| | | 45 | | /// </para> |
| | | 46 | | /// <para> |
| | | 47 | | /// <b>Under the scope's lock</b> (#202). The transaction first locks the scope's row |
| | | 48 | | /// (<see cref="IInvariantLocks" />), which every writer that can take an owner out of the scope |
| | | 49 | | /// also holds, and reads the scope again under it. So the deletion and an owner change of the same |
| | | 50 | | /// scope run one after the other: an owner removal that follows finds the scope gone (AF-22a), |
| | | 51 | | /// and the deletion never acts on a scope another request removed after it was read. |
| | | 52 | | /// </para> |
| | | 53 | | /// </remarks> |
| | 14 | 54 | | public class HardDeleteScopeCommandHandler( |
| | 14 | 55 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 14 | 56 | | IAsyncRepository<Scope, long> scopeWriter, |
| | 14 | 57 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 14 | 58 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 14 | 59 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 14 | 60 | | IAsyncReadOnlyRepository<ScopePermission, long> scopePermissionReader, |
| | 14 | 61 | | IErasedSubjects erasedSubjects, |
| | 14 | 62 | | IScopeCascades scopeCascades, |
| | 14 | 63 | | IWriteTransactions transactions, |
| | 14 | 64 | | IInvariantLocks locks) |
| | | 65 | | : ICommandHandlerAsync<HardDeleteScopeCommand, HardDeleteScopeCommandOutput> |
| | | 66 | | { |
| | | 67 | | public async Task<DataOutput<HardDeleteScopeCommandOutput?>> HandleAsync(HardDeleteScopeCommand command, Cancellatio |
| | | 68 | | { |
| | 14 | 69 | | var output = DataOutput<HardDeleteScopeCommandOutput?>.New; |
| | | 70 | | |
| | | 71 | | // Step 2 (AF-05a): locate the scope in ANY deletion state — an already logically-deleted |
| | | 72 | | // scope can still be hard-deleted. |
| | 14 | 73 | | var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 74 | | |
| | 14 | 75 | | if (scope is null) |
| | | 76 | | { |
| | 2 | 77 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | // Steps 3-6 land together or not at all (#212): leaving this block by any return or exception |
| | | 81 | | // before the commit rolls every one of them back. |
| | 12 | 82 | | await using var transaction = await transactions.BeginAsync(); |
| | 12 | 83 | | await locks.LockAsync([scope.Id]); |
| | | 84 | | |
| | | 85 | | // Read again under the lock: a hard delete that committed since the read above answers AF-05a. |
| | 12 | 86 | | if (!await scopeReader.Query().AnyAsync(x => x.Id == scope.Id)) |
| | | 87 | | { |
| | 0 | 88 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | // The scope's members, regardless of individual deletion state: the rows steps 3 and 4 act on, |
| | | 92 | | // and the totals step 7 reports. Only the ids are read (#204). |
| | 12 | 93 | | var users = await personReader.Query() |
| | 12 | 94 | | .Where(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id) |
| | 12 | 95 | | .Select(p => new { p.Id, p.PublicId }) |
| | 12 | 96 | | .ToListAsync(); |
| | 12 | 97 | | var googleUsers = await googleUserReader.Query() |
| | 12 | 98 | | .Where(g => g.ScopeId == scope.Id) |
| | 12 | 99 | | .Select(g => new { g.Id, g.PublicId }) |
| | 12 | 100 | | .ToListAsync(); |
| | 12 | 101 | | var applicationIds = await applicationReader.Query() |
| | 12 | 102 | | .Where(a => a.ScopeId == scope.Id) |
| | 12 | 103 | | .Select(a => a.Id) |
| | 12 | 104 | | .ToListAsync(); |
| | 12 | 105 | | var scopePermissionCount = await scopePermissionReader.Query() |
| | 12 | 106 | | .CountAsync(p => p.ScopeId == scope.Id); |
| | | 107 | | |
| | | 108 | | // Step 3 (NFR-21, #195): tombstone exactly the persons and Google Users step 4 removes, |
| | | 109 | | // before any of it happens. Owners are not members and are not removed, so they are not |
| | | 110 | | // tombstoned. |
| | 12 | 111 | | var erased = users |
| | 5009 | 112 | | .Select(user => new ErasedSubject |
| | 5009 | 113 | | { |
| | 5009 | 114 | | Id = user.PublicId, Kind = (int)ErasedSubjectKinds.Person, CreatedAt = DateTime.UtcNow |
| | 5009 | 115 | | }) |
| | 5 | 116 | | .Concat(googleUsers.Select(googleUser => new ErasedSubject |
| | 5 | 117 | | { |
| | 5 | 118 | | Id = googleUser.PublicId, Kind = (int)ErasedSubjectKinds.GoogleUser, CreatedAt = DateTime.UtcNow |
| | 5 | 119 | | })) |
| | 12 | 120 | | .ToList(); |
| | | 121 | | |
| | 12 | 122 | | if (erased.Count > 0) |
| | | 123 | | { |
| | 7 | 124 | | await erasedSubjects.RecordAsync(erased); |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | // Step 4: delete the members explicitly, in an order that never violates a foreign key |
| | | 128 | | // (applications reference their owning person, so they go first), in batches. |
| | 12 | 129 | | await scopeCascades.DeleteMembersAsync(new ScopeMemberIds( |
| | 12 | 130 | | applicationIds, |
| | 5 | 131 | | googleUsers.Select(googleUser => googleUser.Id).ToList(), |
| | 5021 | 132 | | users.Select(user => user.Id).ToList())); |
| | | 133 | | |
| | | 134 | | // Steps 5-6: delete the scope; its ON DELETE CASCADE foreign keys clear the SCOPE_OWNER and any |
| | | 135 | | // remaining SCOPE_USER join rows, as well as any remaining scope permissions. Owner person |
| | | 136 | | // records are untouched. |
| | 12 | 137 | | var scopeDelete = await scopeWriter.DeleteAsync(scope); |
| | | 138 | | |
| | 12 | 139 | | if (!scopeDelete.Success) |
| | | 140 | | { |
| | 1 | 141 | | return output.WithErrors(scopeDelete.Errors); |
| | | 142 | | } |
| | | 143 | | |
| | 11 | 144 | | await transaction.CommitAsync(); |
| | | 145 | | |
| | | 146 | | // Step 7: return the scope id and the member/permission totals. |
| | 11 | 147 | | return output |
| | 11 | 148 | | .WithData(new HardDeleteScopeCommandOutput |
| | 11 | 149 | | { |
| | 11 | 150 | | Id = scope.PublicId, |
| | 11 | 151 | | UserCount = users.Count, |
| | 11 | 152 | | GoogleUserCount = googleUsers.Count, |
| | 11 | 153 | | ApplicationCount = applicationIds.Count, |
| | 11 | 154 | | ScopePermissionCount = scopePermissionCount |
| | 11 | 155 | | }) |
| | 11 | 156 | | .WithMessage(ScopeMessages.ScopeHardDeletedSuccessfully); |
| | 14 | 157 | | } |
| | | 158 | | } |