< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Controllers.PersonController
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/PersonController.cs
Line coverage
100%
Covered lines: 92
Uncovered lines: 0
Coverable lines: 92
Total lines: 383
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 17
Fully covered methods: 17
Total methods: 17
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CreateAdmin()100%11100%
CreateUser()100%11100%
CreateScopeOwner()100%11100%
AddScopeOwner()100%11100%
PromoteScopeUser()100%11100%
Update()100%11100%
Delete()100%11100%
HardDelete()100%11100%
RemoveScopeOwner()100%11100%
GetById()100%11100%
ListScopePersons()100%11100%
ListScopeOwners()100%11100%
ListScopeAdmins()100%11100%
ListSystemAdmins()100%11100%
SendVerificationEmail()100%11100%
SendPasswordReset()100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/PersonController.cs

#LineLine coverage
 1using ArturRios.Heimdall.Command.Input;
 2using ArturRios.Heimdall.Command.Output;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Heimdall.Query.Input;
 5using ArturRios.Heimdall.Query.Output;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.WebApi.Security;
 8using ArturRios.Mediator.Command;
 9using ArturRios.Mediator.Query;
 10using ArturRios.Output;
 11using ArturRios.Util.WebApi.AspNetCore;
 12using ArturRios.Util.WebApi.Security.Attributes;
 13using Microsoft.AspNetCore.Mvc;
 14using Microsoft.AspNetCore.RateLimiting;
 15
 16namespace ArturRios.Heimdall.WebApi.Controllers;
 17
 18[Route("api")]
 28519public class PersonController(CommandMediator commandMediator, QueryMediator queryMediator) : Controller
 20{
 21    /// <summary>
 22    ///     Creates a <c>ScopeAdmin</c> or <c>SystemAdmin</c> person with no scope (UC-06 path b).
 23    ///     Restricted to System Admins (AF-06c).
 24    /// </summary>
 25    /// <remarks>
 26    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 27    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 28    ///     header.
 29    /// </remarks>
 30    [HttpPost("persons")]
 31    [EnableRateLimiting(Startup.AccountCreationRateLimitPolicy)]
 32    [RoleRequirement((int)Roles.SystemAdmin)]
 33    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateAdmin(
 34        [FromBody] CreateAdminCommand command)
 35    {
 7136        var result = await commandMediator
 7137            .ExecuteCommandAsync<CreateAdminCommand, CreatePersonCommandOutput>(command);
 38
 7139        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7140    }
 41
 42    /// <summary>
 43    ///     Creates a <c>User</c> within a scope (UC-06 path a). A System Admin or an owner of the scope
 44    ///     may call it; the ownership check (AF-06e) is enforced by the handler from the acting user.
 45    /// </summary>
 46    /// <remarks>
 47    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 48    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 49    ///     header.
 50    /// </remarks>
 51    [HttpPost("scopes/{scopeId:guid}/persons")]
 52    [EnableRateLimiting(Startup.AccountCreationRateLimitPolicy)]
 53    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 54    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateUser(
 55        Guid scopeId, [FromBody] CreateUserCommand command)
 56    {
 1257        command.ScopeId = scopeId;
 1258        HttpContext.ApplyActor(command);
 59
 1260        var result = await commandMediator
 1261            .ExecuteCommandAsync<CreateUserCommand, CreatePersonCommandOutput>(command);
 62
 1263        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 1264    }
 65
 66    /// <summary>
 67    ///     Creates a brand-new <c>ScopeAdmin</c> person directly as a co-owner of a scope (UC-06 path
 68    ///     c, FR-SC-12). A System Admin or an owner of the scope may call it; the ownership check
 69    ///     (AF-06e) is enforced by the handler from the acting user.
 70    /// </summary>
 71    /// <remarks>
 72    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 73    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 74    ///     header.
 75    /// </remarks>
 76    [HttpPost("scopes/{scopeId:guid}/owners")]
 77    [EnableRateLimiting(Startup.AccountCreationRateLimitPolicy)]
 78    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 79    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateScopeOwner(
 80        Guid scopeId, [FromBody] CreateScopeOwnerCommand command)
 81    {
 582        command.ScopeId = scopeId;
 583        HttpContext.ApplyActor(command);
 84
 585        var result = await commandMediator
 586            .ExecuteCommandAsync<CreateScopeOwnerCommand, CreatePersonCommandOutput>(command);
 87
 588        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 589    }
 90
 91    /// <summary>
 92    ///     Adds an existing <c>ScopeAdmin</c> or <c>SystemAdmin</c> person as an additional owner
 93    ///     of a scope (UC-21, FR-SC-08/FR-SC-09). The attribute keeps a <c>User</c> out — they can
 94    ///     never be a System Admin nor an existing owner — while the rules that depend on data it
 95    ///     cannot see are the handler's: whether the acting Scope Admin owns this scope (AF-21c),
 96    ///     whether the scope is active (AF-21a), whether the named person is a usable administrator
 97    ///     (AF-21b), and whether they already own it (AF-21d).
 98    /// </summary>
 99    [HttpPost("scopes/{scopeId:guid}/owners/{personId:guid}")]
 100    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 101    public async Task<ActionResult<DataOutput<AddScopeOwnerCommandOutput?>>> AddScopeOwner(
 102        Guid scopeId, Guid personId)
 103    {
 10104        var command = new AddScopeOwnerCommand { ScopeId = scopeId, PersonId = personId };
 10105        HttpContext.ApplyActor(command);
 106
 10107        var result = await commandMediator
 10108            .ExecuteCommandAsync<AddScopeOwnerCommand, AddScopeOwnerCommandOutput>(command);
 109
 10110        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 10111    }
 112
 113    /// <summary>
 114    ///     Promotes an existing <c>User</c> of a scope to <c>ScopeAdmin</c>, making them a co-owner of
 115    ///     that scope (UC-23, FR-SC-08/FR-SC-13/FR-RO-03). The attribute keeps a <c>User</c> out —
 116    ///     they can never be a System Admin nor an existing owner — while the rules that depend on
 117    ///     data it cannot see are the handler's: whether the acting Scope Admin owns this scope
 118    ///     (AF-23c), whether the scope is active (AF-23a), whether the named person is a <c>User</c>
 119    ///     of it (AF-23b), and whether they already hold the role (AF-23d).
 120    /// </summary>
 121    [HttpPost("scopes/{scopeId:guid}/users/{personId:guid}/promote")]
 122    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 123    public async Task<ActionResult<DataOutput<PromoteScopeUserCommandOutput?>>> PromoteScopeUser(
 124        Guid scopeId, Guid personId)
 125    {
 12126        var command = new PromoteScopeUserCommand { ScopeId = scopeId, PersonId = personId };
 12127        HttpContext.ApplyActor(command);
 128
 12129        var result = await commandMediator
 12130            .ExecuteCommandAsync<PromoteScopeUserCommand, PromoteScopeUserCommandOutput>(command);
 131
 12132        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 12133    }
 134
 135    /// <summary>
 136    ///     Updates a person's name and email, and — for a System Admin — their role (UC-08). Open to
 137    ///     any authenticated actor because a User may update their own record; the per-actor rule and
 138    ///     the role-change restriction (AF-08c) are enforced by the handler. The response's
 139    ///     <c>ownedScopeIds</c> follows FR-PE-19, as <c>GET /api/persons/{id}</c> does.
 140    /// </summary>
 141    /// <remarks>
 142    ///     A caller changing their own email presents <c>currentPassword</c>, and <c>twoFactorCode</c>
 143    ///     when their second factor is active (FR-PE-14) — six digits for an authenticator-app code,
 144    ///     or a recovery code, matched as <c>2fa/verify</c> matches one (<c>XXXX-XXXX-XXXX-XXXX</c>,
 145    ///     hyphens, spaces and case ignored, the shorter <c>XXXX-XXXX</c> codes still accepted,
 146    ///     FR-2F-20) — so this action verifies a password like the
 147    ///     credential endpoints do, and sits under their rate limit (NFR-18), as every
 148    ///     password-verifying endpoint does. An own email change mails the previous address, so it is
 149    ///     charged to the person's account-email budget once the credentials are accepted: 429 "An
 150    ///     email of this kind was sent to you recently. Try again in a few minutes." inside the
 151    ///     cooldown, 429 "You have reached the limit for account emails. Try again later." once
 152    ///     the window's five are spent (AF-08j), both with <c>Retry-After</c>; nothing changes then, and
 153    ///     no recovery or email code is spent (an accepted authenticator code's time step is recorded,
 154    ///     as for any accepted code). While the caller's email sign-in method is enabled, an own
 155    ///     email change answers 409 "Turn off the email sign-in method before changing your email
 156    ///     address." (AF-08k), once the credentials are accepted.
 157    /// </remarks>
 158    [HttpPut("persons/{id:guid}")]
 159    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 160    public async Task<ActionResult<DataOutput<UpdatePersonCommandOutput?>>> Update(
 161        Guid id, [FromBody] UpdatePersonCommand command)
 162    {
 32163        command.Id = id;
 32164        HttpContext.ApplyActor(command);
 165
 32166        var result = await commandMediator
 32167            .ExecuteCommandAsync<UpdatePersonCommand, UpdatePersonCommandOutput>(command);
 168
 32169        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 32170    }
 171
 172    /// <summary>
 173    ///     Logically deletes a person by setting <c>IsDeleted = true</c> (UC-09, FR-PE-06). The
 174    ///     attribute keeps a plain <c>User</c> out; the owner rule (AF-09c) is data-dependent and is
 175    ///     therefore enforced by the handler, as are the self-deletion (AF-09d) and last-owner
 176    ///     (AF-09e) refusals.
 177    /// </summary>
 178    [HttpDelete("persons/{id:guid}")]
 179    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 180    public async Task<ActionResult<DataOutput<DeletePersonCommandOutput?>>> Delete(Guid id)
 181    {
 9182        var command = new DeletePersonCommand { Id = id };
 9183        HttpContext.ApplyActor(command);
 184
 9185        var result = await commandMediator
 9186            .ExecuteCommandAsync<DeletePersonCommand, DeletePersonCommandOutput>(command);
 187
 9188        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 9189    }
 190
 191    /// <summary>
 192    ///     Permanently (hard) deletes a person, removing the applications they own, their password
 193    ///     reset and email verification tokens, and their scope membership/ownership rows (UC-10,
 194    ///     FR-PE-07). Restricted to System Admins; the self-deletion refusal (AF-10c) and the
 195    ///     last-owner guard (AF-10b) are enforced by the handler.
 196    /// </summary>
 197    [HttpDelete("persons/{id:guid}/hard")]
 198    [RoleRequirement((int)Roles.SystemAdmin)]
 199    public async Task<ActionResult<DataOutput<HardDeletePersonCommandOutput?>>> HardDelete(Guid id)
 200    {
 7201        var command = new HardDeletePersonCommand { Id = id };
 7202        HttpContext.ApplyActor(command);
 203
 7204        var result = await commandMediator
 7205            .ExecuteCommandAsync<HardDeletePersonCommand, HardDeletePersonCommandOutput>(command);
 206
 7207        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7208    }
 209
 210    /// <summary>
 211    ///     Removes a person's ownership of a scope (UC-22, FR-SC-08/FR-SC-10). The attribute keeps a
 212    ///     <c>User</c> out — they can never be a System Admin nor an existing owner — while the rules
 213    ///     that depend on data it cannot see are the handler's: whether the acting Scope Admin owns
 214    ///     this scope (AF-22c), whether the scope is active and the named person actually owns it
 215    ///     (AF-22a), and whether the scope would be left without an owner (AF-22b, NFR-12).
 216    /// </summary>
 217    [HttpDelete("scopes/{scopeId:guid}/owners/{personId:guid}")]
 218    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 219    public async Task<ActionResult<DataOutput<RemoveScopeOwnerCommandOutput?>>> RemoveScopeOwner(
 220        Guid scopeId, Guid personId)
 221    {
 10222        var command = new RemoveScopeOwnerCommand { ScopeId = scopeId, PersonId = personId };
 10223        HttpContext.ApplyActor(command);
 224
 10225        var result = await commandMediator
 10226            .ExecuteCommandAsync<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput>(command);
 227
 10228        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 10229    }
 230
 231    /// <summary>
 232    ///     Retrieves a single person by their public identifier (UC-07, FR-PE-03). Open to any
 233    ///     authenticated actor; the per-actor visibility rule (AF-07b) is data-dependent and is
 234    ///     therefore enforced by the handler. <c>ownedScopeIds</c> lists every scope the person owns
 235    ///     for a System Admin and for the person themselves; a Scope Admin reading somebody else sees
 236    ///     only the scopes they own too (FR-PE-19).
 237    /// </summary>
 238    [HttpGet("persons/{id:guid}")]
 239    public async Task<ActionResult<DataOutput<PersonOutput?>>> GetById(
 240        Guid id, [FromQuery] bool includeDeleted = false)
 241    {
 46242        var query = new GetPersonByIdQuery { Id = id, IncludeDeleted = includeDeleted };
 46243        HttpContext.ApplyActor(query);
 244
 46245        var result = await queryMediator.ExecuteQueryAsync<GetPersonByIdQuery, PersonOutput>(query);
 246
 46247        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 46248    }
 249
 250    /// <summary>
 251    ///     Lists the <c>User</c> persons of a scope (UC-07, FR-PE-04). A System Admin or an owner of
 252    ///     the scope may call it; the ownership check (AF-07b) is enforced by the handler from the
 253    ///     acting user. <c>ownedScopeIds</c> is trimmed for a Scope Admin as on
 254    ///     <c>GET /api/persons/{id}</c> (FR-PE-19).
 255    /// </summary>
 256    [HttpGet("scopes/{scopeId:guid}/persons")]
 257    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 258    public async Task<ActionResult<PaginatedOutput<PersonOutput>>> ListScopePersons(
 259        Guid scopeId, [FromQuery] ListScopePersonsQuery query)
 260    {
 26261        query.ScopeId = scopeId;
 26262        HttpContext.ApplyActor(query);
 263
 26264        var result = await queryMediator
 26265            .ExecutePaginatedQueryAsync<ListScopePersonsQuery, PersonOutput>(query);
 266
 26267        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 26268    }
 269
 270    /// <summary>
 271    ///     Lists the owners of a scope (UC-07, FR-PE-04). A System Admin or an
 272    ///     owner of the scope may call it; the ownership check (AF-07b) is enforced by the handler
 273    ///     from the acting user. For a Scope Admin each owner's <c>ownedScopeIds</c> lists only the
 274    ///     scopes the caller owns too, this one among them; a System Admin sees them all (FR-PE-19).
 275    /// </summary>
 276    [HttpGet("scopes/{scopeId:guid}/owners")]
 277    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 278    public async Task<ActionResult<PaginatedOutput<PersonOutput>>> ListScopeOwners(
 279        Guid scopeId, [FromQuery] ListScopeOwnersQuery query)
 280    {
 7281        query.ScopeId = scopeId;
 7282        HttpContext.ApplyActor(query);
 283
 7284        var result = await queryMediator
 7285            .ExecutePaginatedQueryAsync<ListScopeOwnersQuery, PersonOutput>(query);
 286
 7287        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7288    }
 289
 290    /// <summary>
 291    ///     Lists the administrators who may own a scope (UC-07 read d, FR-PE-12) — every
 292    ///     <c>ScopeAdmin</c>, and for a System Admin caller every <c>SystemAdmin</c> too — projected to
 293    ///     identifier, name, and email: the source for an owner picker. A System Admin or a Scope
 294    ///     Admin may call it. Optionally excludes the current owners of a named scope, in which case
 295    ///     the handler requires the caller to be entitled to manage that scope.
 296    /// </summary>
 297    [HttpGet("persons/scope-admins")]
 298    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 299    public async Task<ActionResult<PaginatedOutput<PersonSummaryOutput>>> ListScopeAdmins(
 300        [FromQuery] ListScopeAdminsQuery query)
 301    {
 8302        HttpContext.ApplyActor(query);
 303
 8304        var result = await queryMediator
 8305            .ExecutePaginatedQueryAsync<ListScopeAdminsQuery, PersonSummaryOutput>(query);
 306
 8307        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 8308    }
 309
 310    /// <summary>
 311    ///     Lists the system's <c>SystemAdmin</c> persons (UC-07 read e, FR-PE-13), projected to
 312    ///     identifier, name, and email: who else holds full access to the deployment. System Admin
 313    ///     only.
 314    /// </summary>
 315    /// <remarks>
 316    ///     A Scope Admin is deliberately excluded: a System Admin's address is not tenant-facing,
 317    ///     which is also why <c>GET /api/persons/scope-admins</c> offers System Admins only to a
 318    ///     System Admin caller.
 319    /// </remarks>
 320    [HttpGet("persons/system-admins")]
 321    [RoleRequirement((int)Roles.SystemAdmin)]
 322    public async Task<ActionResult<PaginatedOutput<PersonSummaryOutput>>> ListSystemAdmins(
 323        [FromQuery] ListSystemAdminsQuery query)
 324    {
 6325        HttpContext.ApplyActor(query);
 326
 6327        var result = await queryMediator
 6328            .ExecutePaginatedQueryAsync<ListSystemAdminsQuery, PersonSummaryOutput>(query);
 329
 6330        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 6331    }
 332
 333    /// <summary>
 334    ///     Has a fresh verification link mailed to a person's own address (UC-51, FR-EV-06): outstanding
 335    ///     links are retired first, as UC-15 does, and the link uses the scope's verification URL. A
 336    ///     System Admin may ask for any person; a Scope Admin only for a <c>User</c> of a scope they own
 337    ///     (AF-51b). No body; the token is never returned.
 338    /// </summary>
 339    /// <remarks>
 340    ///     Refused when the person is deleted or restricted (409, AF-51c) or already verified (400,
 341    ///     AF-51d), and with 429 when an email of this kind went to them within the cooldown,
 342    ///     <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c> (five minutes by default, AF-51e). 200 means
 343    ///     the email was handed to the sender.
 344    /// </remarks>
 345    [HttpPost("persons/{id:guid}/verification-email")]
 346    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 347    public async Task<ActionResult<DataOutput<SendPersonVerificationEmailCommandOutput?>>> SendVerificationEmail(
 348        Guid id)
 349    {
 13350        var command = new SendPersonVerificationEmailCommand { Id = id };
 13351        HttpContext.ApplyActor(command);
 352
 13353        var result = await commandMediator
 13354            .ExecuteCommandAsync<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommandOutput>(command);
 355
 13356        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 13357    }
 358
 359    /// <summary>
 360    ///     Has a password-reset link mailed to a person's own address (UC-52, FR-PR-05). A System Admin
 361    ///     may ask for any person; a Scope Admin only for a <c>User</c> of a scope they own (AF-52b). No
 362    ///     body; neither a token nor a password is accepted or returned.
 363    /// </summary>
 364    /// <remarks>
 365    ///     Eligibility is UC-12's: refused (409) when the person is deleted or restricted (AF-52c) or
 366    ///     their account could not sign in — a User whose scope is deleted, a Scope Admin with no live
 367    ///     scope (AF-52d). 429 when a reset went to them within the cooldown (AF-52e). Unlike UC-12 the
 368    ///     send is not queued: the caller is authenticated and owed a real answer. 200 means the email
 369    ///     was handed to the sender.
 370    /// </remarks>
 371    [HttpPost("persons/{id:guid}/password-reset")]
 372    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 373    public async Task<ActionResult<DataOutput<SendPersonPasswordResetCommandOutput?>>> SendPasswordReset(Guid id)
 374    {
 11375        var command = new SendPersonPasswordResetCommand { Id = id };
 11376        HttpContext.ApplyActor(command);
 377
 11378        var result = await commandMediator
 11379            .ExecuteCommandAsync<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput>(command);
 380
 11381        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 11382    }
 383}