diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index b2e480f6..3f07ab81 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -24,7 +27,9 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangeEmail([FromBody] ChangeEmailRequest body) + public async Task ChangeEmail( + [FromBody] ChangeEmailRequest body, + [FromServices] IAuditService auditService) { if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) { @@ -38,11 +43,20 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email); - return result.Match( - success => Ok(), - alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), - unchanged => Problem(AccountError.EmailChangeUnchanged), - tooMany => Problem(AccountError.EmailChangeTooMany), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.EmailChangeRequested, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new EmailChangeRequestedMetadata(body.Email)); + return Ok(); + }, + alreadyInUse => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse)), + unchanged => Task.FromResult(Problem(AccountError.EmailChangeUnchanged)), + tooMany => Task.FromResult(Problem(AccountError.EmailChangeTooMany)), 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")); diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index a42e772f..0d5652d1 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; @@ -22,7 +25,9 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangePassword([FromBody] ChangePasswordRequest body) + public async Task ChangePassword( + [FromBody] ChangePasswordRequest body, + [FromServices] IAuditService auditService) { // OAuth-only accounts that have never set a password must go through the email-confirmed // /password/set flow rather than silently setting one through this endpoint. @@ -38,8 +43,16 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword); - return result.Match( - success => Ok(), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.PasswordChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent()); + return 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")); diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 759fa11c..1f8405fb 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using System.Net.Mime; namespace OpenShock.API.Controller.Account.Authenticated; @@ -21,17 +24,29 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged - public async Task ChangeUsername([FromBody] ChangeUsernameRequest body) + public async Task ChangeUsername( + [FromBody] ChangeUsernameRequest body, + [FromServices] IAuditService auditService) { + var oldUsername = CurrentUser.Name; var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System)); - return result.Match( - success => Ok(), - usernametaken => Problem(AccountError.UsernameTaken), - usernameerror => Problem(AccountError.UsernameInvalid(usernameerror)), - recentlychanged => Problem(AccountError.UsernameRecentlyChanged), - accountdeactivated => Problem(AccountError.AccountDeactivated), + return await result.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.UsernameChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new UsernameChangedMetadata(oldUsername, body.Username)); + return Ok(); + }, + usernametaken => Task.FromResult(Problem(AccountError.UsernameTaken)), + usernameerror => Task.FromResult(Problem(AccountError.UsernameInvalid(usernameerror))), + recentlychanged => Task.FromResult(Problem(AccountError.UsernameRecentlyChanged)), + accountdeactivated => Task.FromResult(Problem(AccountError.AccountDeactivated)), notfound => throw new Exception("Unexpected result, apparently our current user does not exist...")); } } \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index 6e7e8efc..105bbd90 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -14,14 +18,23 @@ public sealed partial class AuthenticatedAccountController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount - public async Task Deactivate() + public async Task Deactivate([FromServices] IAuditService auditService) { var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true); - return deactivationResult.Match( - success => NoContent(), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), + return await deactivationResult.Match>( + async success => + { + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.AccountDeactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new AccountDeactivatedMetadata(DeleteLater: true)); + return NoContent(); + }, + cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), notFound => throw new Exception("This is not supposed to happen, wtf?") ); } diff --git a/API/Controller/Account/Authenticated/GetAuditLog.cs b/API/Controller/Account/Authenticated/GetAuditLog.cs new file mode 100644 index 00000000..beaee914 --- /dev/null +++ b/API/Controller/Account/Authenticated/GetAuditLog.cs @@ -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 +{ + /// + /// Get the audit log for the current user's account. + /// + /// Page, sort, and search parameters. + /// + /// + /// A page of audit log entries. + [HttpGet("audit-log")] + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task> GetAuditLog( + [FromQuery] PaginationQuery pagination, + [FromServices] IAuditService auditService, + CancellationToken cancellationToken) + { + var paged = await auditService.GetPagedForUserAsync(CurrentUser.Id, pagination, cancellationToken); + return MapPaged(paged); + } + + internal static PagedResult MapPaged(PagedResult 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, + }; +} diff --git a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs index 8e4be621..c29477b2 100644 --- a/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs +++ b/API/Controller/Account/Authenticated/OAuthConnectionRemove.cs @@ -1,5 +1,9 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.OAuthConnection; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account.Authenticated; @@ -10,19 +14,31 @@ public sealed partial class AuthenticatedAccountController /// /// Provider key (e.g. discord). /// + /// /// /// Connection removed. /// No connection found for this provider. [HttpDelete("connections/{provider}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveOAuthConnection([FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, CancellationToken cancellationToken) + public async Task RemoveOAuthConnection( + [FromRoute] string provider, + [FromServices] IOAuthConnectionService connectionService, + [FromServices] IAuditService auditService, + CancellationToken cancellationToken) { var deleted = await connectionService.TryRemoveConnectionAsync(CurrentUser.Id, provider, cancellationToken); if (!deleted) return NotFound(); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.OAuthDisconnected, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new OAuthDisconnectedMetadata(provider)); + return NoContent(); } } \ No newline at end of file diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index 44a565d6..c559351b 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -1,6 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; namespace OpenShock.API.Controller.Account; @@ -10,12 +13,22 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout([FromServices] ISessionService sessionService) + public async Task Logout( + [FromServices] ISessionService sessionService, + [FromServices] IAuditService auditService) { - // 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.DeleteSessionAsync(session); + await auditService.LogAsync( + session.UserId, + AuditAction.Logout, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent()); + } } // Make sure cookie is removed, no matter if authenticated or not diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 93b840d0..c378c6af 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -3,7 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Account; @@ -20,8 +24,8 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, cancellationToken); + public Task EmailVerify([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, auditService, cancellationToken); /// /// Verify a pending email change. Deprecated: use POST /email-change/verify instead. @@ -35,16 +39,25 @@ public Task EmailVerify([FromQuery(Name = "token")] string token, [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) - => VerifyPendingEmailChange(token, cancellationToken); + public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, [FromServices] IAuditService auditService, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, auditService, cancellationToken); - private async Task VerifyPendingEmailChange(string token, CancellationToken cancellationToken) + private async Task VerifyPendingEmailChange(string token, IAuditService auditService, CancellationToken cancellationToken) { var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - return result.Match( - success => Ok(), - notFound => Problem(AccountError.EmailChangeNotFound), - emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); + return await result.Match>( + async success => + { + await auditService.LogAsync( + success.Value.UserId, + AuditAction.EmailChanged, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new EmailChangedMetadata(success.Value.OldEmail, success.Value.NewEmail)); + return Ok(); + }, + notFound => Task.FromResult(Problem(AccountError.EmailChangeNotFound)), + emailTaken => Task.FromResult(Problem(AccountError.EmailChangeAlreadyInUse))); } } \ No newline at end of file diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index 210c14dc..9c1d6cfb 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,15 +17,29 @@ public sealed partial class AdminController /// Unauthorized [HttpPut("users/{userId}/deactivate")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task DeactivateUser([FromRoute] Guid userId, [FromQuery(Name="deleteLater")] bool deleteLater, [FromServices] IAccountService accountService) + public async Task DeactivateUser( + [FromRoute] Guid userId, + [FromQuery(Name = "deleteLater")] bool deleteLater, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater); - return deactivationResult.Match( - success => Ok("Account deactivated"), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await deactivationResult.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountDeactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new AccountDeactivatedMetadata(deleteLater), + actorId: CurrentUser.Id); + return Ok("Account deactivated"); + }, + cannotDeactivatePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + alreadyDeactivated => Task.FromResult(Problem(AccountActivationError.AlreadyDeactivated)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 85a22f6a..20e422dd 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,14 +17,26 @@ public sealed partial class AdminController /// Unauthorized [HttpDelete("users/{userId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task DeleteUser([FromRoute] Guid userId, [FromServices] IAccountService accountService) + public async Task DeleteUser( + [FromRoute] Guid userId, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId); - return result.Match( - success => Ok("Account deleted"), - cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await result.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountDeleted, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + actorId: CurrentUser.Id); + return Ok("Account deleted"); + }, + cannotDeletePrivledged => Task.FromResult(Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount)), + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/Admin/GetAuditLog.cs b/API/Controller/Admin/GetAuditLog.cs new file mode 100644 index 00000000..b3facc99 --- /dev/null +++ b/API/Controller/Admin/GetAuditLog.cs @@ -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 +{ + /// + /// Get audit log entries across all users. Optionally filter by subject user or actor. + /// + /// Page, sort, and search parameters. + /// Optional: filter by the subject user (the account that was affected). + /// Optional: filter by the actor (who performed the action). + /// + /// + /// A page of audit log entries. + [HttpGet("audit-log")] + [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task> 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); + } +} diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index 2a0596ca..c6674a0a 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -1,6 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Admin; @@ -13,13 +17,25 @@ public sealed partial class AdminController /// Unauthorized [HttpPut("users/{userId}/reactivate")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ReactivateUser([FromRoute] Guid userId, [FromServices] IAccountService accountService) + public async Task ReactivateUser( + [FromRoute] Guid userId, + [FromServices] IAccountService accountService, + [FromServices] IAuditService auditService) { var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId); - return reactivationResult.Match( - success => Ok("Account reactivated"), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") + return await reactivationResult.Match>( + async success => + { + await auditService.LogAsync( + userId, + AuditAction.AccountReactivated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + actorId: CurrentUser.Id); + return Ok("Account reactivated"); + }, + unauthorized => Task.FromResult(Problem(AccountActivationError.Unauthorized)), + notFound => Task.FromResult(NotFound("User not found")) ); } } \ No newline at end of file diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 52f38bf6..81140734 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -6,6 +6,9 @@ using OpenShock.Common.Options; using OpenShock.API.OAuth; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.OAuth; @@ -115,6 +118,14 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) return RedirectFrontendConnections("linkFailed"); } + var auditService = HttpContext.RequestServices.GetRequiredService(); + await auditService.LogAsync( + userId, + AuditAction.OAuthConnected, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new OAuthConnectedMetadata(provider)); + // Direct sign-in var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index a109e80b..ae652978 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -7,7 +7,10 @@ using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; +using OpenShock.Common.Utils; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; namespace OpenShock.API.Controller.Tokens; @@ -38,34 +41,57 @@ public TokenDeleteController(IApiTokenService tokenService, ILogger(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound - public async Task DeleteToken([FromRoute] Guid tokenId, CancellationToken cancellationToken) + public async Task DeleteToken( + [FromRoute] Guid tokenId, + [FromServices] IAuditService auditService, + 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(); + var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, CurrentUser.Id, cancellationToken); + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) + { + if (info is not null) + await auditService.LogAsync(CurrentUser.Id, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, info.Value.Name)); + 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(); + var info = await _tokenService.GetTokenAuditInfoAsync(tokenId, cancellationToken: cancellationToken); + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) + { + if (info is not null) + await auditService.LogAsync(info.Value.OwnerId, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, info.Value.Name), + actorId: CurrentUser.Id); + 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); + var ownedInfo = await _tokenService.GetTokenAuditInfoAsync(tokenId, userId, cancellationToken); if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken)) { + if (ownedInfo is not null) + await auditService.LogAsync(userId, AuditAction.ApiTokenDeleted, + HttpContext.GetRemoteIP(), HttpContext.GetUserAgent(), + new ApiTokenDeletedMetadata(tokenId, ownedInfo.Value.Name)); return Ok(); } diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index 5f3786c7..67fae8bb 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -5,7 +5,10 @@ using OpenShock.API.Models.Response; using OpenShock.API.Services.Token; using OpenShock.Common.Errors; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Tokens; @@ -82,9 +85,19 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] - public Task CreateToken([FromBody] CreateTokenRequest body, [FromServices] IApiTokenService tokenService) + public async Task CreateToken( + [FromBody] CreateTokenRequest body, + [FromServices] IApiTokenService tokenService, + [FromServices] IAuditService auditService) { - return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + var result = await tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.ApiTokenCreated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => p.ToString()).ToList())); + return result; } /// @@ -117,9 +130,19 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("2")] - public Task CreateTokenV2([FromBody] CreateTokenRequestV2 body, [FromServices] IApiTokenService tokenService) + public async Task CreateTokenV2( + [FromBody] CreateTokenRequestV2 body, + [FromServices] IApiTokenService tokenService, + [FromServices] IAuditService auditService) { - return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + var result = await tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + await auditService.LogAsync( + CurrentUser.Id, + AuditAction.ApiTokenCreated, + ipAddress: HttpContext.GetRemoteIP(), + userAgent: HttpContext.GetUserAgent(), + metadata: new ApiTokenCreatedMetadata(result.Id, result.Name, result.Permissions.Select(p => p.ToString()).ToList())); + return result; } /// diff --git a/API/Models/Response/AuditLogEntryResponse.cs b/API/Models/Response/AuditLogEntryResponse.cs new file mode 100644 index 00000000..78b60eb8 --- /dev/null +++ b/API/Models/Response/AuditLogEntryResponse.cs @@ -0,0 +1,15 @@ +using OpenShock.Common.Models; + +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; } +} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 398df273..14e93301 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -616,7 +616,7 @@ await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name), return new Success(); } - public async Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) + public async Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(token); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -629,6 +629,7 @@ public async Task> TryVerifyEmailAsy { ChangeId = x.Id, UserId = x.UserId, + OldEmail = x.User.Email, x.NewEmail, x.SecurityStampAtCreate }) @@ -660,7 +661,7 @@ await _db.UserEmailChanges .Where(c => c.Id == change.ChangeId && c.UsedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsedAt, now), cancellationToken); - return new Success(); + return new Success<(Guid, string, string)>((change.UserId, change.OldEmail, change.NewEmail)); } private async Task CheckPassword(string password, User user) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 64c4a1ae..077f146c 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -139,7 +139,7 @@ public interface IAccountService /// /// /// - Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); + Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); } public readonly struct AccountIsOAuthOnly; diff --git a/API/Services/Token/ApiTokenService.cs b/API/Services/Token/ApiTokenService.cs index 5fc944ad..61527315 100644 --- a/API/Services/Token/ApiTokenService.cs +++ b/API/Services/Token/ApiTokenService.cs @@ -205,4 +205,15 @@ public async Task DeleteToken(Guid tokenId, Guid? ownerId = null, Cancella var nDeleted = await Tokens(ownerId).Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); return nDeleted > 0; } + + /// + public async Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) + { + var result = await Tokens(ownerId) + .Where(x => x.Id == tokenId) + .Select(x => new { x.UserId, x.Name }) + .FirstOrDefaultAsync(cancellationToken); + + return result is null ? null : (result.UserId, result.Name); + } } diff --git a/API/Services/Token/IApiTokenService.cs b/API/Services/Token/IApiTokenService.cs index 032dcdf7..04a15020 100644 --- a/API/Services/Token/IApiTokenService.cs +++ b/API/Services/Token/IApiTokenService.cs @@ -56,4 +56,9 @@ public interface IApiTokenService /// Delete a token by id (optionally restricted to an owner). Returns false if no token was deleted. /// Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); + + /// + /// Returns the minimum info needed to write an audit log entry for a token deletion, or null if the token does not exist. + /// + Task<(Guid OwnerId, string Name)?> GetTokenAuditInfoAsync(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs b/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs new file mode 100644 index 00000000..6e57b0f1 --- /dev/null +++ b/Common/Migrations/20260612135418_AddUserAuditLog.Designer.cs @@ -0,0 +1,1594 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260612135418_AddUserAuditLog")] + partial class AddUserAuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); + + b.Navigation("ApiTokens"); + + b.Navigation("AuditLogs"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260612135418_AddUserAuditLog.cs b/Common/Migrations/20260612135418_AddUserAuditLog.cs new file mode 100644 index 00000000..6f14e307 --- /dev/null +++ b/Common/Migrations/20260612135418_AddUserAuditLog.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddUserAuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + + migrationBuilder.CreateTable( + name: "user_audit_logs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + actor_id = table.Column(type: "uuid", nullable: false), + action = table.Column(type: "audit_action", nullable: false), + ip_address = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + user_agent = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + metadata = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("user_audit_logs_pkey", x => x.id); + table.ForeignKey( + name: "fk_user_audit_logs_actor_id", + column: x => x.actor_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_audit_logs_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_actor_id", + table: "user_audit_logs", + column: "actor_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_created_at", + table: "user_audit_logs", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "IX_user_audit_logs_user_id", + table: "user_audit_logs", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_audit_logs"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 81f0c618..e7a665d4 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -24,6 +24,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); @@ -801,6 +802,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_activation_requests", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => { b.Property("DeactivatedUserId") @@ -1318,6 +1369,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") @@ -1476,8 +1548,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => { + b.Navigation("ActorAuditLogs"); + b.Navigation("ApiTokens"); + b.Navigation("AuditLogs"); + b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/Models/AuditAction.cs b/Common/Models/AuditAction.cs new file mode 100644 index 00000000..1ea86f23 --- /dev/null +++ b/Common/Models/AuditAction.cs @@ -0,0 +1,20 @@ +using NpgsqlTypes; + +namespace OpenShock.Common.Models; + +public enum AuditAction +{ + [PgName("login")] Login, + [PgName("logout")] Logout, + [PgName("password_changed")] PasswordChanged, + [PgName("email_change_requested")] EmailChangeRequested, + [PgName("email_changed")] EmailChanged, + [PgName("username_changed")] UsernameChanged, + [PgName("api_token_created")] ApiTokenCreated, + [PgName("api_token_deleted")] ApiTokenDeleted, + [PgName("oauth_connected")] OAuthConnected, + [PgName("oauth_disconnected")] OAuthDisconnected, + [PgName("account_deactivated")] AccountDeactivated, + [PgName("account_reactivated")] AccountReactivated, + [PgName("account_deleted")] AccountDeleted, +} diff --git a/Common/Models/AuditMetadata.cs b/Common/Models/AuditMetadata.cs new file mode 100644 index 00000000..1aa651f5 --- /dev/null +++ b/Common/Models/AuditMetadata.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace OpenShock.Common.Models; + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "t")] +[JsonDerivedType(typeof(LoginMetadata), "login")] +[JsonDerivedType(typeof(UsernameChangedMetadata), "usernameChanged")] +[JsonDerivedType(typeof(EmailChangeRequestedMetadata), "emailChangeRequested")] +[JsonDerivedType(typeof(EmailChangedMetadata), "emailChanged")] +[JsonDerivedType(typeof(ApiTokenCreatedMetadata), "apiTokenCreated")] +[JsonDerivedType(typeof(ApiTokenDeletedMetadata), "apiTokenDeleted")] +[JsonDerivedType(typeof(OAuthConnectedMetadata), "oauthConnected")] +[JsonDerivedType(typeof(OAuthDisconnectedMetadata), "oauthDisconnected")] +[JsonDerivedType(typeof(AccountDeactivatedMetadata), "accountDeactivated")] +public abstract record AuditMetadata; + +public sealed record LoginMetadata(Guid SessionId) : AuditMetadata; + +public sealed record UsernameChangedMetadata(string Old, string New) : AuditMetadata; + +public sealed record EmailChangeRequestedMetadata(string NewEmail) : AuditMetadata; + +public sealed record EmailChangedMetadata(string Old, string New) : AuditMetadata; + +public sealed record ApiTokenCreatedMetadata(Guid TokenId, string Name, IReadOnlyList Permissions) : AuditMetadata; + +public sealed record ApiTokenDeletedMetadata(Guid TokenId, string Name) : AuditMetadata; + +public sealed record OAuthConnectedMetadata(string Provider) : AuditMetadata; + +public sealed record OAuthDisconnectedMetadata(string Provider) : AuditMetadata; + +public sealed record AccountDeactivatedMetadata(bool DeleteLater) : AuditMetadata; diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 4e11c2cb..8438ea8a 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -3,6 +3,7 @@ using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -42,9 +43,20 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); - - var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); - + var auditService = HttpContext.RequestServices.GetRequiredService(); + + var remoteIp = HttpContext.GetRemoteIP(); + var userAgent = HttpContext.GetUserAgent(); + + var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString()); + + await auditService.LogAsync( + accountId, + AuditAction.Login, + ipAddress: remoteIp, + userAgent: userAgent, + metadata: new LoginMetadata(session.Id)); + HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { Expires = DateTimeOffset.UtcNow.Add(Duration.LoginSessionLifetime), diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 66de70f0..20f5fc5d 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; @@ -130,6 +131,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet DataProtectionKeys { get; set; } + public DbSet UserAuditLogs { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) @@ -151,6 +154,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("shocker_model_type", ["caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330"]) .HasPostgresEnum("match_type_enum", ["exact", "contains"]) .HasPostgresEnum("configuration_value_type", ["string", "bool", "int", "float", "json"]) + .HasPostgresEnum("audit_action", [ + "login", "logout", "password_changed", "email_change_requested", "email_changed", + "username_changed", "api_token_created", "api_token_deleted", + "oauth_connected", "oauth_disconnected", + "account_deactivated", "account_reactivated", "account_deleted" + ]) .HasCollation("public", "ndcoll", "und-u-ks-level2", "icu", false); // Add case-insensitive, accent-sensitive comparison collation modelBuilder.Entity(entity => @@ -920,5 +929,50 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.ShockerControlLogCount) .HasColumnName("shocker_control_log_count"); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_audit_logs_pkey"); + + entity.ToTable("user_audit_logs"); + + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.ActorId); + entity.HasIndex(e => e.CreatedAt); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.ActorId).HasColumnName("actor_id"); + entity.Property(e => e.Action) + .HasColumnType("audit_action") + .HasColumnName("action"); + entity.Property(e => e.IpAddress) + .VarCharWithLength(HardLimits.IpAddressMaxLength) + .HasColumnName("ip_address"); + entity.Property(e => e.UserAgent) + .VarCharWithLength(HardLimits.UserAgentMaxLength) + .HasColumnName("user_agent"); + entity.Property(e => e.Metadata) + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + + entity.HasOne(e => e.User).WithMany(u => u.AuditLogs) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_audit_logs_user_id"); + + entity.HasOne(e => e.Actor).WithMany(u => u.ActorAuditLogs) + .HasForeignKey(e => e.ActorId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_audit_logs_actor_id"); + }); } } diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index bf5a2990..24a72892 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -41,4 +41,6 @@ public sealed class User public ICollection NameChanges { get; } = []; public ICollection EmailChanges { get; } = []; public ICollection PasswordResets { get; } = []; + public ICollection AuditLogs { get; } = []; + public ICollection ActorAuditLogs { get; } = []; } diff --git a/Common/OpenShockDb/UserAuditLog.cs b/Common/OpenShockDb/UserAuditLog.cs new file mode 100644 index 00000000..9ba3b91b --- /dev/null +++ b/Common/OpenShockDb/UserAuditLog.cs @@ -0,0 +1,28 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.OpenShockDb; + +public sealed class UserAuditLog +{ + public required Guid Id { get; set; } + + /// The account being affected. + public required Guid UserId { get; set; } + + /// Who performed the action. Equal to UserId for self-service actions; an admin's Id for admin-initiated actions. + public required Guid ActorId { get; set; } + + public required AuditAction Action { get; set; } + + public string? IpAddress { get; set; } + + public string? UserAgent { get; set; } + + public AuditMetadata? Metadata { get; set; } + + public required DateTime CreatedAt { get; set; } + + // Navigations + public User User { get; set; } = null!; + public User Actor { get; set; } = null!; +} diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..1e08cb08 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -16,6 +16,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.BatchUpdate; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); diff --git a/Common/Services/Audit/AuditService.cs b/Common/Services/Audit/AuditService.cs new file mode 100644 index 00000000..b9c661ee --- /dev/null +++ b/Common/Services/Audit/AuditService.cs @@ -0,0 +1,78 @@ +using System.Net; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils.Pagination; + +namespace OpenShock.Common.Services.Audit; + +public sealed class AuditService : IAuditService +{ + private static readonly IReadOnlyDictionary> Sorters = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["createdAt"] = (q, desc) => desc ? q.OrderByDescending(x => x.CreatedAt) : q.OrderBy(x => x.CreatedAt), + ["action"] = (q, desc) => desc ? q.OrderByDescending(x => x.Action) : q.OrderBy(x => x.Action), + }; + + private const string DefaultSort = "createdAt"; + + private readonly OpenShockContext _db; + + public AuditService(OpenShockContext db) + { + _db = db; + } + + public async Task LogAsync( + Guid userId, + AuditAction action, + IPAddress? ipAddress = null, + string? userAgent = null, + AuditMetadata? metadata = null, + Guid? actorId = null, + CancellationToken cancellationToken = default) + { + _db.UserAuditLogs.Add(new UserAuditLog + { + Id = Guid.CreateVersion7(), + UserId = userId, + ActorId = actorId ?? userId, + Action = action, + IpAddress = ipAddress?.ToString(), + UserAgent = userAgent, + Metadata = metadata, + CreatedAt = DateTime.UtcNow, + }); + + await _db.SaveChangesAsync(cancellationToken); + } + + public Task> GetPagedForUserAsync( + Guid userId, + PaginationQuery pagination, + CancellationToken cancellationToken = default) + { + return _db.UserAuditLogs + .Where(x => x.UserId == userId) + .ApplySort(pagination, Sorters, DefaultSort) + .ThenBy(x => x.Id) + .ToPagedResultAsync(pagination, cancellationToken); + } + + public Task> GetPagedAsync( + Guid? userId, + Guid? actorId, + PaginationQuery pagination, + CancellationToken cancellationToken = default) + { + var query = _db.UserAuditLogs.AsQueryable(); + + if (userId.HasValue) query = query.Where(x => x.UserId == userId.Value); + if (actorId.HasValue) query = query.Where(x => x.ActorId == actorId.Value); + + return query + .ApplySort(pagination, Sorters, DefaultSort) + .ThenBy(x => x.Id) + .ToPagedResultAsync(pagination, cancellationToken); + } +} diff --git a/Common/Services/Audit/IAuditService.cs b/Common/Services/Audit/IAuditService.cs new file mode 100644 index 00000000..e2d93369 --- /dev/null +++ b/Common/Services/Audit/IAuditService.cs @@ -0,0 +1,32 @@ +using System.Net; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils.Pagination; + +namespace OpenShock.Common.Services.Audit; + +public interface IAuditService +{ + /// + /// Appends an audit log entry. ActorId defaults to UserId (self-service action). + /// + Task LogAsync( + Guid userId, + AuditAction action, + IPAddress? ipAddress = null, + string? userAgent = null, + AuditMetadata? metadata = null, + Guid? actorId = null, + CancellationToken cancellationToken = default); + + Task> GetPagedForUserAsync( + Guid userId, + PaginationQuery pagination, + CancellationToken cancellationToken = default); + + Task> GetPagedAsync( + Guid? userId, + Guid? actorId, + PaginationQuery pagination, + CancellationToken cancellationToken = default); +} diff --git a/Cron/Jobs/ClearOldAuditLogsJob.cs b/Cron/Jobs/ClearOldAuditLogsJob.cs new file mode 100644 index 00000000..ff3a7f70 --- /dev/null +++ b/Cron/Jobs/ClearOldAuditLogsJob.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +[CronJob("0 0 * * *")] +public sealed class ClearOldAuditLogsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public ClearOldAuditLogsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var cutoff = DateTime.UtcNow - Duration.AuditRetentionTime; + var deleted = await _db.UserAuditLogs + .Where(x => x.CreatedAt < cutoff) + .ExecuteDeleteAsync(); + + _logger.LogInformation("Deleted {Count} audit log entries older than {Cutoff}", deleted, cutoff); + return deleted; + } +}