-
Notifications
You must be signed in to change notification settings - Fork 12
feat: Account automation tokens #309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
6b95b49
Initial proposal
hhvrc 636b767
temporary migration
hhvrc 1fdec31
revert passwordreset endpoints
hhvrc bec2d4d
Update LoginV2.cs
hhvrc af602a0
a
hhvrc 818aea7
Revert "temporary migration"
hhvrc d8f6358
restore whitespace changes
hhvrc c61efd1
Update PasswordResetInitiateV2.cs
hhvrc 8380e0c
Merge remote-tracking branch 'origin/develop' into feat/managed-bypas…
hhvrc 8d6b508
fix: restore usings dropped by the develop merge
hhvrc b5768a1
fix: Guard admin accounts on bypassed password reset and log bypass use
hhvrc 0b0660f
Merge branch 'develop' into feat/managed-bypass-tokens
hhvrc 24fff89
fix: treat System accounts as privileged in bypass guards, bound bypa…
hhvrc 5cff05b
Merge branch 'develop' into feat/managed-bypass-tokens
hhvrc 177dd54
feat: restore database-managed bypass tokens
hhvrc 88a8ca6
feat: automation tokens with automated accounts and auditing
hhvrc f60468c
fix: harden automation token deletion, cleanup and privileged checks
hhvrc a108957
refactor: delete expired automated accounts in a single statement
hhvrc 5f51cb8
fix: address automation token review findings
hhvrc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| using System.Net.Mime; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using OpenShock.API.Controller.Admin.DTOs; | ||
| using OpenShock.Common.Models; | ||
| using OpenShock.Common.OpenShockDb; | ||
| using OpenShock.Common.Services.Audit; | ||
| using OpenShock.Common.Services.AutomationTokens; | ||
| using OpenShock.Common.Utils; | ||
|
|
||
| namespace OpenShock.API.Controller.Admin; | ||
|
|
||
| public sealed partial class AdminController | ||
| { | ||
| /// <summary> | ||
| /// Creates an automation token. The secret is only returned once, in this response | ||
| /// </summary> | ||
| [HttpPost("automationTokens")] | ||
| [Consumes(MediaTypeNames.Application.Json)] | ||
| [ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)] | ||
| public async Task<CreatedAutomationTokenDto> CreateAutomationToken( | ||
| [FromBody] CreateAutomationTokenDto body, | ||
| [FromServices] IAuditService auditService, | ||
| CancellationToken ct) | ||
| { | ||
| PedanticallyEnsureAdmin(); | ||
|
|
||
| var secret = IAutomationTokenService.GenerateSecret(); | ||
| var token = new AutomationToken | ||
| { | ||
| Id = Guid.CreateVersion7(), | ||
| Name = body.Name.Trim(), | ||
| TokenHash = HashingUtils.HashToken(secret), | ||
| Types = [.. body.Types.Distinct()], | ||
| AutoCleanupUsers = body.AutoCleanupUsers, | ||
| AutoCleanupAfter = body.AutoCleanupAfter, | ||
| }; | ||
|
|
||
| await using var transaction = await _db.Database.BeginTransactionAsync(ct); | ||
|
|
||
| _db.AutomationTokens.Add(token); | ||
| await _db.SaveChangesAsync(ct); | ||
|
|
||
| await auditService.LogAsync( | ||
| CurrentUser.Id, | ||
| action: AuditAction.AutomationTokenCreated, | ||
| actorId: CurrentUser.Id, | ||
| metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), | ||
| cancellationToken: ct); | ||
|
|
||
| await transaction.CommitAsync(ct); | ||
|
|
||
| return new CreatedAutomationTokenDto | ||
| { | ||
| Id = token.Id, | ||
| Name = token.Name, | ||
| Secret = secret, | ||
| Types = token.Types, | ||
| CreatedAt = token.CreatedAt, | ||
| AutoCleanupUsers = token.AutoCleanupUsers, | ||
| AutoCleanupAfter = token.AutoCleanupAfter, | ||
| }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Npgsql; | ||
| using OpenShock.Common.Models; | ||
| using OpenShock.Common.OpenShockDb; | ||
| using OpenShock.Common.Services.Audit; | ||
|
|
||
| namespace OpenShock.API.Controller.Admin; | ||
|
|
||
| public sealed partial class AdminController | ||
| { | ||
| /// <summary> | ||
| /// Deletes an automation token together with every account it created. Refused while any of those | ||
| /// accounts holds a privileged role | ||
| /// </summary> | ||
| [HttpDelete("automationTokens/{id}")] | ||
| [ProducesResponseType(StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| [ProducesResponseType(StatusCodes.Status409Conflict)] | ||
| public async Task<IActionResult> DeleteAutomationToken( | ||
| [FromRoute] Guid id, | ||
| [FromServices] IAuditService auditService, | ||
| CancellationToken ct) | ||
| { | ||
| PedanticallyEnsureAdmin(); | ||
|
|
||
| var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct); | ||
| if (token is null) return NotFound(); | ||
|
|
||
| await using var transaction = await _db.Database.BeginTransactionAsync(ct); | ||
|
|
||
| var createdUsers = await _db.Users | ||
| .Where(u => u.CreatedByAutomationTokenId == id) | ||
| .Select(u => new { u.Id, u.Roles }) | ||
| .ToListAsync(ct); | ||
|
|
||
| if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict(); | ||
|
|
||
| var userIds = createdUsers.Select(u => u.Id).ToArray(); | ||
|
|
||
| // The roles are checked again as the rows are deleted, so an account promoted since it was read is kept. | ||
| await _db.Database.ExecuteSqlAsync( | ||
| $"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct); | ||
|
|
||
| // The foreign key restricts, so this fails while any account still references the token: a privileged | ||
| // one, or one created or promoted meanwhile. On success, exactly userIds went with it. | ||
| try | ||
| { | ||
| await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); | ||
| } | ||
| catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation) | ||
| { | ||
| return PrivilegedAccountConflict(); | ||
| } | ||
|
|
||
| await auditService.LogAsync( | ||
| CurrentUser.Id, | ||
| action: AuditAction.AutomationTokenDeleted, | ||
| actorId: CurrentUser.Id, | ||
| metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length), | ||
| cancellationToken: ct); | ||
|
|
||
| await transaction.CommitAsync(ct); | ||
|
|
||
| // Each deleted account's own audit log goes with it, so the log line is the lasting record of what went. | ||
| foreach (var userId in userIds) | ||
| { | ||
| _logger.LogInformation( | ||
| "Deleted account {UserId} together with automation token {AutomationTokenId}", | ||
| userId, token.Id); | ||
| } | ||
|
|
||
| return Ok(); | ||
|
|
||
| IActionResult PrivilegedAccountConflict() => Problem( | ||
| "This automation token created a privileged account; remove its privileged roles before deleting the token.", | ||
| statusCode: StatusCodes.Status409Conflict); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using OpenShock.API.Controller.Admin.DTOs; | ||
|
|
||
| namespace OpenShock.API.Controller.Admin; | ||
|
|
||
| public sealed partial class AdminController | ||
| { | ||
| /// <summary> | ||
| /// Lists all automation tokens | ||
| /// </summary> | ||
| [HttpGet("automationTokens")] | ||
| public async IAsyncEnumerable<AutomationTokenDto> ListAutomationTokens() | ||
| { | ||
| PedanticallyEnsureAdmin(); | ||
|
|
||
| await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable()) | ||
| { | ||
| yield return AutomationTokenDto.FromEntity(token); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| using System.Net.Mime; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using OpenShock.API.Controller.Admin.DTOs; | ||
| using OpenShock.Common.Models; | ||
| using OpenShock.Common.OpenShockDb; | ||
| using OpenShock.Common.Services.Audit; | ||
|
|
||
| namespace OpenShock.API.Controller.Admin; | ||
|
|
||
| public sealed partial class AdminController | ||
| { | ||
| /// <summary> | ||
| /// Updates an automation token | ||
| /// </summary> | ||
| [HttpPatch("automationTokens/{id}")] | ||
| [Consumes(MediaTypeNames.Application.Json)] | ||
| [ProducesResponseType<AutomationTokenDto>(StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| public async Task<IActionResult> PatchAutomationToken( | ||
| [FromRoute] Guid id, | ||
| [FromBody] PatchAutomationTokenDto body, | ||
| [FromServices] IAuditService auditService, | ||
| CancellationToken ct) | ||
| { | ||
| PedanticallyEnsureAdmin(); | ||
|
|
||
| var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); | ||
| if (token is null) return NotFound(); | ||
|
|
||
| var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers; | ||
|
|
||
| // Switching cleanup off without naming a delay clears the delay, so it can be unset at all. | ||
| var autoCleanupAfter = body.AutoCleanupAfter ?? | ||
| (body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter); | ||
|
|
||
| if (autoCleanupUsers && autoCleanupAfter is null) | ||
| return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest); | ||
|
|
||
| if (body.Name is not null) token.Name = body.Name.Trim(); | ||
| if (body.Types is not null) token.Types = [.. body.Types.Distinct()]; | ||
| token.AutoCleanupUsers = autoCleanupUsers; | ||
| token.AutoCleanupAfter = autoCleanupAfter; | ||
|
|
||
| await using var transaction = await _db.Database.BeginTransactionAsync(ct); | ||
|
|
||
| await _db.SaveChangesAsync(ct); | ||
|
|
||
| await auditService.LogAsync( | ||
| CurrentUser.Id, | ||
| action: AuditAction.AutomationTokenUpdated, | ||
| actorId: CurrentUser.Id, | ||
| metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), | ||
| cancellationToken: ct); | ||
|
|
||
| await transaction.CommitAsync(ct); | ||
|
|
||
| return Ok(AutomationTokenDto.FromEntity(token)); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using OpenShock.API.Controller.Admin.DTOs; | ||
| using OpenShock.Common.Models; | ||
| using OpenShock.Common.OpenShockDb; | ||
| using OpenShock.Common.Services.Audit; | ||
| using OpenShock.Common.Services.AutomationTokens; | ||
| using OpenShock.Common.Utils; | ||
|
|
||
| namespace OpenShock.API.Controller.Admin; | ||
|
|
||
| public sealed partial class AdminController | ||
| { | ||
| /// <summary> | ||
| /// Rotates the secret of an automation token. The new secret is only returned once, in this response | ||
| /// </summary> | ||
| [HttpPost("automationTokens/{id}/rotate")] | ||
| [ProducesResponseType<CreatedAutomationTokenDto>(StatusCodes.Status200OK)] | ||
| [ProducesResponseType(StatusCodes.Status404NotFound)] | ||
| public async Task<IActionResult> RotateAutomationToken( | ||
| [FromRoute] Guid id, | ||
| [FromServices] IAuditService auditService, | ||
| CancellationToken ct) | ||
| { | ||
| PedanticallyEnsureAdmin(); | ||
|
|
||
| var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); | ||
| if (token is null) return NotFound(); | ||
|
|
||
| var secret = IAutomationTokenService.GenerateSecret(); | ||
| token.TokenHash = HashingUtils.HashToken(secret); | ||
| token.LastUsedAt = null; | ||
| token.UseCount = 0; | ||
| token.LastRotatedAt = DateTime.UtcNow; | ||
|
|
||
| await using var transaction = await _db.Database.BeginTransactionAsync(ct); | ||
|
|
||
| await _db.SaveChangesAsync(ct); | ||
|
|
||
| await auditService.LogAsync( | ||
| CurrentUser.Id, | ||
| action: AuditAction.AutomationTokenRotated, | ||
| actorId: CurrentUser.Id, | ||
| metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name), | ||
| cancellationToken: ct); | ||
|
|
||
| await transaction.CommitAsync(ct); | ||
|
|
||
| // Accounts the token created stay linked to it (and to its cleanup schedule) across rotations, | ||
| // since the token id doesn't change. | ||
|
|
||
| return Ok(new CreatedAutomationTokenDto | ||
| { | ||
| Id = token.Id, | ||
| Name = token.Name, | ||
| Secret = secret, | ||
| Types = token.Types, | ||
| CreatedAt = token.CreatedAt, | ||
| LastRotatedAt = token.LastRotatedAt, | ||
| AutoCleanupUsers = token.AutoCleanupUsers, | ||
| AutoCleanupAfter = token.AutoCleanupAfter, | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.