Skip to content

Commit c5a2b5c

Browse files
feat: super admin sees all machines across every account
sankara@reddy.sh is the super admin. The overview endpoint returns machines from every account (via a table scan) and includes the owner sub on each row so the portal can pass it back for rename/revoke/remove actions. Normal users are unaffected — they still see only their own machines. - Add listAllMachines() scan to store.ts - Add SUPER_ADMINS set and isSuperAdmin() check in handler.ts - overview() uses listAllMachines for super admins, includes owner field - machineAction() accepts owner param for cross-account actions - Portal.jsx passes owner on rename/revoke/remove when present Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent d20bb05 commit c5a2b5c

3 files changed

Lines changed: 54 additions & 15 deletions

File tree

amplify/functions/api/handler.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { liveness, mergeEntries, profileOf } from '../../../shared/profile.mjs';
3838
import { cached, fail, json, preflight, type Cors } from './http';
3939
import { callerOf, type Caller } from './jwt';
4040
import {
41-
diffEndings, hashesEqual, isStale, newSecret, packSnapshot, previousOf, sha256Hex, unpackSnapshot,
41+
diffEndings, hashesEqual, isStale, newSecret, packSnapshot, previousOf, sha256Hex, unpackSnapshot,
4242
} from './protocol';
4343
import * as store from './store';
4444

@@ -59,6 +59,13 @@ const BOARD_CACHE_SECONDS = 60;
5959
// failure than a board that says a machine's detail did not fit.
6060
const OVERVIEW_SNAPSHOT_BUDGET = 4 * 1024 * 1024;
6161

62+
// Super admins see every machine on every account. Keep this a compile-time
63+
// constant so it is never wider than what the code reviews say it is.
64+
const SUPER_ADMINS = new Set([
65+
'sankara@reddy.sh',
66+
]);
67+
const isSuperAdmin = (c: Caller) => SUPER_ADMINS.has(c.email ?? '');
68+
6269
/* ── the event ──────────────────────────────────────────────────────── */
6370

6471
function rawBody(event: LambdaFunctionURLEvent): Buffer | null {
@@ -401,7 +408,10 @@ function publicMachine(m: Record<string, any>) {
401408
}
402409

403410
async function overview(caller: Caller, event: LambdaFunctionURLEvent, cors: Cors) {
404-
const rows = await store.listMachines(caller.sub);
411+
const admin = isSuperAdmin(caller);
412+
const rows = admin
413+
? await store.listAllMachines()
414+
: await store.listMachines(caller.sub);
405415
rows.sort((a, b) => ((a.createdAt ?? '') < (b.createdAt ?? '') ? 1 : -1));
406416

407417
// `?live=0` asks for the rows without the readings — a much smaller answer,
@@ -426,6 +436,8 @@ async function overview(caller: Caller, event: LambdaFunctionURLEvent, cors: Cor
426436

427437
const machines = rows.map((m) => ({
428438
...publicMachine(m),
439+
// Super admin sees which account owns each machine.
440+
...(admin ? { owner: m.sub } : {}),
429441
snapshot: byId.get(m.id) ?? null,
430442
// Says which of the two reasons a reading is missing: the caller did not
431443
// ask for it, or it did not fit. A board that cannot tell them apart shows
@@ -436,7 +448,7 @@ async function overview(caller: Caller, event: LambdaFunctionURLEvent, cors: Cor
436448
return json(200, {
437449
generatedAt: new Date().toISOString(),
438450
machines,
439-
account: { publicId: publicIdOf(caller.sub), email: caller.email },
451+
account: { publicId: publicIdOf(caller.sub), email: caller.email, superAdmin: admin },
440452
}, cors);
441453
}
442454

@@ -453,16 +465,21 @@ async function machineAction(
453465
const id = str(req?.id, 64);
454466
if (!id) return fail(400, 'id is required', cors);
455467

456-
const m = await store.getMachine(caller.sub, id);
468+
// Super admins pass the owner's sub so the action reaches the right
469+
// partition. Normal callers always act on their own machines.
470+
const admin = isSuperAdmin(caller);
471+
const ownerSub = admin && typeof req?.owner === 'string' ? req.owner : caller.sub;
472+
473+
const m = await store.getMachine(ownerSub, id);
457474
if (!m) return fail(404, 'no such machine on this account', cors);
458475

459476
if (path === '/api/v1/machines/rename') {
460477
const label = str(req?.label, 120).trim();
461478
if (!label) return fail(400, 'a machine needs a name', cors);
462-
const taken = (await store.listMachines(caller.sub))
479+
const taken = (await store.listMachines(ownerSub))
463480
.some((other) => other.id !== id && other.label === label);
464481
if (taken) return fail(409, 'another machine on this board already has that name', cors);
465-
await store.updateMachine(caller.sub, id, { label });
482+
await store.updateMachine(ownerSub, id, { label });
466483
return json(200, { ok: true, label }, cors);
467484
}
468485

@@ -471,7 +488,7 @@ async function machineAction(
471488
// 401 and it stops rather than buffering. The pointers go too, so nothing
472489
// can resolve to this machine on the strength of a credential it no longer
473490
// has.
474-
await store.updateMachine(caller.sub, id, {
491+
await store.updateMachine(ownerSub, id, {
475492
status: 'revoked',
476493
keyHash: null,
477494
enrollTokenHash: null,
@@ -481,16 +498,16 @@ async function machineAction(
481498
if (m.keyHash) await store.dropKeyPointer(m.keyHash);
482499
if (m.enrollTokenHash) await store.dropTokenPointer(m.enrollTokenHash);
483500
await store.dropLive(id);
484-
await rebuildAggregate(caller.sub, new Date().toISOString());
501+
await rebuildAggregate(ownerSub, new Date().toISOString());
485502
return json(200, { ok: true }, cors);
486503
}
487504

488505
if (path === '/api/v1/machines/remove') {
489506
if (m.keyHash) await store.dropKeyPointer(m.keyHash);
490507
if (m.enrollTokenHash) await store.dropTokenPointer(m.enrollTokenHash);
491508
await store.dropLive(id);
492-
await store.deleteMachine(caller.sub, id);
493-
await rebuildAggregate(caller.sub, new Date().toISOString());
509+
await store.deleteMachine(ownerSub, id);
510+
await rebuildAggregate(ownerSub, new Date().toISOString());
494511
return json(200, { ok: true }, cors);
495512
}
496513

amplify/functions/api/store.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
GetCommand,
3333
PutCommand,
3434
QueryCommand,
35-
UpdateCommand,
35+
UpdateCommand
3636
} from '@aws-sdk/lib-dynamodb';
3737

3838
const TABLE = process.env.TABLE_NAME as string;
@@ -105,6 +105,25 @@ export async function listMachines(sub: string): Promise<Machine[]> {
105105
return out;
106106
}
107107

108+
/* All machines across every account. Only for the super admin overview —
109+
* a table scan, so this must never be on a hot path. */
110+
export async function listAllMachines(): Promise<Machine[]> {
111+
const out: Machine[] = [];
112+
let start: Record<string, any> | undefined;
113+
do {
114+
const page = await doc.send(new ScanCommand({
115+
TableName: TABLE,
116+
FilterExpression: 'begins_with(#sk, :m)',
117+
ExpressionAttributeNames: { '#sk': 'sk' },
118+
ExpressionAttributeValues: { ':m': 'M#' },
119+
ExclusiveStartKey: start,
120+
}));
121+
out.push(...(page.Items ?? []));
122+
start = page.LastEvaluatedKey;
123+
} while (start);
124+
return out;
125+
}
126+
108127
export async function putMachine(item: Machine): Promise<void> {
109128
await doc.send(new PutCommand({ TableName: TABLE, Item: item }));
110129
}

site/src/components/Portal.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,22 +182,25 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
182182
}
183183
},
184184
rename: async (id, label) => {
185-
await api('/api/v1/machines/rename', { method: 'POST', body: { id, label } })
185+
const owner = (machines ?? []).find(m => m.id === id)?.owner
186+
await api('/api/v1/machines/rename', { method: 'POST', body: { id, label, owner } })
186187
setMachines(prev => (prev ?? []).map(m => (m.id === id ? { ...m, label } : m)))
187188
},
188189
/* Revoking clears the key hash: the next heartbeat gets a 401 and the
189190
agent stops. Re-joining takes a fresh registration. */
190191
revoke: async (id) => {
191-
await api('/api/v1/machines/revoke', { method: 'POST', body: { id } })
192+
const owner = (machines ?? []).find(m => m.id === id)?.owner
193+
await api('/api/v1/machines/revoke', { method: 'POST', body: { id, owner } })
192194
setMachines(prev => (prev ?? []).map(m => (
193195
m.id === id ? { ...m, status: 'revoked', snapshot: null } : m
194196
)))
195197
},
196198
remove: async (id) => {
197-
await api('/api/v1/machines/remove', { method: 'POST', body: { id } })
199+
const owner = (machines ?? []).find(m => m.id === id)?.owner
200+
await api('/api/v1/machines/remove', { method: 'POST', body: { id, owner } })
198201
setMachines(prev => (prev ?? []).filter(m => m.id !== id))
199202
},
200-
}), [])
203+
}), [machines])
201204

202205
/* ── the public leaderboard, which nobody joins by accident ── */
203206
const publicBoard = useMemo(() => ({

0 commit comments

Comments
 (0)