@fuzdev/fuz_app 0.53.0 → 0.54.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/dist/auth/CLAUDE.md +19 -5
- package/dist/auth/permit_offer_actions.d.ts.map +1 -1
- package/dist/auth/permit_offer_actions.js +9 -6
- package/dist/auth/request_context.d.ts +47 -2
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +57 -2
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +14 -11
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +12 -0
- package/package.json +1 -1
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -360,7 +360,10 @@ CRUD + listing:
|
|
|
360
360
|
- `query_permit_find_active_for_actor`, `query_permit_list_for_actor`.
|
|
361
361
|
- `query_permit_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
|
|
362
362
|
handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
|
|
363
|
-
callers keep semantics).
|
|
363
|
+
callers keep semantics). Use only when checking an arbitrary `actor_id`
|
|
364
|
+
that isn't the request actor (e.g., post-mutation verification, scripts,
|
|
365
|
+
audit-time checks). For the request actor, prefer `has_scoped_role` /
|
|
366
|
+
`has_any_scoped_role` on the in-memory `auth.permits` snapshot.
|
|
364
367
|
- `query_permit_find_account_id_for_role(deps, role)` — joins
|
|
365
368
|
permit → actor → account, returns first match. Used by daemon token
|
|
366
369
|
middleware to resolve the keeper account.
|
|
@@ -653,7 +656,17 @@ without being blocked.
|
|
|
653
656
|
identity (the audit-log SSE uses this to close only the revoked session's
|
|
654
657
|
stream on `session_revoke`).
|
|
655
658
|
- `get_request_context(c)`, `require_request_context(c)` (throws on misuse
|
|
656
|
-
— misconfigured middleware surfaces immediately)
|
|
659
|
+
— misconfigured middleware surfaces immediately).
|
|
660
|
+
- **In-memory permit predicates** — `has_role(ctx, role, now?)`,
|
|
661
|
+
`has_scoped_role(ctx, role, scope_id, now?)`,
|
|
662
|
+
`has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
|
|
663
|
+
`RequestContext | null` (null returns `false`) so they drop into
|
|
664
|
+
`auth: 'public'` handlers without a manual narrow. `scope_id === null`
|
|
665
|
+
matches global permits only; UUID matches that exact scope. Empty
|
|
666
|
+
`roles` short-circuits `has_any_scoped_role` to `false`. Decide-time
|
|
667
|
+
predicates only — the predicate / mutation race window is the same as
|
|
668
|
+
the SQL `query_permit_has_role` style and only a transactional re-check
|
|
669
|
+
inside the UPDATE/INSERT closes it.
|
|
657
670
|
- `build_request_context(deps, account_id)` — shared helper used by
|
|
658
671
|
session, bearer, and daemon token middleware; does
|
|
659
672
|
`account → actor → permits` and returns `null` if either lookup misses.
|
|
@@ -1172,9 +1185,10 @@ codegen invariant and grow the surface linearly per role.
|
|
|
1172
1185
|
`eligible_roles` is checked against `roles.role_options` at factory
|
|
1173
1186
|
time so typos throw at startup instead of at first call.
|
|
1174
1187
|
|
|
1175
|
-
Grant branch uses `
|
|
1176
|
-
(distinguishes new grant from idempotent
|
|
1177
|
-
|
|
1188
|
+
Grant branch uses `has_scoped_role(auth, role, null)` for a
|
|
1189
|
+
benign-TOCTOU pre-check (distinguishes new grant from idempotent
|
|
1190
|
+
re-grant) — reads from the in-memory `auth.permits` snapshot, no DB
|
|
1191
|
+
roundtrip — then `query_grant_permit` for the actual insert. Revoke branch filters
|
|
1178
1192
|
`query_permit_find_active_for_actor` in JS for the matching
|
|
1179
1193
|
`(actor, role, scope_id IS NULL)` row before calling
|
|
1180
1194
|
`query_revoke_permit`. Bundle is **not** included in
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permit_offer_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAa,KAAK,aAAa,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxF,OAAO,EAAmC,KAAK,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"permit_offer_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/permit_offer_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAa,KAAK,aAAa,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAGxF,OAAO,EAAmC,KAAK,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAkBzF,OAAO,EAA4B,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AACpF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAChD,OAAO,EAON,KAAK,kBAAkB,EACvB,MAAM,iCAAiC,CAAC;AAmCzC;;;;;;;;GAQG;AACH,MAAM,MAAM,0BAA0B,GAAG,CACxC,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAC,EACrE,IAAI,EAAE,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,EACnC,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,iDAAiD;AACjD,MAAM,WAAW,wBAAwB;IACxC;;;OAGG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,0BAA0B,CAAC;CACvC;AA4BD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,EAAE,0BASvC,CAAC;AAcF;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAsB,SAAQ,IAAI,CAClD,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C;IACA,+EAA+E;IAC/E,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CAChD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,qBAAqB,EAC3B,UAAS,wBAA6B,KACpC,KAAK,CAAC,SAAS,CAudjB,CAAC"}
|
|
@@ -42,10 +42,10 @@ import { emit_after_commit } from '../http/pending_effects.js';
|
|
|
42
42
|
import { BUILTIN_ROLE_OPTIONS, ROLE_ADMIN } from './role_schema.js';
|
|
43
43
|
import { PERMIT_OFFER_DEFAULT_TTL_MS, to_permit_offer_json } from './permit_offer_schema.js';
|
|
44
44
|
import { query_permit_offer_create, query_permit_offer_decline, query_permit_offer_retract, query_permit_offer_list, query_permit_offer_history_for_account, query_accept_offer, PermitOfferAlreadyTerminalError, PermitOfferExpiredError, PermitOfferNotFoundError, PermitOfferSelfTargetError, } from './permit_offer_queries.js';
|
|
45
|
-
import { query_permit_find_active_role_for_actor,
|
|
45
|
+
import { query_permit_find_active_role_for_actor, query_revoke_permit } from './permit_queries.js';
|
|
46
46
|
import { query_actor_by_id } from './account_queries.js';
|
|
47
47
|
import { audit_log_fire_and_forget } from './audit_log_queries.js';
|
|
48
|
-
import { has_role } from './request_context.js';
|
|
48
|
+
import { has_role, has_scoped_role } from './request_context.js';
|
|
49
49
|
import { build_permit_offer_accepted_notification, build_permit_offer_declined_notification, build_permit_offer_received_notification, build_permit_offer_retracted_notification, build_permit_offer_supersede_notification, build_permit_revoke_notification, } from './permit_offer_notifications.js';
|
|
50
50
|
import { ERROR_ACCOUNT_NOT_FOUND, ERROR_PERMIT_NOT_FOUND, ERROR_ROLE_NOT_WEB_GRANTABLE, } from '../http/error_schemas.js';
|
|
51
51
|
import { ERROR_OFFER_EXPIRED, ERROR_OFFER_NOT_AUTHORIZED, ERROR_OFFER_NOT_FOUND, ERROR_OFFER_ROLE_NOT_GRANTABLE, ERROR_OFFER_SELF_TARGET, ERROR_OFFER_TERMINAL, permit_offer_create_action_spec, permit_offer_accept_action_spec, permit_offer_decline_action_spec, permit_offer_retract_action_spec, permit_offer_list_action_spec, permit_offer_history_action_spec, permit_revoke_action_spec, } from './permit_offer_action_specs.js';
|
|
@@ -61,10 +61,13 @@ const fan_out_audit_events = (events, on_audit_event, log) => {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
|
-
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
65
|
+
const default_authorize = async (auth, input, _deps, _ctx) => {
|
|
65
66
|
// Caller must hold an active permit for the offered role. Global (no scope)
|
|
66
67
|
// check — the scope-aware "only in this classroom" policy is consumer-level.
|
|
67
|
-
|
|
68
|
+
// Reads from the in-memory `auth.permits` snapshot loaded once per request
|
|
69
|
+
// by `create_request_context_middleware`; no DB roundtrip needed.
|
|
70
|
+
return has_scoped_role(auth, input.role, null);
|
|
68
71
|
};
|
|
69
72
|
/**
|
|
70
73
|
* Authorization callback that admits any admin and otherwise falls back to
|
|
@@ -79,10 +82,10 @@ const default_authorize = async (auth, input, _deps, ctx) => {
|
|
|
79
82
|
* classroom_student in their own scope) wrap this and short-circuit `true`
|
|
80
83
|
* before delegating.
|
|
81
84
|
*/
|
|
82
|
-
export const authorize_admin_or_holder = async (auth, input, _deps,
|
|
85
|
+
export const authorize_admin_or_holder = async (auth, input, _deps, _ctx) => {
|
|
83
86
|
if (has_role(auth, ROLE_ADMIN))
|
|
84
87
|
return true;
|
|
85
|
-
return
|
|
88
|
+
return has_scoped_role(auth, input.role, null);
|
|
86
89
|
};
|
|
87
90
|
/**
|
|
88
91
|
* Narrow `ctx.auth` to non-null. The RPC dispatcher has already enforced
|
|
@@ -56,13 +56,58 @@ export declare const require_request_context: (c: Context) => RequestContext;
|
|
|
56
56
|
* Check if a request context has an active permit for a given role.
|
|
57
57
|
*
|
|
58
58
|
* Checks the permits already loaded in the context (no DB query).
|
|
59
|
+
* Null-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric
|
|
60
|
+
* with `has_scoped_role` / `has_any_scoped_role` so the three helpers
|
|
61
|
+
* compose freely in the same predicate (e.g.
|
|
62
|
+
* `has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).
|
|
59
63
|
*
|
|
60
|
-
* @param ctx - the request context
|
|
64
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
61
65
|
* @param role - the role to check
|
|
62
66
|
* @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
|
|
63
67
|
* @returns `true` if the actor has an active permit for the role
|
|
64
68
|
*/
|
|
65
|
-
export declare const has_role: (ctx: RequestContext, role: string, now?: Date) => boolean;
|
|
69
|
+
export declare const has_role: (ctx: RequestContext | null, role: string, now?: Date) => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the request context holds an active permit for `role` at `scope_id`.
|
|
72
|
+
*
|
|
73
|
+
* Walks the in-memory `ctx.permits` snapshot loaded once per request by
|
|
74
|
+
* `create_request_context_middleware`; zero DB roundtrip per check. The
|
|
75
|
+
* "freshness" framing of a SQL re-query is illusory because the race window
|
|
76
|
+
* is between predicate and the actual mutation, not predicate and middleware
|
|
77
|
+
* load. Closing that race needs a transactional re-check inside the
|
|
78
|
+
* UPDATE/INSERT, which neither style provides.
|
|
79
|
+
*
|
|
80
|
+
* Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same
|
|
81
|
+
* convention as `has_role`; lets the helper drop into `auth: 'public'`
|
|
82
|
+
* handlers without a manual narrow. See `cell_authorize` for the
|
|
83
|
+
* resource-side analog.
|
|
84
|
+
*
|
|
85
|
+
* `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
|
|
86
|
+
* JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
|
|
87
|
+
*
|
|
88
|
+
* - `scope_id === null` matches global permits (`scope_id IS NULL`).
|
|
89
|
+
* - `scope_id === '<uuid>'` matches permits bound to that exact scope.
|
|
90
|
+
*
|
|
91
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
92
|
+
* @param role - the role to check
|
|
93
|
+
* @param scope_id - the scope to check (`null` for global)
|
|
94
|
+
* @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
|
|
95
|
+
* @returns `true` iff the actor holds an active permit for the role at the requested scope
|
|
96
|
+
*/
|
|
97
|
+
export declare const has_scoped_role: (ctx: RequestContext | null, role: string, scope_id: string | null, now?: Date) => boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Whether the request context holds an active permit for any role in `roles`
|
|
100
|
+
* at `scope_id`. Empty `roles` short-circuits to `false` — documents intent
|
|
101
|
+
* at the call site ("zero roles trivially admit no-one"). Same scope and
|
|
102
|
+
* null-tolerance semantics as `has_scoped_role`.
|
|
103
|
+
*
|
|
104
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
105
|
+
* @param roles - the roles that would admit the caller (any-of)
|
|
106
|
+
* @param scope_id - the scope to check (`null` for global)
|
|
107
|
+
* @param now - current time (defaults to `new Date()`, pass for testability)
|
|
108
|
+
* @returns `true` iff the actor holds an active permit for any role in `roles` at the requested scope
|
|
109
|
+
*/
|
|
110
|
+
export declare const has_any_scoped_role: (ctx: RequestContext | null, roles: ReadonlyArray<string>, scope_id: string | null, now?: Date) => boolean;
|
|
66
111
|
/**
|
|
67
112
|
* Create middleware that builds the request context from a session cookie.
|
|
68
113
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,QAAQ,GACpB,KAAK,cAAc,GAAG,IAAI,EAC1B,MAAM,MAAM,EACZ,MAAK,IAAiB,KACpB,OAAyF,CAAC;AAE7F;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,GAAG,IAAI,EAC1B,MAAM,MAAM,EACZ,UAAU,MAAM,GAAG,IAAI,EACvB,MAAK,IAAiB,KACpB,OAKF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,cAAc,GAAG,IAAI,EAC1B,OAAO,aAAa,CAAC,MAAM,CAAC,EAC5B,UAAU,MAAM,GAAG,IAAI,EACvB,MAAK,IAAiB,KACpB,OAMF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBA6CF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
|
|
@@ -60,13 +60,68 @@ export const require_request_context = (c) => {
|
|
|
60
60
|
* Check if a request context has an active permit for a given role.
|
|
61
61
|
*
|
|
62
62
|
* Checks the permits already loaded in the context (no DB query).
|
|
63
|
+
* Null-tolerant — `null` ctx (unauthenticated) returns `false`. Symmetric
|
|
64
|
+
* with `has_scoped_role` / `has_any_scoped_role` so the three helpers
|
|
65
|
+
* compose freely in the same predicate (e.g.
|
|
66
|
+
* `has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)`).
|
|
63
67
|
*
|
|
64
|
-
* @param ctx - the request context
|
|
68
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
65
69
|
* @param role - the role to check
|
|
66
70
|
* @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
|
|
67
71
|
* @returns `true` if the actor has an active permit for the role
|
|
68
72
|
*/
|
|
69
|
-
export const has_role = (ctx, role, now = new Date()) => ctx
|
|
73
|
+
export const has_role = (ctx, role, now = new Date()) => ctx?.permits.some((p) => p.role === role && is_permit_active(p, now)) ?? false;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the request context holds an active permit for `role` at `scope_id`.
|
|
76
|
+
*
|
|
77
|
+
* Walks the in-memory `ctx.permits` snapshot loaded once per request by
|
|
78
|
+
* `create_request_context_middleware`; zero DB roundtrip per check. The
|
|
79
|
+
* "freshness" framing of a SQL re-query is illusory because the race window
|
|
80
|
+
* is between predicate and the actual mutation, not predicate and middleware
|
|
81
|
+
* load. Closing that race needs a transactional re-check inside the
|
|
82
|
+
* UPDATE/INSERT, which neither style provides.
|
|
83
|
+
*
|
|
84
|
+
* Null-tolerant — `null` ctx (unauthenticated) returns `false`. Same
|
|
85
|
+
* convention as `has_role`; lets the helper drop into `auth: 'public'`
|
|
86
|
+
* handlers without a manual narrow. See `cell_authorize` for the
|
|
87
|
+
* resource-side analog.
|
|
88
|
+
*
|
|
89
|
+
* `scope_id` semantics: in-memory `permit.scope_id` is `string | null`, so
|
|
90
|
+
* JS `===` matches the SQL `IS NOT DISTINCT FROM` semantics exactly:
|
|
91
|
+
*
|
|
92
|
+
* - `scope_id === null` matches global permits (`scope_id IS NULL`).
|
|
93
|
+
* - `scope_id === '<uuid>'` matches permits bound to that exact scope.
|
|
94
|
+
*
|
|
95
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
96
|
+
* @param role - the role to check
|
|
97
|
+
* @param scope_id - the scope to check (`null` for global)
|
|
98
|
+
* @param now - current time (defaults to `new Date()`, pass for testability and hot-path efficiency)
|
|
99
|
+
* @returns `true` iff the actor holds an active permit for the role at the requested scope
|
|
100
|
+
*/
|
|
101
|
+
export const has_scoped_role = (ctx, role, scope_id, now = new Date()) => {
|
|
102
|
+
if (!ctx)
|
|
103
|
+
return false;
|
|
104
|
+
return ctx.permits.some((p) => p.role === role && p.scope_id === scope_id && is_permit_active(p, now));
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Whether the request context holds an active permit for any role in `roles`
|
|
108
|
+
* at `scope_id`. Empty `roles` short-circuits to `false` — documents intent
|
|
109
|
+
* at the call site ("zero roles trivially admit no-one"). Same scope and
|
|
110
|
+
* null-tolerance semantics as `has_scoped_role`.
|
|
111
|
+
*
|
|
112
|
+
* @param ctx - the request context, or `null` for unauthenticated callers
|
|
113
|
+
* @param roles - the roles that would admit the caller (any-of)
|
|
114
|
+
* @param scope_id - the scope to check (`null` for global)
|
|
115
|
+
* @param now - current time (defaults to `new Date()`, pass for testability)
|
|
116
|
+
* @returns `true` iff the actor holds an active permit for any role in `roles` at the requested scope
|
|
117
|
+
*/
|
|
118
|
+
export const has_any_scoped_role = (ctx, roles, scope_id, now = new Date()) => {
|
|
119
|
+
if (!ctx)
|
|
120
|
+
return false;
|
|
121
|
+
if (roles.length === 0)
|
|
122
|
+
return false;
|
|
123
|
+
return ctx.permits.some((p) => roles.includes(p.role) && p.scope_id === scope_id && is_permit_active(p, now));
|
|
124
|
+
};
|
|
70
125
|
/**
|
|
71
126
|
* Create middleware that builds the request context from a session cookie.
|
|
72
127
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAYhD,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC3C,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C,CAAC;AAOF;;;;;;;GAOG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,yBAAyB,EAC/B,SAAS,6BAA6B,KACpC,KAAK,CAAC,SAAS,CA+GjB,CAAC"}
|
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { rpc_action } from '../actions/action_rpc.js';
|
|
35
35
|
import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
|
|
36
|
-
import { query_grant_permit,
|
|
36
|
+
import { query_grant_permit, query_revoke_permit } from './permit_queries.js';
|
|
37
37
|
import { audit_log_fire_and_forget } from './audit_log_queries.js';
|
|
38
|
+
import { is_permit_active } from './account_schema.js';
|
|
39
|
+
import { has_scoped_role } from './request_context.js';
|
|
38
40
|
import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec, } from './self_service_role_action_specs.js';
|
|
39
41
|
const require_request_auth = (auth) => {
|
|
40
42
|
if (!auth)
|
|
@@ -73,13 +75,13 @@ export const create_self_service_role_actions = (deps, options) => {
|
|
|
73
75
|
// Pre-check for idempotent re-grant. `query_grant_permit` is itself
|
|
74
76
|
// idempotent (returns the existing permit instead of inserting), but
|
|
75
77
|
// it doesn't signal "already existed" vs "newly inserted" — so we
|
|
76
|
-
// peek first.
|
|
77
|
-
//
|
|
78
|
+
// peek first. Reads from the in-memory `auth.permits` snapshot
|
|
79
|
+
// (no DB roundtrip). The TOCTOU window is benign for self-service:
|
|
80
|
+
// two concurrent grants both observe "no permit", both call
|
|
78
81
|
// `query_grant_permit`, and one collapses onto the other inside the
|
|
79
82
|
// query's `ON CONFLICT DO NOTHING`. Worst case both responses report
|
|
80
83
|
// `changed: true`; the DB still ends up with exactly one permit.
|
|
81
|
-
|
|
82
|
-
if (already) {
|
|
84
|
+
if (has_scoped_role(auth, input.role, null)) {
|
|
83
85
|
return { ok: true, enabled: true, changed: false };
|
|
84
86
|
}
|
|
85
87
|
const permit = await query_grant_permit(ctx, {
|
|
@@ -103,12 +105,13 @@ export const create_self_service_role_actions = (deps, options) => {
|
|
|
103
105
|
}, deps);
|
|
104
106
|
return { ok: true, enabled: true, changed: true };
|
|
105
107
|
}
|
|
106
|
-
// Find an active global permit for this (actor, role)
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
const
|
|
108
|
+
// Find an active global permit for this (actor, role) in the in-memory
|
|
109
|
+
// `auth.permits` snapshot. No DB roundtrip — same correctness-equivalent
|
|
110
|
+
// pattern as `has_scoped_role` above (race window is between predicate
|
|
111
|
+
// and `query_revoke_permit`'s actual UPDATE, not between predicate and
|
|
112
|
+
// middleware load).
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const target = auth.permits.find((p) => p.role === input.role && p.scope_id === null && is_permit_active(p, now));
|
|
112
115
|
if (!target) {
|
|
113
116
|
return { ok: true, enabled: false, changed: false };
|
|
114
117
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAShE,CAAC;AA+FF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,
|
|
1
|
+
{"version":3,"file":"schema_generators.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/schema_generators.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AACtB,OAAO,EAKN,KAAK,YAAY,EACjB,MAAM,yBAAyB,CAAC;AAIjC;;;GAGG;AACH,eAAO,MAAM,aAAa,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,MAAM,GAAG,IAShE,CAAC;AA+FF,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,GAAI,OAAO,YAAY,EAAE,cAAc,CAAC,CAAC,OAAO,KAAG,OA+EnF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,gBAAgB,CAAC,CAAC,SAAS,KAAG,MAa9E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,cAAc,CAAC,CAAC,OAAO,KACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAkB5B,CAAC"}
|
|
@@ -182,6 +182,18 @@ export const generate_valid_value = (field, field_schema) => {
|
|
|
182
182
|
}
|
|
183
183
|
return 'test_value';
|
|
184
184
|
}
|
|
185
|
+
case 'literal': {
|
|
186
|
+
// Zod 4 stores literal values on `def.values` (always an array, even
|
|
187
|
+
// for single-valued literals). Returning the first literal satisfies
|
|
188
|
+
// `z.literal('foo')` as well as required discriminator fields in
|
|
189
|
+
// `z.discriminatedUnion` variants — without this branch the literal
|
|
190
|
+
// would fall through to the default and break parse.
|
|
191
|
+
const literal_def = zod_unwrap_def(field_schema);
|
|
192
|
+
if (literal_def.values && literal_def.values.length > 0) {
|
|
193
|
+
return literal_def.values[0];
|
|
194
|
+
}
|
|
195
|
+
return 'test_value';
|
|
196
|
+
}
|
|
185
197
|
case 'enum': {
|
|
186
198
|
const enum_def = zod_unwrap_def(field_schema);
|
|
187
199
|
if ('entries' in enum_def) {
|