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:
parent
59c3f949d0
commit
3333202f3a
@ -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. |
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
605
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs
generated
Normal file
605
portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
104
portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs
Normal file
104
portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user