@usehercules/convex 0.0.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/dist/_generated/component.d.ts +184 -0
  4. package/dist/_generated/component.d.ts.map +1 -0
  5. package/dist/_generated/component.js +11 -0
  6. package/dist/_generated/component.js.map +1 -0
  7. package/dist/checker/cli.d.ts +3 -0
  8. package/dist/checker/cli.d.ts.map +1 -0
  9. package/dist/checker/cli.js +71 -0
  10. package/dist/checker/cli.js.map +1 -0
  11. package/dist/checker/index.d.ts +28 -0
  12. package/dist/checker/index.d.ts.map +1 -0
  13. package/dist/checker/index.js +1928 -0
  14. package/dist/checker/index.js.map +1 -0
  15. package/dist/client/access-admin.d.ts +818 -0
  16. package/dist/client/access-admin.d.ts.map +1 -0
  17. package/dist/client/access-admin.js +1830 -0
  18. package/dist/client/access-admin.js.map +1 -0
  19. package/dist/client/http.d.ts +19 -0
  20. package/dist/client/http.d.ts.map +1 -0
  21. package/dist/client/http.js +76 -0
  22. package/dist/client/http.js.map +1 -0
  23. package/dist/client/index.d.ts +440 -0
  24. package/dist/client/index.d.ts.map +1 -0
  25. package/dist/client/index.js +654 -0
  26. package/dist/client/index.js.map +1 -0
  27. package/dist/component/authz.d.ts +114 -0
  28. package/dist/component/authz.d.ts.map +1 -0
  29. package/dist/component/authz.js +168 -0
  30. package/dist/component/authz.js.map +1 -0
  31. package/dist/component/checks.d.ts +86 -0
  32. package/dist/component/checks.d.ts.map +1 -0
  33. package/dist/component/checks.js +184 -0
  34. package/dist/component/checks.js.map +1 -0
  35. package/dist/component/convex.config.d.ts +3 -0
  36. package/dist/component/convex.config.d.ts.map +1 -0
  37. package/dist/component/convex.config.js +3 -0
  38. package/dist/component/convex.config.js.map +1 -0
  39. package/dist/component/effective.d.ts +82 -0
  40. package/dist/component/effective.d.ts.map +1 -0
  41. package/dist/component/effective.js +757 -0
  42. package/dist/component/effective.js.map +1 -0
  43. package/dist/component/queries.d.ts +170 -0
  44. package/dist/component/queries.d.ts.map +1 -0
  45. package/dist/component/queries.js +633 -0
  46. package/dist/component/queries.js.map +1 -0
  47. package/dist/component/schema.d.ts +258 -0
  48. package/dist/component/schema.d.ts.map +1 -0
  49. package/dist/component/schema.js +222 -0
  50. package/dist/component/schema.js.map +1 -0
  51. package/dist/component/sync.d.ts +85 -0
  52. package/dist/component/sync.d.ts.map +1 -0
  53. package/dist/component/sync.js +851 -0
  54. package/dist/component/sync.js.map +1 -0
  55. package/dist/shared/projection-protocol.d.ts +1624 -0
  56. package/dist/shared/projection-protocol.d.ts.map +1 -0
  57. package/dist/shared/projection-protocol.js +561 -0
  58. package/dist/shared/projection-protocol.js.map +1 -0
  59. package/dist/shared/sync.d.ts +24 -0
  60. package/dist/shared/sync.d.ts.map +1 -0
  61. package/dist/shared/sync.js +18 -0
  62. package/dist/shared/sync.js.map +1 -0
  63. package/dist/shared/token.d.ts +5 -0
  64. package/dist/shared/token.d.ts.map +1 -0
  65. package/dist/shared/token.js +19 -0
  66. package/dist/shared/token.js.map +1 -0
  67. package/package.json +89 -0
@@ -0,0 +1,757 @@
1
+ import { actionMatches, isOwnerOnlyLever, MANAGE_ACTION, WILDCARD_ACTION, } from "./authz";
2
+ import { parseTokenIdentifier } from "../shared/token";
3
+ import schema from "./schema";
4
+ const DEFAULT_SCOPE_SENTINEL = "__hercules_default_scope__";
5
+ const MAX_AUTHORIZATION_ANCESTORS = 10;
6
+ export function normalizeAuthorizationAncestors(ancestors) {
7
+ if ((ancestors?.length ?? 0) > MAX_AUTHORIZATION_ANCESTORS) {
8
+ return null;
9
+ }
10
+ const normalized = [];
11
+ const seen = new Set();
12
+ for (const ancestor of ancestors ?? []) {
13
+ if (ancestor.resourceType.length === 0 || ancestor.resourceId.length === 0) {
14
+ return null;
15
+ }
16
+ const key = `${ancestor.resourceType}\0${ancestor.resourceId}`;
17
+ if (seen.has(key))
18
+ continue;
19
+ seen.add(key);
20
+ normalized.push(ancestor);
21
+ }
22
+ return normalized;
23
+ }
24
+ export async function evaluateEffectiveAccess(ctx, args) {
25
+ if (!args.tokenIdentifier) {
26
+ return deny("missing_identity");
27
+ }
28
+ const token = parseTokenIdentifier(args.tokenIdentifier);
29
+ if (!token) {
30
+ return deny("invalid_identity");
31
+ }
32
+ const state = await ctx.db.query("sync_state").unique();
33
+ if (!state) {
34
+ return deny("mirror_not_ready");
35
+ }
36
+ if (token.issuer !== state.expectedIssuer) {
37
+ return deny("unexpected_issuer");
38
+ }
39
+ if (!args.scopeId) {
40
+ return deny("scope_missing", state.sourceVersion);
41
+ }
42
+ if (args.resourceId !== undefined && args.resourceType === undefined) {
43
+ return deny("invalid_request", state.sourceVersion);
44
+ }
45
+ const ancestors = normalizeAuthorizationAncestors(args.ancestors);
46
+ if (ancestors === null) {
47
+ return deny("invalid_request", state.sourceVersion);
48
+ }
49
+ const defaultScope = await ctx.db
50
+ .query("scopes")
51
+ .withIndex("by_kind", (q) => q.eq("kind", "default"))
52
+ .unique();
53
+ if (!defaultScope) {
54
+ return deny("default_scope_missing", state.sourceVersion);
55
+ }
56
+ const effectiveScopeId = args.scopeId === DEFAULT_SCOPE_SENTINEL ? defaultScope.accessScopeId : args.scopeId;
57
+ const scope = effectiveScopeId === defaultScope.accessScopeId
58
+ ? defaultScope
59
+ : await ctx.db
60
+ .query("scopes")
61
+ .withIndex("by_scope_id", (q) => q.eq("accessScopeId", effectiveScopeId))
62
+ .unique();
63
+ if (!scope) {
64
+ return deny("scope_missing", state.sourceVersion);
65
+ }
66
+ if (scope.status === "disabled") {
67
+ return deny("scope_disabled", state.sourceVersion);
68
+ }
69
+ const principal = await ctx.db
70
+ .query("principals")
71
+ .withIndex("by_scope_auth_user", (q) => q.eq("accessScopeId", scope.accessScopeId).eq("herculesAuthUserId", token.subject))
72
+ .unique();
73
+ if (!principal) {
74
+ return deny("principal_missing", state.sourceVersion);
75
+ }
76
+ // E1 (impersonation fence, defense in depth): the caller is authorized AS a
77
+ // user. The by_scope_auth_user lookup is keyed only on (scope,
78
+ // herculesAuthUserId); a group principal that smuggled a victim's
79
+ // herculesAuthUserId would resolve here. The parse fence rejects that payload,
80
+ // but require type==="user" at the resolution point too so a group principal
81
+ // is never authorized as a user.
82
+ if (principal.type !== "user") {
83
+ return deny("principal_missing", state.sourceVersion);
84
+ }
85
+ if (principal.status !== "active") {
86
+ return deny(`principal_${principal.status}`, state.sourceVersion, principal.principalId);
87
+ }
88
+ // H2 cross-scope fence (app-level standing): acting in a non-default scope
89
+ // ALSO requires an ACTIVE user principal in the default (app) scope. The
90
+ // control plane enforces this on every app-user mutation
91
+ // (loadActivePrincipalByAuthUser); without the same fence here, a user
92
+ // blocked/suspended/removed at the app level would keep full org-scope
93
+ // access at runtime until each org membership was also revoked. Fail
94
+ // closed: a missing or non-user app-scope principal denies too.
95
+ if (scope.accessScopeId !== defaultScope.accessScopeId) {
96
+ const appPrincipal = await ctx.db
97
+ .query("principals")
98
+ .withIndex("by_scope_auth_user", (q) => q.eq("accessScopeId", defaultScope.accessScopeId).eq("herculesAuthUserId", token.subject))
99
+ .unique();
100
+ if (!appPrincipal || appPrincipal.type !== "user") {
101
+ return deny("app_principal_missing", state.sourceVersion, principal.principalId);
102
+ }
103
+ if (appPrincipal.status !== "active") {
104
+ return deny(`app_principal_${appPrincipal.status}`, state.sourceVersion, principal.principalId);
105
+ }
106
+ }
107
+ // The permission catalog is deployment-wide; rows are pinned to the default
108
+ // scope id (schema note on `permissions`), so this default-scope read returns
109
+ // the whole catalog.
110
+ const catalogPermissions = await ctx.db
111
+ .query("permissions")
112
+ .withIndex("by_scope", (q) => q.eq("accessScopeId", defaultScope.accessScopeId))
113
+ .collect();
114
+ // permissionId -> (resourceType, action) lookup for translating role and
115
+ // direct-permission bindings (which reference a permissionId) into canonical
116
+ // entries.
117
+ const permissionById = new Map(catalogPermissions.map((permission) => [
118
+ permission.permissionId,
119
+ { resourceType: permission.resourceType, action: permission.action },
120
+ ]));
121
+ const principalIds = await collectPrincipalIds(ctx, {
122
+ principalId: principal.principalId,
123
+ scopeId: scope.accessScopeId,
124
+ });
125
+ const grantContributions = await collectGrantContributions(ctx, {
126
+ principalIds,
127
+ scopeId: scope.accessScopeId,
128
+ resourceType: args.resourceType,
129
+ resourceId: args.resourceId,
130
+ ancestors,
131
+ permissionById,
132
+ });
133
+ // §0b: resolve the principal's wildcard mode from its effective roles.
134
+ // immutable (Owner) dominates default (Admin) dominates none.
135
+ const wildcard = await resolvePrincipalWildcard(ctx, {
136
+ roleIds: grantContributions.roleIds,
137
+ targetScopeId: scope.accessScopeId,
138
+ });
139
+ // Role-permission rows resolve to a net {allow, deny} contribution per role
140
+ // (allow adds / deny removes, org-scope override layered on top). The net
141
+ // allow set is emitted as type-level allow entries. For an un-narrowed Admin
142
+ // (wildcard "default"), its net deny set is also emitted as type-level deny
143
+ // entries so an explicit narrowing deny short-circuits at authz step 3 before
144
+ // the Admin default-allow at step 4 — mirroring the canonical query.ts deny
145
+ // subtraction. (For "none" roles the deny set is a pure within-role override
146
+ // already folded into the allow set, so it need not be emitted; Owner is
147
+ // immutable and short-circuits, so its deny is moot.) The cross-layer
148
+ // deny-override (authz step 3) is otherwise driven by direct-permission and
149
+ // resource deny grants, which short-circuit globally.
150
+ const roleEntries = await collectRolePermissionEntries(ctx, {
151
+ roleIds: grantContributions.roleIds,
152
+ targetScopeId: scope.accessScopeId,
153
+ permissionById,
154
+ });
155
+ // A role bound to a single resource (a role_binding carrying resourceType)
156
+ // grants that role's permission set, but scoped to the resource. Expand each
157
+ // such binding's role contribution into instance/type-level entries
158
+ // (mirroring collectRolePermissionEntries, then re-targeting from scope-level
159
+ // to the resource target).
160
+ const resourceRoleEntries = await collectResourceRoleEntries(ctx, {
161
+ grants: grantContributions.resourceRoleGrants,
162
+ targetScopeId: scope.accessScopeId,
163
+ targetResourceType: args.resourceType,
164
+ targetResourceId: args.resourceId,
165
+ permissionById,
166
+ });
167
+ const entries = [
168
+ ...roleEntries,
169
+ ...resourceRoleEntries,
170
+ ...grantContributions.entries,
171
+ ];
172
+ return {
173
+ allowed: true,
174
+ reasonCode: "allowed",
175
+ sourceVersion: state.sourceVersion,
176
+ scopeId: scope.accessScopeId,
177
+ principalId: principal.principalId,
178
+ effectiveRoleIds: grantContributions.roleIds,
179
+ catalogPermissions: catalogPermissions.map((permission) => ({
180
+ permissionId: permission.permissionId,
181
+ key: permission.key,
182
+ resourceType: permission.resourceType,
183
+ action: permission.action,
184
+ classification: permission.classification,
185
+ })),
186
+ wildcard,
187
+ entries,
188
+ };
189
+ }
190
+ /**
191
+ * Grant-side superset action tokens (`manage`, `*`) are never runtime-
192
+ * checkable: can() requests carry concrete verbs only, so the authorize gate
193
+ * (checks.ts evaluatePermissionDecision) rejects a request whose resolved
194
+ * catalog action is a superset token with `invalid_request` — even for an
195
+ * Owner. Shared by that gate and {@link enumeratePermissions} so the two stay
196
+ * consistent by construction.
197
+ */
198
+ export function isSupersetAction(action) {
199
+ return action === MANAGE_ACTION || action === WILDCARD_ACTION;
200
+ }
201
+ /**
202
+ * Enumerate the catalog permissions this principal can exercise, by set-
203
+ * membership over the assembled entries — matching the canonical platform query
204
+ * (selectEffectivePermissionsByPrincipalIds). A catalog permission is reported
205
+ * when:
206
+ * - Owner (immutable) → always (the whole catalog).
207
+ * - Admin (default) → unless it is an Owner-only lever or is denied by a
208
+ * matching deny entry (a narrowing role-permission deny or a direct/resource
209
+ * deny).
210
+ * - else / additionally → there is a matching allow entry whose action
211
+ * covers the catalog permission, and no matching deny entry overrides it.
212
+ *
213
+ * Superset-action catalog keys (`:manage`, `:*`) are control-plane management
214
+ * gates, not runtime-checkable permissions: the authorize gate rejects them
215
+ * with `invalid_request` ({@link isSupersetAction}), so they are excluded here
216
+ * for every wildcard mode — getEffectivePermissions must never advertise a key
217
+ * the runtime will then deny. The capability such a grant confers is still
218
+ * fully reported: a `manage`/`*` allow entry expands onto the concrete-verb
219
+ * catalog keys it covers via actionMatches.
220
+ */
221
+ export function enumeratePermissions(catalogPermissions, wildcard, entries, args) {
222
+ const entryMatchesPermission = (entry, permission) => {
223
+ if (entry.objectType === "resource" && entry.objectId !== args.resourceId) {
224
+ return false;
225
+ }
226
+ return ((entry.resourceType === WILDCARD_ACTION || entry.resourceType === permission.resourceType) &&
227
+ actionMatches(entry.action, permission.action));
228
+ };
229
+ const isDenied = (permission) => entries.some((entry) => entry.effect === "deny" && entryMatchesPermission(entry, permission));
230
+ const isAllowed = (permission) => entries.some((entry) => entry.effect === "allow" && entryMatchesPermission(entry, permission));
231
+ return catalogPermissions
232
+ .filter((permission) => {
233
+ // Control-plane-only keys: never advertised at runtime (see doc above).
234
+ if (isSupersetAction(permission.action))
235
+ return false;
236
+ if (wildcard === "immutable")
237
+ return true;
238
+ if (isDenied(permission))
239
+ return false;
240
+ const ownerOnly = isOwnerOnlyLever({
241
+ resourceType: permission.resourceType,
242
+ action: permission.action,
243
+ classification: permission.classification,
244
+ });
245
+ if (wildcard === "default" && !ownerOnly) {
246
+ return true;
247
+ }
248
+ if (ownerOnly)
249
+ return false;
250
+ return isAllowed(permission);
251
+ })
252
+ .map((permission) => ({
253
+ permissionId: permission.permissionId,
254
+ key: permission.key,
255
+ resourceType: permission.resourceType,
256
+ action: permission.action,
257
+ classification: permission.classification,
258
+ }))
259
+ .sort((a, b) => a.key.localeCompare(b.key) || a.permissionId.localeCompare(b.permissionId));
260
+ }
261
+ // Exported so the consumer-plane role-listing query (queries.collectPrincipalScopeRoles)
262
+ // shares the SAME E3 blocked-group fence rather than re-expanding memberships
263
+ // without checking the group principal is an active, in-scope group.
264
+ export async function collectPrincipalIds(ctx, args) {
265
+ const principalIds = new Set([args.principalId]);
266
+ const memberships = await ctx.db
267
+ .query("principal_memberships")
268
+ .withIndex("by_member", (q) => q.eq("accessScopeId", args.scopeId).eq("memberPrincipalId", args.principalId))
269
+ .collect();
270
+ for (const membership of memberships) {
271
+ // E3 (blocked group fence): a membership only confers the group's authority
272
+ // when the group principal actually exists, is a group, lives in this scope,
273
+ // and is active. A blocked/suspended/removed group, a deleted group, or a row
274
+ // that points at a non-group principal must grant nothing. The membership query
275
+ // is already scope-pinned, so a same-id group resolution confirms scope.
276
+ const groupPrincipal = await ctx.db
277
+ .query("principals")
278
+ .withIndex("by_principal_id", (q) => q.eq("principalId", membership.groupPrincipalId))
279
+ .unique();
280
+ if (groupPrincipal &&
281
+ groupPrincipal.type === "group" &&
282
+ groupPrincipal.accessScopeId === args.scopeId &&
283
+ groupPrincipal.status === "active") {
284
+ principalIds.add(membership.groupPrincipalId);
285
+ }
286
+ }
287
+ return [...principalIds];
288
+ }
289
+ async function collectGrantContributions(ctx, args) {
290
+ // sync.ts schedules an exact-identity binding deletion at expiresAt so this
291
+ // query is reactively invalidated. Keep the timestamp check as a fail-closed
292
+ // fallback if the scheduled mutation is delayed.
293
+ const now = Date.now();
294
+ const principalIds = new Set(args.principalIds);
295
+ const scopeRoleIds = new Set();
296
+ const entries = [];
297
+ const resourceRoleGrants = [];
298
+ const seenRoleBindings = new Set();
299
+ const seenPermissionBindings = new Set();
300
+ const targets = dedupeBindingTargets([
301
+ { inherited: false },
302
+ ...(args.resourceType
303
+ ? [
304
+ { resourceType: args.resourceType, inherited: false },
305
+ ...(args.resourceId
306
+ ? [
307
+ {
308
+ resourceType: args.resourceType,
309
+ resourceId: args.resourceId,
310
+ inherited: false,
311
+ },
312
+ ]
313
+ : []),
314
+ ]
315
+ : []),
316
+ ...args.ancestors.map((ancestor) => ({ ...ancestor, inherited: true })),
317
+ ]);
318
+ for (const target of targets) {
319
+ for (const principalId of principalIds) {
320
+ const [roleBindings, permissionBindings] = await Promise.all([
321
+ ctx.db
322
+ .query("role_bindings")
323
+ .withIndex("by_subject_scope_resource", (q) => q
324
+ .eq("subjectPrincipalId", principalId)
325
+ .eq("accessScopeId", args.scopeId)
326
+ .eq("resourceType", target.resourceType)
327
+ .eq("resourceId", target.resourceId))
328
+ .collect(),
329
+ ctx.db
330
+ .query("permission_bindings")
331
+ .withIndex("by_subject_principal_scope_resource", (q) => q
332
+ .eq("subjectPrincipalId", principalId)
333
+ .eq("accessScopeId", args.scopeId)
334
+ .eq("resourceType", target.resourceType)
335
+ .eq("resourceId", target.resourceId))
336
+ .collect(),
337
+ ]);
338
+ collectRoleBindings(roleBindings, target);
339
+ collectDirectPermissionBindings(permissionBindings, target);
340
+ }
341
+ }
342
+ const candidateRoleIds = new Set([
343
+ ...scopeRoleIds,
344
+ ...resourceRoleGrants.map((grant) => grant.roleId),
345
+ ]);
346
+ const validRoleIds = new Set();
347
+ for (const roleId of candidateRoleIds) {
348
+ const role = await ctx.db
349
+ .query("roles")
350
+ .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
351
+ .unique();
352
+ if (role)
353
+ validRoleIds.add(roleId);
354
+ }
355
+ const effectiveRoleIds = [...scopeRoleIds].filter((roleId) => validRoleIds.has(roleId));
356
+ const resourceRoleIds = new Set(resourceRoleGrants
357
+ .map((grant) => grant.roleId)
358
+ .filter((roleId) => validRoleIds.has(roleId)));
359
+ // Resource-scoped roles participate in role-subject rules for the bounded
360
+ // resource targets in this evaluation, but stay out of effectiveRoleIds so
361
+ // they cannot acquire scope-wide role permissions or wildcard behavior.
362
+ for (const target of targets) {
363
+ const applicableRoleIds = target.resourceType === undefined
364
+ ? new Set(effectiveRoleIds)
365
+ : new Set([...effectiveRoleIds, ...resourceRoleIds]);
366
+ for (const roleId of applicableRoleIds) {
367
+ const bindings = await ctx.db
368
+ .query("permission_bindings")
369
+ .withIndex("by_subject_role_scope_resource", (q) => q
370
+ .eq("subjectRoleId", roleId)
371
+ .eq("accessScopeId", args.scopeId)
372
+ .eq("resourceType", target.resourceType)
373
+ .eq("resourceId", target.resourceId))
374
+ .collect();
375
+ collectDirectPermissionBindings(bindings, target);
376
+ }
377
+ }
378
+ return {
379
+ roleIds: effectiveRoleIds,
380
+ entries,
381
+ resourceRoleGrants: resourceRoleGrants.filter((grant) => validRoleIds.has(grant.roleId)),
382
+ };
383
+ // A resource-target role binding scopes the role to instances of
384
+ // resourceType. Captured for downstream role-permission expansion against the
385
+ // target (needs role-permission + scope context). A type-wide binding
386
+ // (resourceId undefined) expands as a type-level entry; a specific binding as
387
+ // an instance-level entry.
388
+ function collectRoleBindings(bindings, target) {
389
+ for (const binding of bindings) {
390
+ if (seenRoleBindings.has(binding.bindingId))
391
+ continue;
392
+ seenRoleBindings.add(binding.bindingId);
393
+ if (typeof binding.expiresAt === "number" && binding.expiresAt <= now)
394
+ continue;
395
+ if (target.inherited && binding.appliesTo !== "self_and_descendants")
396
+ continue;
397
+ if (binding.resourceType === undefined) {
398
+ scopeRoleIds.add(binding.roleId);
399
+ continue;
400
+ }
401
+ if (!binding.resourceType)
402
+ continue;
403
+ resourceRoleGrants.push({
404
+ bindingId: binding.bindingId,
405
+ roleId: binding.roleId,
406
+ effect: "allow",
407
+ resourceType: binding.resourceType,
408
+ resourceId: binding.resourceId,
409
+ inherited: target.inherited,
410
+ });
411
+ }
412
+ }
413
+ // Direct-permission bindings become entries carrying the referenced
414
+ // permission's (resourceType, action), with the resource target preserved so
415
+ // instance-level matching works (authz.entryMatches). A binding with a
416
+ // resourceType but no resourceId is type-wide => a scope/type-level entry; a
417
+ // binding with a concrete resourceId is instance-level.
418
+ function collectDirectPermissionBindings(bindings, target) {
419
+ for (const binding of bindings) {
420
+ if (seenPermissionBindings.has(binding.bindingId))
421
+ continue;
422
+ seenPermissionBindings.add(binding.bindingId);
423
+ if (typeof binding.expiresAt === "number" && binding.expiresAt <= now)
424
+ continue;
425
+ if (target.inherited && binding.appliesTo !== "self_and_descendants")
426
+ continue;
427
+ const permission = args.permissionById.get(binding.permissionId);
428
+ if (!permission)
429
+ continue;
430
+ // Fail closed: a resource-target binding MUST agree with the permission's
431
+ // resourceType. A binding whose resourceType does not match the
432
+ // permission confers NOTHING — never fall back to the permission's
433
+ // resourceType, which would silently grant the permission onto a
434
+ // foreign/garbage resource type.
435
+ if (!target.inherited &&
436
+ binding.resourceType !== undefined &&
437
+ binding.resourceType !== permission.resourceType) {
438
+ continue;
439
+ }
440
+ // A concrete resourceId stays instance-level. A scope binding
441
+ // (resourceType undefined) or a type-wide binding (resourceType set,
442
+ // resourceId undefined) is type-level.
443
+ const isInstanceLevel = args.resourceId !== undefined;
444
+ entries.push({
445
+ effect: binding.effect,
446
+ resourceType: permission.resourceType,
447
+ action: permission.action,
448
+ objectType: isInstanceLevel ? "resource" : "scope",
449
+ objectId: isInstanceLevel ? args.resourceId : undefined,
450
+ permissionId: binding.permissionId,
451
+ });
452
+ }
453
+ }
454
+ }
455
+ function dedupeBindingTargets(targets) {
456
+ const deduped = [];
457
+ const seen = new Set();
458
+ for (const target of targets) {
459
+ const key = JSON.stringify([target.resourceType, target.resourceId]);
460
+ if (seen.has(key))
461
+ continue;
462
+ seen.add(key);
463
+ deduped.push(target);
464
+ }
465
+ return deduped;
466
+ }
467
+ /**
468
+ * Resolve the union of EFFECTIVE wildcard modes across the principal's effective
469
+ * roles. immutable (Owner) dominates default (Admin) dominates none, matching
470
+ * "Owner short-circuits, Admin allows-minus-levers" when a user holds multiple
471
+ * roles.
472
+ *
473
+ * The wire never carries a pre-computed effective wildcard (the deployment-wide
474
+ * catalog role is shared across scopes). It carries only the INTRINSIC
475
+ * `baseWildcard`. The effective wildcard is derived PER SCOPE here: a
476
+ * `default`-wildcard role (Admin) stays `default` when un-narrowed in this
477
+ * scope, but downgrades to `none` when narrowed. Narrowed = the presence of an
478
+ * ALLOW row in the role's base role-permissions UNION that scope's overrides (a
479
+ * deny-only override does NOT un-narrow, because narrowing keys on the presence
480
+ * of an allow row, not the net set). This is the prior narrowed-Admin downgrade
481
+ * (rawAllow.size === 0 stays default; any allow row downgrades to none).
482
+ */
483
+ async function resolvePrincipalWildcard(ctx, args) {
484
+ let mode = "none";
485
+ for (const roleId of args.roleIds) {
486
+ const role = await ctx.db
487
+ .query("roles")
488
+ .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
489
+ .unique();
490
+ if (!role)
491
+ continue;
492
+ if (role.baseWildcard === "immutable")
493
+ return "immutable";
494
+ if (role.baseWildcard === "default") {
495
+ const contribution = await resolveRoleNetPermissionIds(ctx, {
496
+ roleId,
497
+ targetScopeId: args.targetScopeId,
498
+ });
499
+ if (!contribution || contribution.rawAllow.size === 0) {
500
+ mode = "default";
501
+ }
502
+ }
503
+ }
504
+ return mode;
505
+ }
506
+ async function collectRolePermissionEntries(ctx, args) {
507
+ const entries = [];
508
+ for (const roleId of args.roleIds) {
509
+ const role = await ctx.db
510
+ .query("roles")
511
+ .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
512
+ .unique();
513
+ if (!role)
514
+ continue;
515
+ const contribution = await resolveRoleNetPermissionIds(ctx, {
516
+ roleId,
517
+ targetScopeId: args.targetScopeId,
518
+ });
519
+ if (!contribution)
520
+ continue;
521
+ for (const permissionId of contribution.allow) {
522
+ const permission = args.permissionById.get(permissionId);
523
+ if (!permission)
524
+ continue;
525
+ entries.push({
526
+ effect: "allow",
527
+ resourceType: permission.resourceType,
528
+ action: permission.action,
529
+ objectType: "scope",
530
+ permissionId,
531
+ });
532
+ }
533
+ // Explicit deny wins across all role contributions. This mirrors the
534
+ // control-plane evaluator, where a deny from one role subtracts an allow
535
+ // from another role or a direct grant.
536
+ for (const permissionId of contribution.deny) {
537
+ const permission = args.permissionById.get(permissionId);
538
+ if (!permission)
539
+ continue;
540
+ entries.push({
541
+ effect: "deny",
542
+ resourceType: permission.resourceType,
543
+ action: permission.action,
544
+ objectType: "scope",
545
+ permissionId,
546
+ });
547
+ }
548
+ }
549
+ return entries;
550
+ }
551
+ /**
552
+ * Expand role bindings scoped to a resource target into entries. A type-wide
553
+ * binding (resourceId undefined) is a type-level entry; a specific binding is an
554
+ * instance-level entry. The binding's effect is propagated so a per-resource
555
+ * deny lands as a deny entry (and short-circuits in the deny-override algebra).
556
+ *
557
+ * Only non-system, non-wildcard roles can be bound to resources. The control
558
+ * plane (classifyResourceGrantOp + resource-effective.ts) rejects ONLY
559
+ * system-source roles on a resource and expands every other non-archived role
560
+ * onto it, so an iam-source role grant is legally accepted, stored, mirrored,
561
+ * shown, and honored there. The runtime must match: skip only `source ===
562
+ * "system"` (Owner/Admin scope memberships) so iam and tenant roles both
563
+ * expand. The wildcard check below still drops any role whose effective
564
+ * wildcard is non-`none`.
565
+ */
566
+ async function collectResourceRoleEntries(ctx, args) {
567
+ const entries = [];
568
+ for (const grant of args.grants) {
569
+ const role = await ctx.db
570
+ .query("roles")
571
+ .withIndex("by_role_id", (q) => q.eq("roleId", grant.roleId))
572
+ .unique();
573
+ if (!role)
574
+ continue;
575
+ const isInstanceLevel = grant.inherited
576
+ ? args.targetResourceId !== undefined
577
+ : grant.resourceId !== undefined;
578
+ // A resource-target binding without a concrete resourceType is malformed and
579
+ // must confer nothing. Defaulting it to the wildcard would emit an
580
+ // all-resources/all-actions entry, escalating a bare per-resource binding to
581
+ // global all-access.
582
+ if (!grant.resourceType)
583
+ continue;
584
+ // Only non-system, non-wildcard roles expand onto resources. A system role
585
+ // (Owner/Admin) is a scope membership that the control plane forbids on a
586
+ // resource, so it is skipped here too. An iam-source role IS a legal
587
+ // resource grant on the control plane, so it must expand at runtime to keep
588
+ // parity. The effective wildcard is derived per scope; an iam role's base
589
+ // wildcard is "none", so it survives, while any wildcard role is excluded.
590
+ const effectiveWildcard = await resolveEffectiveWildcard(ctx, {
591
+ role,
592
+ targetScopeId: args.targetScopeId,
593
+ });
594
+ if (role.source === "system" || effectiveWildcard !== "none")
595
+ continue;
596
+ const contribution = await resolveRoleNetPermissionIds(ctx, {
597
+ roleId: grant.roleId,
598
+ targetScopeId: args.targetScopeId,
599
+ });
600
+ if (!contribution)
601
+ continue;
602
+ for (const permissionId of contribution.allow) {
603
+ const permission = args.permissionById.get(permissionId);
604
+ if (!permission)
605
+ continue;
606
+ // The binding scopes the role to instances of resourceType, so only the
607
+ // role's permissions on that resourceType apply to a flat target. An
608
+ // inherited role is retargeted to the requested child type.
609
+ if ((!grant.inherited && permission.resourceType !== grant.resourceType) ||
610
+ (grant.inherited && permission.resourceType !== args.targetResourceType)) {
611
+ continue;
612
+ }
613
+ entries.push({
614
+ effect: grant.effect,
615
+ resourceType: permission.resourceType,
616
+ action: permission.action,
617
+ objectType: isInstanceLevel ? "resource" : "scope",
618
+ objectId: isInstanceLevel
619
+ ? grant.inherited
620
+ ? args.targetResourceId
621
+ : grant.resourceId
622
+ : undefined,
623
+ permissionId,
624
+ });
625
+ }
626
+ for (const permissionId of contribution.deny) {
627
+ const permission = args.permissionById.get(permissionId);
628
+ if (!permission ||
629
+ (!grant.inherited && permission.resourceType !== grant.resourceType) ||
630
+ (grant.inherited && permission.resourceType !== args.targetResourceType)) {
631
+ continue;
632
+ }
633
+ entries.push({
634
+ effect: "deny",
635
+ resourceType: permission.resourceType,
636
+ action: permission.action,
637
+ objectType: isInstanceLevel ? "resource" : "scope",
638
+ objectId: isInstanceLevel
639
+ ? grant.inherited
640
+ ? args.targetResourceId
641
+ : grant.resourceId
642
+ : undefined,
643
+ permissionId,
644
+ });
645
+ }
646
+ }
647
+ return entries;
648
+ }
649
+ /**
650
+ * Resolve a role's net permission contribution for the target scope: the role's
651
+ * BASE role_permission rows (deployment-wide; allow adds, deny removes), then —
652
+ * layered on top — the target scope's role_permission_overrides (base then
653
+ * override, same layering order as before). Returns null when the role row is
654
+ * missing.
655
+ */
656
+ async function resolveRoleNetPermissionIds(ctx, args) {
657
+ const role = await ctx.db
658
+ .query("roles")
659
+ .withIndex("by_role_id", (q) => q.eq("roleId", args.roleId))
660
+ .unique();
661
+ if (!role)
662
+ return null;
663
+ const contribution = { allow: new Set(), deny: new Set(), rawAllow: new Set() };
664
+ // Base map (deployment-wide).
665
+ await applyBaseRolePermissionRows(ctx, { roleId: args.roleId, contribution });
666
+ // Per-scope override, layered on top of the base (preserves per-scope
667
+ // overrides; deny wins within each layer, base folded before override).
668
+ await applyRolePermissionOverrideRows(ctx, {
669
+ accessScopeId: args.targetScopeId,
670
+ roleId: args.roleId,
671
+ contribution,
672
+ });
673
+ return contribution;
674
+ }
675
+ /**
676
+ * Fold the deployment-wide BASE role_permission rows into a {allow, deny}
677
+ * contribution: an allow row adds to allow / clears deny, a deny row adds to
678
+ * deny / clears allow. rawAllow accumulates every allow row regardless of a
679
+ * later deny.
680
+ */
681
+ async function applyBaseRolePermissionRows(ctx, args) {
682
+ const rows = await ctx.db
683
+ .query("role_permissions")
684
+ .withIndex("by_role", (q) => q.eq("roleId", args.roleId))
685
+ .collect();
686
+ applyRolePermissionRows(rows, args.contribution);
687
+ }
688
+ /**
689
+ * Fold one scope's role_permission_override rows into the contribution on top of
690
+ * the base map (same allow-adds / deny-removes semantics).
691
+ */
692
+ async function applyRolePermissionOverrideRows(ctx, args) {
693
+ const rows = await ctx.db
694
+ .query("role_permission_overrides")
695
+ .withIndex("by_scope_role", (q) => q.eq("accessScopeId", args.accessScopeId).eq("roleId", args.roleId))
696
+ .collect();
697
+ applyRolePermissionRows(rows, args.contribution);
698
+ }
699
+ /**
700
+ * Apply one layer's effect rows into a {allow, deny, rawAllow} contribution
701
+ * using canonical semantics: within the layer, deny wins regardless of row
702
+ * order, so apply the allow rows first and then let the deny rows override.
703
+ * rawAllow records every allow row's permissionId (never cleared) so the
704
+ * narrowed-Admin downgrade keys on the PRESENCE of an allow row, not the net set
705
+ * — a deny-only override therefore never un-narrows.
706
+ */
707
+ function applyRolePermissionRows(rows, contribution) {
708
+ for (const row of rows) {
709
+ if (row.effect === "allow") {
710
+ contribution.rawAllow.add(row.permissionId);
711
+ contribution.allow.add(row.permissionId);
712
+ contribution.deny.delete(row.permissionId);
713
+ }
714
+ }
715
+ for (const row of rows) {
716
+ if (row.effect === "deny") {
717
+ contribution.allow.delete(row.permissionId);
718
+ contribution.deny.add(row.permissionId);
719
+ }
720
+ }
721
+ }
722
+ /**
723
+ * Derive a single role's EFFECTIVE wildcard for the target scope from its
724
+ * intrinsic baseWildcard plus per-scope narrowing — the same derivation
725
+ * resolvePrincipalWildcard applies, exposed for the resource-role gate. Owner
726
+ * (immutable) stays immutable; a default (Admin) role stays default when
727
+ * un-narrowed and downgrades to none when narrowed (any allow row present in
728
+ * base UNION overrides); everything else is none.
729
+ */
730
+ async function resolveEffectiveWildcard(ctx, args) {
731
+ if (args.role.baseWildcard === "immutable")
732
+ return "immutable";
733
+ if (args.role.baseWildcard === "default") {
734
+ const contribution = await resolveRoleNetPermissionIds(ctx, {
735
+ roleId: args.role.roleId,
736
+ targetScopeId: args.targetScopeId,
737
+ });
738
+ if (!contribution || contribution.rawAllow.size === 0) {
739
+ return "default";
740
+ }
741
+ return "none";
742
+ }
743
+ return "none";
744
+ }
745
+ function deny(reasonCode, sourceVersion, principalId) {
746
+ return {
747
+ allowed: false,
748
+ reasonCode,
749
+ sourceVersion,
750
+ principalId,
751
+ effectiveRoleIds: [],
752
+ catalogPermissions: [],
753
+ wildcard: "none",
754
+ entries: [],
755
+ };
756
+ }
757
+ //# sourceMappingURL=effective.js.map