| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 4 | | using ArturRios.Heimdall.Query.Input; |
| | | 5 | | using ArturRios.Heimdall.Query.Output; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Heimdall.Shared.Services; |
| | | 8 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="GetPersonByIdQuery" /> (UC-07, FR-PE-03): retrieves a person by their |
| | | 16 | | /// <c>PublicId</c>, excluding logically deleted persons unless explicitly requested (FR-PE-08), |
| | | 17 | | /// then applies the use case's per-actor visibility rule. A missing person is AF-07a |
| | | 18 | | /// (<c>PersonNotFound</c>); a person the caller may not see is AF-07b |
| | | 19 | | /// (<c>NotAuthorizedToViewPerson</c>). Both are returned as errors rather than thrown. A Scope |
| | | 20 | | /// Admin reading somebody else sees only the ownerships of scopes they own too (FR-PE-19). |
| | | 21 | | /// </summary> |
| | 63 | 22 | | public class GetPersonByIdQueryHandler(IAsyncReadOnlyRepository<Person, long> personReader) |
| | | 23 | | : IQueryHandlerAsync<GetPersonByIdQuery, PersonOutput> |
| | | 24 | | { |
| | | 25 | | /// <summary> |
| | | 26 | | /// The person plus the internal ids the visibility rule needs. Internal ids never reach the |
| | | 27 | | /// caller — only <see cref="Output" /> is returned. |
| | | 28 | | /// </summary> |
| | | 29 | | private sealed class PersonProjection |
| | | 30 | | { |
| | 139 | 31 | | public Guid PublicId { get; init; } |
| | | 32 | | |
| | 72 | 33 | | public long RoleId { get; init; } |
| | | 34 | | |
| | 69 | 35 | | public long? MembershipScopeId { get; init; } |
| | | 36 | | |
| | 174 | 37 | | public List<OwnedScope> OwnedScopes { get; init; } = []; |
| | | 38 | | |
| | 165 | 39 | | public PersonOutput Output { get; init; } = null!; |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | /// <summary> |
| | | 43 | | /// One scope the person owns, by both ids: the internal one to compare, the public one to |
| | | 44 | | /// report. |
| | | 45 | | /// </summary> |
| | | 46 | | private sealed class OwnedScope |
| | | 47 | | { |
| | 21 | 48 | | public long ScopeId { get; init; } |
| | | 49 | | |
| | 24 | 50 | | public Guid PublicId { get; init; } |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | public async Task<DataOutput<PersonOutput?>> HandleAsync(GetPersonByIdQuery query, CancellationToken cancellationTok |
| | | 54 | | { |
| | 63 | 55 | | var output = DataOutput<PersonOutput?>.New; |
| | | 56 | | |
| | 63 | 57 | | var person = await personReader.Query() |
| | 63 | 58 | | .Where(x => x.PublicId == query.Id && (query.IncludeDeleted || !x.IsDeleted)) |
| | 63 | 59 | | .Select(x => new PersonProjection |
| | 63 | 60 | | { |
| | 63 | 61 | | PublicId = x.PublicId, |
| | 63 | 62 | | RoleId = x.RoleId, |
| | 63 | 63 | | MembershipScopeId = x.ScopeMembership != null ? x.ScopeMembership.ScopeId : null, |
| | 63 | 64 | | OwnedScopes = x.ScopeOwnerships |
| | 63 | 65 | | .Select(ownership => new OwnedScope { ScopeId = ownership.ScopeId, PublicId = ownership.Scope.Public |
| | 63 | 66 | | .ToList(), |
| | 63 | 67 | | Output = new PersonOutput |
| | 63 | 68 | | { |
| | 63 | 69 | | Id = x.PublicId, |
| | 63 | 70 | | Name = x.Name, |
| | 63 | 71 | | Email = x.Email, |
| | 63 | 72 | | Role = (int)x.RoleId, |
| | 63 | 73 | | EmailVerified = x.EmailVerified, |
| | 63 | 74 | | TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive, |
| | 63 | 75 | | IsDeleted = x.IsDeleted, |
| | 63 | 76 | | ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null, |
| | 63 | 77 | | CreatedAt = x.CreatedAt, |
| | 63 | 78 | | UpdatedAt = x.UpdatedAt |
| | 63 | 79 | | } |
| | 63 | 80 | | }) |
| | 63 | 81 | | .FirstOrDefaultAsync(); |
| | | 82 | | |
| | | 83 | | // AF-07a: no such person (or it is logically deleted and was not explicitly requested). |
| | 63 | 84 | | if (person is null) |
| | | 85 | | { |
| | 4 | 86 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | // A System Admin and the person themselves see every ownership; anyone else only those of |
| | | 90 | | // the scopes they own (FR-PE-19). |
| | 59 | 91 | | var visibleScopeIds = query.ActingPersonId == person.PublicId |
| | 59 | 92 | | ? null |
| | 59 | 93 | | : await OwnedScopeVisibility.ScopesVisibleToAsync(personReader, query.ActingRole, query.ActingPersonId); |
| | | 94 | | |
| | | 95 | | // AF-07b: the caller is not allowed to see this person. |
| | 59 | 96 | | if (!MayView(query, person, visibleScopeIds)) |
| | | 97 | | { |
| | 6 | 98 | | return output.WithError(PersonMessages.NotAuthorizedToViewPerson); |
| | | 99 | | } |
| | | 100 | | |
| | 53 | 101 | | person.Output.OwnedScopeIds = person.OwnedScopes |
| | 13 | 102 | | .Where(scope => visibleScopeIds is null || visibleScopeIds.Contains(scope.ScopeId)) |
| | 11 | 103 | | .Select(scope => scope.PublicId) |
| | 53 | 104 | | .ToList(); |
| | | 105 | | |
| | 53 | 106 | | return output |
| | 53 | 107 | | .WithData(person.Output) |
| | 53 | 108 | | .WithMessage(PersonMessages.PersonRetrievedSuccessfully); |
| | 63 | 109 | | } |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// UC-07 step 2: a System Admin sees anyone; anyone sees themselves; a Scope Admin sees the |
| | | 113 | | /// Users of the scopes they own and the Scope Admins co-owning those scopes. Everything else |
| | | 114 | | /// is denied — in particular a User seeing another person, and a Scope Admin seeing a System |
| | | 115 | | /// Admin or an unrelated Scope Admin. <paramref name="ownedScopeIds" /> are the scopes the |
| | | 116 | | /// actor owns, or <c>null</c> for a System Admin or a self read. |
| | | 117 | | /// </summary> |
| | | 118 | | private static bool MayView(GetPersonByIdQuery query, PersonProjection person, IReadOnlyCollection<long>? ownedScope |
| | | 119 | | { |
| | 59 | 120 | | if (query.ActingRole == (int)Roles.SystemAdmin || query.ActingPersonId == person.PublicId) |
| | | 121 | | { |
| | 48 | 122 | | return true; |
| | | 123 | | } |
| | | 124 | | |
| | 11 | 125 | | if (query.ActingRole != (int)Roles.ScopeAdmin || ownedScopeIds is null) |
| | | 126 | | { |
| | 2 | 127 | | return false; |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | // ownedScopeIds are the actor's own ownerships. A logically deleted actor owns nothing: they |
| | | 131 | | // can no longer authenticate (UC-11 AF-11c), so a token issued before their deletion must |
| | | 132 | | // not keep granting reads until it expires. |
| | 9 | 133 | | if (person.RoleId == (long)Roles.User) |
| | | 134 | | { |
| | 5 | 135 | | return person.MembershipScopeId is not null && ownedScopeIds.Contains(person.MembershipScopeId.Value); |
| | | 136 | | } |
| | | 137 | | |
| | 4 | 138 | | return person.RoleId == (long)Roles.ScopeAdmin && |
| | 7 | 139 | | person.OwnedScopes.Any(scope => ownedScopeIds.Contains(scope.ScopeId)); |
| | | 140 | | } |
| | | 141 | | } |