Skip to content
2 changes: 1 addition & 1 deletion API.IntegrationTests/Helpers/TestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static async Task<AuthenticatedUser> CreateAndLoginUser(
// 2. Create session via ISessionService (stored in Redis)
await using var scope = factory.Services.CreateAsyncScope();
var sessionService = scope.ServiceProvider.GetRequiredService<ISessionService>();
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1");
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1", actorId: userId);

return new AuthenticatedUser(userId, username, email, session.Token);
}
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/Authenticated/ChangeEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email);
var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id);

return result.Match<IActionResult>(
success => Ok(),
Expand Down
5 changes: 2 additions & 3 deletions API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword);
var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id);

return result.Match<IActionResult>(
success => Ok(),
notActivated => throw new UnreachableException("Authenticated user is not activated"),
deactivated => throw new UnreachableException("Authenticated user is deactivated"),
notFound => throw new UnreachableException("Authenticated user not found in database"));

}
}
}
4 changes: 2 additions & 2 deletions API/Controller/Account/Authenticated/ChangeUsername.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body)
{
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username,
CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, actorId: CurrentUser.Id,
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));

return result.Match<IActionResult>(
success => Ok(),
Expand Down
49 changes: 49 additions & 0 deletions API/Controller/Account/Authenticated/GetAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Response;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Utils.Pagination;

namespace OpenShock.API.Controller.Account.Authenticated;

public sealed partial class AuthenticatedAccountController
{
/// <summary>
/// Get the audit log for the current user's account.
/// </summary>
/// <param name="pagination">Page, sort, and search parameters.</param>
/// <param name="auditService"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">A page of audit log entries.</response>
[HttpGet("audit-log")]
[ProducesResponseType<PagedResult<AuditLogEntryResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<PagedResult<AuditLogEntryResponse>> GetAuditLog(
[FromQuery] PaginationQuery pagination,
[FromServices] IAuditService auditService,
CancellationToken cancellationToken)
{
var paged = await auditService.GetPagedForUserAsync(CurrentUser.Id, pagination, cancellationToken);
return MapPaged(paged);
}

internal static PagedResult<AuditLogEntryResponse> MapPaged(PagedResult<UserAuditLog> paged) => new()
{
Items = paged.Items.Select(MapEntry).ToArray(),
Page = paged.Page,
PageSize = paged.PageSize,
TotalCount = paged.TotalCount,
};

private static AuditLogEntryResponse MapEntry(UserAuditLog x) => new()
{
Id = x.Id,
UserId = x.UserId,
ActorId = x.ActorId,
Action = x.Action,
IpAddress = x.IpAddress,
UserAgent = x.UserAgent,
Metadata = x.Metadata,
CreatedAt = x.CreatedAt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken)
{
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken);
var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, actorId: CurrentUser.Id, cancellationToken);

if (!deleted)
return NotFound();
Expand Down
6 changes: 5 additions & 1 deletion API/Controller/Account/Logout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ public async Task<IActionResult> Logout([FromServices] ISessionService sessionSe
// Remove session if valid
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
{
await sessionService.DeleteSessionByTokenAsync(sessionToken);
var session = await sessionService.GetSessionByTokenAsync(sessionToken);
if (session is not null)
{
await sessionService.LogoutSessionAsync(session);
}
}

// Make sure cookie is removed, no matter if authenticated or not
Expand Down
7 changes: 5 additions & 2 deletions API/Controller/Admin/DeactivateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpPut("users/{userId}/deactivate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeactivateUser([FromRoute] Guid userId, [FromQuery(Name="deleteLater")] bool deleteLater, [FromServices] IAccountService accountService)
public async Task<IActionResult> DeactivateUser(
[FromRoute] Guid userId,
[FromQuery(Name = "deleteLater")] bool deleteLater,
[FromServices] IAccountService accountService)
{
PedanticallyEnsureAdmin();

var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater);
return deactivationResult.Match(
return deactivationResult.Match<IActionResult>(
success => Ok("Account deactivated"),
cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount),
alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated),
Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Admin/DeleteUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpDelete("users/{userId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeleteUser([FromRoute] Guid userId, [FromServices] IAccountService accountService)
public async Task<IActionResult> DeleteUser(
[FromRoute] Guid userId,
[FromServices] IAccountService accountService)
{
PedanticallyEnsureAdmin();

var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId);
return result.Match(
return result.Match<IActionResult>(
success => Ok("Account deleted"),
cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount),
unauthorized => Problem(AccountActivationError.Unauthorized),
Expand Down
33 changes: 33 additions & 0 deletions API/Controller/Admin/GetAuditLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Controller.Account.Authenticated;
using OpenShock.API.Models.Response;
using OpenShock.Common.Services.Audit;
using OpenShock.Common.Utils.Pagination;

namespace OpenShock.API.Controller.Admin;

public sealed partial class AdminController
{
/// <summary>
/// Get audit log entries across all users. Optionally filter by subject user or actor.
/// </summary>
/// <param name="pagination">Page, sort, and search parameters.</param>
/// <param name="userId">Optional: filter by the subject user (the account that was affected).</param>
/// <param name="actorId">Optional: filter by the actor (who performed the action).</param>
/// <param name="auditService"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">A page of audit log entries.</response>
[HttpGet("audit-log")]
[ProducesResponseType<PagedResult<AuditLogEntryResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<PagedResult<AuditLogEntryResponse>> GetAdminAuditLog(
[FromQuery] PaginationQuery pagination,
[FromQuery] Guid? userId,
[FromQuery] Guid? actorId,
[FromServices] IAuditService auditService,
CancellationToken cancellationToken)
{
var paged = await auditService.GetPagedAsync(userId, actorId, pagination, cancellationToken);
return AuthenticatedAccountController.MapPaged(paged);
}
}
2 changes: 1 addition & 1 deletion API/Controller/Admin/PatchUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public async Task<IActionResult> ModifyUser([FromRoute] Guid userId, [FromBody]

if (body.Name is not null)
{
await accountService.ChangeUsernameAsync(userId, body.Name, ignoreLimit: true, ct);
await accountService.ChangeUsernameAsync(userId, body.Name, actorId: CurrentUser.Id, ignoreLimit: true, cancellationToken: ct);
}

return Ok();
Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Admin/ReactivateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpPut("users/{userId}/reactivate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ReactivateUser([FromRoute] Guid userId, [FromServices] IAccountService accountService)
public async Task<IActionResult> ReactivateUser(
[FromRoute] Guid userId,
[FromServices] IAccountService accountService)
{
PedanticallyEnsureAdmin();

var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId);
return reactivationResult.Match(
return reactivationResult.Match<IActionResult>(
success => Ok("Account reactivated"),
unauthorized => Problem(AccountActivationError.Unauthorized),
notFound => NotFound("User not found")
Expand Down
3 changes: 2 additions & 1 deletion API/Controller/OAuth/HandOff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Services.OAuthConnection;
using OpenShock.Common.Options;
using OpenShock.Common.OpenShockDb;
using OpenShock.API.OAuth;
using OpenShock.Common.Extensions;

Expand Down Expand Up @@ -108,7 +109,7 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken))
return RedirectFrontendConnections(connection.UserId == userId ? "alreadyLinked" : "linkedToAnotherAccount");
}

var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, cancellationToken);
var ok = await connectionService.TryAddConnectionAsync(userId, provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, actorId: userId, cancellationToken);
if (!ok)
{
await HttpContext.SignOutAsync(OAuthConstants.FlowScheme);
Expand Down
22 changes: 11 additions & 11 deletions API/Controller/Tokens/DeleteToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,37 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger<TokenDeleteC
[HttpDelete("{tokenId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> DeleteToken([FromRoute] Guid tokenId, CancellationToken cancellationToken)
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
public async Task<IActionResult> DeleteToken(
[FromRoute] Guid tokenId,
CancellationToken cancellationToken)
{
// If a token tries to delete itself, let it
if (User.TryGetClaimValueAsGuid(OpenShockAuthClaims.ApiTokenId, out var currentApiTokenId) && currentApiTokenId == tokenId)
{
if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok();
if (await _tokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken))
return Ok();

// If we get here, it's a race-condition or something weird!
_logger.LogWarning("Token {TokenId} attempted self-deletion but no record was found (possible race-condition).", tokenId);

return Problem(ApiTokenError.ApiTokenNotFound);
}

var userIdentity = User.TryGetOpenShockUserIdentity();
if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf); // If user is null then ApiToken must have been here, and it cant delete others
if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf);

// If a privileged user is trying to delete the token, let them
// If a privileged user is trying to delete the token, let them (any owner)
if (userIdentity.IsAdminOrSystem())
{
if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok();
if (await _tokenService.DeleteToken(tokenId, actorId: CurrentUser.Id, cancellationToken: cancellationToken))
return Ok();

return Problem(ApiTokenError.ApiTokenNotFound);
}

// A normal user is trying to delete the token, delete it if they own it
var userId = userIdentity.GetClaimValueAsGuid(ClaimTypes.NameIdentifier);
if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken))
{
if (await _tokenService.DeleteToken(tokenId, actorId: userId, ownerId: userId, cancellationToken))
return Ok();
}

return Problem(ApiTokenError.ApiTokenNotFound);
}
Expand Down
9 changes: 7 additions & 2 deletions API/Controller/Tokens/Tokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using OpenShock.API.Models.Response;
using OpenShock.API.Services.Token;
using OpenShock.Common.Errors;
using OpenShock.Common.Extensions;
using OpenShock.Common.Problems;
using OpenShock.Common.Utils;

Expand Down Expand Up @@ -82,7 +83,9 @@ public async Task<IActionResult> GetTokenByIdV2([FromRoute] Guid tokenId, [FromS
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
[MapToApiVersion("1")]
public Task<TokenCreatedResponse> CreateToken([FromBody] CreateTokenRequest body, [FromServices] IApiTokenService tokenService)
public Task<TokenCreatedResponse> CreateToken(
[FromBody] CreateTokenRequest body,
[FromServices] IApiTokenService tokenService)
{
return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body);
}
Expand Down Expand Up @@ -117,7 +120,9 @@ public async Task<IActionResult> EditToken([FromRoute] Guid tokenId, [FromBody]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
[MapToApiVersion("2")]
public Task<TokenCreatedResponseV2> CreateTokenV2([FromBody] CreateTokenRequestV2 body, [FromServices] IApiTokenService tokenService)
public Task<TokenCreatedResponseV2> CreateTokenV2(
[FromBody] CreateTokenRequestV2 body,
[FromServices] IApiTokenService tokenService)
{
return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body);
}
Expand Down
16 changes: 16 additions & 0 deletions API/Models/Response/AuditLogEntryResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;

namespace OpenShock.API.Models.Response;

public sealed class AuditLogEntryResponse
{
public required Guid Id { get; init; }
public required Guid UserId { get; init; }
public required Guid ActorId { get; init; }
public required AuditAction Action { get; init; }
public required string? IpAddress { get; init; }
public required string? UserAgent { get; init; }
public required AuditMetadata? Metadata { get; init; }
public required DateTime CreatedAt { get; init; }
}
Loading
Loading