From 3333202f3a73ae8ad784987fcf7cc4c636c2b477 Mon Sep 17 00:00:00 2001 From: Diseri Pearson Date: Mon, 18 May 2026 10:45:31 +0200 Subject: [PATCH] Dual-token rotation grace window (24h default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token rotation used to be immediate cutover — push gap from when ops rotates to when the customer's .env is updated and portal restarted. Now the old token keeps working for 24h after rotation, so customer ops has a full workday to swap it in without dropping a single push tick. Backend - Customer entity gains PreviousTokenHash + PreviousTokenExpiresAt (both nullable). Non-unique index on PreviousTokenHash so the OR-lookup in FindByTokenAsync stays cheap. - CustomerService.RotateTokenAsync(id, graceWindow=null, ct): copies the existing TokenHash into PreviousTokenHash with PreviousTokenExpiresAt = now + graceWindow (default 24h, lifted to CustomerService.DefaultTokenGracePeriod), then issues a new current token. Second rotation overwrites the previous slot — at most one previous token is ever honoured. - CustomerService.FindByTokenAsync matches either current OR (previous AND PreviousTokenExpiresAt > now). IsActive=false still rejects both. - DTO exposes PreviousTokenExpiresAt so the UI can render the grace window status. - New EF migration AddPreviousTokenGraceWindow on AdminDbContext. Frontend - Customers table "Token" column shows an "Old token valid until …" orange tag with a tooltip whenever the grace window is active, plus the issue/rotation date as before. - TokenShownOnceModal mentions the 24h grace window so ops knows they have time to update .env without urgency. - Rotate-token popconfirm copy updated to reflect the new behavior. Tests (+5, 61/61 passing) - CustomerTokenGraceTests covers: create doesn't set previous; rotate moves current into previous slot with future expiry; zero grace window rejects original immediately; second rotation overwrites previous (original dies, first-rotation becomes the new previous); inactive customer rejects both current AND previous. Verified end-to-end on the dev host - Migration applied cleanly on the existing admin_fleet DB (existing DEV0001 customer got NULL previous columns, no data loss). - Created GRACE01 → got token1. - Rotated → got token2. PreviousTokenExpiresAt = +24h. Both token1 and token2 push successfully (200). - Rotated again → got token3. token1 push now returns 401 (gone). token2 push still 200 (now the previous). token3 push 200 (current). Docs - FLEET-DESIGN.md §6 rewritten — no longer "immediate cutover". - §11 "open seams" row for this feature marked as shipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- portal/docs/FLEET-DESIGN.md | 4 +- portal/frontend/src/api/customers.ts | 2 + .../customers/TokenShownOnceModal.tsx | 5 + .../frontend/src/pages/AdminCustomersPage.tsx | 22 +- .../Tau.Acuvim.Portal/DTOs/CustomerDtos.cs | 3 + .../Tau.Acuvim.Portal/Data/AdminDbContext.cs | 5 + .../Domain/Fleet/Customer.cs | 7 + .../Endpoints/AdminCustomersEndpoints.cs | 2 +- ...30_AddPreviousTokenGraceWindow.Designer.cs | 605 ++++++++++++++++++ ...60518084030_AddPreviousTokenGraceWindow.cs | 55 ++ .../Admin/AdminDbContextModelSnapshot.cs | 9 + .../Services/CustomerService.cs | 34 +- .../CustomerTokenGraceTests.cs | 104 +++ 13 files changed, 847 insertions(+), 10 deletions(-) create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs create mode 100644 portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs diff --git a/portal/docs/FLEET-DESIGN.md b/portal/docs/FLEET-DESIGN.md index 984e0d8..9bfeb42 100644 --- a/portal/docs/FLEET-DESIGN.md +++ b/portal/docs/FLEET-DESIGN.md @@ -225,7 +225,7 @@ Push runs on its own DI scope and its own DbContext. If the push DB query hangs, - Token = 32 random bytes, hex-encoded (64 chars). Generated server-side via `RandomNumberGenerator`. - Stored as **SHA-256 hex hash**, UNIQUE indexed → ingest endpoint does a single O(log N) indexed lookup. Bcrypt is the wrong tool for a high-throughput, high-entropy machine token. - Shown **once** in the Admin UI at creation/rotation. Admin only stores the hash. Lost token → rotate. -- Rotation: **immediate cutover** — new value replaces old hash. Customer ops updates `.env` + restarts. Brief push gap acceptable; can revisit with dual-token grace window later. +- Rotation: **dual-token with 24h grace window** (shipped after the initial design). On rotate, the current `TokenHash` is copied into `PreviousTokenHash` with `PreviousTokenExpiresAt = now + 24h`, then a fresh current is generated. `FindByTokenAsync` matches either current OR (previous AND not expired AND `IsActive`). Customer ops has the full window to update `.env` + restart without dropping a push tick. A second rotation within the grace window overwrites the previous slot — at most one previous token is ever honoured at a time. Grace duration is `CustomerService.DefaultTokenGracePeriod` (parameterizable on `RotateTokenAsync` for tests; lift to config if you need per-customer tuning). - `Customers.IsActive=false` → all that customer's pushes get 401 until reactivated. --- @@ -321,7 +321,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ | Bidirectional Admin → Customer commands | New WebSocket or long-poll channel on customer side; gated by mutual cert or a second token. | | Branding sync (for the "Admin sees customer's brand when drilling in" niceness) | Push branding row from customer; Admin renders the customer's brand on Customer-detail pages. | | Forward compatibility | Versioned URL `/v1/fleet/ingest`. Admin tolerates unknown JSON fields. Strictness on response shape only. | -| Dual-token grace window for rotation | `Customers.PreviousTokenHash` column; ingest accepts either for 24h after rotation. | +| ~~Dual-token grace window for rotation~~ | **Shipped.** See §6 — `Customers.PreviousTokenHash` + `PreviousTokenExpiresAt`; ingest accepts either while previous is unexpired. Default 24h. | | Sharded Admin (10000+ customers) | Customer's `FleetIngest__Url` already supports any host — point different customers at different Admin instances; aggregate at a tier above with Promscale or similar if needed. | | Hard-delete / GDPR | Admin Customers page → "Decommission" action: `DELETE FROM fleet.* WHERE CustomerId = …` cascade. Logged. | diff --git a/portal/frontend/src/api/customers.ts b/portal/frontend/src/api/customers.ts index c5c6749..08af487 100644 --- a/portal/frontend/src/api/customers.ts +++ b/portal/frontend/src/api/customers.ts @@ -7,6 +7,8 @@ export interface CustomerListItem { isActive: boolean; tokenIssuedAt: string; tokenRotatedAt: string | null; + // Non-null while the previous (pre-rotation) token is still accepted by ingest. + previousTokenExpiresAt: string | null; firstSeenAt: string | null; lastSeenAt: string | null; createdAt: string; diff --git a/portal/frontend/src/components/customers/TokenShownOnceModal.tsx b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx index f0b17c3..685d224 100644 --- a/portal/frontend/src/components/customers/TokenShownOnceModal.tsx +++ b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx @@ -55,6 +55,11 @@ export function TokenShownOnceModal({ open, customerCode, token, onClose }: Prop Set this as FleetIngest__Token in the customer's .env, alongside FleetIngest__Url and FleetIngest__Enabled=true. + + If this is a rotation (not the first issue), the old token continues to + work for 24h. Update the customer's .env and restart their portal + within that window to avoid dropped pushes. + diff --git a/portal/frontend/src/pages/AdminCustomersPage.tsx b/portal/frontend/src/pages/AdminCustomersPage.tsx index 7ec1572..897240f 100644 --- a/portal/frontend/src/pages/AdminCustomersPage.tsx +++ b/portal/frontend/src/pages/AdminCustomersPage.tsx @@ -94,11 +94,25 @@ export function AdminCustomersPage() { v ? new Date(v).toLocaleString() : Never, }, { - title: 'Token issued', + title: 'Token', key: 'token', render: (_, c) => { const ts = c.tokenRotatedAt ?? c.tokenIssuedAt; - return new Date(ts).toLocaleDateString(); + const issued = {new Date(ts).toLocaleDateString()}; + if (!c.previousTokenExpiresAt) return issued; + const exp = new Date(c.previousTokenExpiresAt); + const stillValid = exp > new Date(); + if (!stillValid) return issued; + return ( + + {issued} + + + Old token valid until {exp.toLocaleTimeString()} + + + + ); }, }, { @@ -109,10 +123,10 @@ export function AdminCustomersPage() { - + rotateMut.mutate(c.id)} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs index 5440830..c6f6aae 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs @@ -7,6 +7,9 @@ public sealed record CustomerListItemDto( bool IsActive, DateTime TokenIssuedAt, DateTime? TokenRotatedAt, + // Non-null while a previous token is still accepted by /api/fleet/ingest + // (rotation grace window). Customer ops should finish updating .env before this. + DateTime? PreviousTokenExpiresAt, DateTime? FirstSeenAt, DateTime? LastSeenAt, DateTime CreatedAt); diff --git a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs index be6ca00..40fb4ef 100644 --- a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs +++ b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs @@ -32,6 +32,11 @@ public class AdminDbContext : IdentityDbContext x.Code).IsUnique(); entity.HasIndex(x => x.TokenHash).IsUnique(); + // Non-unique: previous-hash slots get overwritten on subsequent rotations, + // and (mathematically) won't collide with current hashes since both are + // SHA-256 of 32 random bytes. Index just makes the OR-lookup in + // FindByTokenAsync cheap. + entity.HasIndex(x => x.PreviousTokenHash); }); builder.Entity(entity => diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs index 86be0da..07b7d81 100644 --- a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs +++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs @@ -15,6 +15,13 @@ public class Customer [MaxLength(64)] public string TokenHash { get; set; } = string.Empty; + // Previous token kept after rotation for a grace window so customer ops has + // time to update .env + restart without dropping pushes. Null when no + // rotation has happened yet or when the previous slot has been cleared. + [MaxLength(64)] + public string? PreviousTokenHash { get; set; } + public DateTime? PreviousTokenExpiresAt { get; set; } + public DateTime TokenIssuedAt { get; set; } = DateTime.UtcNow; public DateTime? TokenRotatedAt { get; set; } diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs index a3255dc..c69c070 100644 --- a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs @@ -34,7 +34,7 @@ public static class AdminCustomersEndpoints group.MapPost("/{id:guid}/rotate-token", async (Guid id, CustomerService svc, CancellationToken ct) => { - var result = await svc.RotateTokenAsync(id, ct); + var result = await svc.RotateTokenAsync(id, ct: ct); return result is null ? Results.NotFound() : Results.Ok(result); }); diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs new file mode 100644 index 0000000..74e2178 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs @@ -0,0 +1,605 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tau.Acuvim.Portal.Data; + +#nullable disable + +namespace Tau.Acuvim.Portal.Migrations.Admin +{ + [DbContext(typeof(AdminDbContext))] + [Migration("20260518084030_AddPreviousTokenGraceWindow")] + partial class AddPreviousTokenGraceWindow + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("app") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "identity"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ApplicationName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FooterText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PrimaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SecondaryColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("WhiteLabelSettings", "app"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PreviousTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenIssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenRotatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("PreviousTokenHash"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("Customers", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("CustomerId", "Id"); + + b.HasIndex("CustomerId", "SiteId"); + + b.ToTable("Devices", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DeviceId") + .HasColumnType("uuid"); + + b.Property("ActivePowerKw") + .HasColumnType("double precision"); + + b.Property("ApparentPowerKva") + .HasColumnType("double precision"); + + b.Property("EnergyExportedKwh") + .HasColumnType("double precision"); + + b.Property("EnergyImportedKwh") + .HasColumnType("double precision"); + + b.Property("FrequencyHz") + .HasColumnType("double precision"); + + b.Property("PowerFactor") + .HasColumnType("double precision"); + + b.Property("ReactivePowerKvar") + .HasColumnType("double precision"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("VoltageV") + .HasColumnType("double precision"); + + b.HasKey("Time", "CustomerId", "DeviceId"); + + b.HasIndex("CustomerId", "Time") + .IsDescending(false, true); + + b.HasIndex("CustomerId", "DeviceId", "Time") + .IsDescending(false, false, true); + + b.ToTable("PowerMeasurements", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LocalMunicipalityId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CustomerId", "Id"); + + b.ToTable("Sites", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchBytes") + .HasColumnType("integer"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ClientHwm") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowsAccepted") + .HasColumnType("integer"); + + b.Property("RowsRejected") + .HasColumnType("integer"); + + b.Property("TimeSpread") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("IngestEvents", "fleet"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site") + .WithMany() + .HasForeignKey("CustomerId", "SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null) + .WithMany() + .HasForeignKey("CustomerId", "DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b => + { + b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs new file mode 100644 index 0000000..81b45f5 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tau.Acuvim.Portal.Migrations.Admin +{ + /// + public partial class AddPreviousTokenGraceWindow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreviousTokenExpiresAt", + schema: "fleet", + table: "Customers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreviousTokenHash", + schema: "fleet", + table: "Customers", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Customers_PreviousTokenHash", + schema: "fleet", + table: "Customers", + column: "PreviousTokenHash"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Customers_PreviousTokenHash", + schema: "fleet", + table: "Customers"); + + migrationBuilder.DropColumn( + name: "PreviousTokenExpiresAt", + schema: "fleet", + table: "Customers"); + + migrationBuilder.DropColumn( + name: "PreviousTokenHash", + schema: "fleet", + table: "Customers"); + } + } +} diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs index fe14d3d..915ca35 100644 --- a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs +++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs @@ -226,6 +226,13 @@ namespace Tau.Acuvim.Portal.Migrations.Admin .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("PreviousTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) @@ -242,6 +249,8 @@ namespace Tau.Acuvim.Portal.Migrations.Admin b.HasIndex("Code") .IsUnique(); + b.HasIndex("PreviousTokenHash"); + b.HasIndex("TokenHash") .IsUnique(); diff --git a/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs index 232c6ad..dc5d41e 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs @@ -8,6 +8,11 @@ namespace Tau.Acuvim.Portal.Services; public sealed class CustomerService(AdminDbContext db) { + // Default grace window for the dual-token rotation flow. 24h gives customer ops + // a workday to update .env + restart without dropping pushes. Parameterizable + // on RotateTokenAsync for tests; can be lifted to config if you need to tune it. + public static readonly TimeSpan DefaultTokenGracePeriod = TimeSpan.FromHours(24); + public async Task> ListAsync(CancellationToken ct = default) => await db.Customers .OrderBy(c => c.Code) @@ -51,10 +56,23 @@ public sealed class CustomerService(AdminDbContext db) return true; } - public async Task RotateTokenAsync(Guid id, CancellationToken ct = default) + public async Task RotateTokenAsync( + Guid id, + TimeSpan? graceWindow = null, + CancellationToken ct = default) { var c = await db.Customers.FindAsync(new object?[] { id }, ct); if (c is null) return null; + + // Move the existing token into the previous slot with an expiry timestamp. + // The previous slot is overwritten on every rotation — at most one previous + // token is honoured at a time. + if (!string.IsNullOrEmpty(c.TokenHash)) + { + c.PreviousTokenHash = c.TokenHash; + c.PreviousTokenExpiresAt = DateTime.UtcNow + (graceWindow ?? DefaultTokenGracePeriod); + } + var token = GenerateToken(); c.TokenHash = HashToken(token); c.TokenRotatedAt = DateTime.UtcNow; @@ -75,7 +93,16 @@ public sealed class CustomerService(AdminDbContext db) { if (string.IsNullOrWhiteSpace(token)) return Task.FromResult(null); var hash = HashToken(token); - return db.Customers.FirstOrDefaultAsync(c => c.TokenHash == hash && c.IsActive, ct); + var now = DateTime.UtcNow; + // Accept either the current token OR an unexpired previous token. The DB + // indexes both columns so this OR-decomposes into two cheap index seeks. + return db.Customers.FirstOrDefaultAsync( + c => c.IsActive && ( + c.TokenHash == hash || + (c.PreviousTokenHash == hash && + c.PreviousTokenExpiresAt != null && + c.PreviousTokenExpiresAt > now)), + ct); } public static string GenerateToken() @@ -97,5 +124,6 @@ public sealed class CustomerService(AdminDbContext db) private static CustomerListItemDto ToDto(Customer c) => new( c.Id, c.Code, c.Name, c.IsActive, - c.TokenIssuedAt, c.TokenRotatedAt, c.FirstSeenAt, c.LastSeenAt, c.CreatedAt); + c.TokenIssuedAt, c.TokenRotatedAt, c.PreviousTokenExpiresAt, + c.FirstSeenAt, c.LastSeenAt, c.CreatedAt); } diff --git a/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs new file mode 100644 index 0000000..0024ec4 --- /dev/null +++ b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using Tau.Acuvim.Portal.Data; +using Tau.Acuvim.Portal.DTOs; +using Tau.Acuvim.Portal.Services; + +namespace Tau.Acuvim.Portal.Tests; + +public class CustomerTokenGraceTests +{ + private static AdminDbContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "grace-" + Guid.NewGuid()) + .Options; + return new AdminDbContext(opts); + } + + private static async Task<(CustomerService svc, Guid id, string originalToken)> SeedAsync() + { + var db = NewDb(); + var svc = new CustomerService(db); + var created = await svc.CreateAsync(new CreateCustomerRequest("ABC0001", "Test")); + return (svc, created.Customer.Id, created.Token); + } + + [Fact] + public async Task Create_DoesNotSetPreviousToken() + { + var (svc, id, _) = await SeedAsync(); + var list = await svc.ListAsync(); + var c = list.Single(x => x.Id == id); + Assert.Null(c.PreviousTokenExpiresAt); + } + + [Fact] + public async Task Rotate_MovesCurrentTokenIntoPreviousSlot() + { + var (svc, id, originalToken) = await SeedAsync(); + + var rotated = await svc.RotateTokenAsync(id); + Assert.NotNull(rotated); + + // Original token still accepted within grace window. + var found = await svc.FindByTokenAsync(originalToken); + Assert.NotNull(found); + + // New token also accepted. + var newToken = rotated!.Token; + Assert.NotEqual(originalToken, newToken); + var foundNew = await svc.FindByTokenAsync(newToken); + Assert.NotNull(foundNew); + + // PreviousTokenExpiresAt populated and in the near future. + var listed = (await svc.ListAsync()).Single(x => x.Id == id); + Assert.NotNull(listed.PreviousTokenExpiresAt); + Assert.True(listed.PreviousTokenExpiresAt > DateTime.UtcNow); + Assert.True(listed.PreviousTokenExpiresAt <= DateTime.UtcNow + CustomerService.DefaultTokenGracePeriod + TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task Rotate_WithZeroGrace_RejectsOriginalImmediately() + { + var (svc, id, originalToken) = await SeedAsync(); + + await svc.RotateTokenAsync(id, graceWindow: TimeSpan.Zero); + + // PreviousExpiresAt is now-ish; the strict-greater-than check in FindByTokenAsync + // rejects it. So the original is dead. + var found = await svc.FindByTokenAsync(originalToken); + Assert.Null(found); + } + + [Fact] + public async Task SecondRotation_OverwritesPreviousSlot_OriginalDies() + { + var (svc, id, originalToken) = await SeedAsync(); + + var firstRotation = await svc.RotateTokenAsync(id); + var secondRotation = await svc.RotateTokenAsync(id); + + Assert.NotNull(firstRotation); + Assert.NotNull(secondRotation); + + // Original is gone — the previous slot now holds the first-rotation token. + Assert.Null(await svc.FindByTokenAsync(originalToken)); + + // First-rotation token is still accepted (it's the new previous). + Assert.NotNull(await svc.FindByTokenAsync(firstRotation!.Token)); + + // Second-rotation token is accepted (current). + Assert.NotNull(await svc.FindByTokenAsync(secondRotation!.Token)); + } + + [Fact] + public async Task InactiveCustomer_RejectsBothCurrentAndPrevious() + { + var (svc, id, originalToken) = await SeedAsync(); + var rotated = await svc.RotateTokenAsync(id); + await svc.UpdateAsync(id, new UpdateCustomerRequest("Test", IsActive: false)); + + Assert.Null(await svc.FindByTokenAsync(originalToken)); + Assert.Null(await svc.FindByTokenAsync(rotated!.Token)); + } +}