diff --git a/API.IntegrationTests/Tests/AutomationTokenTests.cs b/API.IntegrationTests/Tests/AutomationTokenTests.cs new file mode 100644 index 00000000..ca3a653b --- /dev/null +++ b/API.IntegrationTests/Tests/AutomationTokenTests.cs @@ -0,0 +1,407 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Automation tokens lift Turnstile (and rate limits, which the test server disables anyway) for any +/// account except privileged ones, so these tests use an invalid Turnstile response to observe whether +/// a token was honored. +/// +public sealed class AutomationTokenTests +{ + private const string Password = "SecurePassword123#"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Signup --- + + [Test] + public async Task Signup_WithTurnstileToken_BypassesTurnstileAndLinksAccount() + { + var (tokenId, secret) = await CreateAutomationTokenInDb(); + using var client = CreateAutomationClient(secret); + var email = TestHelper.UniqueEmail("autosignup"); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autosignup"), + password = Password, + email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email); + await Assert.That(user.CreatedByAutomationTokenId).IsEqualTo(tokenId); + await Assert.That(await HasUsedAuditAsync(db, user.Id)).IsTrue(); + } + + [Test] + public async Task Signup_WithUnknownToken_StillRequiresTurnstile() + { + using var client = CreateAutomationClient(IAutomationTokenService.GenerateSecret()); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autounknown"), + password = Password, + email = TestHelper.UniqueEmail("autounknown"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task Signup_WithRateLimitOnlyToken_StillRequiresTurnstile() + { + var (_, secret) = await CreateAutomationTokenInDb([AutomationTokenType.RateLimit]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autoratelimit"), + password = Password, + email = TestHelper.UniqueEmail("autoratelimit"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + // --- Privileged callers (middleware) --- + + [Test] + public async Task PrivilegedSession_TokenIsRefused() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autostaffsession", [RoleType.Staff]); + + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, staff.SessionToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autostaffsessionsignup"), + password = Password, + email = TestHelper.UniqueEmail("autostaffsessionsignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task PrivilegedApiToken_TokenIsRefused() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autostaffapi", [RoleType.Staff]); + var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, staff.Id); + + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autostaffapisignup"), + password = Password, + email = TestHelper.UniqueEmail("autostaffapisignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task NormalApiToken_TokenIsHonored() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autonormalapi"), TestHelper.UniqueEmail("autonormalapi"), Password); + var (_, apiToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId); + + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, apiToken); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = TestHelper.UniqueUsername("autonormalapisignup"), + password = Password, + email = TestHelper.UniqueEmail("autonormalapisignup"), + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Login --- + + [Test] + public async Task Login_ForNormalAccount_BypassesTurnstileAndAudits() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var email = TestHelper.UniqueEmail("autologin"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autologin"), email, Password); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = email, + password = Password, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue(); + } + + [Test] + public async Task Login_ForPrivilegedAccount_Returns403() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var admin = await CreateUserWithRoles("autologinadmin", [RoleType.Admin]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = admin.Email, + password = Password, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + var body = await response.Content.ReadAsStringAsync(); + await Assert.That(body).Contains("AutomationToken.NotAllowedForAccount"); + } + + // --- Password reset --- + + [Test] + public async Task PasswordReset_ForPrivilegedAccount_ReturnsOkWithoutStartingReset() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var staff = await CreateUserWithRoles("autoresetstaff", [RoleType.Staff]); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new + { + email = staff.Email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == staff.Id)).IsFalse(); + } + + [Test] + public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits() + { + var (_, secret) = await CreateAutomationTokenInDb(); + var email = TestHelper.UniqueEmail("autoreset"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autoreset"), email, Password); + using var client = CreateAutomationClient(secret); + + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new + { + email, + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.UserPasswordResets.AnyAsync(r => r.UserId == userId)).IsTrue(); + await Assert.That(await HasUsedAuditAsync(db, userId)).IsTrue(); + } + + // --- Admin endpoints --- + + [Test] + public async Task Create_ReturnsWorkingSecret() + { + using var client = await CreateAdminClient("autocreate"); + + var response = await client.PostAsync("/1/admin/automationTokens", TestHelper.JsonContent(new + { + name = "created-by-test", + types = new[] { "Turnstile" } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var id = json.RootElement.GetProperty("id").GetGuid(); + var secret = json.RootElement.GetProperty("secret").GetString()!; + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var resolved = await service.ResolveAsync(secret, CancellationToken.None); + await Assert.That(resolved).IsNotNull(); + await Assert.That(resolved!.Id).IsEqualTo(id); + await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile); + } + + [Test] + public async Task Rotate_OldSecretStopsResolving() + { + var (tokenId, oldSecret) = await CreateAutomationTokenInDb(); + using var client = await CreateAdminClient("autorotate"); + + var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var newSecret = json.RootElement.GetProperty("secret").GetString()!; + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await service.ResolveAsync(oldSecret, CancellationToken.None)).IsNull(); + await Assert.That((await service.ResolveAsync(newSecret, CancellationToken.None))?.Id).IsEqualTo(tokenId); + } + + [Test] + public async Task Patch_CleanupRequiresDelay_AndSwitchingOffClearsIt() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + using var client = await CreateAdminClient("autopatch"); + + var missingDelay = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = true + })); + await Assert.That(missingDelay.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + var enable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = true, + autoCleanupAfter = "01:00:00" + })); + await Assert.That(enable.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var disable = await client.PatchAsync($"/1/admin/automationTokens/{tokenId}", TestHelper.JsonContent(new + { + autoCleanupUsers = false + })); + await Assert.That(disable.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var token = await db.AutomationTokens.AsNoTracking().FirstAsync(t => t.Id == tokenId); + await Assert.That(token.AutoCleanupUsers).IsFalse(); + await Assert.That(token.AutoCleanupAfter).IsNull(); + } + + [Test] + public async Task Delete_WithPrivilegedCreatedAccount_Returns409AndKeepsEverything() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + var normalId = await CreateUserCreatedByToken(tokenId, "autodelnormal", []); + var staffId = await CreateUserCreatedByToken(tokenId, "autodelstaff", [RoleType.Staff]); + using var client = await CreateAdminClient("autodelete409"); + + var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsTrue(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == normalId)).IsTrue(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == staffId)).IsTrue(); + } + + [Test] + public async Task Delete_DeletesTokenAndTheAccountsItCreated() + { + var (tokenId, _) = await CreateAutomationTokenInDb(); + var createdId = await CreateUserCreatedByToken(tokenId, "autodelcreated", []); + var unrelatedId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("autodelunrelated"), TestHelper.UniqueEmail("autodelunrelated"), Password); + using var client = await CreateAdminClient("autodelete"); + + var response = await client.DeleteAsync($"/1/admin/automationTokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Assert.That(await db.AutomationTokens.AnyAsync(t => t.Id == tokenId)).IsFalse(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == createdId)).IsFalse(); + await Assert.That(await db.Users.AnyAsync(u => u.Id == unrelatedId)).IsTrue(); + } + + // --- Helpers --- + + private HttpClient CreateAutomationClient(string secret) + { + var client = WebApplicationFactory.CreateClient(); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + return client; + } + + private async Task<(Guid Id, string Secret)> CreateAutomationTokenInDb(List? types = null) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var secret = IAutomationTokenService.GenerateSecret(); + var id = Guid.CreateVersion7(); + db.AutomationTokens.Add(new AutomationToken + { + Id = id, + Name = "test-token", + TokenHash = HashingUtils.HashToken(secret), + Types = types ?? [AutomationTokenType.Turnstile, AutomationTokenType.RateLimit] + }); + await db.SaveChangesAsync(); + return (id, secret); + } + + private async Task CreateUserWithRoles(string prefix, List roles) + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Users.Where(u => u.Id == user.Id).ExecuteUpdateAsync(s => s.SetProperty(u => u.Roles, roles)); + + return user; + } + + private async Task CreateUserCreatedByToken(Guid tokenId, string prefix, List roles) + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Users.Where(u => u.Id == userId).ExecuteUpdateAsync(s => s + .SetProperty(u => u.CreatedByAutomationTokenId, tokenId) + .SetProperty(u => u.Roles, roles)); + + return userId; + } + + private async Task CreateAdminClient(string prefix) + { + var admin = await CreateUserWithRoles(prefix, [RoleType.Admin]); + return TestHelper.CreateAuthenticatedClient(WebApplicationFactory, admin.SessionToken); + } + + private static Task HasUsedAuditAsync(OpenShockContext db, Guid userId) => + db.UserAuditLogs.AnyAsync(l => l.UserId == userId && l.Action == AuditAction.AutomationTokenUsed); +} diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index b9139e4d..bbf67df7 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -8,6 +8,8 @@ using OpenShock.Common.Errors; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; using Results = OpenShock.Common.Results; @@ -28,11 +30,12 @@ public sealed partial class AccountController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain, AutomationTokenNotAllowedForAccount [MapToApiVersion("2")] public async Task LoginV2( [FromBody] LoginV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { var cookieDomain = GetCurrentCookieDomain(); @@ -41,6 +44,11 @@ public async Task LoginV2( var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; + // Checked before the password is: with Turnstile and rate limits lifted, an automation token must not + // be usable to guess passwords of privileged accounts. + if (!await automationTokens.CanUseForLoginAsync(body.UsernameOrEmail, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); + var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (getAccountResult is not User account) { @@ -53,6 +61,9 @@ public async Task LoginV2( _ => throw new UnreachableException() }; } + + if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); await CreateSession(account.Id, cookieDomain); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 1bc1cc31..7e40bf5d 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -5,7 +5,9 @@ using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Internal.Common.Problems; @@ -32,11 +34,20 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, [FromServices] IAutomationTokenService automationTokens, CancellationToken cancellationToken) { var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; + // Privileged accounts must never be reached through a bypassed flow. The lookup runs only on the + // bypass path, so the normal path keeps its timing profile, and the response stays the generic + // 200 so this does not become an admin-account oracle. + if (!await automationTokens.TryRecordUseByEmailAsync(body.Email, AutomationTokenFlow.PasswordReset, cancellationToken)) + { + _logger.LogWarning("Refused a bypassed password reset for a privileged account"); + return Ok(); + } + await _accountService.CreatePasswordResetFlowAsync(body.Email); return Ok(); diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 2b02a349..f666a0a5 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -46,12 +46,18 @@ public async Task SignUpV2( var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); if (turnstileError is not null) return turnstileError; - var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); - return creationAction switch + // Created with an automation token, the account is linked to it and the use audited in the same transaction. + var creationAction = await _accountService.CreateAccountWithActivationFlowAsync( + body.Email, body.Username, body.Password, cancellationToken); + if (creationAction is not User _) { - User _ => Ok(), - AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists), - _ => throw new UnreachableException() - }; + return creationAction switch + { + AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists), + _ => throw new UnreachableException() + }; + } + + return Ok(); } } \ No newline at end of file diff --git a/API/Controller/Admin/AutomationTokenCreate.cs b/API/Controller/Admin/AutomationTokenCreate.cs new file mode 100644 index 00000000..f5b34f89 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenCreate.cs @@ -0,0 +1,63 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Creates an automation token. The secret is only returned once, in this response + /// + [HttpPost("automationTokens")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task CreateAutomationToken( + [FromBody] CreateAutomationTokenDto body, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var secret = IAutomationTokenService.GenerateSecret(); + var token = new AutomationToken + { + Id = Guid.CreateVersion7(), + Name = body.Name.Trim(), + TokenHash = HashingUtils.HashToken(secret), + Types = [.. body.Types.Distinct()], + AutoCleanupUsers = body.AutoCleanupUsers, + AutoCleanupAfter = body.AutoCleanupAfter, + }; + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + _db.AutomationTokens.Add(token); + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenCreated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenCreatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + return new CreatedAutomationTokenDto + { + Id = token.Id, + Name = token.Name, + Secret = secret, + Types = token.Types, + CreatedAt = token.CreatedAt, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }; + } +} diff --git a/API/Controller/Admin/AutomationTokenDelete.cs b/API/Controller/Admin/AutomationTokenDelete.cs new file mode 100644 index 00000000..e552a9bc --- /dev/null +++ b/API/Controller/Admin/AutomationTokenDelete.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Deletes an automation token together with every account it created. Refused while any of those + /// accounts holds a privileged role + /// + [HttpDelete("automationTokens/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task DeleteAutomationToken( + [FromRoute] Guid id, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return NotFound(); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var createdUsers = await _db.Users + .Where(u => u.CreatedByAutomationTokenId == id) + .Select(u => new { u.Id, u.Roles }) + .ToListAsync(ct); + + if (createdUsers.Any(u => PrivilegedRoles.Any(u.Roles))) return PrivilegedAccountConflict(); + + var userIds = createdUsers.Select(u => u.Id).ToArray(); + + // The roles are checked again as the rows are deleted, so an account promoted since it was read is kept. + await _db.Database.ExecuteSqlAsync( + $"DELETE FROM users WHERE id = ANY({userIds}) AND NOT (roles && {PrivilegedRoles.All})", ct); + + // The foreign key restricts, so this fails while any account still references the token: a privileged + // one, or one created or promoted meanwhile. On success, exactly userIds went with it. + try + { + await _db.AutomationTokens.Where(t => t.Id == id).ExecuteDeleteAsync(ct); + } + catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.ForeignKeyViolation) + { + return PrivilegedAccountConflict(); + } + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenDeleted, + actorId: CurrentUser.Id, + metadata: new AutomationTokenDeletedMetadata(token.Id, token.Name, userIds.Length), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + // Each deleted account's own audit log goes with it, so the log line is the lasting record of what went. + foreach (var userId in userIds) + { + _logger.LogInformation( + "Deleted account {UserId} together with automation token {AutomationTokenId}", + userId, token.Id); + } + + return Ok(); + + IActionResult PrivilegedAccountConflict() => Problem( + "This automation token created a privileged account; remove its privileged roles before deleting the token.", + statusCode: StatusCodes.Status409Conflict); + } +} diff --git a/API/Controller/Admin/AutomationTokenList.cs b/API/Controller/Admin/AutomationTokenList.cs new file mode 100644 index 00000000..57672668 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenList.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Lists all automation tokens + /// + [HttpGet("automationTokens")] + public async IAsyncEnumerable ListAutomationTokens() + { + PedanticallyEnsureAdmin(); + + await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable()) + { + yield return AutomationTokenDto.FromEntity(token); + } + } +} diff --git a/API/Controller/Admin/AutomationTokenPatch.cs b/API/Controller/Admin/AutomationTokenPatch.cs new file mode 100644 index 00000000..09012466 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenPatch.cs @@ -0,0 +1,60 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Updates an automation token + /// + [HttpPatch("automationTokens/{id}")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PatchAutomationToken( + [FromRoute] Guid id, + [FromBody] PatchAutomationTokenDto body, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return NotFound(); + + var autoCleanupUsers = body.AutoCleanupUsers ?? token.AutoCleanupUsers; + + // Switching cleanup off without naming a delay clears the delay, so it can be unset at all. + var autoCleanupAfter = body.AutoCleanupAfter ?? + (body.AutoCleanupUsers == false ? null : token.AutoCleanupAfter); + + if (autoCleanupUsers && autoCleanupAfter is null) + return Problem("AutoCleanupAfter is required when AutoCleanupUsers is true.", statusCode: StatusCodes.Status400BadRequest); + + if (body.Name is not null) token.Name = body.Name.Trim(); + if (body.Types is not null) token.Types = [.. body.Types.Distinct()]; + token.AutoCleanupUsers = autoCleanupUsers; + token.AutoCleanupAfter = autoCleanupAfter; + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenUpdated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenUpdatedMetadata(token.Id, token.Name, [.. token.Types.Select(t => t.ToString())], token.AutoCleanupUsers, token.AutoCleanupAfter), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + return Ok(AutomationTokenDto.FromEntity(token)); + } +} diff --git a/API/Controller/Admin/AutomationTokenRotate.cs b/API/Controller/Admin/AutomationTokenRotate.cs new file mode 100644 index 00000000..482e7a57 --- /dev/null +++ b/API/Controller/Admin/AutomationTokenRotate.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Controller.Admin.DTOs; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.Admin; + +public sealed partial class AdminController +{ + /// + /// Rotates the secret of an automation token. The new secret is only returned once, in this response + /// + [HttpPost("automationTokens/{id}/rotate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RotateAutomationToken( + [FromRoute] Guid id, + [FromServices] IAuditService auditService, + CancellationToken ct) + { + PedanticallyEnsureAdmin(); + + var token = await _db.AutomationTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return NotFound(); + + var secret = IAutomationTokenService.GenerateSecret(); + token.TokenHash = HashingUtils.HashToken(secret); + token.LastUsedAt = null; + token.UseCount = 0; + token.LastRotatedAt = DateTime.UtcNow; + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + await _db.SaveChangesAsync(ct); + + await auditService.LogAsync( + CurrentUser.Id, + action: AuditAction.AutomationTokenRotated, + actorId: CurrentUser.Id, + metadata: new AutomationTokenRotatedMetadata(token.Id, token.Name), + cancellationToken: ct); + + await transaction.CommitAsync(ct); + + // Accounts the token created stay linked to it (and to its cleanup schedule) across rotations, + // since the token id doesn't change. + + return Ok(new CreatedAutomationTokenDto + { + Id = token.Id, + Name = token.Name, + Secret = secret, + Types = token.Types, + CreatedAt = token.CreatedAt, + LastRotatedAt = token.LastRotatedAt, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }); + } +} diff --git a/API/Controller/Admin/DTOs/AutomationTokenDto.cs b/API/Controller/Admin/DTOs/AutomationTokenDto.cs new file mode 100644 index 00000000..bef4b4d1 --- /dev/null +++ b/API/Controller/Admin/DTOs/AutomationTokenDto.cs @@ -0,0 +1,41 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +public sealed class AutomationTokenDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public required IReadOnlyList Types { get; init; } + public required DateTime CreatedAt { get; init; } + public DateTime? LastUsedAt { get; init; } + public DateTime? LastRotatedAt { get; init; } + public long UseCount { get; init; } + public bool AutoCleanupUsers { get; init; } + public TimeSpan? AutoCleanupAfter { get; init; } + + public static AutomationTokenDto FromEntity(AutomationToken token) => new() + { + Id = token.Id, + Name = token.Name, + Types = token.Types, + CreatedAt = token.CreatedAt, + LastUsedAt = token.LastUsedAt, + LastRotatedAt = token.LastRotatedAt, + UseCount = token.UseCount, + AutoCleanupUsers = token.AutoCleanupUsers, + AutoCleanupAfter = token.AutoCleanupAfter, + }; +} + +public sealed class CreatedAutomationTokenDto +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public required string Secret { get; init; } + public required IReadOnlyList Types { get; init; } + public required DateTime CreatedAt { get; init; } + public DateTime? LastRotatedAt { get; init; } + public bool AutoCleanupUsers { get; init; } + public TimeSpan? AutoCleanupAfter { get; init; } +} diff --git a/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs new file mode 100644 index 00000000..524376ed --- /dev/null +++ b/API/Controller/Admin/DTOs/CreateAutomationTokenDto.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Admin.DTOs; + +public sealed class CreateAutomationTokenDto : IValidatableObject +{ + [Required] + [MinLength(1)] + [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] + public required string Name { get; init; } + + [Required] + [MinLength(1)] + public required IReadOnlyList Types { get; init; } + + /// + /// Whether the automated accounts this token creates are deleted once has + /// passed since they were created. Off by default; accounts the token didn't create are never deleted. + /// + public bool AutoCleanupUsers { get; init; } = false; + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable Validate(ValidationContext _) + { + if (string.IsNullOrWhiteSpace(Name)) + yield return new ValidationResult( + $"{nameof(Name)} must not be blank.", + [nameof(Name)]); + + if (AutoCleanupUsers && AutoCleanupAfter is null) + yield return new ValidationResult( + $"{nameof(AutoCleanupAfter)} is required when {nameof(AutoCleanupUsers)} is true.", + [nameof(AutoCleanupAfter)]); + + if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero) + yield return new ValidationResult( + $"{nameof(AutoCleanupAfter)} must be positive.", + [nameof(AutoCleanupAfter)]); + } +} + +public sealed class PatchAutomationTokenDto : IValidatableObject +{ + [MinLength(1)] + [MaxLength(ApiHardLimits.ApiKeyNameMaxLength)] + public string? Name { get; init; } + + [MinLength(1)] + public IReadOnlyList? Types { get; init; } + + public bool? AutoCleanupUsers { get; init; } + + public TimeSpan? AutoCleanupAfter { get; init; } + + public IEnumerable Validate(ValidationContext _) + { + if (Name is not null && string.IsNullOrWhiteSpace(Name)) + yield return new ValidationResult( + $"{nameof(Name)} must not be blank.", + [nameof(Name)]); + + if (AutoCleanupAfter is { } d && d <= TimeSpan.Zero) + yield return new ValidationResult( + $"{nameof(AutoCleanupAfter)} must be positive.", + [nameof(AutoCleanupAfter)]); + } +} diff --git a/API/Controller/Admin/GetUser.cs b/API/Controller/Admin/GetUser.cs index 2571f56c..c13ce360 100644 --- a/API/Controller/Admin/GetUser.cs +++ b/API/Controller/Admin/GetUser.cs @@ -87,6 +87,8 @@ public sealed class AdminUserView public required DateTimeOffset? ActivatedAt { get; init; } + public required Guid? CreatedByAutomationTokenId { get; init; } + public required AdminUserView_UserActivationRequest? ActivationRequest { get; init; } public required AdminUserView_UserDeactivation? Deactivation { get; init; } @@ -113,6 +115,7 @@ public sealed class AdminUserView u.Roles, u.CreatedAt, u.ActivatedAt, + u.CreatedByAutomationTokenId, Hubs = u.Devices.Select(hub => new AdminUserView_Hub { @@ -201,6 +204,7 @@ public sealed class AdminUserView Roles = user.Roles, CreatedAt = user.CreatedAt, ActivatedAt = user.ActivatedAt, + CreatedByAutomationTokenId = user.CreatedByAutomationTokenId, Hubs = user.Hubs, ApiTokens = user.ApiTokens, PasswordResets = user.PasswordResets, diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index dda6408e..a5f2289a 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -9,7 +9,9 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Results; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.Webhook; using OpenShock.Internal.Common.Utils; @@ -25,6 +27,7 @@ public sealed partial class TokensController /// /// /// + /// /// /// /// The tokens were deleted if found @@ -35,6 +38,7 @@ public sealed partial class TokensController public async Task ReportTokens( [FromBody] ReportTokensRequest body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] IAutomationTokenService automationTokens, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { @@ -49,6 +53,10 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } + // The middleware already refused the token for a privileged session; this audits the use. + if (!await automationTokens.TryRecordUseAsync(CurrentUser.Id, AutomationTokenFlow.ReportTokens, cancellationToken)) + return Problem(AutomationTokenError.NotAllowedForAccount); + var reportId = Guid.CreateVersion7(); int nAffected = 0; diff --git a/API/Errors/AutomationTokenError.cs b/API/Errors/AutomationTokenError.cs new file mode 100644 index 00000000..8e760d22 --- /dev/null +++ b/API/Errors/AutomationTokenError.cs @@ -0,0 +1,11 @@ +using System.Net; +using OpenShock.Common.Problems; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Errors; + +public static class AutomationTokenError +{ + public static OpenShockProblem NotAllowedForAccount => new("AutomationToken.NotAllowedForAccount", "Automation tokens cannot be used with privileged accounts", HttpStatusCode.Forbidden); +} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index de1c043d..47c7ef48 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -7,6 +7,7 @@ using OpenShock.Common.Options; using OpenShock.Common.Results; using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -26,6 +27,7 @@ public sealed class AccountService : IAccountService private readonly IRedisPubService _redisPubService; private readonly ISessionService _sessionService; private readonly IAuditService _auditService; + private readonly IAutomationTokenService _automationTokens; private readonly MailOptions _mailOptions; private readonly ILogger _logger; @@ -36,14 +38,16 @@ public sealed class AccountService : IAccountService /// Used to notify the email outbox delivery job that mail was enqueued. /// /// + /// The automation token this request carries, if any; an account it creates is linked to it. /// Decides whether an activation link can ever reach the user. /// - public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, MailOptions mailOptions, ILogger logger) + public AccountService(OpenShockContext db, IRedisPubService redisPubService, ISessionService sessionService, IAuditService auditService, IAutomationTokenService automationTokens, MailOptions mailOptions, ILogger logger) { _db = db; _redisPubService = redisPubService; _sessionService = sessionService; _auditService = auditService; + _automationTokens = automationTokens; _mailOptions = mailOptions; _logger = logger; } @@ -91,7 +95,7 @@ private async Task IsEmailProviderBlacklisted(string email) return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); } - private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation) + private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation, Guid? createdByAutomationTokenId) { email = email.ToLowerInvariant(); @@ -106,7 +110,8 @@ private async Task CreateAccount(string email, string use Id = Guid.CreateVersion7(), Name = username, Email = email, - PasswordHash = HashingUtils.HashPassword(password) + PasswordHash = HashingUtils.HashPassword(password), + CreatedByAutomationTokenId = createdByAutomationTokenId }; _db.Users.Add(user); @@ -124,27 +129,40 @@ await _db.Users } /// - public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password) + public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default) { // With mail disabled the activation email is never delivered, so an activation flow would leave // the account permanently unusable. Activate on creation instead. var mailEnabled = _mailOptions.IsEnabled; - var accountCreate = await CreateAccount(email, username, password, !mailEnabled); - if (accountCreate.Value is not User user || !mailEnabled) return accountCreate; + // An account an automation token creates must never exist without the audit entry for that use, so the + // account, its activation request and the audit entry commit together. + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); + + // Created with an automation token, the account is linked to it on insert, which makes it an automated account. + var accountCreate = await CreateAccount(email, username, password, !mailEnabled, _automationTokens.Current?.Id); + if (accountCreate.Value is not User user) return accountCreate; - // The real activation token is minted by the outbox delivery job at send time; here we record the - // request (with a seeded hash) and durably enqueue the email. - user.UserActivationRequest = new UserActivationRequest + if (mailEnabled) { - UserId = user.Id, - TokenHash = SeedTokenHash() - }; + // The real activation token is minted by the outbox delivery job at send time; here we record the + // request (with a seeded hash) and durably enqueue the email. + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = SeedTokenHash() + }; - _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username)); + _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username)); - await _db.SaveChangesAsync(); - await NotifyEmailOutboxAsync(); + await _db.SaveChangesAsync(cancellationToken); + } + + await _automationTokens.RecordSignupAsync(user.Id, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + if (mailEnabled) await NotifyEmailOutboxAsync(); return user; } diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 9a801d9c..069019bb 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -15,8 +15,9 @@ public interface IAccountService /// /// /// + /// /// - public Task CreateAccountWithActivationFlowAsync(string email, string username, string password); + public Task CreateAccountWithActivationFlowAsync(string email, string username, string password, CancellationToken cancellationToken = default); /// /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 0df2e762..cc7bd4d5 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -1,6 +1,8 @@ using System.Net; using OpenShock.API.Options; +using OpenShock.Common.Extensions; using OpenShock.Common.Results; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Services.Turnstile; @@ -11,13 +13,20 @@ public sealed class CloudflareTurnstileService : ICloudflareTurnstileService private readonly HttpClient _httpClient; private readonly TurnstileOptions _options; private readonly IHostEnvironment _environment; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; - public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions options, IHostEnvironment environment, ILogger logger) + public CloudflareTurnstileService( + HttpClient httpClient, + TurnstileOptions options, + IHostEnvironment environment, + IHttpContextAccessor httpContextAccessor, + ILogger logger) { _httpClient = httpClient; _options = options; _environment = environment; + _httpContextAccessor = httpContextAccessor; _logger = logger; } @@ -44,6 +53,12 @@ public async Task> VerifyUserResponse { if (!_options.Enabled) return new Success(); + // An admin-issued automation token resolved earlier in the pipeline counts as a Turnstile pass + // if it carries the Turnstile type. Controllers separately check, through IAutomationTokenService, + // that the account they act on is not privileged. + if (_httpContextAccessor.HttpContext?.IsBypassed(AutomationTokenType.Turnstile) ?? false) + return new Success(); + if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); if (_environment.IsDevelopment() && responseToken == "dev-bypass") diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..91d36e13 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,6 +6,7 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + public const string AutomationTokenHeaderName = "X-OpenShock-Automation-Token"; public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 938112b2..77e209ba 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -1,11 +1,52 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Constants; +using OpenShock.Common.Services.AutomationTokens; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { + public static bool TryGetAutomationTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Request.Headers.TryGetValue(AuthConstants.AutomationTokenHeaderName, out var value) && !string.IsNullOrEmpty(value)) + { + token = value!; + return true; + } + + token = null; + return false; + } + + /// + /// Stores the result of resolving the automation-token header. Called by the automation-token middleware. + /// + public static void SetResolvedAutomationToken(this HttpContext context, ResolvedAutomationToken resolved) + { + ArgumentNullException.ThrowIfNull(context); + context.SetItemByType(resolved); + } + + /// + /// Returns the automation token resolved earlier in the pipeline, or null if no header was + /// present (or it did not resolve to a known token). + /// + public static ResolvedAutomationToken? GetResolvedAutomationToken(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + return context.GetItemByType(); + } + + /// + /// Returns true if the request presented an automation token that grants . + /// + public static bool IsBypassed(this HttpContext context, AutomationTokenType type) => + context.GetResolvedAutomationToken()?.Types.Contains(type) ?? false; + // FullName is only null for open generic parameters, which a closed T never is. private static string ItemKeyOf() => typeof(T).FullName!; diff --git a/Common/Middleware/AutomationTokenMiddleware.cs b/Common/Middleware/AutomationTokenMiddleware.cs new file mode 100644 index 00000000..0a6fb02c --- /dev/null +++ b/Common/Middleware/AutomationTokenMiddleware.cs @@ -0,0 +1,119 @@ +using System.Security.Claims; +using OpenShock.Common.Extensions; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.AutomationTokens; +using Microsoft.Extensions.Logging; + +namespace OpenShock.Common.Middleware; + +/// +/// Resolves the X-OpenShock-Automation-Token header (if present) into a +/// stored on . Downstream guards (rate limiter partition selectors, +/// the turnstile service, controllers needing post-auth user linkage) read the cached value +/// synchronously and filter on the type they care about. +/// +/// Runs before UseRateLimiter so the rate limiter can honor the bypass for the very same request. +/// +public sealed class AutomationTokenMiddleware +{ + private readonly RequestDelegate _next; + + public AutomationTokenMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IAutomationTokenService automationTokens, ILogger logger) + { + if (!context.TryGetAutomationTokenFromHeader(out var secret)) + { + await _next(context); + return; + } + + var resolved = await automationTokens.ResolveAsync(secret, context.RequestAborted); + if (resolved is null) + { + // A presented-but-unmatched token is either a stale or rotated secret, or someone probing for one. + LogUnmatched(logger); + await _next(context); + return; + } + + // An automation token never applies to a privileged account, so it is dropped for one, rate limits + // and Turnstile included. Flows that pick the account from the request body (login, password reset) + // check the same rule. + if (await IsPrivilegedCallerAsync(context, automationTokens)) + { + logger.LogWarning( + "Automation token {AutomationTokenName} ({AutomationTokenId}) refused for a privileged account on {Method} {Path}", + ForLog(resolved.Name), resolved.Id, ForLog(context.Request.Method), ForLog(context.Request.Path.Value)); + await _next(context); + return; + } + + context.SetResolvedAutomationToken(resolved); + automationTokens.RecordRequest(resolved); + + // A credential that switches off Turnstile and rate limiting should never be used without + // leaving a trace. Logged at warning so it stands out in a production log, and the secret + // itself is never written, only which token was used, what it disabled, and for what. + logger.LogWarning( + "Automation token {AutomationTokenName} ({AutomationTokenId}) accepted for {Types} on {Method} {Path} from {RemoteIp}", + ForLog(resolved.Name), resolved.Id, string.Join(",", resolved.Types), ForLog(context.Request.Method), + ForLog(context.Request.Path.Value), context.Connection.RemoteIpAddress); + + await _next(context); + } + + private static async Task IsPrivilegedCallerAsync(HttpContext context, IAutomationTokenService automationTokens) + { + // Authentication already ran for the default scheme, so a cookie session's roles are on its claims. + if (context.User.TryGetOpenShockUserIdentity() is { } identity) + return PrivilegedRoles.Any(identity.GetRoles()); + + // Authorization also ran, so an endpoint's own scheme (hub or API token) may have authenticated the + // request too. Those identities carry the owning account's id but no roles, so look the roles up. + if (context.User.Identities.FirstOrDefault(x => x.IsAuthenticated) is { } other && + Guid.TryParse(other.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var userId)) + return await automationTokens.IsUserPrivilegedAsync(userId, context.RequestAborted); + + // Not authenticated yet, as on an anonymous endpoint, but the header still names the account the + // caller acts as. + if (context.TryGetApiTokenFromHeader(out var apiToken)) + return await automationTokens.IsApiTokenOwnerPrivilegedAsync(apiToken, context.RequestAborted); + + return false; + } + + // An unmatched token needs no credential to present, so the warning it produces is volume an + // unauthenticated caller controls. Keep the audit signal, but collapse it to at most one line + // per window and report how many attempts that line stands for. + private static readonly TimeSpan UnmatchedWarningInterval = TimeSpan.FromMinutes(1); + private static long _unmatchedWindowStartTicks; + private static long _unmatchedAttempts; + + private static void LogUnmatched(ILogger logger) + { + Interlocked.Increment(ref _unmatchedAttempts); + + var now = Environment.TickCount64; + var windowStart = Interlocked.Read(ref _unmatchedWindowStartTicks); + if (now - windowStart < UnmatchedWarningInterval.TotalMilliseconds) return; + + // Loser of the race has nothing to report; the winner owns this window's line. + if (Interlocked.CompareExchange(ref _unmatchedWindowStartTicks, now, windowStart) != windowStart) return; + + var attempts = Interlocked.Exchange(ref _unmatchedAttempts, 0); + + // Deliberately free of request data: the path, method and header are all caller-controlled, + // and this line exists to say that probing is happening, not to echo the probe back. + logger.LogWarning( + "Automation token presented but matched nothing ({Attempts} attempt(s) in the last {Interval})", + attempts, UnmatchedWarningInterval); + } + + // Request data would otherwise reach the log verbatim, where a newline in it forges a log line. + private static string ForLog(string? value) => + string.IsNullOrEmpty(value) ? string.Empty : value.ReplaceLineEndings(string.Empty); +} diff --git a/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs new file mode 100644 index 00000000..c6f41cae --- /dev/null +++ b/Common/Migrations/20260930204951_AddAutomationTokens.Designer.cs @@ -0,0 +1,1767 @@ +// +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.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +[Migration("20260930204951_AddAutomationTokens")] +partial class AddAutomationTokens +{ + /// + 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", "11.0.0-rc.1.26425.128") + .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", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" }); + 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[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + 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[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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.Property>("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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("automation_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("automation_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("automation_tokens"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + 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"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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.Property>("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("CreatedByAutomationTokenId"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + 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"); + }); + + 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("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + + 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.SetNull) + .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.AutomationToken", b => + { + b.Navigation("CreatedUsers"); + }); + + 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/20260930204951_AddAutomationTokens.cs b/Common/Migrations/20260930204951_AddAutomationTokens.cs new file mode 100644 index 00000000..141fb21d --- /dev/null +++ b/Common/Migrations/20260930204951_AddAutomationTokens.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +/// +public partial class AddAutomationTokens : Migration +{ + // Same as AddOAuthSupport's view, plus users.created_by_automation_token_id + public const string Query_Create_AdminUsersView = + """ + CREATE VIEW admin_users_view AS + SELECT + u.id, + u.name, + u.email, + (CASE + WHEN u.password_hash IS NULL THEN NULL + ELSE SPLIT_PART(u.password_hash, ':', 1) + END) AS password_hash_type, + u.roles, + u.created_at, + u.activated_at, + u.created_by_automation_token_id, + deact.created_at AS deactivated_at, + deact.deactivated_by_user_id, + (SELECT COUNT(*) FROM api_tokens token WHERE token.user_id = u.id) AS api_token_count, + (SELECT COUNT(*) FROM user_password_resets reset WHERE reset.user_id = u.id) AS password_reset_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + INNER JOIN user_shares share ON share.shocker_id = shocker.id + WHERE device.owner_id = u.id + ) AS shocker_user_share_count, + (SELECT COUNT(*) FROM public_shares share WHERE share.owner_id = u.id) AS shocker_public_share_count, + (SELECT COUNT(*) FROM user_email_changes entry WHERE entry.user_id = u.id) AS email_change_request_count, + (SELECT COUNT(*) FROM user_name_changes entry WHERE entry.user_id = u.id) AS name_change_request_count, + (SELECT COUNT(*) FROM devices device WHERE device.owner_id = u.id) AS device_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + WHERE device.owner_id = u.id + ) AS shocker_count, + ( + SELECT COUNT(*) FROM devices device + INNER JOIN shockers shocker ON shocker.device_id = device.id + INNER JOIN shocker_control_logs log ON log.shocker_id = shocker.id + WHERE device.owner_id = u.id + ) AS shocker_control_log_count + FROM + users u + LEFT JOIN LATERAL ( + SELECT + d.created_at, + d.deactivated_by_user_id + FROM user_deactivations d + WHERE d.deactivated_user_id = u.id + ORDER BY d.created_at DESC + LIMIT 1 + ) AS deact ON TRUE; + """; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(AddAdminUsersView.Query_Drop_AdminUsersView); + + 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,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .Annotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .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", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .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", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .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", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + + migrationBuilder.AddColumn( + name: "created_by_automation_token_id", + table: "users", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "automation_tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, collation: "C"), + types = table.Column>(type: "automation_token_type[]", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + last_used_at = table.Column(type: "timestamp with time zone", nullable: true), + last_rotated_at = table.Column(type: "timestamp with time zone", nullable: true), + use_count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + auto_cleanup_users = table.Column(type: "boolean", nullable: false, defaultValue: false), + auto_cleanup_after = table.Column(type: "interval", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("automation_tokens_pkey", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_users_created_by_automation_token_id", + table: "users", + column: "created_by_automation_token_id"); + + migrationBuilder.CreateIndex( + name: "IX_automation_tokens_token_hash", + table: "automation_tokens", + column: "token_hash", + unique: true); + + migrationBuilder.AddForeignKey( + name: "fk_users_created_by_automation_token_id", + table: "users", + column: "created_by_automation_token_id", + principalTable: "automation_tokens", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql(Query_Create_AdminUsersView); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(AddAdminUsersView.Query_Drop_AdminUsersView); + + migrationBuilder.DropForeignKey( + name: "fk_users_created_by_automation_token_id", + table: "users"); + + migrationBuilder.DropTable( + name: "automation_tokens"); + + migrationBuilder.DropIndex( + name: "IX_users_created_by_automation_token_id", + table: "users"); + + migrationBuilder.DropColumn( + name: "created_by_automation_token_id", + table: "users"); + + 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", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .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", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .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,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .OldAnnotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .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", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + + migrationBuilder.Sql(AddOAuthSupport.Query_Create_AdminUsersView); + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index a51b5749..a6e656a0 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using System.Collections.Generic; using System.Net; @@ -10,1666 +10,1760 @@ #nullable disable -namespace OpenShock.Common.Migrations +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +partial class OpenShockContextModelSnapshot : ModelSnapshot { - [DbContext(typeof(MigrationOpenShockContext))] - partial class OpenShockContextModelSnapshot : ModelSnapshot + // If you encounter a merge conflict in the line below, it means you need to + // discard one of the migration branches and recreate its migrations on top of + // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info. + public override string LastMigrationId => "20260930204951_AddAutomationTokens"; + + protected override void BuildModel(ModelBuilder modelBuilder) { - protected override void BuildModel(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.9") - .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[] { "stop", "shock", "vibrate", "sound" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); - 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[] { "bcrypt_enhanced", "pbkdf2" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); - 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.EmailOutboxMessage", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("AttemptCount") - .IsConcurrencyToken() - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("attempt_count"); - - b.Property("CoalesceKey") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("coalesce_key"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("FailedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("failed_at"); - - b.Property("LastError") - .HasMaxLength(1024) - .HasColumnType("character varying(1024)") - .HasColumnName("last_error"); - - b.Property("NextAttemptAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("next_attempt_at") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property>("Payload") - .IsRequired() - .HasColumnType("jsonb") - .HasColumnName("payload"); - - b.Property("Recipient") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)") - .HasColumnName("recipient"); - - b.Property("RecipientName") - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("recipient_name"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("sent_at"); - - b.Property("Status") - .HasColumnType("email_status") - .HasColumnName("status"); - - b.Property("Type") - .HasColumnType("email_type") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("email_outbox_pkey"); - - b.HasIndex("CoalesceKey"); - - b.HasIndex("Recipient"); - - b.HasIndex("Status", "NextAttemptAt"); - - b.ToTable("email_outbox", (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("Reason") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("reason"); - - b.Property("UserAgent") - .HasMaxLength(1024) - .HasColumnType("character varying(1024)") - .HasColumnName("user_agent"); - - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") + .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", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" }); + 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[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + 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[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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.Property>("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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("automation_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("automation_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("automation_tokens"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + 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"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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.Property>("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("CreatedByAutomationTokenId"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + 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"); + }); + + 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("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + 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.HasKey("Id") - .HasName("user_audit_logs_pkey"); - - b.HasIndex("ActorId"); + b.HasIndex("UserId"); - 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.ToTable("user_audit_logs"); + }); - b.Property("SecurityStampAtCreate") - .HasColumnType("uuid") - .HasColumnName("security_stamp_at_create"); + 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"); + }); + + 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("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("token_hash") - .UseCollation("C"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); - b.Property("UsedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("used_at"); + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("user_id"); + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); - b.HasKey("Id") - .HasName("user_email_changes_pkey"); + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); - b.HasIndex("CreatedAt"); + b.HasKey("Id") + .HasName("user_email_changes_pkey"); - b.HasIndex("UsedAt"); + b.HasIndex("CreatedAt"); - b.HasIndex("UserId"); + b.HasIndex("UsedAt"); - b.ToTable("user_email_changes", (string)null); - }); + b.HasIndex("UserId"); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); + b.ToTable("user_email_changes"); + }); - 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.SetNull) - .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"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); - b.Navigation("Shocker"); - }); + 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"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + + 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.SetNull) + .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"); - 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.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("Owner"); + b.Navigation("RecipientUser"); + }); - 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"); - 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.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("Invite"); + b.Navigation("Shocker"); + }); - b.Navigation("Shocker"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Navigation("CreatedUsers"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => - { - b.Navigation("OtaUpdates"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); - b.Navigation("Shockers"); - }); + b.Navigation("Shockers"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => - { - b.Navigation("ShockerMappings"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => - { - b.Navigation("PublicShareMappings"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); - b.Navigation("ShockerControlLogs"); + b.Navigation("ShockerControlLogs"); - b.Navigation("ShockerShareCodes"); + b.Navigation("ShockerShareCodes"); - b.Navigation("UserShareInviteShockerMappings"); + b.Navigation("UserShareInviteShockerMappings"); - b.Navigation("UserShares"); - }); + b.Navigation("UserShares"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => - { - b.Navigation("ActorAuditLogs"); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); - b.Navigation("ApiTokens"); + b.Navigation("ApiTokens"); - b.Navigation("AuditLogs"); + b.Navigation("AuditLogs"); - b.Navigation("Devices"); + b.Navigation("Devices"); - b.Navigation("EmailChanges"); + b.Navigation("EmailChanges"); - b.Navigation("IncomingUserShareInvites"); + b.Navigation("IncomingUserShareInvites"); - b.Navigation("IncomingUserShares"); + b.Navigation("IncomingUserShares"); - b.Navigation("NameChanges"); + b.Navigation("NameChanges"); - b.Navigation("OAuthConnections"); + b.Navigation("OAuthConnections"); - b.Navigation("OutgoingUserShareInvites"); + b.Navigation("OutgoingUserShareInvites"); - b.Navigation("OwnedPublicShares"); + b.Navigation("OwnedPublicShares"); - b.Navigation("PasswordResets"); + b.Navigation("PasswordResets"); - b.Navigation("ReportedApiTokens"); + b.Navigation("ReportedApiTokens"); - b.Navigation("ShockerControlLogs"); + b.Navigation("ShockerControlLogs"); - b.Navigation("UserActivationRequest"); + b.Navigation("UserActivationRequest"); - b.Navigation("UserDeactivation"); - }); + b.Navigation("UserDeactivation"); + }); - modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => - { - b.Navigation("ShockerMappings"); - }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); #pragma warning restore 612, 618 - } } } diff --git a/Common/Models/AuditMetadata.cs b/Common/Models/AuditMetadata.cs index 1aa651f5..7e3930d2 100644 --- a/Common/Models/AuditMetadata.cs +++ b/Common/Models/AuditMetadata.cs @@ -12,6 +12,11 @@ namespace OpenShock.Common.Models; [JsonDerivedType(typeof(OAuthConnectedMetadata), "oauthConnected")] [JsonDerivedType(typeof(OAuthDisconnectedMetadata), "oauthDisconnected")] [JsonDerivedType(typeof(AccountDeactivatedMetadata), "accountDeactivated")] +[JsonDerivedType(typeof(AutomationTokenCreatedMetadata), "automationTokenCreated")] +[JsonDerivedType(typeof(AutomationTokenUpdatedMetadata), "automationTokenUpdated")] +[JsonDerivedType(typeof(AutomationTokenRotatedMetadata), "automationTokenRotated")] +[JsonDerivedType(typeof(AutomationTokenDeletedMetadata), "automationTokenDeleted")] +[JsonDerivedType(typeof(AutomationTokenUsedMetadata), "automationTokenUsed")] public abstract record AuditMetadata; public sealed record LoginMetadata(Guid SessionId) : AuditMetadata; @@ -31,3 +36,16 @@ public sealed record OAuthConnectedMetadata(string Provider) : AuditMetadata; public sealed record OAuthDisconnectedMetadata(string Provider) : AuditMetadata; public sealed record AccountDeactivatedMetadata(bool DeleteLater) : AuditMetadata; + +public sealed record AutomationTokenCreatedMetadata(Guid TokenId, string Name, IReadOnlyList Types, bool AutoCleanupUsers, TimeSpan? AutoCleanupAfter) : AuditMetadata; + +/// The token's settings after the update. +public sealed record AutomationTokenUpdatedMetadata(Guid TokenId, string Name, IReadOnlyList Types, bool AutoCleanupUsers, TimeSpan? AutoCleanupAfter) : AuditMetadata; + +public sealed record AutomationTokenRotatedMetadata(Guid TokenId, string Name) : AuditMetadata; + +/// The token was deleted together with the accounts it created. +public sealed record AutomationTokenDeletedMetadata(Guid TokenId, string Name, int DeletedAccountCount) : AuditMetadata; + +/// An automation token was used on this account. is a AutomationTokenFlow name. +public sealed record AutomationTokenUsedMetadata(Guid TokenId, string Name, string Flow) : AuditMetadata; diff --git a/Common/OpenShockDb/Enums/AuditAction.cs b/Common/OpenShockDb/Enums/AuditAction.cs index 157ec5e0..ae4a67d8 100644 --- a/Common/OpenShockDb/Enums/AuditAction.cs +++ b/Common/OpenShockDb/Enums/AuditAction.cs @@ -19,4 +19,9 @@ public enum AuditAction [PgName("account_deactivated")] AccountDeactivated, [PgName("account_reactivated")] AccountReactivated, [PgName("account_deleted")] AccountDeleted, + [PgName("automation_token_created")] AutomationTokenCreated, + [PgName("automation_token_updated")] AutomationTokenUpdated, + [PgName("automation_token_rotated")] AutomationTokenRotated, + [PgName("automation_token_deleted")] AutomationTokenDeleted, + [PgName("automation_token_used")] AutomationTokenUsed, } diff --git a/Common/OpenShockDb/Enums/AutomationTokenFlow.cs b/Common/OpenShockDb/Enums/AutomationTokenFlow.cs new file mode 100644 index 00000000..d2a37805 --- /dev/null +++ b/Common/OpenShockDb/Enums/AutomationTokenFlow.cs @@ -0,0 +1,12 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// The account flow an automation token was used in, recorded on entries. +/// +public enum AutomationTokenFlow +{ + Signup, + Login, + PasswordReset, + ReportTokens, +} diff --git a/Common/OpenShockDb/Enums/AutomationTokenType.cs b/Common/OpenShockDb/Enums/AutomationTokenType.cs new file mode 100644 index 00000000..61cdd96f --- /dev/null +++ b/Common/OpenShockDb/Enums/AutomationTokenType.cs @@ -0,0 +1,14 @@ +using NpgsqlTypes; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// A protection that an automation token is allowed to switch off. +/// +[PgEnum(Name = "automation_token_type")] +public enum AutomationTokenType +{ + [PgName("turnstile")] Turnstile = 0, + [PgName("rate_limit")] RateLimit = 1, +} diff --git a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs index 4ab9640f..4bea31ed 100644 --- a/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs +++ b/Common/OpenShockDb/Extensions/NpgsqlEnumExtensions.cs @@ -46,6 +46,7 @@ private static PgEnumInfo BuildInfo() where TEnum : struct, Enum BuildInfo(), BuildInfo(), BuildInfo(), + BuildInfo(), ]; public static NpgsqlDbContextOptionsBuilder MapPgEnums(this NpgsqlDbContextOptionsBuilder builder) diff --git a/Common/OpenShockDb/Models/AdminUsersView.cs b/Common/OpenShockDb/Models/AdminUsersView.cs index 0d7f64e8..ac6dbaad 100644 --- a/Common/OpenShockDb/Models/AdminUsersView.cs +++ b/Common/OpenShockDb/Models/AdminUsersView.cs @@ -19,6 +19,8 @@ public sealed class AdminUsersView public required DateTime? ActivatedAt { get; set; } + public required Guid? CreatedByAutomationTokenId { get; set; } + public required DateTime? DeactivatedAt { get; set; } public required Guid? DeactivatedByUserId { get; set; } diff --git a/Common/OpenShockDb/Models/AutomationToken.cs b/Common/OpenShockDb/Models/AutomationToken.cs new file mode 100644 index 00000000..d1ebd54e --- /dev/null +++ b/Common/OpenShockDb/Models/AutomationToken.cs @@ -0,0 +1,30 @@ +namespace OpenShock.Common.OpenShockDb; + +public sealed class AutomationToken +{ + public required Guid Id { get; set; } + + public required string Name { get; set; } + + public required string TokenHash { get; set; } + + public required List Types { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public DateTime? LastRotatedAt { get; set; } + + public long UseCount { get; set; } + + public bool AutoCleanupUsers { get; set; } + + /// + /// With , how long after creation the accounts this token created are deleted. + /// + public TimeSpan? AutoCleanupAfter { get; set; } + + // Navigations + public ICollection CreatedUsers { get; } = []; +} diff --git a/Common/OpenShockDb/Models/User.cs b/Common/OpenShockDb/Models/User.cs index 7fb234eb..ea267f86 100644 --- a/Common/OpenShockDb/Models/User.cs +++ b/Common/OpenShockDb/Models/User.cs @@ -24,6 +24,13 @@ public sealed class User public DateTime CreatedAt { get; set; } public DateTime? ActivatedAt { get; set; } + /// + /// The admin-issued automation token that created this account, which makes it an automated (bot) + /// account rather than one a person signed up for; these are mainly used by automated tests. + /// The account is deleted together with the token. + /// + public Guid? CreatedByAutomationTokenId { get; set; } + // Navigations public UserActivationRequest? UserActivationRequest { get; set; } public UserDeactivation? UserDeactivation { get; set; } @@ -41,4 +48,5 @@ public sealed class User public ICollection PasswordResets { get; } = []; public ICollection AuditLogs { get; } = []; public ICollection ActorAuditLogs { get; } = []; + public AutomationToken? CreatedByAutomationToken { get; set; } } diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 4a03292f..ea03bab8 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -116,6 +116,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet EmailProviderBlacklists { get; set; } public DbSet EmailOutbox { get; set; } + + public DbSet AutomationTokens { get; set; } public DbSet DataProtectionKeys { get; set; } @@ -626,6 +628,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); entity.Property(e => e.ActivatedAt) .HasColumnName("activated_at"); + entity.Property(e => e.CreatedByAutomationTokenId) + .HasColumnName("created_by_automation_token_id"); + + entity.HasIndex(e => e.CreatedByAutomationTokenId); + + entity.HasOne(d => d.CreatedByAutomationToken).WithMany(p => p.CreatedUsers) + .HasForeignKey(d => d.CreatedByAutomationTokenId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_created_by_automation_token_id"); }); modelBuilder.Entity(entity => @@ -854,6 +865,41 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("automation_tokens_pkey"); + + entity.ToTable("automation_tokens"); + + entity.HasIndex(e => e.TokenHash).IsUnique(); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.Name) + .VarCharWithLength(ApiHardLimits.ApiKeyNameMaxLength) + .HasColumnName("name"); + entity.Property(e => e.TokenHash) + .UseCollation("C") + .VarCharWithLength(HardLimits.Sha256HashHexLength) + .HasColumnName("token_hash"); + entity.Property(e => e.Types) + .HasColumnType("automation_token_type[]") + .HasColumnName("types"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at"); + entity.Property(e => e.LastRotatedAt).HasColumnName("last_rotated_at"); + entity.Property(e => e.UseCount) + .HasDefaultValue(0L) + .HasColumnName("use_count"); + entity.Property(e => e.AutoCleanupUsers) + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + entity.Property(e => e.AutoCleanupAfter).HasColumnName("auto_cleanup_after"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id).HasName("email_outbox_pkey"); @@ -936,6 +982,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); entity.Property(e => e.ActivatedAt) .HasColumnName("activated_at"); + entity.Property(e => e.CreatedByAutomationTokenId) + .HasColumnName("created_by_automation_token_id"); entity.Property(e => e.DeactivatedAt) .HasColumnName("deactivated_at"); entity.Property(e => e.DeactivatedByUserId) diff --git a/Common/OpenShockDb/PrivilegedRoles.cs b/Common/OpenShockDb/PrivilegedRoles.cs new file mode 100644 index 00000000..8be5165d --- /dev/null +++ b/Common/OpenShockDb/PrivilegedRoles.cs @@ -0,0 +1,19 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// The roles that make an account privileged: automation tokens never apply to such an account, and +/// automated-account cleanup never deletes one. +/// +/// +/// Npgsql can't translate LINQ predicates over the roles enum array (Roles.Any(...) and +/// Roles.Contains(...) both fail), so queries read the roles and check them with , +/// or pass to SQL as a role_type[] parameter. +/// +public static class PrivilegedRoles +{ + public static readonly RoleType[] All = [RoleType.Staff, RoleType.Admin, RoleType.System]; + + public static bool Contains(RoleType role) => role is RoleType.Staff or RoleType.Admin or RoleType.System; + + public static bool Any(IEnumerable roles) => roles.Any(Contains); +} diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index e0bc4ae5..c617bd03 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using OpenShock.Common.HealthChecks; +using OpenShock.Common.Middleware; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Redis; @@ -114,6 +115,10 @@ exception is null await redisConnection.CreateIndexAsync(typeof(DevicePair)); await redisConnection.CreateIndexAsync(typeof(LcgNode)); + // Resolve the X-OpenShock-Automation-Token header (if present) before rate limiting so the + // rate limiter partition selectors can honor the bypass for this same request. + app.UseMiddleware(); + app.UseRateLimiter(); app.UseOpenTelemetryPrometheusScrapingEndpoint(context => diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 5d7d0f6a..5a47ae60 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -23,6 +23,7 @@ using OpenShock.Common.Problems; using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.Services.AutomationTokens; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Geo; @@ -345,6 +346,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Ensure GeoOptions is always resolvable so IpEnrichmentService can activate even in hosts // (Cron, LiveControlGateway, SeedE2E) that don't call RegisterGeoOptions(). TryAdd leaves the @@ -380,6 +382,17 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } + // If the request resolved an automation token granting RateLimit, skip every limiter on it. + // Selectors are sync and the AutomationTokenMiddleware (which runs before UseRateLimiter) + // has already populated HttpContext.Items, so this is just a dictionary lookup. + static RateLimitPartition? TryBypass(HttpContext ctx) + { + var bypass = ctx.GetResolvedAutomationToken(); + return bypass is not null && bypass.Types.Contains(AutomationTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter($"automation-token-{bypass.Id}") + : null; + } + options.OnRejected = async (context, cancellationToken) => { var logger = context.HttpContext.RequestServices.GetRequiredService() @@ -408,6 +421,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Fixed window at 10k requests allows 20k bursts if burst occurs at window boundary options.GlobalLimiter = PartitionedRateLimiter.Create(context => { + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + var user = context.User; var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) @@ -443,7 +458,9 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Authentication endpoints limiter options.AddPolicy("auth", context => { - var ip = context.GetRemoteIP(); + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + + var ip = context.GetRemoteIP().ToString(); return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, @@ -452,7 +469,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga }); // Token reporting endpoint concurrency limiter - options.AddPolicy("token-reporting", _ => + options.AddPolicy("token-reporting", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("token-reporting", _ => new ConcurrencyLimiterOptions { PermitLimit = 5, @@ -461,7 +479,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga })); // Log fetching endpoint concurrency limiter - options.AddPolicy("shocker-logs", _ => + options.AddPolicy("shocker-logs", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("shocker-logs", _ => new ConcurrencyLimiterOptions { PermitLimit = 10, diff --git a/Common/Services/AutomationTokens/AutomationTokenService.cs b/Common/Services/AutomationTokens/AutomationTokenService.cs new file mode 100644 index 00000000..7ee6d8e4 --- /dev/null +++ b/Common/Services/AutomationTokens/AutomationTokenService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.AutomationTokens; + +public sealed class AutomationTokenService : IAutomationTokenService +{ + private readonly OpenShockContext _db; + private readonly IBatchUpdateService _batchUpdateService; + private readonly IAuditService _auditService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AutomationTokenService( + OpenShockContext db, + IBatchUpdateService batchUpdateService, + IAuditService auditService, + IHttpContextAccessor httpContextAccessor) + { + _db = db; + _batchUpdateService = batchUpdateService; + _auditService = auditService; + _httpContextAccessor = httpContextAccessor; + } + + public ResolvedAutomationToken? Current => _httpContextAccessor.HttpContext?.GetResolvedAutomationToken(); + + public async Task ResolveAsync(string secret, CancellationToken ct) + { + if (secret.Length != IAutomationTokenService.SecretLength) return null; + + var tokenHash = HashingUtils.HashToken(secret); + + return await _db.AutomationTokens + .AsNoTracking() + .Where(t => t.TokenHash == tokenHash) + .Select(t => new ResolvedAutomationToken(t.Id, t.Name, t.Types, t.TokenHash)) + .FirstOrDefaultAsync(ct); + } + + public async Task IsApiTokenOwnerPrivilegedAsync(string apiToken, CancellationToken ct) + { + var tokenHash = HashingUtils.HashToken(apiToken); + + var roles = await _db.ApiTokens + .Where(t => t.TokenHash == tokenHash) + .Select(t => t.User.Roles) + .FirstOrDefaultAsync(ct); + + return roles is not null && PrivilegedRoles.Any(roles); + } + + public void RecordRequest(ResolvedAutomationToken automationToken) => + _batchUpdateService.UpdateAutomationTokenUsed(automationToken.Id, automationToken.TokenHash); + + public async Task RecordSignupAsync(Guid userId, CancellationToken ct) + { + if (Current is not { } automationToken) return; + + await AuditUseAsync(automationToken, userId, AutomationTokenFlow.Signup, ct); + } + + public async Task CanUseForLoginAsync(string usernameOrEmail, CancellationToken ct) + { + if (Current is null) return true; + + // Same lookup as AccountService.GetAccountByCredentialsAsync + var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); + var roles = await _db.Users + .Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail) + .Select(u => u.Roles) + .FirstOrDefaultAsync(ct); + + return roles is null || !PrivilegedRoles.Any(roles); + } + + public async Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, CancellationToken ct) + { + if (Current is not { } automationToken) return true; + + if (await IsUserPrivilegedAsync(userId, ct)) return false; + + await AuditUseAsync(automationToken, userId, flow, ct); + + return true; + } + + public async Task TryRecordUseByEmailAsync(string email, AutomationTokenFlow flow, CancellationToken ct) + { + if (Current is null) return true; + + email = email.ToLowerInvariant(); + var userId = await _db.Users + .Where(u => u.Email == email) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(ct); + + if (userId is null) return true; + + return await TryRecordUseAsync(userId.Value, flow, ct); + } + + public async Task IsUserPrivilegedAsync(Guid userId, CancellationToken ct) + { + var roles = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.Roles) + .FirstOrDefaultAsync(ct); + + return roles is not null && PrivilegedRoles.Any(roles); + } + + private Task AuditUseAsync(ResolvedAutomationToken automationToken, Guid userId, AutomationTokenFlow flow, CancellationToken ct) => + _auditService.LogAsync( + userId, + action: AuditAction.AutomationTokenUsed, + // A password reset request proves nothing about who is asking, so it has no actor. In every + // other flow the caller is the account itself, or has just created it. + actorId: flow == AutomationTokenFlow.PasswordReset ? null : userId, + metadata: new AutomationTokenUsedMetadata(automationToken.Id, automationToken.Name, flow.ToString()), + cancellationToken: ct); +} diff --git a/Common/Services/AutomationTokens/IAutomationTokenService.cs b/Common/Services/AutomationTokens/IAutomationTokenService.cs new file mode 100644 index 00000000..2f363aac --- /dev/null +++ b/Common/Services/AutomationTokens/IAutomationTokenService.cs @@ -0,0 +1,80 @@ +using OpenShock.Common.OpenShockDb; +using OpenShock.Internal.Common.Utils; + +namespace OpenShock.Common.Services.AutomationTokens; + +/// +/// An automation token may be used with any account except privileged ones (see ). +/// Every use in an account flow is written to that account's audit log. Accounts the token itself created are +/// linked to it, which is what makes them automated accounts; only those are ever eligible for auto-cleanup, +/// and they are deleted with the token. +/// +public interface IAutomationTokenService +{ + /// + /// Length (in chars) of the opaque random secret. The full secret is base62-style alphanumeric. + /// + const int SecretLength = 128; + + /// + /// Generates a new opaque automation-token secret. The secret is sent via the + /// X-OpenShock-Automation-Token header. It has no prefix because it lives in a dedicated + /// header and is not confused with any other token. + /// + static string GenerateSecret() => CryptoUtils.RandomString(SecretLength); + + /// + /// The automation token the current request resolved, or null if it presented none (or one + /// that was refused). + /// + ResolvedAutomationToken? Current { get; } + + /// + /// Looks up the token the supplied secret belongs to. Secrets of the wrong length are rejected + /// without a database round trip. + /// + Task ResolveAsync(string secret, CancellationToken ct); + + /// + /// True if belongs to a privileged account. Used before the API-token + /// scheme has authenticated the request, so the token itself is not validated here. + /// + Task IsApiTokenOwnerPrivilegedAsync(string apiToken, CancellationToken ct); + + /// + /// True if holds a privileged role. + /// + Task IsUserPrivilegedAsync(Guid userId, CancellationToken ct); + + /// + /// Counts one accepted request against the token. Batched, never written per request. + /// + void RecordRequest(ResolvedAutomationToken automationToken); + + /// + /// If the current request resolved an automation token, audits its use to create . + /// The account itself is linked to the token when it is created. + /// + Task RecordSignupAsync(Guid userId, CancellationToken ct); + + /// + /// Checked before the password is verified, so an automation token (which also lifts Turnstile and rate + /// limits) can never be used to guess passwords of privileged accounts. Returns true when + /// no automation token was used or no such account exists; the normal credential check handles the latter. + /// + Task CanUseForLoginAsync(string usernameOrEmail, CancellationToken ct); + + /// + /// If the current request resolved an automation token, audits a use against . + /// Returns false ONLY when an automation token was used AND the account is privileged. Callers MUST treat + /// that as "bypass not honored" and reject the request. + /// + Task TryRecordUseAsync(Guid userId, AutomationTokenFlow flow, CancellationToken ct); + + /// + /// Same contract as but resolves the user by email first. + /// If no matching user exists, this is a no-op and returns true so the caller does not + /// expose user-existence by behaving differently, which matters on flows like password-reset initiation. + /// + Task TryRecordUseByEmailAsync(string email, AutomationTokenFlow flow, CancellationToken ct); +} diff --git a/Common/Services/AutomationTokens/ResolvedAutomationToken.cs b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs new file mode 100644 index 00000000..3f5e855a --- /dev/null +++ b/Common/Services/AutomationTokens/ResolvedAutomationToken.cs @@ -0,0 +1,13 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Services.AutomationTokens; + +/// +/// The result of resolving the X-OpenShock-Automation-Token header against the database. +/// Set on by the automation-token middleware +/// so that downstream guards (rate limiter, turnstile, etc.) can read it synchronously without +/// re-hitting the database. +/// identifies which secret of the token was presented, so usage counted against +/// a secret that has since been rotated away is not credited to its replacement. +/// +public sealed record ResolvedAutomationToken(Guid Id, string Name, IReadOnlyList Types, string TokenHash); diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 04bc83f1..4d4a4e89 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -34,6 +34,32 @@ public Dictionary DequeueAll() } } +internal sealed class ConcurrentCountingBatchQueue where TKey : notnull +{ + private readonly Lock _lock = new(); + private Dictionary _dictionary = new(); + + public void Increment(TKey key) => Add(key, 1); + + public void Add(TKey key, long count) + { + lock (_lock) + { + _dictionary[key] = _dictionary.GetValueOrDefault(key) + count; + } + } + + public Dictionary DequeueAll() + { + lock (_lock) + { + var items = _dictionary; + _dictionary = new Dictionary(); + return items; + } + } +} + public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService { private static readonly TimeSpan Interval = TimeSpan.FromSeconds(10); @@ -44,6 +70,7 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService private readonly ConcurrentUniqueBatchQueue _tokenLastUsed = new(); private readonly ConcurrentUniqueBatchQueue _sessionLastUsed = new(); + private readonly ConcurrentCountingBatchQueue<(Guid Id, string TokenHash)> _automationTokenUses = new(); public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { @@ -66,6 +93,39 @@ await db.ApiTokens.Where(x => keys.Contains(x.Id)) await db.SaveChangesAsync(); } + private async Task UpdateAutomationTokens() + { + var uses = _automationTokenUses.DequeueAll(); + + // Skip if there is nothing + if (uses.Count < 1) return; + + var now = DateTime.UtcNow; + + try + { + await using var db = await _dbFactory.CreateDbContextAsync(); + foreach (var ((automationTokenId, tokenHash), count) in uses.ToArray()) + { + // Matching the hash drops uses of a secret rotated away since, so they never count towards + // the new one. Rotation resets the counters, and this keeps them reset. + await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == tokenHash) + .ExecuteUpdateAsync(x => x + .SetProperty(y => y.LastUsedAt, now) + .SetProperty(y => y.UseCount, y => y.UseCount + count)); + + uses.Remove((automationTokenId, tokenHash)); + } + } + catch + { + // Unlike a time of last use, a count can't be rebuilt by the next request, so put back what + // wasn't written for the next flush to retry. + foreach (var (key, count) in uses) _automationTokenUses.Add(key, count); + throw; + } + } + private async Task UpdateSessions() { var sessionsToUpdate = new List>(); @@ -91,6 +151,11 @@ public void UpdateApiTokenLastUsed(Guid apiTokenId) _tokenLastUsed.Enqueue(apiTokenId, false); } + public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash) + { + _automationTokenUses.Increment((automationTokenId, tokenHash)); + } + public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed) { // Only hash new tokens, old ones are 64 chars long @@ -127,7 +192,7 @@ private async Task Flush() { try { - await Task.WhenAll(UpdateTokens(), UpdateSessions()); + await Task.WhenAll(UpdateTokens(), UpdateAutomationTokens(), UpdateSessions()); } catch (Exception e) { diff --git a/Common/Services/BatchUpdate/IBatchUpdateService.cs b/Common/Services/BatchUpdate/IBatchUpdateService.cs index ea42604a..4b5444da 100644 --- a/Common/Services/BatchUpdate/IBatchUpdateService.cs +++ b/Common/Services/BatchUpdate/IBatchUpdateService.cs @@ -7,5 +7,11 @@ public interface IBatchUpdateService /// /// public void UpdateApiTokenLastUsed(Guid apiTokenId); + /// + /// Count one accepted request for an automation token and bump its time of last use + /// + /// + /// Hash of the secret presented; the use is dropped if the token was rotated since + public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash); public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed); } \ No newline at end of file diff --git a/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs new file mode 100644 index 00000000..9ea04479 --- /dev/null +++ b/Cron.IntegrationTests/Tests/DeleteExpiredAutomatedAccountsTests.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; +using OpenShock.Cron.Jobs; + +namespace OpenShock.Cron.IntegrationTests.Tests; + +/// +/// Runs the automated-account cleanup against real Postgres, so the interval arithmetic, the join to the +/// token and the raw delete's role_type[] parameter are exercised, not just the LINQ shape. +/// +public sealed class DeleteExpiredAutomatedAccountsTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CronApplicationFactory Factory { get; init; } + + [Test] + public async Task Execute_DeletesOnlyExpiredNonPrivilegedAccountsOfCleanupTokens() + { + var cleanupToken = Guid.CreateVersion7(); + var keepToken = Guid.CreateVersion7(); + var longAgo = DateTime.UtcNow - TimeSpan.FromHours(2); + + var expired = Guid.CreateVersion7(); + var expiredStaff = Guid.CreateVersion7(); + var fresh = Guid.CreateVersion7(); + var expiredOfKeepToken = Guid.CreateVersion7(); + var expiredUnlinked = Guid.CreateVersion7(); + + await using (var db = await Factory.DbContextFactory.CreateDbContextAsync()) + { + db.AutomationTokens.Add(NewToken(cleanupToken, autoCleanup: true)); + db.AutomationTokens.Add(NewToken(keepToken, autoCleanup: false)); + + db.Users.Add(NewUser(expired, longAgo, cleanupToken, [])); + db.Users.Add(NewUser(expiredStaff, longAgo, cleanupToken, [RoleType.Staff])); + db.Users.Add(NewUser(fresh, DateTime.UtcNow, cleanupToken, [])); + db.Users.Add(NewUser(expiredOfKeepToken, longAgo, keepToken, [])); + db.Users.Add(NewUser(expiredUnlinked, longAgo, null, [])); + + await db.SaveChangesAsync(); + } + + await using var scope = Factory.Services.CreateAsyncScope(); + var job = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + await job.Execute(); + + await using var verify = await Factory.DbContextFactory.CreateDbContextAsync(); + var remaining = await verify.Users + .Where(u => new[] { expired, expiredStaff, fresh, expiredOfKeepToken, expiredUnlinked }.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(); + + await Assert.That(remaining).DoesNotContain(expired); + await Assert.That(remaining).Contains(expiredStaff); + await Assert.That(remaining).Contains(fresh); + await Assert.That(remaining).Contains(expiredOfKeepToken); + await Assert.That(remaining).Contains(expiredUnlinked); + } + + private static AutomationToken NewToken(Guid id, bool autoCleanup) => new() + { + Id = id, + Name = "cleanup-test", + TokenHash = HashingUtils.HashToken(Guid.NewGuid().ToString("N")), + Types = [AutomationTokenType.Turnstile], + AutoCleanupUsers = autoCleanup, + AutoCleanupAfter = TimeSpan.FromHours(1) + }; + + private static User NewUser(Guid id, DateTime createdAt, Guid? tokenId, List roles) + { + var suffix = Guid.NewGuid().ToString("N")[..12]; + return new User + { + Id = id, + Name = $"auto{suffix}", + Email = $"auto-{suffix}@test.org", + SecurityStamp = Guid.CreateVersion7(), + Roles = roles, + CreatedAt = createdAt, + ActivatedAt = createdAt, + CreatedByAutomationTokenId = tokenId + }; + } +} diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs new file mode 100644 index 00000000..fd364a85 --- /dev/null +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Hard-deletes automated accounts (mainly test accounts) that were created through an admin-issued +/// automation token whose owner enabled auto-cleanup (off by default), once the token's configured lifetime +/// has passed since the account was created. Accounts the token was merely used with are never touched, +/// only the ones it created. +/// +[CronJob("0 * * * *")] // Every hour +public sealed class DeleteExpiredAutomatedAccountsJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public DeleteExpiredAutomatedAccountsJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var now = DateTime.UtcNow; + + // Selecting and deleting in one statement means an account promoted between a read and the delete can't + // slip through: Postgres rechecks a deleted row that changed under it. It doesn't recheck joined rows, so + // the tokens are locked first. That waits out a cleanup setting being changed and evaluates the committed + // one, and holds off a later change until these deletes commit. + var deleted = await _db.Database.SqlQuery($""" + WITH t AS ( + SELECT id, auto_cleanup_after + FROM automation_tokens + WHERE auto_cleanup_users + AND auto_cleanup_after IS NOT NULL + FOR SHARE + ) + DELETE FROM users AS u + USING t + WHERE t.id = u.created_by_automation_token_id + AND u.created_at + t.auto_cleanup_after < {now} + AND NOT (u.roles && {PrivilegedRoles.All}) + RETURNING u.id AS "UserId", u.created_by_automation_token_id AS "AutomationTokenId" + """).ToListAsync(); + + if (deleted.Count == 0) + { + _logger.LogDebug("No automation-token-created accounts eligible for cleanup"); + return 0; + } + + // A user's audit log is deleted with the user, so the log line is the lasting record of what went. + foreach (var account in deleted) + { + _logger.LogInformation( + "Automation-token cleanup deleted account {UserId} created by automation token {AutomationTokenId}", + account.UserId, account.AutomationTokenId); + } + + _logger.LogInformation("Automation-token cleanup: {DeletedCount} accounts deleted", deleted.Count); + + return deleted.Count; + } + + private sealed record DeletedAccount(Guid UserId, Guid AutomationTokenId); +}