@forgezero/access 0.1.0 → 0.1.2

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 CHANGED
@@ -66,10 +66,50 @@ integration suite.
66
66
  | `/client` | the browser half, including the 428 replay |
67
67
  | `/testing` | decide without a server |
68
68
  | `/pipeline` · `/authenticator` | the resolver, and WebAuthn |
69
+ | `/header` | verified header identities with fresh, host-owned RBAC assignments |
70
+ | `/principal` | generic multi-assignment principals, route groups, methods and expiry |
71
+ | `/principal-session` | short-lived server sessions bound to an opaque client identity |
72
+
73
+ Header identities are a separate principal, not a browser session. A source
74
+ verifier authenticates one configured header and returns only a stable subject;
75
+ the host resolves that subject's role assignments on every request. Roles are
76
+ never accepted from the header value, and a header principal cannot satisfy a
77
+ session or fresh-action factor. Multiple source adapters can coexist, but a
78
+ request presenting more than one source is refused as ambiguous.
79
+
80
+ `accessGroup` is the single open, host-defined route classification used by
81
+ generic principal assignments—`public`, `user`, `admin`, `custody`,
82
+ `orchestration`, or any future product vocabulary. Session policies remain the
83
+ generic package's authentication boundary; ForgeZero additionally requires its
84
+ `public` group to use anonymous authentication and rejects contradictions at
85
+ boot. The package does not hard-code a purpose or identity type.
69
86
 
70
- Full documentation: **https://forgezero.net/docs/access**
87
+ ```ts
88
+ import { headerIdentityResolver } from '@forgezero/access/header';
89
+ import { decidePrincipalAccess } from '@forgezero/access/principal';
90
+
91
+ const resolveHeader = headerIdentityResolver(
92
+ [{
93
+ key: 'partner-sso',
94
+ header: 'x-partner-identity',
95
+ verify: ({ value, request }) => verifyPartnerAssertion(value, request)
96
+ }],
97
+ // Read the current admin/custodian assignment. Do not cache it in the token.
98
+ ({ principalKey }) => memberships.assignments(principalKey)
99
+ );
100
+
101
+ const principal = await resolveHeader(request);
102
+ const decision = decidePrincipalAccess({
103
+ access, principal, roles: await policy.roles(), routeKey: 'api/orders/write', method: 'POST'
104
+ });
105
+ if (!decision.allow) {
106
+ return new Response('Forbidden', { status: 403 });
107
+ }
108
+ ```
109
+
110
+ Full documentation: **https://www.forgezero.net/docs/access**
71
111
 
72
112
  ## Licence
73
113
 
74
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
114
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
75
115
  deploys — and usable entirely on its own, with no ForgeZero account.
@@ -60,13 +60,16 @@ function defineFactors(factors) {
60
60
  return factors;
61
61
  }
62
62
  function page(label, options = {}) {
63
- return { kind: "page", label, ...options };
63
+ return { kind: "page", label, accessGroup: "default", ...options };
64
64
  }
65
65
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, ...options };
66
+ return { kind: "action", label, method, accessGroup: "default", ...options };
67
67
  }
68
68
  function defineRoutes(routes) {
69
69
  for (const [key, route] of Object.entries(routes)) {
70
+ if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
+ throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
+ }
70
73
  const isApi = key.startsWith("api/");
71
74
  if (isApi !== (route.kind === "action")) {
72
75
  throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
@@ -242,11 +245,11 @@ function authorise(args) {
242
245
  return { allow: false, status: 404, code: "NOT_FOUND" };
243
246
  }
244
247
  const policy = access.sessionPolicyFor(routeKey);
245
- if (!policy || policy.factors.length === 0)
248
+ if ((policy?.factors.length ?? 0) === 0)
246
249
  return { allow: true };
247
250
  if (!session)
248
251
  return { allow: false, status: 401, code: "AUTH_REQUIRED" };
249
- const missing = policy.factors.filter((factor) => !session.factors.includes(factor));
252
+ const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
250
253
  if (missing.length > 0) {
251
254
  return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
252
255
  }
@@ -288,7 +291,10 @@ function required(state, name, condition) {
288
291
  function loadTarget(options) {
289
292
  const as = options.as ?? "target";
290
293
  const code = options.code ?? "NOT_FOUND";
291
- const readId = options.id ?? ((context) => context.params.id);
294
+ const readId = options.id ?? ((context) => {
295
+ const id = context.params.id;
296
+ return typeof id === "string" ? id : undefined;
297
+ });
292
298
  return define("loadTarget", options.routes, [{ status: 404, code, when: "no record matches the identifier" }], async (context) => {
293
299
  const id = readId(context);
294
300
  if (!id)
package/dist/effects.js CHANGED
@@ -60,13 +60,16 @@ function defineFactors(factors) {
60
60
  return factors;
61
61
  }
62
62
  function page(label, options = {}) {
63
- return { kind: "page", label, ...options };
63
+ return { kind: "page", label, accessGroup: "default", ...options };
64
64
  }
65
65
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, ...options };
66
+ return { kind: "action", label, method, accessGroup: "default", ...options };
67
67
  }
68
68
  function defineRoutes(routes) {
69
69
  for (const [key, route] of Object.entries(routes)) {
70
+ if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
+ throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
+ }
70
73
  const isApi = key.startsWith("api/");
71
74
  if (isApi !== (route.kind === "action")) {
72
75
  throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
@@ -242,11 +245,11 @@ function authorise(args) {
242
245
  return { allow: false, status: 404, code: "NOT_FOUND" };
243
246
  }
244
247
  const policy = access.sessionPolicyFor(routeKey);
245
- if (!policy || policy.factors.length === 0)
248
+ if ((policy?.factors.length ?? 0) === 0)
246
249
  return { allow: true };
247
250
  if (!session)
248
251
  return { allow: false, status: 401, code: "AUTH_REQUIRED" };
249
- const missing = policy.factors.filter((factor) => !session.factors.includes(factor));
252
+ const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
250
253
  if (missing.length > 0) {
251
254
  return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
252
255
  }
package/dist/elysia.js CHANGED
@@ -60,13 +60,16 @@ function defineFactors(factors) {
60
60
  return factors;
61
61
  }
62
62
  function page(label, options = {}) {
63
- return { kind: "page", label, ...options };
63
+ return { kind: "page", label, accessGroup: "default", ...options };
64
64
  }
65
65
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, ...options };
66
+ return { kind: "action", label, method, accessGroup: "default", ...options };
67
67
  }
68
68
  function defineRoutes(routes) {
69
69
  for (const [key, route] of Object.entries(routes)) {
70
+ if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
+ throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
+ }
70
73
  const isApi = key.startsWith("api/");
71
74
  if (isApi !== (route.kind === "action")) {
72
75
  throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
@@ -242,11 +245,11 @@ function authorise(args) {
242
245
  return { allow: false, status: 404, code: "NOT_FOUND" };
243
246
  }
244
247
  const policy = access.sessionPolicyFor(routeKey);
245
- if (!policy || policy.factors.length === 0)
248
+ if ((policy?.factors.length ?? 0) === 0)
246
249
  return { allow: true };
247
250
  if (!session)
248
251
  return { allow: false, status: 401, code: "AUTH_REQUIRED" };
249
- const missing = policy.factors.filter((factor) => !session.factors.includes(factor));
252
+ const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
250
253
  if (missing.length > 0) {
251
254
  return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
252
255
  }
@@ -286,6 +289,46 @@ function defineHandlers(routes, handlers) {
286
289
  }
287
290
  return handlers;
288
291
  }
292
+ function recordFromValidator(value, part) {
293
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
294
+ return value;
295
+ }
296
+ throw new AccessError("VALIDATOR_RESULT_INVALID", `The validator returned an unusable ${part} value.`);
297
+ }
298
+ function headersFromValidator(value) {
299
+ try {
300
+ return new Headers(value);
301
+ } catch {
302
+ throw new AccessError("VALIDATOR_RESULT_INVALID", "The validator returned an unusable headers value.");
303
+ }
304
+ }
305
+ function urlWithQuery(url, query) {
306
+ const validated = new URL(url);
307
+ validated.search = "";
308
+ for (const [name, value] of Object.entries(query)) {
309
+ const values = Array.isArray(value) ? value : [value];
310
+ for (const item of values) {
311
+ if (item !== undefined && item !== null)
312
+ validated.searchParams.append(name, String(item));
313
+ }
314
+ }
315
+ return validated;
316
+ }
317
+ async function responsePayload(response, routeKey) {
318
+ let text;
319
+ try {
320
+ text = await response.clone().text();
321
+ } catch {
322
+ throw new AccessError("RESPONSE_UNREADABLE", `"${routeKey}" returned a response that could not be inspected.`);
323
+ }
324
+ if (text.length === 0)
325
+ return;
326
+ try {
327
+ return JSON.parse(text);
328
+ } catch {
329
+ throw new AccessError("RESPONSE_NOT_JSON", `"${routeKey}" returned a non-JSON body for a declared response.`);
330
+ }
331
+ }
289
332
  var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
333
  function windowSeconds(window) {
291
334
  const match = /^(\d+)([smhd])$/.exec(window);
@@ -370,13 +413,15 @@ function createPipeline(options) {
370
413
  }
371
414
  }
372
415
  let body;
373
- const query = Object.fromEntries(url.searchParams);
416
+ let validatedParams = params;
417
+ let validatedQuery = Object.fromEntries(url.searchParams);
418
+ let validatedHeaders = new Headers(request.headers);
374
419
  if (validator) {
375
420
  const checks = [];
376
421
  if (route.params)
377
422
  checks.push(["params", route.params, params]);
378
423
  if (route.query)
379
- checks.push(["query", route.query, query]);
424
+ checks.push(["query", route.query, validatedQuery]);
380
425
  if (route.headers)
381
426
  checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
427
  if (route.body) {
@@ -393,8 +438,20 @@ function createPipeline(options) {
393
438
  errors: result2.errors
394
439
  });
395
440
  }
396
- if (where === "body")
397
- body = result2.value;
441
+ switch (where) {
442
+ case "params":
443
+ validatedParams = recordFromValidator(result2.value, "params");
444
+ break;
445
+ case "query":
446
+ validatedQuery = recordFromValidator(result2.value, "query");
447
+ break;
448
+ case "headers":
449
+ validatedHeaders = headersFromValidator(result2.value);
450
+ break;
451
+ case "body":
452
+ body = result2.value;
453
+ break;
454
+ }
398
455
  }
399
456
  } else if (route.body) {
400
457
  body = await request.json().catch(() => {
@@ -404,11 +461,11 @@ function createPipeline(options) {
404
461
  const context = {
405
462
  route: routeKey,
406
463
  method: route.method,
407
- url,
408
- params,
409
- query,
464
+ url: route.query && validator ? urlWithQuery(url, validatedQuery) : url,
465
+ params: validatedParams,
466
+ query: validatedQuery,
410
467
  body,
411
- headers: request.headers,
468
+ headers: validatedHeaders,
412
469
  session,
413
470
  realm,
414
471
  stage,
@@ -508,16 +565,19 @@ function createPipeline(options) {
508
565
  }
509
566
  const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
567
  if (validateResponses && validator && route.response) {
511
- const schema = route.response[200];
512
- if (schema && !(result instanceof Response)) {
513
- const check = validator.validate(schema, result);
514
- if (!check.ok) {
515
- throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned a shape it does not declare: ${check.errors[0]?.message}`);
516
- }
568
+ const status = result instanceof Response ? result.status : 200;
569
+ const schema = route.response[status];
570
+ if (!schema) {
571
+ throw new AccessError("RESPONSE_STATUS_UNDECLARED", `"${routeKey}" returned undeclared status ${status}.`);
572
+ }
573
+ const payload = result instanceof Response ? await responsePayload(result, routeKey) : result;
574
+ const check = validator.validate(schema, payload);
575
+ if (!check.ok) {
576
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned status ${status} with a body that does not match its declaration.`);
517
577
  }
518
578
  }
519
579
  runAfter(result);
520
- record({ "access.outcome": "allowed", "access.status": 200 });
580
+ record({ "access.outcome": "allowed", "access.status": result instanceof Response ? result.status : 200 });
521
581
  return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
582
  };
523
583
  }
package/dist/fetch.js CHANGED
@@ -60,13 +60,16 @@ function defineFactors(factors) {
60
60
  return factors;
61
61
  }
62
62
  function page(label, options = {}) {
63
- return { kind: "page", label, ...options };
63
+ return { kind: "page", label, accessGroup: "default", ...options };
64
64
  }
65
65
  function action(label, method, options = {}) {
66
- return { kind: "action", label, method, ...options };
66
+ return { kind: "action", label, method, accessGroup: "default", ...options };
67
67
  }
68
68
  function defineRoutes(routes) {
69
69
  for (const [key, route] of Object.entries(routes)) {
70
+ if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
71
+ throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
72
+ }
70
73
  const isApi = key.startsWith("api/");
71
74
  if (isApi !== (route.kind === "action")) {
72
75
  throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
@@ -242,11 +245,11 @@ function authorise(args) {
242
245
  return { allow: false, status: 404, code: "NOT_FOUND" };
243
246
  }
244
247
  const policy = access.sessionPolicyFor(routeKey);
245
- if (!policy || policy.factors.length === 0)
248
+ if ((policy?.factors.length ?? 0) === 0)
246
249
  return { allow: true };
247
250
  if (!session)
248
251
  return { allow: false, status: 401, code: "AUTH_REQUIRED" };
249
- const missing = policy.factors.filter((factor) => !session.factors.includes(factor));
252
+ const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
250
253
  if (missing.length > 0) {
251
254
  return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
252
255
  }
@@ -286,6 +289,46 @@ function defineHandlers(routes, handlers) {
286
289
  }
287
290
  return handlers;
288
291
  }
292
+ function recordFromValidator(value, part) {
293
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
294
+ return value;
295
+ }
296
+ throw new AccessError("VALIDATOR_RESULT_INVALID", `The validator returned an unusable ${part} value.`);
297
+ }
298
+ function headersFromValidator(value) {
299
+ try {
300
+ return new Headers(value);
301
+ } catch {
302
+ throw new AccessError("VALIDATOR_RESULT_INVALID", "The validator returned an unusable headers value.");
303
+ }
304
+ }
305
+ function urlWithQuery(url, query) {
306
+ const validated = new URL(url);
307
+ validated.search = "";
308
+ for (const [name, value] of Object.entries(query)) {
309
+ const values = Array.isArray(value) ? value : [value];
310
+ for (const item of values) {
311
+ if (item !== undefined && item !== null)
312
+ validated.searchParams.append(name, String(item));
313
+ }
314
+ }
315
+ return validated;
316
+ }
317
+ async function responsePayload(response, routeKey) {
318
+ let text;
319
+ try {
320
+ text = await response.clone().text();
321
+ } catch {
322
+ throw new AccessError("RESPONSE_UNREADABLE", `"${routeKey}" returned a response that could not be inspected.`);
323
+ }
324
+ if (text.length === 0)
325
+ return;
326
+ try {
327
+ return JSON.parse(text);
328
+ } catch {
329
+ throw new AccessError("RESPONSE_NOT_JSON", `"${routeKey}" returned a non-JSON body for a declared response.`);
330
+ }
331
+ }
289
332
  var WINDOWS = { s: 1, m: 60, h: 3600, d: 86400 };
290
333
  function windowSeconds(window) {
291
334
  const match = /^(\d+)([smhd])$/.exec(window);
@@ -370,13 +413,15 @@ function createPipeline(options) {
370
413
  }
371
414
  }
372
415
  let body;
373
- const query = Object.fromEntries(url.searchParams);
416
+ let validatedParams = params;
417
+ let validatedQuery = Object.fromEntries(url.searchParams);
418
+ let validatedHeaders = new Headers(request.headers);
374
419
  if (validator) {
375
420
  const checks = [];
376
421
  if (route.params)
377
422
  checks.push(["params", route.params, params]);
378
423
  if (route.query)
379
- checks.push(["query", route.query, query]);
424
+ checks.push(["query", route.query, validatedQuery]);
380
425
  if (route.headers)
381
426
  checks.push(["headers", route.headers, Object.fromEntries(request.headers)]);
382
427
  if (route.body) {
@@ -393,8 +438,20 @@ function createPipeline(options) {
393
438
  errors: result2.errors
394
439
  });
395
440
  }
396
- if (where === "body")
397
- body = result2.value;
441
+ switch (where) {
442
+ case "params":
443
+ validatedParams = recordFromValidator(result2.value, "params");
444
+ break;
445
+ case "query":
446
+ validatedQuery = recordFromValidator(result2.value, "query");
447
+ break;
448
+ case "headers":
449
+ validatedHeaders = headersFromValidator(result2.value);
450
+ break;
451
+ case "body":
452
+ body = result2.value;
453
+ break;
454
+ }
398
455
  }
399
456
  } else if (route.body) {
400
457
  body = await request.json().catch(() => {
@@ -404,11 +461,11 @@ function createPipeline(options) {
404
461
  const context = {
405
462
  route: routeKey,
406
463
  method: route.method,
407
- url,
408
- params,
409
- query,
464
+ url: route.query && validator ? urlWithQuery(url, validatedQuery) : url,
465
+ params: validatedParams,
466
+ query: validatedQuery,
410
467
  body,
411
- headers: request.headers,
468
+ headers: validatedHeaders,
412
469
  session,
413
470
  realm,
414
471
  stage,
@@ -508,16 +565,19 @@ function createPipeline(options) {
508
565
  }
509
566
  const result = await (telemetry ? telemetry.span("handler", { "access.route": routeKey }, async () => handler(context, state)) : handler(context, state));
510
567
  if (validateResponses && validator && route.response) {
511
- const schema = route.response[200];
512
- if (schema && !(result instanceof Response)) {
513
- const check = validator.validate(schema, result);
514
- if (!check.ok) {
515
- throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned a shape it does not declare: ${check.errors[0]?.message}`);
516
- }
568
+ const status = result instanceof Response ? result.status : 200;
569
+ const schema = route.response[status];
570
+ if (!schema) {
571
+ throw new AccessError("RESPONSE_STATUS_UNDECLARED", `"${routeKey}" returned undeclared status ${status}.`);
572
+ }
573
+ const payload = result instanceof Response ? await responsePayload(result, routeKey) : result;
574
+ const check = validator.validate(schema, payload);
575
+ if (!check.ok) {
576
+ throw new AccessError("RESPONSE_MISMATCH", `"${routeKey}" returned status ${status} with a body that does not match its declaration.`);
517
577
  }
518
578
  }
519
579
  runAfter(result);
520
- record({ "access.outcome": "allowed", "access.status": 200 });
580
+ record({ "access.outcome": "allowed", "access.status": result instanceof Response ? result.status : 200 });
521
581
  return result instanceof Response ? result : new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
522
582
  };
523
583
  }
@@ -0,0 +1,41 @@
1
+ import { type PrincipalAssignment, type RbacPrincipal } from './principal';
2
+ export interface HeaderIdentity {
3
+ subject: string;
4
+ /** Authentication facts established by the source, never RBAC assignments. */
5
+ claims?: Readonly<Record<string, string>>;
6
+ }
7
+ /** A header is transport; this verifier is the authentication boundary. */
8
+ export interface HeaderIdentitySource {
9
+ key: string;
10
+ header: string;
11
+ verify(input: {
12
+ value: string;
13
+ request: Request;
14
+ source: string;
15
+ }): Promise<HeaderIdentity | undefined>;
16
+ }
17
+ export interface HeaderPrincipal extends RbacPrincipal {
18
+ kind: 'header-identity';
19
+ /** Stable RBAC principal key. Safe to persist in a membership row. */
20
+ principalKey: string;
21
+ source: string;
22
+ subject: string;
23
+ claims: Readonly<Record<string, string>>;
24
+ }
25
+ /**
26
+ * The host owns assignments. It may read a database, configuration service or
27
+ * code policy; the credential itself never supplies roles. This is called for
28
+ * every request so assignment, disablement and revocation take effect at once.
29
+ */
30
+ export interface HeaderAssignmentResolver {
31
+ (input: {
32
+ principalKey: string;
33
+ source: string;
34
+ subject: string;
35
+ claims: Readonly<Record<string, string>>;
36
+ request: Request;
37
+ }): Promise<readonly PrincipalAssignment[]>;
38
+ }
39
+ export declare function defineHeaderIdentitySources<const T extends readonly HeaderIdentitySource[]>(sources: T): T;
40
+ /** Resolve exactly one authenticated header source and its fresh RBAC assignment. */
41
+ export declare function headerIdentityResolver(sourcesInput: readonly HeaderIdentitySource[], resolveAssignments: HeaderAssignmentResolver): (request: Request) => Promise<HeaderPrincipal | undefined>;