@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 +42 -2
- package/dist/conditions.js +11 -5
- package/dist/effects.js +7 -4
- package/dist/elysia.js +79 -19
- package/dist/fetch.js +79 -19
- package/dist/header.d.ts +41 -0
- package/dist/header.js +432 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -4
- package/dist/pipeline.js +79 -19
- package/dist/principal-session.d.ts +47 -0
- package/dist/principal-session.js +548 -0
- package/dist/principal.d.ts +56 -0
- package/dist/principal.js +366 -0
- package/dist/testing.js +7 -4
- package/package.json +23 -10
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
|
-
|
|
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.
|
package/dist/conditions.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 (
|
|
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
|
|
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) =>
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
397
|
-
|
|
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:
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
397
|
-
|
|
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:
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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/header.d.ts
ADDED
|
@@ -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>;
|