ada-agent 0.6.1 → 0.8.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 +13 -0
- package/docs/enterprise-stage2-oidc.md +135 -0
- package/docs/enterprise.md +134 -0
- package/package.json +1 -1
- package/src/client/cli.ts +98 -9
- package/src/client/settings.ts +21 -3
- package/src/selfcheck.ts +153 -0
- package/src/server/enterprise.ts +408 -0
- package/src/server/index.ts +277 -25
- package/src/server/oauth.ts +17 -12
- package/src/server/oidc.ts +300 -0
- package/src/server/providers/anthropic.ts +10 -1
- package/src/server/providers/openai-compat.ts +23 -4
package/src/selfcheck.ts
CHANGED
|
@@ -293,6 +293,159 @@ async function main(): Promise<void> {
|
|
|
293
293
|
assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// --- enterprise control plane: seats, policy, metering, audit (temp data dir, no HTTP) ---
|
|
297
|
+
{
|
|
298
|
+
const dir = join(tmpdir(), `ada-ent-${Date.now()}`);
|
|
299
|
+
const ent = await import("./server/enterprise.ts");
|
|
300
|
+
process.env.ADA_DATA_DIR = dir;
|
|
301
|
+
try {
|
|
302
|
+
assert.equal(ent.enterpriseMode(dir), false, "no seats + no admin key → enterprise mode off");
|
|
303
|
+
const key = ent.createSeat("alice", "admin", dir);
|
|
304
|
+
assert.ok(key.startsWith("ada_sk_") && key.length > 40, "seat keys are long and prefixed");
|
|
305
|
+
assert.equal(ent.enterpriseMode(dir), true, "a seat activates enterprise mode");
|
|
306
|
+
assert.deepEqual(ent.identifySeat(key, dir), { user: "alice", role: "admin" }, "seat key resolves to its identity");
|
|
307
|
+
assert.equal(ent.identifySeat("ada_sk_wrong", dir), null, "unknown key → null");
|
|
308
|
+
// The auth-bypass the review caught: Object.prototype keys must NOT authenticate.
|
|
309
|
+
for (const evil of ["toString", "constructor", "__proto__", "valueOf", "hasOwnProperty"]) {
|
|
310
|
+
assert.equal(ent.identifySeat(evil, dir), null, `prototype key "${evil}" must not authenticate`);
|
|
311
|
+
}
|
|
312
|
+
assert.equal(ent.listSeats(dir)[0]!.keyPrefix.length, 14, "listing exposes only a key prefix");
|
|
313
|
+
assert.equal(ent.disableSeat(key.slice(0, 8), dir), null, "too-short prefix refused");
|
|
314
|
+
assert.equal(ent.disableSeat(key.slice(0, 14), dir), "alice", "disable by unique prefix");
|
|
315
|
+
assert.equal(ent.identifySeat(key, dir), null, "disabled seat no longer authenticates");
|
|
316
|
+
|
|
317
|
+
assert.ok(ent.modelAllowed("claude-opus-4-8", {}), "empty policy allows everything");
|
|
318
|
+
const pol = { models: ["@cf/*", "claude-*"] };
|
|
319
|
+
assert.ok(ent.modelAllowed("@cf/moonshotai/kimi-k2.7-code", pol), "wildcard allowlist matches");
|
|
320
|
+
assert.ok(!ent.modelAllowed("gpt-5", pol), "non-listed model denied");
|
|
321
|
+
|
|
322
|
+
ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 100, completionTokens: 20 }, dir);
|
|
323
|
+
ent.appendUsage({ ts: Date.now(), user: "alice", model: "m1", provider: "p", promptTokens: 50, completionTokens: 10 }, dir);
|
|
324
|
+
ent.appendUsage({ ts: Date.now() - 90 * 86_400_000, user: "old", model: "m1", provider: "p", promptTokens: 999, completionTokens: 999 }, dir);
|
|
325
|
+
const sum = ent.usageSummary(30, dir);
|
|
326
|
+
assert.equal(sum.byUser.alice!.requests, 2, "usage aggregates per user");
|
|
327
|
+
assert.equal(sum.totals.promptTokens, 150, "old rows fall outside the window");
|
|
328
|
+
|
|
329
|
+
assert.ok(ent.auditTail(10, dir).some((e) => e.event === "seat_created"), "audit log records seat creation");
|
|
330
|
+
|
|
331
|
+
const sse = 'data: {"choices":[]}\n\ndata: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"completion_tokens_details":{"reasoning_tokens":2}}}\n\ndata: [DONE]\n\n';
|
|
332
|
+
assert.deepEqual(ent.extractLastUsage(sse), { promptTokens: 11, completionTokens: 7 }, "usage extracted from SSE tail (nested details ok)");
|
|
333
|
+
assert.equal(ent.extractLastUsage("no usage here"), null, "no usage → null");
|
|
334
|
+
// A trailing "usage": null must not hide the real one earlier in the stream.
|
|
335
|
+
assert.deepEqual(ent.extractLastUsage('{"usage":{"prompt_tokens":5,"completion_tokens":3}}\n{"usage":null}'), { promptTokens: 5, completionTokens: 3 }, "trailing usage:null skipped, real one found");
|
|
336
|
+
|
|
337
|
+
// policy validation rejects malformed shapes, accepts good ones
|
|
338
|
+
assert.ok("error" in ent.validatePolicy({ models: [1, 2] }), "non-string models rejected");
|
|
339
|
+
assert.ok("error" in ent.validatePolicy({ permissions: [{ tool: "x" }] }), "permission without action rejected");
|
|
340
|
+
assert.ok("policy" in ent.validatePolicy({ models: ["@cf/*"], permissions: [{ tool: "bash", action: "deny" }] }), "valid policy accepted");
|
|
341
|
+
|
|
342
|
+
// corrupt users.json → CorruptStore (fail-closed), NOT an empty map that unlocks the backend
|
|
343
|
+
writeFileSync(join(dir, "users.json"), "{ this is not json");
|
|
344
|
+
assert.throws(() => ent.loadSeats(dir), (e: unknown) => e instanceof ent.CorruptStore, "corrupt users.json throws CorruptStore");
|
|
345
|
+
assert.equal(ent.enterpriseMode(dir), true, "corrupt store → still enterprise (locked), never open");
|
|
346
|
+
} finally {
|
|
347
|
+
delete process.env.ADA_DATA_DIR;
|
|
348
|
+
rmSync(dir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- OIDC SSO (Stage 2): JIT seat invariants + hermetic RS256 id-token verification ---
|
|
353
|
+
{
|
|
354
|
+
const dir = join(tmpdir(), `ada-oidc-${Date.now()}`);
|
|
355
|
+
const ent = await import("./server/enterprise.ts");
|
|
356
|
+
const oidc = await import("./server/oidc.ts");
|
|
357
|
+
const { generateKeyPairSync, sign } = await import("node:crypto");
|
|
358
|
+
const savedEnv = { ...process.env };
|
|
359
|
+
const iss = "https://idp.example.com";
|
|
360
|
+
process.env.ADA_DATA_DIR = dir;
|
|
361
|
+
process.env.ADA_OIDC_ISSUER = iss;
|
|
362
|
+
process.env.ADA_OIDC_CLIENT_ID = "ada-client";
|
|
363
|
+
process.env.ADA_OIDC_ALLOWED_GROUPS = "engineering";
|
|
364
|
+
process.env.ADA_OIDC_ADMIN_GROUP = "admins";
|
|
365
|
+
try {
|
|
366
|
+
// JIT seat provisioning invariants (the load-bearing new behavior).
|
|
367
|
+
const ext = `${iss}#sub-123`;
|
|
368
|
+
const k1 = ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir);
|
|
369
|
+
assert.ok(k1 && k1.startsWith("ada_sk_") && k1.length > 40, "OIDC JIT mints a valid seat key");
|
|
370
|
+
assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), k1, "same iss#sub reuses one seat (no key rotation)");
|
|
371
|
+
assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "admin", dir), k1, "existing seat is NOT auto-escalated to admin on login");
|
|
372
|
+
assert.deepEqual(ent.identifySeat(k1!, dir), { user: "sso-user", role: "dev" }, "SSO seat key authenticates like any seat");
|
|
373
|
+
assert.equal(ent.disableSeatByExternalId(ext, dir), "sso-user", "disable-by-externalId offboards");
|
|
374
|
+
assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), null, "disabled externalId denies re-login (fail-closed deprovision, no resurrect)");
|
|
375
|
+
assert.equal(ent.identifySeat(k1!, dir), null, "disabled SSO seat no longer authenticates");
|
|
376
|
+
assert.equal(ent.seatByExternalId("__proto__", dir), null, "externalId scan is prototype-safe");
|
|
377
|
+
// admin→dev downgrade when the admin group drops off a later login.
|
|
378
|
+
const ext2 = `${iss}#boss`;
|
|
379
|
+
const kb = ent.upsertSeatForSSO(ext2, iss, "boss", "admin", dir);
|
|
380
|
+
assert.equal(ent.identifySeat(kb!, dir)!.role, "admin", "admin seat provisioned");
|
|
381
|
+
assert.equal(ent.upsertSeatForSSO(ext2, iss, "boss", "dev", dir), kb, "downgrade reuses the same key");
|
|
382
|
+
assert.equal(ent.identifySeat(kb!, dir)!.role, "dev", "admin→dev downgrade on group removal");
|
|
383
|
+
|
|
384
|
+
// group/domain gate.
|
|
385
|
+
assert.ok(oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["engineering"] }), "allowed group provisions");
|
|
386
|
+
assert.ok(!oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["other"], email: "x@evil.com" }), "non-allowed group/domain refused");
|
|
387
|
+
assert.equal(oidc.mapIdentityToSeatFields({ iss, sub: "z", name: "z", groups: ["admins"] }).role, "admin", "admin group → admin role");
|
|
388
|
+
|
|
389
|
+
// Hermetic RS256 verification: sign a token locally, verify via an injected JWKS key.
|
|
390
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
391
|
+
const pubJwk = { ...(publicKey.export({ format: "jwk" }) as Record<string, unknown>), kid: "test", kty: "RSA" };
|
|
392
|
+
const getKey = (kid: string) => (kid === "test" ? pubJwk : null);
|
|
393
|
+
const now = 1_800_000_000_000;
|
|
394
|
+
const sec = Math.floor(now / 1000);
|
|
395
|
+
const b64u = (o: unknown): string => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
|
|
396
|
+
const mkToken = (payload: Record<string, unknown>, alg = "RS256"): string => {
|
|
397
|
+
const head = b64u({ alg, kid: "test", typ: "JWT" });
|
|
398
|
+
const body = b64u(payload);
|
|
399
|
+
if (alg === "none") return `${head}.${body}.`;
|
|
400
|
+
return `${head}.${body}.${sign("RSA-SHA256", Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
|
|
401
|
+
};
|
|
402
|
+
const good = { iss, aud: "ada-client", sub: "sub-123", exp: sec + 3600, iat: sec, groups: ["engineering"], email: "dev@corp.com" };
|
|
403
|
+
const id = await oidc.verifyOidcToken(mkToken(good), { getKey, now });
|
|
404
|
+
assert.ok(id && id.sub === "sub-123" && id.iss === iss, "valid RS256 id_token verifies");
|
|
405
|
+
const validTok = mkToken(good);
|
|
406
|
+
assert.equal(await oidc.verifyOidcToken(`${validTok.slice(0, -4)}AAAA`, { getKey, now }), null, "tampered signature → null");
|
|
407
|
+
assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, aud: "someone-else" }), { getKey, now }), null, "wrong audience → null");
|
|
408
|
+
assert.equal(await oidc.verifyOidcToken(mkToken(good, "none"), { getKey, now }), null, "alg=none → null (no key confusion)");
|
|
409
|
+
assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, exp: sec - 7200 }), { getKey, now }), null, "expired token → null");
|
|
410
|
+
// email is trusted only when the IdP marks it verified (domain-provisioning fail-open fix).
|
|
411
|
+
const idU = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: false }), { getKey, now });
|
|
412
|
+
assert.ok(idU && idU.email === undefined, "unverified email dropped from identity");
|
|
413
|
+
const idV = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: true }), { getKey, now });
|
|
414
|
+
assert.equal(idV!.email, "x@corp.com", "verified email kept");
|
|
415
|
+
|
|
416
|
+
// SSRF guard classifies against a parsed IP (net.isIP), not a string prefix.
|
|
417
|
+
for (const bad of ["https://[::1]/keys", "https://[fe80::1]/keys", "https://[fc00::1]/keys", "https://[::ffff:127.0.0.1]/keys", "https://127.0.0.1/keys", "https://10.1.2.3/keys", "http://idp.okta.com/keys"]) {
|
|
418
|
+
assert.throws(() => oidc.assertSafeJwksUri(bad), `jwks_uri rejected: ${bad}`);
|
|
419
|
+
}
|
|
420
|
+
for (const ok of ["https://fcm.googleapis.com/keys", "https://fd-idp.corp.com/keys", "https://your-tenant.okta.com/oauth2/v1/keys"]) {
|
|
421
|
+
assert.doesNotThrow(() => oidc.assertSafeJwksUri(ok), `jwks_uri allowed: ${ok}`);
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
for (const k of ["ADA_DATA_DIR", "ADA_OIDC_ISSUER", "ADA_OIDC_CLIENT_ID", "ADA_OIDC_ALLOWED_GROUPS", "ADA_OIDC_ADMIN_GROUP"]) {
|
|
425
|
+
if (savedEnv[k] === undefined) delete process.env[k];
|
|
426
|
+
else process.env[k] = savedEnv[k];
|
|
427
|
+
}
|
|
428
|
+
rmSync(dir, { recursive: true, force: true });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- org policy merge: restrictive wins, org can tighten but never loosen ---
|
|
433
|
+
{
|
|
434
|
+
const { permissionFor, setActiveAgentPermissions, setOrgPermissions } = await import("./client/settings.ts");
|
|
435
|
+
setActiveAgentPermissions([{ tool: "bash", action: "allow" }]);
|
|
436
|
+
setOrgPermissions([{ tool: "bash", action: "deny" }]);
|
|
437
|
+
assert.equal(permissionFor("bash", "x"), "deny", "org deny beats local allow");
|
|
438
|
+
setOrgPermissions([{ tool: "bash", action: "ask" }]);
|
|
439
|
+
assert.equal(permissionFor("bash", "x"), "ask", "org ask upgrades local allow");
|
|
440
|
+
setActiveAgentPermissions([{ tool: "bash", action: "deny" }]);
|
|
441
|
+
setOrgPermissions([{ tool: "bash", action: "allow" }]);
|
|
442
|
+
assert.equal(permissionFor("bash", "x"), "deny", "org allow cannot loosen a local deny");
|
|
443
|
+
setActiveAgentPermissions([]);
|
|
444
|
+
assert.equal(permissionFor("bash", "x"), null, "org allow cannot loosen the default gating");
|
|
445
|
+
setOrgPermissions(null);
|
|
446
|
+
setActiveAgentPermissions(null);
|
|
447
|
+
}
|
|
448
|
+
|
|
296
449
|
// --- @codebase semantic search: pure parts (no network / no embedding model needed) ---
|
|
297
450
|
{
|
|
298
451
|
const { chunkText, cosine, walkFiles } = await import("./client/embed-index.ts");
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Enterprise control plane: seats (per-user client keys), org policy, usage metering, audit log.
|
|
2
|
+
// One deployment = one org (it's self-hosted; multi-org is the SaaS upgrade path, not v1).
|
|
3
|
+
//
|
|
4
|
+
// Enterprise mode ACTIVATES when a seat exists or ADA_ADMIN_KEY is set — with neither, the backend
|
|
5
|
+
// behaves exactly as before (dev-open / ADA_CLIENT_KEYS / login). Bootstrap:
|
|
6
|
+
//
|
|
7
|
+
// ADA_ADMIN_KEY=<random> ada-server
|
|
8
|
+
// curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" localhost:8787/v1/users -d '{"name":"alice"}'
|
|
9
|
+
//
|
|
10
|
+
// Security posture (hardened after an adversarial review):
|
|
11
|
+
// - lookups are own-property + format-guarded (no prototype-key auth bypass);
|
|
12
|
+
// - writes are atomic (tmp + rename); a corrupt/unreadable store fails CLOSED (never dev-open);
|
|
13
|
+
// - key comparisons are timing-safe.
|
|
14
|
+
// ponytail: file-backed under ~/.ada/server — fine to ~50 seats. Postgres + rotating usage logs are
|
|
15
|
+
// the upgrade path when an org outgrows files (usageSummary/auditTail read whole files: OK to
|
|
16
|
+
// low-millions of rows, then rotate).
|
|
17
|
+
|
|
18
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
// Resolved once: an empty ADA_DATA_DIR means "unset", and a relative path can't scatter the auth
|
|
24
|
+
// store across working directories (which would itself be a fail-open).
|
|
25
|
+
const DATA_DIR = resolve(process.env.ADA_DATA_DIR || join(homedir(), ".ada", "server"));
|
|
26
|
+
function dataDir(): string {
|
|
27
|
+
return DATA_DIR;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Thrown when a store file exists but can't be read/parsed — callers must fail CLOSED, never open. */
|
|
31
|
+
export class CorruptStore extends Error {}
|
|
32
|
+
|
|
33
|
+
export interface Seat {
|
|
34
|
+
name: string;
|
|
35
|
+
role: "admin" | "dev";
|
|
36
|
+
created: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
externalId?: string; // OIDC SSO: issuer-scoped stable id (`iss#sub`) — non-secret, the deprovision target
|
|
39
|
+
iss?: string; // OIDC issuer the seat was provisioned from (so an issuer change is detectable)
|
|
40
|
+
}
|
|
41
|
+
export interface PolicyRule {
|
|
42
|
+
tool?: string;
|
|
43
|
+
pattern?: string;
|
|
44
|
+
action: "allow" | "ask" | "deny";
|
|
45
|
+
}
|
|
46
|
+
export interface Policy {
|
|
47
|
+
models?: string[];
|
|
48
|
+
permissions?: PolicyRule[];
|
|
49
|
+
}
|
|
50
|
+
export interface UsageRow {
|
|
51
|
+
ts: number;
|
|
52
|
+
user: string;
|
|
53
|
+
model: string;
|
|
54
|
+
provider: string;
|
|
55
|
+
promptTokens: number;
|
|
56
|
+
completionTokens: number;
|
|
57
|
+
}
|
|
58
|
+
export interface Identity {
|
|
59
|
+
user: string;
|
|
60
|
+
role: "admin" | "dev";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const usersFile = (dir: string): string => join(dir, "users.json");
|
|
64
|
+
const policyFile = (dir: string): string => join(dir, "policy.json");
|
|
65
|
+
const usageFile = (dir: string): string => join(dir, "usage.jsonl");
|
|
66
|
+
const auditFile = (dir: string): string => join(dir, "audit.jsonl");
|
|
67
|
+
|
|
68
|
+
function atomicWrite(file: string, data: string): void {
|
|
69
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
70
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
71
|
+
writeFileSync(tmp, data);
|
|
72
|
+
renameSync(tmp, file); // atomic on the same filesystem — a crash can't leave a torn file
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errno(e: unknown): string | undefined {
|
|
76
|
+
return (e as NodeJS.ErrnoException).code;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Seats keyed by full key. Missing file → empty (prototype-free) map. Any OTHER read/parse error
|
|
80
|
+
* → CorruptStore (callers fail closed — a torn users.json must not silently disable auth). */
|
|
81
|
+
export function loadSeats(dir = dataDir()): Record<string, Seat> {
|
|
82
|
+
const map: Record<string, Seat> = Object.create(null); // no Object.prototype — belt-and-suspenders with the own-property check
|
|
83
|
+
let text: string;
|
|
84
|
+
try {
|
|
85
|
+
text = readFileSync(usersFile(dir), "utf8");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (errno(e) === "ENOENT") return map;
|
|
88
|
+
throw new CorruptStore(`users.json unreadable: ${e instanceof Error ? e.message : e}`);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const parsed = (JSON.parse(text) as { users?: Record<string, Seat> }).users ?? {};
|
|
92
|
+
for (const [k, v] of Object.entries(parsed)) map[k] = v;
|
|
93
|
+
return map;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
throw new CorruptStore(`users.json corrupt: ${e instanceof Error ? e.message : e}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function saveSeats(seats: Record<string, Seat>, dir = dataDir()): void {
|
|
100
|
+
atomicWrite(usersFile(dir), JSON.stringify({ users: seats }, null, 2));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Enterprise mode = admin key set, or seats exist. A corrupt seat store counts as enterprise
|
|
104
|
+
* (locked), never as "no seats" — fail closed. */
|
|
105
|
+
export function enterpriseMode(dir = dataDir()): boolean {
|
|
106
|
+
if (process.env.ADA_ADMIN_KEY) return true;
|
|
107
|
+
try {
|
|
108
|
+
return Object.keys(loadSeats(dir)).length > 0;
|
|
109
|
+
} catch {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function timingEqual(a: string, b: string): boolean {
|
|
115
|
+
const ab = Buffer.from(a);
|
|
116
|
+
const bb = Buffer.from(b);
|
|
117
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolve a bearer token to a seat identity (or the bootstrap admin). Null = not a seat. Throws
|
|
121
|
+
* CorruptStore if the seat store can't be read (caller returns 503, never dev-open). */
|
|
122
|
+
export function identifySeat(token: string, dir = dataDir()): Identity | null {
|
|
123
|
+
const admin = process.env.ADA_ADMIN_KEY;
|
|
124
|
+
if (admin && timingEqual(token, admin)) return { user: "admin", role: "admin" };
|
|
125
|
+
if (!token.startsWith("ada_sk_")) return null; // format guard — "toString"/"__proto__"/… never reach the map
|
|
126
|
+
const seats = loadSeats(dir); // may throw CorruptStore
|
|
127
|
+
if (!Object.prototype.hasOwnProperty.call(seats, token)) return null; // own-property only
|
|
128
|
+
const seat = seats[token]!;
|
|
129
|
+
return seat.disabled ? null : { user: seat.name, role: seat.role };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Create a seat; returns its full key (shown once — only a prefix is ever listed again). */
|
|
133
|
+
export function createSeat(name: string, role: "admin" | "dev" = "dev", dir = dataDir()): string {
|
|
134
|
+
const key = `ada_sk_${randomBytes(24).toString("hex")}`;
|
|
135
|
+
const seats = loadSeats(dir);
|
|
136
|
+
seats[key] = { name, role, created: new Date().toISOString() };
|
|
137
|
+
saveSeats(seats, dir);
|
|
138
|
+
appendAudit({ ts: Date.now(), user: "-", event: "seat_created", detail: `${name} (${role})` }, dir);
|
|
139
|
+
return key;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Find a seat by its OIDC externalId (`iss#sub`). Scans the (small) seat map by VALUE — externalId
|
|
143
|
+
* is compared, never used as a lookup key, so it's inherently prototype-safe. Inherits CorruptStore
|
|
144
|
+
* from loadSeats (callers fail closed). */
|
|
145
|
+
export function seatByExternalId(externalId: string, dir = dataDir()): { key: string; seat: Seat } | null {
|
|
146
|
+
const seats = loadSeats(dir);
|
|
147
|
+
for (const key of Object.keys(seats)) {
|
|
148
|
+
const seat = seats[key]!;
|
|
149
|
+
if (seat.externalId === externalId) return { key, seat };
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** JIT-provision (or reuse) a seat for a verified OIDC identity. Returns the seat's `ada_sk_` key, or
|
|
155
|
+
* null if the seat exists but is DISABLED (a deprovisioned user must not be resurrected by re-login).
|
|
156
|
+
* - existing enabled seat → reuse its key (NO rotation); if it was admin and the login no longer
|
|
157
|
+
* carries the admin group, downgrade admin→dev (privilege revocation). Never auto-ESCALATE here.
|
|
158
|
+
* - new identity → mint one key, stamp externalId+iss, one `seat_created` audit row. */
|
|
159
|
+
export function upsertSeatForSSO(externalId: string, iss: string, name: string, role: "admin" | "dev", dir = dataDir()): string | null {
|
|
160
|
+
const seats = loadSeats(dir);
|
|
161
|
+
let foundKey: string | null = null;
|
|
162
|
+
for (const key of Object.keys(seats)) {
|
|
163
|
+
if (seats[key]!.externalId === externalId) {
|
|
164
|
+
foundKey = key;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (foundKey) {
|
|
169
|
+
const seat = seats[foundKey]!;
|
|
170
|
+
if (seat.disabled) return null; // deprovisioned — do NOT resurrect
|
|
171
|
+
if (seat.role === "admin" && role === "dev") {
|
|
172
|
+
seat.role = "dev";
|
|
173
|
+
saveSeats(seats, dir);
|
|
174
|
+
appendAudit({ ts: Date.now(), user: name, event: "role_changed", detail: `${name} admin→dev (SSO group removed)` }, dir);
|
|
175
|
+
}
|
|
176
|
+
return foundKey;
|
|
177
|
+
}
|
|
178
|
+
const key = `ada_sk_${randomBytes(24).toString("hex")}`;
|
|
179
|
+
seats[key] = { name, role, created: new Date().toISOString(), externalId, iss };
|
|
180
|
+
saveSeats(seats, dir);
|
|
181
|
+
appendAudit({ ts: Date.now(), user: name, event: "seat_created", detail: `${name} (${role}) via OIDC ${externalId}` }, dir);
|
|
182
|
+
return key;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Immediate offboarding: disable the seat for an OIDC externalId. The admin endpoint (and, later,
|
|
186
|
+
* SCIM DELETE) call this — the next identifySeat for that key returns null (401). */
|
|
187
|
+
export function disableSeatByExternalId(externalId: string, dir = dataDir()): string | null {
|
|
188
|
+
const seats = loadSeats(dir);
|
|
189
|
+
for (const key of Object.keys(seats)) {
|
|
190
|
+
const seat = seats[key]!;
|
|
191
|
+
if (seat.externalId === externalId) {
|
|
192
|
+
if (seat.disabled) return seat.name; // idempotent
|
|
193
|
+
seat.disabled = true;
|
|
194
|
+
saveSeats(seats, dir);
|
|
195
|
+
appendAudit({ ts: Date.now(), user: seat.name, event: "seat_disabled", detail: `${seat.name} (SSO ${externalId})` }, dir);
|
|
196
|
+
return seat.name;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Disable (not delete — the audit trail keeps the history) the seat whose key starts with prefix. */
|
|
203
|
+
export function disableSeat(prefix: string, dir = dataDir()): string | null {
|
|
204
|
+
if (prefix.length < 12) return null; // too short to be safely unique
|
|
205
|
+
const seats = loadSeats(dir);
|
|
206
|
+
const keys = Object.keys(seats).filter((k) => k.startsWith(prefix));
|
|
207
|
+
if (keys.length !== 1) return null;
|
|
208
|
+
seats[keys[0]!]!.disabled = true;
|
|
209
|
+
saveSeats(seats, dir);
|
|
210
|
+
appendAudit({ ts: Date.now(), user: "-", event: "seat_disabled", detail: seats[keys[0]!]!.name }, dir);
|
|
211
|
+
return seats[keys[0]!]!.name;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Key prefixes + metadata for listing — full keys are never returned after creation. Display-only,
|
|
215
|
+
* so a corrupt store yields [] rather than crashing the banner. */
|
|
216
|
+
export function listSeats(dir = dataDir()): Array<Seat & { keyPrefix: string }> {
|
|
217
|
+
try {
|
|
218
|
+
return Object.entries(loadSeats(dir)).map(([k, s]) => ({ ...s, keyPrefix: k.slice(0, 14) }));
|
|
219
|
+
} catch {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let lastGoodPolicy: Policy | null = null;
|
|
225
|
+
/** Missing file → {} (no policy = allow all, legitimate). A corrupt EXISTING file → last-known-good
|
|
226
|
+
* if we have one, else CorruptStore (fail closed — a security control must not degrade to allow-all). */
|
|
227
|
+
export function loadPolicy(dir = dataDir()): Policy {
|
|
228
|
+
let text: string;
|
|
229
|
+
try {
|
|
230
|
+
text = readFileSync(policyFile(dir), "utf8");
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (errno(e) === "ENOENT") return {};
|
|
233
|
+
if (lastGoodPolicy) return lastGoodPolicy;
|
|
234
|
+
throw new CorruptStore(`policy.json unreadable: ${e instanceof Error ? e.message : e}`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
lastGoodPolicy = JSON.parse(text) as Policy;
|
|
238
|
+
return lastGoodPolicy;
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (lastGoodPolicy) return lastGoodPolicy;
|
|
241
|
+
throw new CorruptStore(`policy.json corrupt: ${e instanceof Error ? e.message : e}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function savePolicy(p: Policy, dir = dataDir()): void {
|
|
246
|
+
atomicWrite(policyFile(dir), JSON.stringify(p, null, 2));
|
|
247
|
+
lastGoodPolicy = p;
|
|
248
|
+
appendAudit({ ts: Date.now(), user: "-", event: "policy_updated", detail: JSON.stringify(p).slice(0, 300) }, dir);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Validate a policy shape from the wire. Returns the typed policy or an error message. */
|
|
252
|
+
export function validatePolicy(raw: unknown): { policy: Policy } | { error: string } {
|
|
253
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "policy must be a JSON object" };
|
|
254
|
+
const r = raw as Record<string, unknown>;
|
|
255
|
+
const out: Policy = {};
|
|
256
|
+
if (r.models !== undefined) {
|
|
257
|
+
if (!Array.isArray(r.models) || r.models.some((m) => typeof m !== "string" || !m.trim())) return { error: "models must be an array of non-empty strings" };
|
|
258
|
+
out.models = r.models as string[];
|
|
259
|
+
}
|
|
260
|
+
if (r.permissions !== undefined) {
|
|
261
|
+
if (!Array.isArray(r.permissions)) return { error: "permissions must be an array" };
|
|
262
|
+
for (const p of r.permissions) {
|
|
263
|
+
const rule = p as Record<string, unknown>;
|
|
264
|
+
if (!rule || typeof rule !== "object" || !["allow", "ask", "deny"].includes(rule.action as string)) return { error: "each permission needs action: allow|ask|deny" };
|
|
265
|
+
if (rule.tool !== undefined && typeof rule.tool !== "string") return { error: "permission.tool must be a string" };
|
|
266
|
+
if (rule.pattern !== undefined && typeof rule.pattern !== "string") return { error: "permission.pattern must be a string" };
|
|
267
|
+
}
|
|
268
|
+
out.permissions = r.permissions as PolicyRule[];
|
|
269
|
+
}
|
|
270
|
+
return { policy: out };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function globMatch(pattern: string, s: string): boolean {
|
|
274
|
+
const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
275
|
+
return re.test(s);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Is this model allowed by org policy? No/empty allowlist = everything allowed. */
|
|
279
|
+
export function modelAllowed(model: string, policy: Policy): boolean {
|
|
280
|
+
if (!Array.isArray(policy.models) || !policy.models.length) return true;
|
|
281
|
+
return policy.models.some((p) => globMatch(p, model));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function appendUsage(row: UsageRow, dir = dataDir()): void {
|
|
285
|
+
try {
|
|
286
|
+
mkdirSync(dir, { recursive: true });
|
|
287
|
+
appendFileSync(usageFile(dir), `${JSON.stringify(row)}\n`);
|
|
288
|
+
} catch {
|
|
289
|
+
/* metering is best-effort; never fail a request over it */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface AuditRow {
|
|
294
|
+
ts: number;
|
|
295
|
+
user: string;
|
|
296
|
+
event: string;
|
|
297
|
+
detail: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function appendAudit(row: AuditRow, dir = dataDir()): void {
|
|
301
|
+
try {
|
|
302
|
+
mkdirSync(dir, { recursive: true });
|
|
303
|
+
appendFileSync(auditFile(dir), `${JSON.stringify(row)}\n`);
|
|
304
|
+
} catch {
|
|
305
|
+
/* best-effort */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function auditTail(limit = 200, dir = dataDir()): AuditRow[] {
|
|
310
|
+
let lines: string[];
|
|
311
|
+
try {
|
|
312
|
+
lines = readFileSync(auditFile(dir), "utf8").split("\n").filter(Boolean);
|
|
313
|
+
} catch {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
const out: AuditRow[] = [];
|
|
317
|
+
for (const l of lines.slice(-limit)) {
|
|
318
|
+
try {
|
|
319
|
+
out.push(JSON.parse(l) as AuditRow); // skip a torn last line instead of losing the whole view
|
|
320
|
+
} catch {
|
|
321
|
+
/* skip corrupt line */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface Bucket {
|
|
328
|
+
requests: number;
|
|
329
|
+
promptTokens: number;
|
|
330
|
+
completionTokens: number;
|
|
331
|
+
}
|
|
332
|
+
export interface UsageSummary {
|
|
333
|
+
since: number;
|
|
334
|
+
totals: Bucket;
|
|
335
|
+
byUser: Record<string, Bucket>;
|
|
336
|
+
byModel: Record<string, Bucket>;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function usageSummary(days = 30, dir = dataDir()): UsageSummary {
|
|
340
|
+
const since = Date.now() - days * 86_400_000;
|
|
341
|
+
const zero = (): Bucket => ({ requests: 0, promptTokens: 0, completionTokens: 0 });
|
|
342
|
+
const out: UsageSummary = { since, totals: zero(), byUser: Object.create(null), byModel: Object.create(null) };
|
|
343
|
+
let lines: string[] = [];
|
|
344
|
+
try {
|
|
345
|
+
lines = readFileSync(usageFile(dir), "utf8").split("\n").filter(Boolean);
|
|
346
|
+
} catch {
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
for (const l of lines) {
|
|
350
|
+
let r: UsageRow;
|
|
351
|
+
try {
|
|
352
|
+
r = JSON.parse(l) as UsageRow;
|
|
353
|
+
} catch {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (r.ts < since) continue;
|
|
357
|
+
for (const b of [out.totals, (out.byUser[r.user] ??= zero()), (out.byModel[r.model] ??= zero())]) {
|
|
358
|
+
b.requests++;
|
|
359
|
+
b.promptTokens += r.promptTokens || 0;
|
|
360
|
+
b.completionTokens += r.completionTokens || 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function matchBraces(text: string, start: number): string | null {
|
|
367
|
+
let depth = 0;
|
|
368
|
+
let inStr = false;
|
|
369
|
+
let esc = false;
|
|
370
|
+
for (let i = start; i < text.length; i++) {
|
|
371
|
+
const c = text[i];
|
|
372
|
+
if (inStr) {
|
|
373
|
+
if (esc) esc = false;
|
|
374
|
+
else if (c === "\\") esc = true;
|
|
375
|
+
else if (c === '"') inStr = false;
|
|
376
|
+
} else if (c === '"') inStr = true;
|
|
377
|
+
else if (c === "{") depth++;
|
|
378
|
+
else if (c === "}" && --depth === 0) return text.slice(start, i + 1);
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Pull the LAST real `"usage": { … }` object out of streamed/response text. Skips a trailing
|
|
384
|
+
* `"usage": null` and keeps scanning backwards, so a null in a late frame doesn't hide a real one. */
|
|
385
|
+
export function extractLastUsage(text: string): { promptTokens: number; completionTokens: number } | null {
|
|
386
|
+
let at = text.lastIndexOf('"usage"');
|
|
387
|
+
while (at >= 0) {
|
|
388
|
+
const brace = text.indexOf("{", at + 7);
|
|
389
|
+
const colon = text.indexOf(":", at + 7);
|
|
390
|
+
if (brace >= 0 && colon >= 0 && text.slice(colon + 1, brace).trim() === "") {
|
|
391
|
+
const obj = matchBraces(text, brace);
|
|
392
|
+
if (obj) {
|
|
393
|
+
try {
|
|
394
|
+
const u = JSON.parse(obj) as { prompt_tokens?: number; completion_tokens?: number };
|
|
395
|
+
if (u.prompt_tokens != null || u.completion_tokens != null) return { promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0 };
|
|
396
|
+
} catch {
|
|
397
|
+
/* malformed — keep looking backwards */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
at = text.lastIndexOf('"usage"', at - 1);
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function storeExists(dir = dataDir()): boolean {
|
|
407
|
+
return existsSync(usersFile(dir)) || existsSync(policyFile(dir));
|
|
408
|
+
}
|