Prototype owner-only administrator account management
This commit is contained in:
1 parent
ac8742c829
commit
882d006b3c
24 files changed
+3438
-15
No files matched your search
+1
-1
@@ -189,7 +189,7 @@ export async function adminLogoutAll(
|
||||
const results = await database.batch([
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = ?
|
||||
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = MAX(updated_at + 1, ?)
|
||||
WHERE id = ? AND enabled = 1 AND EXISTS (
|
||||
SELECT 1 FROM admin_sessions s WHERE s.token_hash = ? AND s.admin_id = admin_accounts.id
|
||||
AND s.generation = admin_accounts.session_generation AND s.revoked_at IS NULL AND s.expires_at > ?
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountRow, type AdminAccountResult, type AdminAccountsDirectory } from '../lib/admin-accounts';
|
||||
|
||||
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
||||
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner' AND s.revoked_at IS NULL
|
||||
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';
|
||||
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 });
|
||||
|
||||
function credentials(identity: AdminIdentity, now: number) {
|
||||
if (!identity || typeof identity.id !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash))
|
||||
throw new AdminError('Administrator sign-in required.', 401);
|
||||
return [identity.sessionHash, identity.id, now];
|
||||
}
|
||||
function authorized(result: D1Result) {
|
||||
if (!result.results.length) throw new AdminError('Administrator owner sign-in required.', 401);
|
||||
}
|
||||
const fingerprint = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2,'0')).join('');
|
||||
|
||||
export async function readAdminAccounts(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminAccountsDirectory> {
|
||||
const args = credentials(identity, now), rawPage = search.get('page') ?? '1';
|
||||
if ([...search.keys()].some(key => key !== 'page' || search.getAll(key).length !== 1) || !/^[1-9][0-9]{0,5}$/.test(rawPage))
|
||||
throw new AdminError('Use a valid account directory page.', 400);
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
database.prepare(`SELECT COUNT(*) total FROM admin_accounts WHERE EXISTS (${authority})`).bind(...args),
|
||||
database.prepare(`SELECT ${accountFields} FROM admin_accounts WHERE EXISTS (${authority}) ORDER BY created_at DESC,id ASC LIMIT 25 OFFSET ?`).bind(...args,(Number(rawPage)-1)*25),
|
||||
]);
|
||||
authorized(results[0]);
|
||||
return { accounts:(results[2].results as Account[]).map(project),total:Number((results[1].results[0] as {total:number}).total),page:Number(rawPage),pageSize:25 };
|
||||
}
|
||||
|
||||
/** No key, key hash or session value enters the durable receipt, audit or response. */
|
||||
export async function applyAdminAccount(database: D1Database, identity: AdminIdentity, input: AdminAccountInput, now = Date.now()): Promise<AdminAccountResult> {
|
||||
const args = credentials(identity, now);
|
||||
if (!validAdminAccountInput(input)) throw new AdminError('Check the account details, operation identifier and operational reason.', 400);
|
||||
const target = input.action === 'provision' ? input.id : input.target;
|
||||
const reason = input.reason.trim();
|
||||
const requestHash = await fingerprint(JSON.stringify(input.action === 'provision'
|
||||
? { 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 }));
|
||||
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),
|
||||
]);
|
||||
authorized(previous[0]);
|
||||
const resolve = (receipt: Receipt | undefined, replayed: boolean): AdminAccountResult | null => {
|
||||
if (!receipt) return null;
|
||||
if (receipt.actor_admin_id !== identity.id || receipt.request_hash !== requestHash || receipt.target_admin_id !== target)
|
||||
throw new AdminError('This operation identifier is bound to a different administrator or request.', 409);
|
||||
return { operationId:receipt.operation_id,replayed,account:project({ ...receipt,id:receipt.target_admin_id }) };
|
||||
};
|
||||
const existing = resolve(previous[1].results[0] as Receipt | undefined,true);
|
||||
if (existing) return existing;
|
||||
const account = previous[2].results[0] as Account | 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)
|
||||
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);
|
||||
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_accounts WHERE id=?) RETURNING id`)
|
||||
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,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)
|
||||
: 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);
|
||||
const result = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
mutation,
|
||||
database.prepare(`INSERT INTO admin_account_operations(operation_id,actor_admin_id,request_hash,target_admin_id,action,name,role,enabled,created_at,updated_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,input.action,target,...args),
|
||||
input.action === 'role'
|
||||
? database.prepare(`INSERT INTO admin_audit(id,actor_admin_id,target_admin_id,action,created_at,detail,reason)
|
||||
SELECT ?,?,?,'role',?,json_object('previousRole',?,'role',?),?
|
||||
WHERE changes()=1 AND EXISTS (${authority})`)
|
||||
.bind(crypto.randomUUID(),identity.id,target,now,account!.role,input.role,reason,...args)
|
||||
: 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),
|
||||
]);
|
||||
authorized(result[0]);
|
||||
const committed = resolve(result[4].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);
|
||||
}
|
||||
+2
-2
@@ -14,8 +14,8 @@ 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','login','logout','logout_all') THEN action ELSE 'unknown' END action,
|
||||
NULL reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit
|
||||
CASE WHEN action IN ('provision','revoke','role','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),
|
||||
json_object('paused',after_paused,'joinLocked',after_join_locked,'revision',expected_revision+1),created_at FROM admin_room_audit
|
||||
|
||||
+22
-1
@@ -460,7 +460,7 @@ export const adminSessions = sqliteTable(
|
||||
);
|
||||
|
||||
// Audit records intentionally have no cascading account/room dependency.
|
||||
// `detail` contains safe operation IDs only, never credentials or game state.
|
||||
// `detail` contains safe role/session metadata only, never credentials or game state.
|
||||
export const adminAudit = sqliteTable(
|
||||
'admin_audit',
|
||||
{
|
||||
@@ -470,10 +470,31 @@ export const adminAudit = sqliteTable(
|
||||
action: text('action').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
detail: text('detail').notNull().default('{}'),
|
||||
reason: text('reason'),
|
||||
},
|
||||
(table) => [index('admin_audit_created_at').on(table.createdAt)],
|
||||
);
|
||||
|
||||
// Durable idempotency receipts contain a request fingerprint, never an access key.
|
||||
export const adminAccountOperations = sqliteTable(
|
||||
'admin_account_operations',
|
||||
{
|
||||
operationId: text('operation_id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
targetAdminId: text('target_admin_id').notNull(),
|
||||
action: text('action').notNull(),
|
||||
name: text('name').notNull(),
|
||||
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
|
||||
enabled: integer('enabled').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check('admin_account_operations_action', sql`${table.action} IN ('provision','role','disable')`),
|
||||
],
|
||||
);
|
||||
|
||||
// Immutable private room snapshots survive room/account deletion; only owner APIs expose them.
|
||||
export const adminRoomBackups = sqliteTable(
|
||||
'admin_room_backups',
|
||||
|
||||
Reference in new issue
Block a user