@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/dist/_generated/component.d.ts +184 -0
- package/dist/_generated/component.d.ts.map +1 -0
- package/dist/_generated/component.js +11 -0
- package/dist/_generated/component.js.map +1 -0
- package/dist/checker/cli.d.ts +3 -0
- package/dist/checker/cli.d.ts.map +1 -0
- package/dist/checker/cli.js +71 -0
- package/dist/checker/cli.js.map +1 -0
- package/dist/checker/index.d.ts +28 -0
- package/dist/checker/index.d.ts.map +1 -0
- package/dist/checker/index.js +1928 -0
- package/dist/checker/index.js.map +1 -0
- package/dist/client/access-admin.d.ts +818 -0
- package/dist/client/access-admin.d.ts.map +1 -0
- package/dist/client/access-admin.js +1830 -0
- package/dist/client/access-admin.js.map +1 -0
- package/dist/client/http.d.ts +19 -0
- package/dist/client/http.d.ts.map +1 -0
- package/dist/client/http.js +76 -0
- package/dist/client/http.js.map +1 -0
- package/dist/client/index.d.ts +440 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +654 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/authz.d.ts +114 -0
- package/dist/component/authz.d.ts.map +1 -0
- package/dist/component/authz.js +168 -0
- package/dist/component/authz.js.map +1 -0
- package/dist/component/checks.d.ts +86 -0
- package/dist/component/checks.d.ts.map +1 -0
- package/dist/component/checks.js +184 -0
- package/dist/component/checks.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/effective.d.ts +82 -0
- package/dist/component/effective.d.ts.map +1 -0
- package/dist/component/effective.js +757 -0
- package/dist/component/effective.js.map +1 -0
- package/dist/component/queries.d.ts +170 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +633 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +258 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +222 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sync.d.ts +85 -0
- package/dist/component/sync.d.ts.map +1 -0
- package/dist/component/sync.js +851 -0
- package/dist/component/sync.js.map +1 -0
- package/dist/shared/projection-protocol.d.ts +1624 -0
- package/dist/shared/projection-protocol.d.ts.map +1 -0
- package/dist/shared/projection-protocol.js +561 -0
- package/dist/shared/projection-protocol.js.map +1 -0
- package/dist/shared/sync.d.ts +24 -0
- package/dist/shared/sync.d.ts.map +1 -0
- package/dist/shared/sync.js +18 -0
- package/dist/shared/sync.js.map +1 -0
- package/dist/shared/token.d.ts +5 -0
- package/dist/shared/token.d.ts.map +1 -0
- package/dist/shared/token.js +19 -0
- package/dist/shared/token.js.map +1 -0
- 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
|