Rotate administrator keys with safe re-enable and exact retry

This commit is contained in:
Quintine committed 2026-09-30 00:01:16 +10:00
1 parent 9cf8249398
commit 55b47ce039
20 files changed
+3072 -104

No files matched your search

+51 -12
View File
@@ -6,6 +6,7 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at>? AND s.generation=a.session_generation`;
const accountFields = 'id,name,role,enabled,created_at,updated_at';
const receiptFields = 'operation_id,actor_admin_id,request_hash,target_admin_id,name,role,enabled,created_at,updated_at';
const allReceipts = `SELECT ${receiptFields} FROM admin_account_operations UNION ALL SELECT ${receiptFields} FROM admin_account_rotations`;
type Account = { id: string; name: string; role: AdminAccountRow['role']; enabled: number; created_at: number; updated_at: number };
type Receipt = Omit<Account, 'id'> & { operation_id: string; actor_admin_id: string; request_hash: string; target_admin_id: string };
const project = (row: Account): AdminAccountRow => ({ id: row.id, name: row.name, role: row.role, enabled: row.enabled === 1, createdAt: row.created_at, updatedAt: row.updated_at });
@@ -43,11 +44,13 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
? { action:input.action,id:input.id,key:input.key,name:input.name,role:input.role,reason:input.reason }
: input.action === 'role'
? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,role:input.role,reason:input.reason }
: { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason }));
: input.action === 'rotate'
? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,key:input.key,reason:input.reason }
: { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason }));
const previous = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${accountFields} FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${accountFields},key_hash FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args),
]);
authorized(previous[0]);
const resolve = (receipt: Receipt | undefined, replayed: boolean): AdminAccountResult | null => {
@@ -58,27 +61,63 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
};
const existing = resolve(previous[1].results[0] as Receipt | undefined,true);
if (existing) return existing;
const account = previous[2].results[0] as Account | undefined;
const account = previous[2].results[0] as (Account & { key_hash: string }) | undefined;
if (input.action !== 'provision') {
if (target === identity.id) throw new AdminError('You cannot disable or demote your own account.',409);
if (!account || !account.enabled || account.updated_at !== input.expectedUpdatedAt)
if (target === identity.id) throw new AdminError('You cannot change your own account credentials or authority.',409);
if (!account || input.action !== 'rotate' && !account.enabled || account.updated_at !== input.expectedUpdatedAt)
throw new AdminError('The account changed or was disabled. Refresh before choosing a new operation.',409);
if (input.action === 'role' && account.role === input.role)
throw new AdminError('Choose a different role for this account.',409);
}
const nextTime = input.action === 'provision' ? now : Math.max(now,account!.updated_at+1);
if (input.action === 'rotate') {
const keyHash = await fingerprint(input.key);
const result = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(`UPDATE admin_accounts SET key_hash=?,enabled=1,session_generation=session_generation+1,updated_at=?
WHERE id=? AND updated_at=? AND role=? AND key_hash=? AND key_hash<>? AND id!=?
AND NOT EXISTS (SELECT 1 FROM admin_account_retired_keys WHERE key_hash=?)
AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(keyHash,nextTime,target,input.expectedUpdatedAt,account!.role,account!.key_hash,keyHash,identity.id,keyHash,...args,input.operationId,input.operationId),
database.prepare(`INSERT INTO admin_account_retired_keys (key_hash,admin_id)
SELECT ?,? WHERE changes()=1 AND EXISTS (${authority})`)
.bind(account!.key_hash,target,...args),
database.prepare(`INSERT INTO admin_account_rotations
(operation_id,actor_admin_id,request_hash,target_admin_id,name,role,previous_enabled,enabled,created_at,updated_at,expected_updated_at,reason,rotated_at)
SELECT ?,?,?,?,name,role,?,enabled,created_at,updated_at,?,?,? FROM admin_accounts
WHERE id=? AND changes()=1 AND EXISTS (${authority})`)
.bind(input.operationId,identity.id,requestHash,target,account!.enabled, input.expectedUpdatedAt,reason,now,target,...args),
database.prepare(`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail,reason)
SELECT ?,?,?,'rotate',?,json_object('previousEnabled',?,'enabled',1),?
WHERE changes()=1 AND EXISTS (${authority})`)
.bind(crypto.randomUUID(),identity.id,target,now,account!.enabled,reason,...args),
database.prepare(`UPDATE admin_sessions SET revoked_at=? WHERE admin_id=? AND revoked_at IS NULL
AND changes()=1 AND EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=? AND actor_admin_id=? AND request_hash=? AND target_admin_id=?)
AND EXISTS (${authority})`)
.bind(now,target,input.operationId,identity.id,requestHash,target,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
]);
authorized(result[0]);
const committed = resolve(result[6].results[0] as Receipt | undefined,result[1].results.length !== 1);
if (committed) return committed;
throw new AdminError('The account or administrator authority changed. Refresh before choosing a new operation.',409);
}
const mutation = input.action === 'provision'
? database.prepare(`INSERT INTO admin_accounts(id,name,role,key_hash,created_at,updated_at)
SELECT ?,?,?,?,?,? WHERE EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_accounts WHERE id=?) RETURNING id`)
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.id)
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.operationId,input.id)
: input.action === 'role'
? database.prepare(`UPDATE admin_accounts SET role=?,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=? AND role<>?
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`)
.bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId)
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId,input.operationId)
: database.prepare(`UPDATE admin_accounts SET enabled=0,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=?
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`)
.bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId);
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId,input.operationId);
const result = await database.batch([
database.prepare(authority).bind(...args),
mutation,
@@ -94,7 +133,7 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
: database.prepare(`UPDATE admin_audit SET actor_admin_id=?,reason=? WHERE id=(
SELECT id FROM admin_audit WHERE target_admin_id=? AND action=? AND actor_admin_id IS NULL ORDER BY rowid DESC LIMIT 1)
AND changes()=1 AND EXISTS (${authority})`).bind(identity.id,reason,target,input.action === 'provision' ? 'provision' : 'revoke',...args),
database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
]);
authorized(result[0]);
const committed = resolve(result[4].results[0] as Receipt | undefined,result[1].results.length !== 1);
+2 -1
View File
@@ -14,7 +14,7 @@ const actionType = `json_extract(${json('action')},'$.type')`;
// bounded groups so SQLite cannot flatten the nine sources back into one union.
const rawEvents = `WITH first_events AS MATERIALIZED (
SELECT 'account' category,rowid row_key,id receipt_id,actor_admin_id actor_id,NULL room_code,target_admin_id target_admin_id,NULL target_seat_id,
CASE WHEN action IN ('provision','revoke','role','login','logout','logout_all') THEN action ELSE 'unknown' END action,
CASE WHEN action IN ('provision','revoke','role','rotate','login','logout','logout_all') THEN action ELSE 'unknown' END action,
reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit
UNION ALL SELECT 'control',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,'room_control',reason,
json_object('paused',before_paused,'joinLocked',before_join_locked,'revision',expected_revision),
@@ -73,6 +73,7 @@ export function projectAdminAudit(row: Row, reasonsVisible: boolean): AdminAudit
const before = parse(row.before_json), after = parse(row.after_json), changes: AuditChange[] = [];
const add = (field: AuditChange['field'], a: string | null, b: string | null) => { if (a !== null || b !== null) changes.push({ field,before:a,after:b }); };
if (row.category === 'account') add('Role',role(after.previousRole),role(after.role));
if (row.category === 'account' && row.action === 'rotate') add('Enabled',flag(after.previousEnabled),flag(after.enabled));
if (row.category === 'lobby' || row.category === 'creation') {
const a = configuration(before), b = configuration(after);
for (const field of ['Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','Player count','AI seats'] as const) add(field,a[field],b[field]);
+29
View File
@@ -495,6 +495,35 @@ export const adminAccountOperations = sqliteTable(
],
);
// Rotation receipts use a separate table: the original operation CHECK remains intact.
// Only the request fingerprint and safe account metadata persist, never either key.
export const adminAccountRotations = sqliteTable(
'admin_account_rotations',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
requestHash: text('request_hash').notNull(),
targetAdminId: text('target_admin_id').notNull(),
name: text('name').notNull(),
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
previousEnabled: integer('previous_enabled').notNull(),
enabled: integer('enabled').notNull(),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
expectedUpdatedAt: integer('expected_updated_at').notNull(),
reason: text('reason').notNull(),
rotatedAt: integer('rotated_at').notNull(),
},
table => [index('admin_account_rotations_target').on(table.targetAdminId, table.rotatedAt)],
);
// Credential history is deliberately separate from public receipts and audit.
// Retained hashes prevent a later A -> B -> A rotation from reactivating A.
export const adminAccountRetiredKeys = sqliteTable('admin_account_retired_keys', {
keyHash: text('key_hash').primaryKey(),
adminId: text('admin_id').notNull(),
});
// Immutable private room snapshots survive room/account deletion; only owner APIs expose them.
export const adminRoomBackups = sqliteTable(
'admin_room_backups',