| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Data.Relational.Core.Repositories; |
| | | 3 | | using ArturRios.Heimdall.Command.Input; |
| | | 4 | | using ArturRios.Heimdall.Command.Output; |
| | | 5 | | using ArturRios.Heimdall.Command.Services; |
| | | 6 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 7 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 8 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 9 | | using ArturRios.Heimdall.Shared.Services; |
| | | 10 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 11 | | using ArturRios.Output; |
| | | 12 | | using ArturRios.Util.Hashing; |
| | | 13 | | using FluentValidation; |
| | | 14 | | using Microsoft.EntityFrameworkCore; |
| | | 15 | | |
| | | 16 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// Handles <see cref="CreateUserCommand" /> (UC-06 path a): validates input, verifies the target |
| | | 20 | | /// scope exists and is active (AF-06b), enforces scope ownership for a Scope Admin actor (AF-06e), |
| | | 21 | | /// checks the email is unique among the scope's Users (AF-06a), then creates a <c>User</c> with a |
| | | 22 | | /// <c>SCOPE_USER</c> row and issues a verification token. A System Admin actor bypasses the |
| | | 23 | | /// ownership check. |
| | | 24 | | /// </summary> |
| | 21 | 25 | | public class CreateUserCommandHandler( |
| | 21 | 26 | | IValidator<CreateUserCommand> validator, |
| | 21 | 27 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 21 | 28 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 21 | 29 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 21 | 30 | | IAsyncRepository<Person, long> personWriter, |
| | 21 | 31 | | IScopeOwnershipChecker scopeOwnership, |
| | 21 | 32 | | IEmailVerificationService emailVerification) |
| | | 33 | | : ICommandHandlerAsync<CreateUserCommand, CreatePersonCommandOutput> |
| | | 34 | | { |
| | | 35 | | public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateUserCommand command, CancellationToken c |
| | | 36 | | { |
| | 21 | 37 | | var output = DataOutput<CreatePersonCommandOutput?>.New; |
| | | 38 | | |
| | | 39 | | // AF-06d: validate input shape. |
| | 21 | 40 | | var validation = await validator.ValidateAsync(command); |
| | | 41 | | |
| | 21 | 42 | | if (!validation.IsValid) |
| | | 43 | | { |
| | 0 | 44 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | // AF-06b: the target scope must exist and not be logically deleted. |
| | 21 | 48 | | var scope = await scopeReader.Query() |
| | 21 | 49 | | .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted); |
| | | 50 | | |
| | 21 | 51 | | if (scope is null) |
| | | 52 | | { |
| | 2 | 53 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | // AF-06e: a Scope Admin actor may only act on a scope they own; a System Admin bypasses. |
| | 19 | 57 | | if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id)) |
| | | 58 | | { |
| | 2 | 59 | | return output.WithError(PersonMessages.NotScopeOwner); |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | // AF-06a: a User's email must be unique among the scope's Users, compared case-insensitively |
| | | 63 | | // (LOWER() in SQL). Trimmed first, here and where it is stored: sign-in and recovery match the |
| | | 64 | | // stored address exactly, case aside, so surrounding whitespace would make it unreachable. |
| | 17 | 65 | | var address = command.Email.Trim(); |
| | 17 | 66 | | var email = address.ToLower(); |
| | | 67 | | |
| | 17 | 68 | | var emailTaken = await personReader.Query().AnyAsync(person => |
| | 17 | 69 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 17 | 70 | | person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id); |
| | | 71 | | |
| | 17 | 72 | | if (emailTaken) |
| | | 73 | | { |
| | 4 | 74 | | return output.WithError(PersonMessages.EmailAlreadyExists); |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // FR-GO-07: the scope's address space is shared with its Google Users, so the second half of |
| | | 78 | | // the rule is a second read — the same pair GoogleSignInCommandHandler makes before signing a |
| | | 79 | | // Google account up, in the other order. Without it the rule held in one direction only: a |
| | | 80 | | // Google sign-up was refused an address a User held, while a User could still be created on |
| | | 81 | | // an address a Google User held, leaving one scope with two identities for one address. |
| | 13 | 82 | | var takenByGoogleUser = await googleUserReader.Query() |
| | 13 | 83 | | .AnyAsync(googleUser => googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email); |
| | | 84 | | |
| | 13 | 85 | | if (takenByGoogleUser) |
| | | 86 | | { |
| | 1 | 87 | | return output.WithError(PersonMessages.EmailAlreadyExists); |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | // Create the User with its SCOPE_USER membership row. |
| | 12 | 91 | | var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password); |
| | | 92 | | |
| | 12 | 93 | | var newPerson = new Person |
| | 12 | 94 | | { |
| | 12 | 95 | | Name = command.Name, |
| | 12 | 96 | | Email = address, |
| | 12 | 97 | | PasswordHash = passwordHash, |
| | 12 | 98 | | Salt = salt, |
| | 12 | 99 | | RoleId = (long)Roles.User, |
| | 12 | 100 | | ScopeMembership = new ScopeUser { ScopeId = scope.Id }, |
| | 12 | 101 | | // Kept in step with the membership row above; see Person.ScopeId for why the scope is |
| | 12 | 102 | | // carried here as well. It is what the per-scope uniqueness index is written over. |
| | 12 | 103 | | ScopeId = scope.Id |
| | 12 | 104 | | }; |
| | | 105 | | |
| | | 106 | | // NFR-23: the basis the tenant declares for its own users, or contract performance. |
| | 12 | 107 | | LegalBasisRecorder.Record(newPerson, scope, DateTime.UtcNow); |
| | | 108 | | |
| | 12 | 109 | | var creation = await personWriter.CreateAsync(newPerson); |
| | | 110 | | |
| | 12 | 111 | | if (!creation.Success) |
| | | 112 | | { |
| | | 113 | | // The per-scope uniqueness index refusing the insert means another request took the |
| | | 114 | | // address between this handler's check above and this write — the race the index exists |
| | | 115 | | // to catch. It is the same conflict AF-06a describes, so it is reported as AF-06a rather |
| | | 116 | | // than as a persistence failure: a caller who lost the race should not be able to tell |
| | | 117 | | // they lost a race, only that the address is taken. |
| | 3 | 118 | | if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage)) |
| | | 119 | | { |
| | 3 | 120 | | return output.WithError(PersonMessages.EmailAlreadyExists); |
| | | 121 | | } |
| | | 122 | | |
| | 0 | 123 | | return output.WithErrors(creation.Errors); |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // FR-EV-01/02: issue and send the verification token. |
| | 9 | 127 | | await emailVerification.IssueAndSendAsync(newPerson); |
| | | 128 | | |
| | 9 | 129 | | return output |
| | 9 | 130 | | .WithData(new CreatePersonCommandOutput |
| | 9 | 131 | | { |
| | 9 | 132 | | Id = newPerson.PublicId, |
| | 9 | 133 | | Name = newPerson.Name, |
| | 9 | 134 | | Email = newPerson.Email, |
| | 9 | 135 | | Role = (int)Roles.User, |
| | 9 | 136 | | EmailVerified = newPerson.EmailVerified, |
| | 9 | 137 | | ScopeId = scope.PublicId, |
| | 9 | 138 | | CreatedAt = newPerson.CreatedAt |
| | 9 | 139 | | }) |
| | 9 | 140 | | .WithMessage(PersonMessages.PersonCreatedSuccessfully); |
| | 21 | 141 | | } |
| | | 142 | | } |