Dual-token rotation grace window (24h default)

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) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 10:45:31 +02:00
parent 59c3f949d0
commit 3333202f3a
13 changed files with 847 additions and 10 deletions

View File

@ -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. |

View File

@ -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;

View File

@ -55,6 +55,11 @@ export function TokenShownOnceModal({ open, customerCode, token, onClose }: Prop
Set this as <Text code>FleetIngest__Token</Text> in the customer's <Text code>.env</Text>,
alongside <Text code>FleetIngest__Url</Text> and <Text code>FleetIngest__Enabled=true</Text>.
</Paragraph>
<Paragraph type="secondary" style={{ fontSize: 12 }}>
If this is a <strong>rotation</strong> (not the first issue), the old token continues to
work for 24h. Update the customer's <Text code>.env</Text> and restart their portal
within that window to avoid dropped pushes.
</Paragraph>
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea value={token ?? ''} readOnly rows={2} style={{ fontFamily: 'monospace' }} />
<Button icon={<CopyOutlined />} onClick={copy}>Copy</Button>

View File

@ -94,11 +94,25 @@ export function AdminCustomersPage() {
v ? new Date(v).toLocaleString() : <Text type="secondary">Never</Text>,
},
{
title: 'Token issued',
title: 'Token',
key: 'token',
render: (_, c) => {
const ts = c.tokenRotatedAt ?? c.tokenIssuedAt;
return new Date(ts).toLocaleDateString();
const issued = <span>{new Date(ts).toLocaleDateString()}</span>;
if (!c.previousTokenExpiresAt) return issued;
const exp = new Date(c.previousTokenExpiresAt);
const stillValid = exp > new Date();
if (!stillValid) return issued;
return (
<Space size={4} direction="vertical">
{issued}
<Tooltip title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}>
<Tag color="orange" style={{ fontSize: 11 }}>
Old token valid until {exp.toLocaleTimeString()}
</Tag>
</Tooltip>
</Space>
);
},
},
{
@ -109,10 +123,10 @@ export function AdminCustomersPage() {
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
Edit
</Button>
<Tooltip title="Generate a new token. Old token stops working immediately.">
<Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes.">
<Popconfirm
title={`Rotate token for ${c.code}?`}
description="The customer's push service will fail until their .env is updated with the new token."
description="The old token stays valid for 24h. Update the customer's .env with the new token before that grace window expires."
okText="Rotate"
okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)}

View File

@ -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);

View File

@ -32,6 +32,11 @@ public class AdminDbContext : IdentityDbContext<ApplicationUser, IdentityRole, s
entity.ToTable("Customers", schema: "fleet");
entity.HasIndex(x => 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<FleetSite>(entity =>

View File

@ -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; }

View File

@ -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);
});

View File

@ -0,0 +1,605 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("FirstSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("PreviousTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreviousTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("TokenIssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("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<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("LocalMunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BatchBytes")
.HasColumnType("integer");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ClientHwm")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Error")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RowsAccepted")
.HasColumnType("integer");
b.Property<int>("RowsRejected")
.HasColumnType("integer");
b.Property<TimeSpan?>("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<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View File

@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations.Admin
{
/// <inheritdoc />
public partial class AddPreviousTokenGraceWindow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "PreviousTokenExpiresAt",
schema: "fleet",
table: "Customers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
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");
}
}
}

View File

@ -226,6 +226,13 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime?>("PreviousTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreviousTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("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();

View File

@ -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<List<CustomerListItemDto>> 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<CustomerWithTokenDto?> RotateTokenAsync(Guid id, CancellationToken ct = default)
public async Task<CustomerWithTokenDto?> 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<Customer?>(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);
}

View File

@ -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<AdminDbContext>()
.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));
}
}