@elinpf/dsh-ops-access-hub 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/README.zh.md +31 -4
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +203 -12
- package/lib/index.d.ts +4 -1
- package/lib/index.js +3 -1
- package/lib/server.d.ts +25 -3
- package/lib/server.js +197 -31
- package/lib/store.d.ts +77 -5
- package/lib/store.js +136 -6
- package/lib/tokens.d.ts +110 -0
- package/lib/tokens.js +131 -0
- package/lib/web.d.ts +10 -1
- package/lib/web.js +246 -3
- package/package.json +1 -1
package/lib/server.js
CHANGED
|
@@ -28,16 +28,33 @@
|
|
|
28
28
|
* read token)
|
|
29
29
|
* - `POST /cases/:id/hit` → bump a case's hit count (read+)
|
|
30
30
|
* - `DELETE /cases/:id` → remove a case (admin)
|
|
31
|
+
* - `GET /whoami` → `{ok,role,actor,source}` for the presented
|
|
32
|
+
* token (read+)
|
|
33
|
+
* - `POST /tokens` → issue a named token, body `{name,role,expiresAt?}`;
|
|
34
|
+
* the plaintext is in this response only (admin)
|
|
35
|
+
* - `GET /tokens` → issued-token roster, metadata only — never
|
|
36
|
+
* the digest or the plaintext (admin)
|
|
37
|
+
* - `PATCH /tokens/:id` → edit a live token's `{name?,role?,expiresAt?}`
|
|
38
|
+
* (`null`/`''` clears the expiry); 404 unknown,
|
|
39
|
+
* 409 revoked or label taken (admin)
|
|
40
|
+
* - `DELETE /tokens/:id` → revoke a named token; 404 unknown, 409 already
|
|
41
|
+
* revoked (admin)
|
|
31
42
|
*
|
|
32
|
-
* Auth:
|
|
33
|
-
*
|
|
34
|
-
*
|
|
43
|
+
* Auth: `Authorization: Bearer <token>`, compared with `crypto.timingSafeEqual`.
|
|
44
|
+
* Two roles — admin (everything) and read (resolving, case writes, the
|
|
45
|
+
* requests list). A token is either one of the two static bootstrap tokens
|
|
46
|
+
* (CLI flag / env, always accepted, the break-glass path) or a named token
|
|
47
|
+
* issued through `POST /tokens` (ADR-0009): independently revocable,
|
|
48
|
+
* optionally expiring, and recorded as the audit `actor`. Named tokens are
|
|
49
|
+
* stored as SHA-256 digests only. Every error response is JSON
|
|
50
|
+
* `{ok:false,error}` and `error` never contains field values.
|
|
35
51
|
*
|
|
36
52
|
* @module
|
|
37
53
|
*/
|
|
38
54
|
import { timingSafeEqual } from 'node:crypto';
|
|
39
55
|
import { createServer } from 'node:http';
|
|
40
56
|
import { NAME_PATTERN } from './store.js';
|
|
57
|
+
import { generateToken, hashToken, parseExpiresAt, parseExpiresAtPatch, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, } from './tokens.js';
|
|
41
58
|
import { WEB_UI_HTML } from './web.js';
|
|
42
59
|
export { NAME_PATTERN };
|
|
43
60
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
@@ -58,17 +75,38 @@ function tokenEqual(a, b) {
|
|
|
58
75
|
return false;
|
|
59
76
|
return timingSafeEqual(ba, bb);
|
|
60
77
|
}
|
|
61
|
-
|
|
78
|
+
/** The presented Bearer token, or null when the header is absent/malformed. */
|
|
79
|
+
function bearerToken(req) {
|
|
62
80
|
const header = req.headers.authorization;
|
|
63
81
|
if (!header || !header.startsWith('Bearer '))
|
|
64
82
|
return null;
|
|
65
|
-
|
|
83
|
+
return header.slice('Bearer '.length).trim();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the presented Bearer token to a principal, or null.
|
|
87
|
+
*
|
|
88
|
+
* Static bootstrap tokens win first (a plain constant-time string compare);
|
|
89
|
+
* otherwise the digest is matched against the named-token roster. A token
|
|
90
|
+
* whose only match is revoked or expired is *not* a principal — it fails
|
|
91
|
+
* authentication like any unknown token.
|
|
92
|
+
*/
|
|
93
|
+
function principalOf(req, opts) {
|
|
94
|
+
const token = bearerToken(req);
|
|
95
|
+
if (token === null)
|
|
96
|
+
return null;
|
|
66
97
|
if (tokenEqual(token, opts.adminToken))
|
|
67
|
-
return 'admin';
|
|
98
|
+
return { role: 'admin', actor: 'admin', source: 'static' };
|
|
68
99
|
if (tokenEqual(token, opts.readToken))
|
|
69
|
-
return 'read';
|
|
100
|
+
return { role: 'read', actor: 'read', source: 'static' };
|
|
101
|
+
const named = opts.store.findActiveTokenByHash(hashToken(token));
|
|
102
|
+
if (named)
|
|
103
|
+
return { role: named.role, actor: named.name, source: 'named' };
|
|
70
104
|
return null;
|
|
71
105
|
}
|
|
106
|
+
/** The audit `actor` of a principal: named tokens only (see `Principal`). */
|
|
107
|
+
function actorOf(principal) {
|
|
108
|
+
return principal.source === 'named' ? principal.actor : undefined;
|
|
109
|
+
}
|
|
72
110
|
function send(res, status, body) {
|
|
73
111
|
const text = JSON.stringify(body);
|
|
74
112
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -194,6 +232,28 @@ function sanitizeCaseInput(raw, partial) {
|
|
|
194
232
|
}
|
|
195
233
|
return out;
|
|
196
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Validate a `PATCH /tokens/:id` body into a `TokenPatch` (ADR-0010). Keeps
|
|
237
|
+
* "field absent" (keep) distinct from `expiresAt: null`/`''` (clear) and
|
|
238
|
+
* refuses an empty patch — a body with nothing to change is a caller error,
|
|
239
|
+
* not a silent 200. Label clashes are a conflict (409), so they are checked by
|
|
240
|
+
* the route, not here (which maps everything to 400).
|
|
241
|
+
*/
|
|
242
|
+
function parseTokenPatch(raw) {
|
|
243
|
+
if (!isPlainObject(raw))
|
|
244
|
+
throw new Error('request body must be a JSON object');
|
|
245
|
+
const patch = {};
|
|
246
|
+
if (raw.name !== undefined)
|
|
247
|
+
patch.name = parseTokenName(raw.name);
|
|
248
|
+
if (raw.role !== undefined)
|
|
249
|
+
patch.role = parseTokenRole(raw.role);
|
|
250
|
+
if (raw.expiresAt !== undefined)
|
|
251
|
+
patch.expiresAt = parseExpiresAtPatch(raw.expiresAt) ?? null;
|
|
252
|
+
if (patch.name === undefined && patch.role === undefined && patch.expiresAt === undefined) {
|
|
253
|
+
throw new Error('no updatable fields (name, role, expiresAt)');
|
|
254
|
+
}
|
|
255
|
+
return patch;
|
|
256
|
+
}
|
|
197
257
|
export function createHubServer(opts) {
|
|
198
258
|
const { store } = opts;
|
|
199
259
|
async function handle(req, res) {
|
|
@@ -208,9 +268,18 @@ export function createHubServer(opts) {
|
|
|
208
268
|
res.end(WEB_UI_HTML);
|
|
209
269
|
return;
|
|
210
270
|
}
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
213
|
-
|
|
271
|
+
const principal = principalOf(req, opts);
|
|
272
|
+
if (!principal) {
|
|
273
|
+
// A digest that matches a revoked/expired record deserves a clearer
|
|
274
|
+
// message than an outright unknown token (the holder already has it).
|
|
275
|
+
const token = bearerToken(req);
|
|
276
|
+
const known = token !== null && opts.store.hasTokenHash(hashToken(token));
|
|
277
|
+
throw new HttpError(401, known ? 'token revoked or expired' : 'missing or invalid bearer token');
|
|
278
|
+
}
|
|
279
|
+
const actor = actorOf(principal);
|
|
280
|
+
if (method === 'GET' && path === '/whoami') {
|
|
281
|
+
return send(res, 200, { ok: true, role: principal.role, actor: principal.actor, source: principal.source });
|
|
282
|
+
}
|
|
214
283
|
if (method === 'GET' && path === '/entries') {
|
|
215
284
|
// Listing never exposes field values — tiers carry only probe state.
|
|
216
285
|
const list = store.list().map((e) => ({
|
|
@@ -226,8 +295,8 @@ export function createHubServer(opts) {
|
|
|
226
295
|
return send(res, 200, list);
|
|
227
296
|
}
|
|
228
297
|
if (method === 'GET' && path === '/audit') {
|
|
229
|
-
if (role !== 'admin')
|
|
230
|
-
throw new HttpError(403, 'read
|
|
298
|
+
if (principal.role !== 'admin')
|
|
299
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
231
300
|
const raw = url.searchParams.get('limit');
|
|
232
301
|
let limit = 100;
|
|
233
302
|
if (raw !== null) {
|
|
@@ -239,6 +308,103 @@ export function createHubServer(opts) {
|
|
|
239
308
|
return send(res, 200, await store.readAudit(limit));
|
|
240
309
|
}
|
|
241
310
|
const parts = path.split('/').filter((p) => p !== '');
|
|
311
|
+
if (parts[0] === 'tokens' && parts.length === 1) {
|
|
312
|
+
if (principal.role !== 'admin')
|
|
313
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
314
|
+
if (method === 'GET') {
|
|
315
|
+
// Roster metadata only — the digest never leaves the store, and the
|
|
316
|
+
// plaintext cannot be reconstructed from anything here.
|
|
317
|
+
return send(res, 200, store.listTokens().map(toTokenView));
|
|
318
|
+
}
|
|
319
|
+
if (method === 'POST') {
|
|
320
|
+
const body = await readBody(req);
|
|
321
|
+
let name;
|
|
322
|
+
let role;
|
|
323
|
+
let expiresAt;
|
|
324
|
+
try {
|
|
325
|
+
name = parseTokenName(body.name);
|
|
326
|
+
role = parseTokenRole(body.role);
|
|
327
|
+
expiresAt = parseExpiresAt(body.expiresAt);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
throw new HttpError(400, err.message);
|
|
331
|
+
}
|
|
332
|
+
if (store.findTokenByName(name))
|
|
333
|
+
throw new HttpError(409, `token name '${name}' is already in use`);
|
|
334
|
+
// Mint, store only the digest, and hand the plaintext back exactly once.
|
|
335
|
+
const plaintext = generateToken();
|
|
336
|
+
let issued;
|
|
337
|
+
try {
|
|
338
|
+
issued = store.putToken({
|
|
339
|
+
name,
|
|
340
|
+
role,
|
|
341
|
+
hash: hashToken(plaintext),
|
|
342
|
+
prefix: tokenPrefix(plaintext),
|
|
343
|
+
createdBy: principal.actor,
|
|
344
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
throw new HttpError(400, err.message);
|
|
349
|
+
}
|
|
350
|
+
await store.save();
|
|
351
|
+
await store.auditToken(principal.role, 'token-create', issued, actor);
|
|
352
|
+
return send(res, 200, { ok: true, ...toTokenView(issued), token: plaintext });
|
|
353
|
+
}
|
|
354
|
+
throw new HttpError(405, 'method not allowed');
|
|
355
|
+
}
|
|
356
|
+
if (parts[0] === 'tokens' && parts.length === 2) {
|
|
357
|
+
if (principal.role !== 'admin')
|
|
358
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
359
|
+
const token = store.getToken(parts[1]);
|
|
360
|
+
if (method === 'PATCH') {
|
|
361
|
+
// Edit in place (ADR-0010): a holder whose label, role or expiry is
|
|
362
|
+
// wrong gets a correction, not a revoke-and-reissue.
|
|
363
|
+
if (!token)
|
|
364
|
+
throw new HttpError(404, 'token not found');
|
|
365
|
+
if (token.revokedAt !== undefined) {
|
|
366
|
+
throw new HttpError(409, 'token is revoked and can no longer be edited; issue a new one');
|
|
367
|
+
}
|
|
368
|
+
const body = await readBody(req);
|
|
369
|
+
let patch;
|
|
370
|
+
try {
|
|
371
|
+
patch = parseTokenPatch(body);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
throw new HttpError(400, err.message);
|
|
375
|
+
}
|
|
376
|
+
// A label another live token holds is a conflict, not a bad request.
|
|
377
|
+
const holder = patch.name === undefined ? undefined : store.findTokenByName(patch.name);
|
|
378
|
+
if (holder && holder.id !== token.id) {
|
|
379
|
+
throw new HttpError(409, `token name '${patch.name}' is already in use`);
|
|
380
|
+
}
|
|
381
|
+
let updated;
|
|
382
|
+
try {
|
|
383
|
+
updated = store.updateToken(token.id, patch);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
throw new HttpError(409, err.message);
|
|
387
|
+
}
|
|
388
|
+
if (!updated)
|
|
389
|
+
throw new HttpError(404, 'token not found');
|
|
390
|
+
// A patch that matched the current values is a no-op: no write, no
|
|
391
|
+
// audit noise (the roster rewrite costs a full re-encrypt).
|
|
392
|
+
if (updated.changes.length > 0) {
|
|
393
|
+
await store.save();
|
|
394
|
+
await store.auditToken(principal.role, 'token-update', updated.token, actor, updated.changes);
|
|
395
|
+
}
|
|
396
|
+
return send(res, 200, { ok: true, ...toTokenView(updated.token), changes: updated.changes });
|
|
397
|
+
}
|
|
398
|
+
if (method !== 'DELETE')
|
|
399
|
+
throw new HttpError(405, 'method not allowed');
|
|
400
|
+
if (!token)
|
|
401
|
+
throw new HttpError(404, 'token not found');
|
|
402
|
+
if (!store.revokeToken(parts[1]))
|
|
403
|
+
throw new HttpError(409, 'token already revoked');
|
|
404
|
+
await store.save();
|
|
405
|
+
await store.auditToken(principal.role, 'token-revoke', token, actor);
|
|
406
|
+
return send(res, 200, { ok: true });
|
|
407
|
+
}
|
|
242
408
|
if (parts[0] === 'requests' && parts.length === 1) {
|
|
243
409
|
if (method === 'GET') {
|
|
244
410
|
// Metadata only — the reviewer fetches values per request (admin).
|
|
@@ -260,8 +426,8 @@ export function createHubServer(opts) {
|
|
|
260
426
|
}));
|
|
261
427
|
return send(res, 200, list);
|
|
262
428
|
}
|
|
263
|
-
if (role !== 'admin')
|
|
264
|
-
throw new HttpError(403, 'read
|
|
429
|
+
if (principal.role !== 'admin')
|
|
430
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
265
431
|
if (method === 'POST') {
|
|
266
432
|
const body = await readBody(req);
|
|
267
433
|
const kind = segment(String(body.kind ?? ''), 'kind');
|
|
@@ -274,23 +440,23 @@ export function createHubServer(opts) {
|
|
|
274
440
|
throw new HttpError(400, 'reason must be a string');
|
|
275
441
|
const request = store.putRequest({ kind, name, tier, fields: body.fields, envelope, reason: body.reason });
|
|
276
442
|
await store.save();
|
|
277
|
-
await store.audit(role, 'request', kind, name, tier);
|
|
443
|
+
await store.audit(principal.role, 'request', kind, name, tier, actor);
|
|
278
444
|
return send(res, 200, { ok: true, id: request.id });
|
|
279
445
|
}
|
|
280
446
|
throw new HttpError(405, 'method not allowed');
|
|
281
447
|
}
|
|
282
448
|
if (parts[0] === 'requests' && parts.length === 2 && method === 'GET') {
|
|
283
449
|
// Full field values for pre-approval review — admin only.
|
|
284
|
-
if (role !== 'admin')
|
|
285
|
-
throw new HttpError(403, 'read
|
|
450
|
+
if (principal.role !== 'admin')
|
|
451
|
+
throw new HttpError(403, 'read role cannot review request contents');
|
|
286
452
|
const request = store.getRequest(parts[1]);
|
|
287
453
|
if (!request)
|
|
288
454
|
throw new HttpError(404, 'request not found');
|
|
289
455
|
return send(res, 200, request);
|
|
290
456
|
}
|
|
291
457
|
if (parts[0] === 'requests' && parts.length === 3 && parts[2] === 'decide') {
|
|
292
|
-
if (role !== 'admin')
|
|
293
|
-
throw new HttpError(403, 'read
|
|
458
|
+
if (principal.role !== 'admin')
|
|
459
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
294
460
|
if (method !== 'POST')
|
|
295
461
|
throw new HttpError(405, 'method not allowed');
|
|
296
462
|
const body = await readBody(req);
|
|
@@ -304,7 +470,7 @@ export function createHubServer(opts) {
|
|
|
304
470
|
: new HttpError(404, 'request not found');
|
|
305
471
|
}
|
|
306
472
|
await store.save();
|
|
307
|
-
await store.audit(role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier);
|
|
473
|
+
await store.audit(principal.role, body.approved ? 'approve' : 'reject', request.kind, request.name, request.tier, actor);
|
|
308
474
|
return send(res, 200, { ok: true });
|
|
309
475
|
}
|
|
310
476
|
if (parts[0] === 'cases' && parts.length === 1) {
|
|
@@ -324,7 +490,7 @@ export function createHubServer(opts) {
|
|
|
324
490
|
throw new HttpError(400, err.message);
|
|
325
491
|
}
|
|
326
492
|
await store.save();
|
|
327
|
-
await store.auditCase(role, 'case-put', record);
|
|
493
|
+
await store.auditCase(principal.role, 'case-put', record, actor);
|
|
328
494
|
return send(res, 200, { ok: true, id: record.id });
|
|
329
495
|
}
|
|
330
496
|
throw new HttpError(405, 'method not allowed');
|
|
@@ -343,17 +509,17 @@ export function createHubServer(opts) {
|
|
|
343
509
|
if (!record)
|
|
344
510
|
throw new HttpError(404, 'case not found');
|
|
345
511
|
await store.save();
|
|
346
|
-
await store.auditCase(role, 'case-put', record);
|
|
512
|
+
await store.auditCase(principal.role, 'case-put', record, actor);
|
|
347
513
|
return send(res, 200, { ok: true });
|
|
348
514
|
}
|
|
349
515
|
if (method === 'DELETE') {
|
|
350
|
-
if (role !== 'admin')
|
|
351
|
-
throw new HttpError(403, 'read
|
|
516
|
+
if (principal.role !== 'admin')
|
|
517
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
352
518
|
const existing = store.getCase(id);
|
|
353
519
|
if (!existing || !store.deleteCase(id))
|
|
354
520
|
throw new HttpError(404, 'case not found');
|
|
355
521
|
await store.save();
|
|
356
|
-
await store.auditCase(role, 'case-delete', existing);
|
|
522
|
+
await store.auditCase(principal.role, 'case-delete', existing, actor);
|
|
357
523
|
return send(res, 200, { ok: true });
|
|
358
524
|
}
|
|
359
525
|
throw new HttpError(405, 'method not allowed');
|
|
@@ -365,7 +531,7 @@ export function createHubServer(opts) {
|
|
|
365
531
|
if (!record || !store.hitCase(parts[1]))
|
|
366
532
|
throw new HttpError(404, 'case not found');
|
|
367
533
|
await store.save();
|
|
368
|
-
await store.auditCase(role, 'case-hit', record);
|
|
534
|
+
await store.auditCase(principal.role, 'case-hit', record, actor);
|
|
369
535
|
return send(res, 200, { ok: true });
|
|
370
536
|
}
|
|
371
537
|
if (parts[0] === 'entries' && parts.length === 4) {
|
|
@@ -377,7 +543,7 @@ export function createHubServer(opts) {
|
|
|
377
543
|
const tierData = entry?.tiers[tier];
|
|
378
544
|
if (!entry || !tierData)
|
|
379
545
|
throw new HttpError(404, 'entry not found');
|
|
380
|
-
await store.audit(role, 'resolve', kind, name, tier);
|
|
546
|
+
await store.audit(principal.role, 'resolve', kind, name, tier, actor);
|
|
381
547
|
return send(res, 200, {
|
|
382
548
|
kind,
|
|
383
549
|
name,
|
|
@@ -387,8 +553,8 @@ export function createHubServer(opts) {
|
|
|
387
553
|
...(tierData.probe ? { probe: tierData.probe } : {}),
|
|
388
554
|
});
|
|
389
555
|
}
|
|
390
|
-
if (role !== 'admin')
|
|
391
|
-
throw new HttpError(403, 'read
|
|
556
|
+
if (principal.role !== 'admin')
|
|
557
|
+
throw new HttpError(403, 'read role cannot access admin endpoints');
|
|
392
558
|
if (method === 'PUT') {
|
|
393
559
|
const body = await readBody(req);
|
|
394
560
|
if (!isPlainObject(body.fields))
|
|
@@ -397,14 +563,14 @@ export function createHubServer(opts) {
|
|
|
397
563
|
const probe = body.probe === undefined ? undefined : sanitizeProbe(body.probe);
|
|
398
564
|
store.putTier(kind, name, tier, { fields: body.fields, envelope, probe });
|
|
399
565
|
await store.save();
|
|
400
|
-
await store.audit(role, 'put', kind, name, tier);
|
|
566
|
+
await store.audit(principal.role, 'put', kind, name, tier, actor);
|
|
401
567
|
return send(res, 200, { ok: true });
|
|
402
568
|
}
|
|
403
569
|
if (method === 'DELETE') {
|
|
404
570
|
if (!store.deleteTier(kind, name, tier))
|
|
405
571
|
throw new HttpError(404, 'entry not found');
|
|
406
572
|
await store.save();
|
|
407
|
-
await store.audit(role, 'delete', kind, name, tier);
|
|
573
|
+
await store.audit(principal.role, 'delete', kind, name, tier, actor);
|
|
408
574
|
return send(res, 200, { ok: true });
|
|
409
575
|
}
|
|
410
576
|
throw new HttpError(405, 'method not allowed');
|
package/lib/store.d.ts
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
|
|
20
20
|
* "fields": { ... }, "status": "pending", ... } },
|
|
21
21
|
* "cases": { "<uuid>": { "title": "...", "symptoms": [...],
|
|
22
|
-
* "rootCause": "...", "fix": "...", "hitCount": 0, ... } }
|
|
22
|
+
* "rootCause": "...", "fix": "...", "hitCount": 0, ... } },
|
|
23
|
+
* "tokens": { "<uuid>": { "name": "alice", "role": "read",
|
|
24
|
+
* "hash": "<sha256 hex>", "prefix": "AbCdEfGh", "createdAt": "<ISO>",
|
|
25
|
+
* "createdBy": "admin", "expiresAt": "<ISO>" } } }
|
|
23
26
|
* ```
|
|
24
27
|
*
|
|
25
28
|
* `requests` is the agent-registration approval queue (see server.ts
|
|
@@ -30,6 +33,11 @@
|
|
|
30
33
|
* routes): distilled postmortems an agent records after an investigation
|
|
31
34
|
* resolves, searchable by later sessions. Cases hold no secret material.
|
|
32
35
|
*
|
|
36
|
+
* `tokens` is the named-token roster (ADR-0009, see tokens.ts): every issued
|
|
37
|
+
* credential keeps its label, role, digest and lifecycle timestamps — never
|
|
38
|
+
* the plaintext, which exists only in the create response. The static
|
|
39
|
+
* bootstrap tokens are env/flag configuration and have no record here.
|
|
40
|
+
*
|
|
33
41
|
* The hub is dumb storage: file fields hold their *content* (inlined at
|
|
34
42
|
* import time) and no kind-specific schema validation happens here.
|
|
35
43
|
*
|
|
@@ -39,6 +47,7 @@
|
|
|
39
47
|
*
|
|
40
48
|
* @module
|
|
41
49
|
*/
|
|
50
|
+
import type { HubToken, TokenChange, TokenPatch, TokenRole } from './tokens.js';
|
|
42
51
|
export type TierName = 'ro' | 'rw';
|
|
43
52
|
/** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
|
|
44
53
|
export declare const NAME_PATTERN: RegExp;
|
|
@@ -113,13 +122,24 @@ export type CaseInput = Partial<Omit<CaseRecord, 'id' | 'hitCount' | 'createdAt'
|
|
|
113
122
|
export interface AuditRecord {
|
|
114
123
|
ts: string;
|
|
115
124
|
role: 'admin' | 'read';
|
|
116
|
-
action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete';
|
|
125
|
+
action: 'resolve' | 'put' | 'delete' | 'request' | 'approve' | 'reject' | 'case-put' | 'case-hit' | 'case-delete' | 'token-create' | 'token-update' | 'token-revoke';
|
|
117
126
|
kind: string;
|
|
118
127
|
name: string;
|
|
119
128
|
/** Absent on `case-*` actions (cases have no tiers). */
|
|
120
129
|
tier?: TierName;
|
|
121
130
|
/** Case title, recorded on `case-*` actions only. */
|
|
122
131
|
title?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Label of the named token that performed the action (ADR-0009). Absent for
|
|
134
|
+
* the static bootstrap tokens — their `role` field already says all there is
|
|
135
|
+
* to know about them.
|
|
136
|
+
*/
|
|
137
|
+
actor?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Which token fields an edit touched (`token-update` only) — the field
|
|
140
|
+
* names, never the values, so the line stays a metadata-only record.
|
|
141
|
+
*/
|
|
142
|
+
changes?: TokenChange[];
|
|
123
143
|
}
|
|
124
144
|
export interface HubStoreOptions {
|
|
125
145
|
dataDir: string;
|
|
@@ -186,13 +206,65 @@ export declare class HubStore {
|
|
|
186
206
|
hitCase(id: string): boolean;
|
|
187
207
|
/** Delete a case. Returns false when absent. */
|
|
188
208
|
deleteCase(id: string): boolean;
|
|
209
|
+
/** The tokens map, created lazily (old data files predate named tokens). */
|
|
210
|
+
private tokens;
|
|
211
|
+
/** Every issued token record, revoked ones included (callers project to `TokenView`). */
|
|
212
|
+
listTokens(): HubToken[];
|
|
213
|
+
getToken(id: string): HubToken | undefined;
|
|
214
|
+
/**
|
|
215
|
+
* The first *non-revoked* token carrying this label, for uniqueness checks.
|
|
216
|
+
* A revoked label is reusable — the person it named is gone.
|
|
217
|
+
*/
|
|
218
|
+
findTokenByName(name: string): HubToken | undefined;
|
|
219
|
+
/**
|
|
220
|
+
* The token a presented digest authenticates as, or undefined when no live
|
|
221
|
+
* token matches. Callers must reject the request either way — a digest that
|
|
222
|
+
* matches only revoked/expired records is an authentication failure.
|
|
223
|
+
*/
|
|
224
|
+
findActiveTokenByHash(hash: string, now?: string): HubToken | undefined;
|
|
225
|
+
/** Whether a digest belongs to any record at all (live or not), for error wording only. */
|
|
226
|
+
hasTokenHash(hash: string): boolean;
|
|
227
|
+
/**
|
|
228
|
+
* Record one issued token. The caller mints the plaintext and passes only
|
|
229
|
+
* its digest + prefix; uniqueness of `name` and validation of the parsed
|
|
230
|
+
* inputs are the caller's job. Throws when the roster is at MAX_TOKENS.
|
|
231
|
+
*/
|
|
232
|
+
putToken(data: {
|
|
233
|
+
name: string;
|
|
234
|
+
role: TokenRole;
|
|
235
|
+
hash: string;
|
|
236
|
+
prefix: string;
|
|
237
|
+
createdBy: string;
|
|
238
|
+
expiresAt?: string;
|
|
239
|
+
}): HubToken;
|
|
240
|
+
/**
|
|
241
|
+
* Revoke one token. Returns false when the id is unknown or the token is
|
|
242
|
+
* already revoked — revocation is a one-way, terminal state.
|
|
243
|
+
*/
|
|
244
|
+
revokeToken(id: string): boolean;
|
|
245
|
+
/**
|
|
246
|
+
* Apply an operator edit to a live token (ADR-0010). Returns the record plus
|
|
247
|
+
* the fields that actually changed — an empty list means the patch matched
|
|
248
|
+
* the current values, so the caller can skip both the save and the audit
|
|
249
|
+
* line. Throws on a revoked record or a label clash; `null` when the id is
|
|
250
|
+
* unknown. Validation of the patch values is the caller's job.
|
|
251
|
+
*/
|
|
252
|
+
updateToken(id: string, patch: TokenPatch): {
|
|
253
|
+
token: HubToken;
|
|
254
|
+
changes: TokenChange[];
|
|
255
|
+
} | null;
|
|
256
|
+
/** Append one audit line for a token-roster action (kind fixed to 'token', name = token label). */
|
|
257
|
+
auditToken(role: AuditRecord['role'], action: 'token-create' | 'token-update' | 'token-revoke', token: {
|
|
258
|
+
id: string;
|
|
259
|
+
name: string;
|
|
260
|
+
}, actor?: string, changes?: TokenChange[]): Promise<void>;
|
|
189
261
|
/** Append one audit line for a case action (kind fixed to 'case', name = case id). */
|
|
190
262
|
auditCase(role: AuditRecord['role'], action: 'case-put' | 'case-hit' | 'case-delete', record: {
|
|
191
263
|
id: string;
|
|
192
264
|
title: string;
|
|
193
|
-
}): Promise<void>;
|
|
194
|
-
/** Append one audit line. Field values are never recorded. */
|
|
195
|
-
audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName): Promise<void>;
|
|
265
|
+
}, actor?: string): Promise<void>;
|
|
266
|
+
/** Append one audit line. Field values are never recorded; `actor` names the (named) token used. */
|
|
267
|
+
audit(role: AuditRecord['role'], action: AuditRecord['action'], kind: string, name: string, tier: TierName, actor?: string): Promise<void>;
|
|
196
268
|
private appendAudit;
|
|
197
269
|
/** Read the most recent `limit` audit records, oldest first. */
|
|
198
270
|
readAudit(limit: number): Promise<AuditRecord[]>;
|
package/lib/store.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
|
|
20
20
|
* "fields": { ... }, "status": "pending", ... } },
|
|
21
21
|
* "cases": { "<uuid>": { "title": "...", "symptoms": [...],
|
|
22
|
-
* "rootCause": "...", "fix": "...", "hitCount": 0, ... } }
|
|
22
|
+
* "rootCause": "...", "fix": "...", "hitCount": 0, ... } },
|
|
23
|
+
* "tokens": { "<uuid>": { "name": "alice", "role": "read",
|
|
24
|
+
* "hash": "<sha256 hex>", "prefix": "AbCdEfGh", "createdAt": "<ISO>",
|
|
25
|
+
* "createdBy": "admin", "expiresAt": "<ISO>" } } }
|
|
23
26
|
* ```
|
|
24
27
|
*
|
|
25
28
|
* `requests` is the agent-registration approval queue (see server.ts
|
|
@@ -30,6 +33,11 @@
|
|
|
30
33
|
* routes): distilled postmortems an agent records after an investigation
|
|
31
34
|
* resolves, searchable by later sessions. Cases hold no secret material.
|
|
32
35
|
*
|
|
36
|
+
* `tokens` is the named-token roster (ADR-0009, see tokens.ts): every issued
|
|
37
|
+
* credential keeps its label, role, digest and lifecycle timestamps — never
|
|
38
|
+
* the plaintext, which exists only in the create response. The static
|
|
39
|
+
* bootstrap tokens are env/flag configuration and have no record here.
|
|
40
|
+
*
|
|
33
41
|
* The hub is dumb storage: file fields hold their *content* (inlined at
|
|
34
42
|
* import time) and no kind-specific schema validation happens here.
|
|
35
43
|
*
|
|
@@ -43,6 +51,7 @@ import { appendFile, chmod, mkdir, open, readFile, rename } from 'node:fs/promis
|
|
|
43
51
|
import { join } from 'node:path';
|
|
44
52
|
import { randomUUID } from 'node:crypto';
|
|
45
53
|
import { decryptDoc, encryptDoc, loadMasterKey } from './crypto.js';
|
|
54
|
+
import { hashEqual, isTokenActive, MAX_TOKENS } from './tokens.js';
|
|
46
55
|
/** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
|
|
47
56
|
export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
|
|
48
57
|
/** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
|
|
@@ -250,13 +259,134 @@ export class HubStore {
|
|
|
250
259
|
delete this.cases()[id];
|
|
251
260
|
return true;
|
|
252
261
|
}
|
|
262
|
+
/** The tokens map, created lazily (old data files predate named tokens). */
|
|
263
|
+
tokens() {
|
|
264
|
+
return (this.doc.tokens ??= {});
|
|
265
|
+
}
|
|
266
|
+
/** Every issued token record, revoked ones included (callers project to `TokenView`). */
|
|
267
|
+
listTokens() {
|
|
268
|
+
return Object.values(this.tokens());
|
|
269
|
+
}
|
|
270
|
+
getToken(id) {
|
|
271
|
+
return this.tokens()[id];
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The first *non-revoked* token carrying this label, for uniqueness checks.
|
|
275
|
+
* A revoked label is reusable — the person it named is gone.
|
|
276
|
+
*/
|
|
277
|
+
findTokenByName(name) {
|
|
278
|
+
return Object.values(this.tokens()).find((t) => t.revokedAt === undefined && t.name === name);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The token a presented digest authenticates as, or undefined when no live
|
|
282
|
+
* token matches. Callers must reject the request either way — a digest that
|
|
283
|
+
* matches only revoked/expired records is an authentication failure.
|
|
284
|
+
*/
|
|
285
|
+
findActiveTokenByHash(hash, now) {
|
|
286
|
+
return Object.values(this.tokens()).find((t) => isTokenActive(t, now) && hashEqual(t.hash, hash));
|
|
287
|
+
}
|
|
288
|
+
/** Whether a digest belongs to any record at all (live or not), for error wording only. */
|
|
289
|
+
hasTokenHash(hash) {
|
|
290
|
+
return Object.values(this.tokens()).some((t) => hashEqual(t.hash, hash));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Record one issued token. The caller mints the plaintext and passes only
|
|
294
|
+
* its digest + prefix; uniqueness of `name` and validation of the parsed
|
|
295
|
+
* inputs are the caller's job. Throws when the roster is at MAX_TOKENS.
|
|
296
|
+
*/
|
|
297
|
+
putToken(data) {
|
|
298
|
+
if (Object.keys(this.tokens()).length >= MAX_TOKENS) {
|
|
299
|
+
throw new Error(`token roster is full (${MAX_TOKENS} tokens); revoke stale tokens first`);
|
|
300
|
+
}
|
|
301
|
+
const token = {
|
|
302
|
+
id: randomUUID(),
|
|
303
|
+
name: data.name,
|
|
304
|
+
role: data.role,
|
|
305
|
+
hash: data.hash,
|
|
306
|
+
prefix: data.prefix,
|
|
307
|
+
createdAt: new Date().toISOString(),
|
|
308
|
+
createdBy: data.createdBy,
|
|
309
|
+
...(data.expiresAt !== undefined ? { expiresAt: data.expiresAt } : {}),
|
|
310
|
+
};
|
|
311
|
+
this.tokens()[token.id] = token;
|
|
312
|
+
return token;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Revoke one token. Returns false when the id is unknown or the token is
|
|
316
|
+
* already revoked — revocation is a one-way, terminal state.
|
|
317
|
+
*/
|
|
318
|
+
revokeToken(id) {
|
|
319
|
+
const token = this.tokens()[id];
|
|
320
|
+
if (!token || token.revokedAt !== undefined)
|
|
321
|
+
return false;
|
|
322
|
+
token.revokedAt = new Date().toISOString();
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Apply an operator edit to a live token (ADR-0010). Returns the record plus
|
|
327
|
+
* the fields that actually changed — an empty list means the patch matched
|
|
328
|
+
* the current values, so the caller can skip both the save and the audit
|
|
329
|
+
* line. Throws on a revoked record or a label clash; `null` when the id is
|
|
330
|
+
* unknown. Validation of the patch values is the caller's job.
|
|
331
|
+
*/
|
|
332
|
+
updateToken(id, patch) {
|
|
333
|
+
const token = this.tokens()[id];
|
|
334
|
+
if (!token)
|
|
335
|
+
return null;
|
|
336
|
+
if (token.revokedAt !== undefined)
|
|
337
|
+
throw new Error('token is revoked and can no longer be edited');
|
|
338
|
+
const changes = [];
|
|
339
|
+
if (patch.name !== undefined && patch.name !== token.name) {
|
|
340
|
+
const clash = Object.values(this.tokens()).find((t) => t.id !== id && t.revokedAt === undefined && t.name === patch.name);
|
|
341
|
+
if (clash)
|
|
342
|
+
throw new Error(`token name '${patch.name}' is already in use`);
|
|
343
|
+
token.name = patch.name;
|
|
344
|
+
changes.push('name');
|
|
345
|
+
}
|
|
346
|
+
if (patch.role !== undefined && patch.role !== token.role) {
|
|
347
|
+
token.role = patch.role;
|
|
348
|
+
changes.push('role');
|
|
349
|
+
}
|
|
350
|
+
if (patch.expiresAt !== undefined) {
|
|
351
|
+
// null = clear the expiry (back to a never-expiring token).
|
|
352
|
+
const next = patch.expiresAt ?? undefined;
|
|
353
|
+
if (next !== token.expiresAt) {
|
|
354
|
+
if (next === undefined)
|
|
355
|
+
delete token.expiresAt;
|
|
356
|
+
else
|
|
357
|
+
token.expiresAt = next;
|
|
358
|
+
changes.push('expiresAt');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { token, changes };
|
|
362
|
+
}
|
|
363
|
+
/** Append one audit line for a token-roster action (kind fixed to 'token', name = token label). */
|
|
364
|
+
async auditToken(role, action, token, actor, changes) {
|
|
365
|
+
await this.appendAudit({
|
|
366
|
+
ts: new Date().toISOString(),
|
|
367
|
+
role,
|
|
368
|
+
action,
|
|
369
|
+
kind: 'token',
|
|
370
|
+
name: token.name,
|
|
371
|
+
...(actor !== undefined ? { actor } : {}),
|
|
372
|
+
...(changes !== undefined ? { changes } : {}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
253
375
|
/** Append one audit line for a case action (kind fixed to 'case', name = case id). */
|
|
254
|
-
async auditCase(role, action, record) {
|
|
255
|
-
await this.appendAudit({
|
|
376
|
+
async auditCase(role, action, record, actor) {
|
|
377
|
+
await this.appendAudit({
|
|
378
|
+
ts: new Date().toISOString(),
|
|
379
|
+
role,
|
|
380
|
+
action,
|
|
381
|
+
kind: 'case',
|
|
382
|
+
name: record.id,
|
|
383
|
+
title: record.title,
|
|
384
|
+
...(actor !== undefined ? { actor } : {}),
|
|
385
|
+
});
|
|
256
386
|
}
|
|
257
|
-
/** Append one audit line. Field values are never recorded. */
|
|
258
|
-
async audit(role, action, kind, name, tier) {
|
|
259
|
-
await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier });
|
|
387
|
+
/** Append one audit line. Field values are never recorded; `actor` names the (named) token used. */
|
|
388
|
+
async audit(role, action, kind, name, tier, actor) {
|
|
389
|
+
await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier, ...(actor !== undefined ? { actor } : {}) });
|
|
260
390
|
}
|
|
261
391
|
async appendAudit(record) {
|
|
262
392
|
await mkdir(this.dataDir, { recursive: true });
|