@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,654 @@
|
|
|
1
|
+
import { ConvexError } from "convex/values";
|
|
2
|
+
// Hard cap on resource-hierarchy depth: a request authorizes against the
|
|
3
|
+
// resource plus at most this many ancestors. Generous for real nesting
|
|
4
|
+
// (folder/file, project/task/comment) while bounding the per-call check count.
|
|
5
|
+
const MAX_AUTHORIZE_CHAIN = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Wires Hercules managed Access Control into a Convex app. Call once in
|
|
8
|
+
* `convex/hercules.ts`, passing the generated `query`/`mutation`/`action`
|
|
9
|
+
* builders and `components`, then re-export the returned builders.
|
|
10
|
+
*
|
|
11
|
+
* Returned builders:
|
|
12
|
+
* - `publicQuery`/`publicMutation`/`publicAction`: no auth.
|
|
13
|
+
* - `authenticatedQuery`/`...Mutation`/`...Action`: require sign-in only.
|
|
14
|
+
* - `accessQuery`/`accessMutation`/`accessAction`: enforce a permission in a
|
|
15
|
+
* scope. Pass `{ permission, scope }`; resolve `scope` with `scopeFromArg`
|
|
16
|
+
* or `scopeFromResource`. Use these for all org-owned reads and writes.
|
|
17
|
+
* - `hasPermission`/`requirePermission`/`requireAnyPermission`/
|
|
18
|
+
* `getEffectivePermissions`: in-handler checks. `getEffectivePermissions`
|
|
19
|
+
* and `hasPermission` accept an optional `{ resource }` ref for per-resource
|
|
20
|
+
* (e.g. per-project) checks.
|
|
21
|
+
* - `getCurrentHerculesAuthUserId`: the verified OIDC subject for linking
|
|
22
|
+
* app-owned domain rows. Do not parse `tokenIdentifier`.
|
|
23
|
+
* - `listMyMemberships`/`listMyRoles`: the caller's own scopes/roles.
|
|
24
|
+
* - `listScopeMembers`/`listScopeRoles`/`listScopePermissions`: complete
|
|
25
|
+
* mirrored admin reads for an in-app management screen. Each self-gates on
|
|
26
|
+
* the matching `system.*:read` permission and returns `[]` when the caller
|
|
27
|
+
* lacks it. Use `createAccessUserActions().listGrantableRoles` instead when
|
|
28
|
+
* choosing a role for a write at an exact target.
|
|
29
|
+
*
|
|
30
|
+
* Reads resolve against the app's local Access Control mirror, which lags the
|
|
31
|
+
* control plane by a short projection-sync window after any change.
|
|
32
|
+
*/
|
|
33
|
+
export function createAccessControl(options) {
|
|
34
|
+
const component = resolveComponent(options);
|
|
35
|
+
return {
|
|
36
|
+
publicQuery: options.query,
|
|
37
|
+
publicMutation: options.mutation,
|
|
38
|
+
publicAction: options.action,
|
|
39
|
+
authenticatedQuery: makeAuthenticatedBuilder(options.query, component),
|
|
40
|
+
authenticatedMutation: makeAuthenticatedBuilder(options.mutation, component),
|
|
41
|
+
authenticatedAction: makeAuthenticatedBuilder(options.action, component),
|
|
42
|
+
accessQuery: makeAccessBuilder(options.query, component),
|
|
43
|
+
accessMutation: makeAccessBuilder(options.mutation, component),
|
|
44
|
+
accessAction: makeAccessBuilder(options.action, component),
|
|
45
|
+
hasPermission: makeHasPermission(component),
|
|
46
|
+
requirePermission: makeRequirePermission(component),
|
|
47
|
+
requireAnyPermission: makeRequireAnyPermission(component),
|
|
48
|
+
getEffectivePermissions: makeGetEffectivePermissions(component),
|
|
49
|
+
checkPermissions: makeCheckPermissions(component),
|
|
50
|
+
getCurrentHerculesAuthUserId,
|
|
51
|
+
getDeploymentEntryStatus: makeGetDeploymentEntryStatus(component),
|
|
52
|
+
filterAuthorizedResources: makeFilterAuthorizedResources(component),
|
|
53
|
+
listMyMemberships: makeListMyMemberships(component),
|
|
54
|
+
listMyRoles: makeListMyRoles(component),
|
|
55
|
+
listScopeMembers: makeListScopeMembers(component),
|
|
56
|
+
listScopeMemberDirectory: makeListScopeMemberDirectory(component),
|
|
57
|
+
getScopeMemberDirectoryEntry: makeGetScopeMemberDirectoryEntry(component),
|
|
58
|
+
listScopeRoles: makeListScopeRoles(component),
|
|
59
|
+
listScopePermissions: makeListScopePermissions(component),
|
|
60
|
+
listDirectSubjectsForResource: makeListDirectSubjectsForResource(component),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Single-tenant apps that don't pass a scope arg: every check resolves to
|
|
64
|
+
// the app's default scope. The component query looks up the default scope
|
|
65
|
+
// row from the mirror, so this helper just returns a sentinel string and
|
|
66
|
+
// authorize resolves it. The sentinel is treated as "use the default scope"
|
|
67
|
+
// inside the authorize implementation (component reads the unique row with
|
|
68
|
+
// kind="default").
|
|
69
|
+
export const DEFAULT_SCOPE_SENTINEL = "__hercules_default_scope__";
|
|
70
|
+
export const defaultScope = () => DEFAULT_SCOPE_SENTINEL;
|
|
71
|
+
// The resourceType `scopeFromResource` emits. An extractor only sees the table
|
|
72
|
+
// row, not the permission catalog, so it cannot know the canonical resource
|
|
73
|
+
// type the checked permission uses (e.g. `app.project` for
|
|
74
|
+
// `app.project:archive`). It emits this sentinel instead, and the component's
|
|
75
|
+
// authorize query substitutes the requested permission's canonical catalog
|
|
76
|
+
// resourceType (resolved by catalog lookup). Resource grants are pinned to that
|
|
77
|
+
// same canonical type on the control plane, so the two match by construction.
|
|
78
|
+
// Mirrored in component/checks.ts (like DEFAULT_SCOPE_SENTINEL above).
|
|
79
|
+
export const PERMISSION_RESOURCE_TYPE_SENTINEL = "__hercules_permission_resource_type__";
|
|
80
|
+
/**
|
|
81
|
+
* Resolves the scope for an `access*` builder from a string arg the caller
|
|
82
|
+
* passes (e.g. the active org id). Use for list/create handlers where the
|
|
83
|
+
* frontend already knows the scope. Throws if the arg is missing or empty.
|
|
84
|
+
*
|
|
85
|
+
* Do not use this for an operation that receives an org-owned row id (read,
|
|
86
|
+
* update, delete): a caller could pair their own scope id with another org's
|
|
87
|
+
* row. Use `scopeFromResource` there so the scope is read from the row.
|
|
88
|
+
*/
|
|
89
|
+
export function scopeFromArg(argKey) {
|
|
90
|
+
return (_ctx, args) => {
|
|
91
|
+
const value = args?.[argKey];
|
|
92
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
93
|
+
throw new ConvexError({
|
|
94
|
+
code: "INVALID_SCOPE_ARG",
|
|
95
|
+
message: `scopeFromArg("${argKey}"): expected non-empty string on args.${argKey}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the scope from a referenced row for an `access*` builder. Reads
|
|
103
|
+
* the row named by `argKey`, returns the row's scope plus the resource id,
|
|
104
|
+
* and lets `authorize` apply resource-level grants on top of the scope check.
|
|
105
|
+
* Use for any read/update/delete that receives an org-owned row id.
|
|
106
|
+
*
|
|
107
|
+
* Params:
|
|
108
|
+
* - `tableName`: the row's table (used in error messages only).
|
|
109
|
+
* - `argKey`: the field on `args` holding the row id.
|
|
110
|
+
* - `options.scopeField`: column carrying the org scope id (default
|
|
111
|
+
* `"orgScopeId"`).
|
|
112
|
+
*
|
|
113
|
+
* Resource type: the emitted `resourceType` defers to the checked permission's
|
|
114
|
+
* canonical catalog resource type (e.g. `app.project` for
|
|
115
|
+
* `app.project:archive`), which is also the type resource grants are pinned
|
|
116
|
+
* to, so grants on the row always match the guarded permission.
|
|
117
|
+
*
|
|
118
|
+
* Hierarchy: pass `options.authorizeAgainst` to declare ordered parent
|
|
119
|
+
* resources. The target and ancestors are evaluated atomically with the same
|
|
120
|
+
* requested permission, so any applicable deny wins. The app owns these
|
|
121
|
+
* relationships; the chain is bounded to ten ancestors.
|
|
122
|
+
*/
|
|
123
|
+
export function scopeFromResource(tableName, argKey, options = {}) {
|
|
124
|
+
const scopeField = options.scopeField ?? "orgScopeId";
|
|
125
|
+
return async (ctx, args) => {
|
|
126
|
+
const id = args?.[argKey];
|
|
127
|
+
if (id == null) {
|
|
128
|
+
throw new ConvexError({
|
|
129
|
+
code: "INVALID_SCOPE_ARG",
|
|
130
|
+
message: `scopeFromResource("${tableName}", "${argKey}"): args.${argKey} is missing`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const row = await ctx.db.get(id);
|
|
134
|
+
if (!row || typeof row !== "object") {
|
|
135
|
+
throw new ConvexError({
|
|
136
|
+
code: "RESOURCE_NOT_FOUND",
|
|
137
|
+
message: `scopeFromResource("${tableName}", "${argKey}"): resource not found`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const scopeId = row[scopeField];
|
|
141
|
+
if (typeof scopeId !== "string" || scopeId.length === 0) {
|
|
142
|
+
throw new ConvexError({
|
|
143
|
+
code: "INVALID_RESOURCE_SCOPE",
|
|
144
|
+
message: `scopeFromResource("${tableName}", "${argKey}"): resource is missing "${scopeField}"`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const ancestors = normalizeAncestors(options.authorizeAgainst?.(row), `scopeFromResource("${tableName}", "${argKey}")`);
|
|
148
|
+
return {
|
|
149
|
+
scopeId,
|
|
150
|
+
resourceType: PERMISSION_RESOURCE_TYPE_SENTINEL,
|
|
151
|
+
resourceId: String(id),
|
|
152
|
+
...(ancestors ? { ancestors } : {}),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolves a specific resource in the default app scope without requiring a
|
|
158
|
+
* scope id column on the row. Use this for single-scope apps that still need
|
|
159
|
+
* resource grants, denies, or per-resource UI checks.
|
|
160
|
+
*
|
|
161
|
+
* The row is loaded from `args[argKey]`, so authorization and mutation stay
|
|
162
|
+
* bound to the same resource. Pass `authorizeAgainst` for trusted parent
|
|
163
|
+
* resources exactly as with {@link scopeFromResource}.
|
|
164
|
+
*/
|
|
165
|
+
export function scopeFromDefaultResource(tableName, argKey, options = {}) {
|
|
166
|
+
return async (ctx, args) => {
|
|
167
|
+
const id = args?.[argKey];
|
|
168
|
+
if (id == null) {
|
|
169
|
+
throw new ConvexError({
|
|
170
|
+
code: "INVALID_SCOPE_ARG",
|
|
171
|
+
message: `scopeFromDefaultResource("${tableName}", "${argKey}"): args.${argKey} is missing`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const row = await ctx.db.get(id);
|
|
175
|
+
if (!row || typeof row !== "object") {
|
|
176
|
+
throw new ConvexError({
|
|
177
|
+
code: "RESOURCE_NOT_FOUND",
|
|
178
|
+
message: `scopeFromDefaultResource("${tableName}", "${argKey}"): resource not found`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const ancestors = normalizeAncestors(options.authorizeAgainst?.(row), `scopeFromDefaultResource("${tableName}", "${argKey}")`);
|
|
182
|
+
return {
|
|
183
|
+
scopeId: DEFAULT_SCOPE_SENTINEL,
|
|
184
|
+
resourceType: PERMISSION_RESOURCE_TYPE_SENTINEL,
|
|
185
|
+
resourceId: String(id),
|
|
186
|
+
...(ancestors ? { ancestors } : {}),
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolves child-creation authorization from an existing parent row. The
|
|
192
|
+
* requested child permission stays unchanged; the parent is supplied as an
|
|
193
|
+
* explicit ancestor and only descendant-enabled bindings apply through it.
|
|
194
|
+
*/
|
|
195
|
+
export function scopeFromParentResource(tableName, argKey, options) {
|
|
196
|
+
const scopeField = options.scopeField ?? "orgScopeId";
|
|
197
|
+
return async (ctx, args) => {
|
|
198
|
+
const id = args?.[argKey];
|
|
199
|
+
if (id == null) {
|
|
200
|
+
throw new ConvexError({
|
|
201
|
+
code: "INVALID_SCOPE_ARG",
|
|
202
|
+
message: `scopeFromParentResource("${tableName}", "${argKey}"): args.${argKey} is missing`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const row = await ctx.db.get(id);
|
|
206
|
+
if (!row || typeof row !== "object") {
|
|
207
|
+
throw new ConvexError({
|
|
208
|
+
code: "RESOURCE_NOT_FOUND",
|
|
209
|
+
message: `scopeFromParentResource("${tableName}", "${argKey}"): resource not found`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const scopeId = row[scopeField];
|
|
213
|
+
if (typeof scopeId !== "string" || scopeId.length === 0) {
|
|
214
|
+
throw new ConvexError({
|
|
215
|
+
code: "INVALID_RESOURCE_SCOPE",
|
|
216
|
+
message: `scopeFromParentResource("${tableName}", "${argKey}"): resource is missing "${scopeField}"`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const ancestors = normalizeAncestors([
|
|
220
|
+
{ type: options.parentResourceType, id: String(id) },
|
|
221
|
+
...(options.authorizeAgainst?.(row) ?? []),
|
|
222
|
+
], `scopeFromParentResource("${tableName}", "${argKey}")`);
|
|
223
|
+
return {
|
|
224
|
+
scopeId,
|
|
225
|
+
resourceType: PERMISSION_RESOURCE_TYPE_SENTINEL,
|
|
226
|
+
ancestors: ancestors,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolves child creation against a parent resource in the default app scope.
|
|
232
|
+
* The parent row is loaded from `args[argKey]`; no scope id field is required
|
|
233
|
+
* on the parent or child tables.
|
|
234
|
+
*/
|
|
235
|
+
export function scopeFromDefaultParentResource(tableName, argKey, options) {
|
|
236
|
+
return async (ctx, args) => {
|
|
237
|
+
const id = args?.[argKey];
|
|
238
|
+
if (id == null) {
|
|
239
|
+
throw new ConvexError({
|
|
240
|
+
code: "INVALID_SCOPE_ARG",
|
|
241
|
+
message: `scopeFromDefaultParentResource("${tableName}", "${argKey}"): args.${argKey} is missing`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const row = await ctx.db.get(id);
|
|
245
|
+
if (!row || typeof row !== "object") {
|
|
246
|
+
throw new ConvexError({
|
|
247
|
+
code: "RESOURCE_NOT_FOUND",
|
|
248
|
+
message: `scopeFromDefaultParentResource("${tableName}", "${argKey}"): resource not found`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const ancestors = normalizeAncestors([
|
|
252
|
+
{ type: options.parentResourceType, id: String(id) },
|
|
253
|
+
...(options.authorizeAgainst?.(row) ?? []),
|
|
254
|
+
], `scopeFromDefaultParentResource("${tableName}", "${argKey}")`);
|
|
255
|
+
return {
|
|
256
|
+
scopeId: DEFAULT_SCOPE_SENTINEL,
|
|
257
|
+
resourceType: PERMISSION_RESOURCE_TYPE_SENTINEL,
|
|
258
|
+
ancestors: ancestors,
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function resolveComponent(options) {
|
|
263
|
+
if (options.component) {
|
|
264
|
+
return options.component;
|
|
265
|
+
}
|
|
266
|
+
const componentName = options.componentName ?? "hercules";
|
|
267
|
+
const component = options.components?.[componentName];
|
|
268
|
+
if (!component) {
|
|
269
|
+
throw new Error("Missing Hercules Access Control component. Install @usehercules/convex in convex/convex.config.ts.");
|
|
270
|
+
}
|
|
271
|
+
return component;
|
|
272
|
+
}
|
|
273
|
+
function makeAuthenticatedBuilder(builder, component) {
|
|
274
|
+
return ((definition) => {
|
|
275
|
+
return builder(wrapDefinition(definition, component, "authenticated"));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function makeAccessBuilder(builder, component) {
|
|
279
|
+
return ((definition) => {
|
|
280
|
+
if (typeof definition !== "object" || definition === null || !("handler" in definition)) {
|
|
281
|
+
throw new Error("access* builders require an object definition with a permission.");
|
|
282
|
+
}
|
|
283
|
+
const accessDefinition = definition;
|
|
284
|
+
if (typeof accessDefinition.permission !== "string" ||
|
|
285
|
+
accessDefinition.permission.length === 0) {
|
|
286
|
+
throw new Error("access* builders require a non-empty permission.");
|
|
287
|
+
}
|
|
288
|
+
if (accessDefinition.scope !== undefined && typeof accessDefinition.scope !== "function") {
|
|
289
|
+
throw new Error("access* builders require scope to be a function.");
|
|
290
|
+
}
|
|
291
|
+
const { permission, scope, ...convexDefinition } = accessDefinition;
|
|
292
|
+
const scopeExtractor = (scope ?? defaultScope);
|
|
293
|
+
return builder(wrapDefinition(convexDefinition, component, "permission", {
|
|
294
|
+
permission,
|
|
295
|
+
scope: scopeExtractor,
|
|
296
|
+
}));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function wrapDefinition(definition, component, mode, access) {
|
|
300
|
+
if (typeof definition === "function") {
|
|
301
|
+
return async (ctx, ...args) => {
|
|
302
|
+
await ensureAuthorized(ctx, component, mode, access, args[0]);
|
|
303
|
+
return definition(ctx, ...args);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const objectDefinition = definition;
|
|
307
|
+
return {
|
|
308
|
+
...objectDefinition,
|
|
309
|
+
handler: async (ctx, ...args) => {
|
|
310
|
+
await ensureAuthorized(ctx, component, mode, access, args[0]);
|
|
311
|
+
return objectDefinition.handler(ctx, ...args);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function resourceArgs(resource) {
|
|
316
|
+
return { resourceType: resource?.type, resourceId: resource?.id };
|
|
317
|
+
}
|
|
318
|
+
function ancestorArgs(ancestors) {
|
|
319
|
+
return ancestors ? { ancestors } : {};
|
|
320
|
+
}
|
|
321
|
+
function normalizeAncestors(ancestors, source = "authorization check") {
|
|
322
|
+
if (!ancestors || ancestors.length === 0)
|
|
323
|
+
return undefined;
|
|
324
|
+
if (ancestors.length > MAX_AUTHORIZE_CHAIN) {
|
|
325
|
+
throw new ConvexError({
|
|
326
|
+
code: "INVALID_SCOPE_ARG",
|
|
327
|
+
message: `${source}: expected at most ${MAX_AUTHORIZE_CHAIN} ancestors`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return ancestors.map((ancestor) => {
|
|
331
|
+
if (!ancestor.type || !ancestor.id) {
|
|
332
|
+
throw new ConvexError({
|
|
333
|
+
code: "INVALID_SCOPE_ARG",
|
|
334
|
+
message: `${source}: ancestors require non-empty type and id`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return { resourceType: ancestor.type, resourceId: ancestor.id };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function getTokenIdentifier(ctx) {
|
|
341
|
+
return (await ctx.auth.getUserIdentity())?.tokenIdentifier ?? undefined;
|
|
342
|
+
}
|
|
343
|
+
async function getCurrentHerculesAuthUserId(ctx) {
|
|
344
|
+
return (await ctx.auth.getUserIdentity())?.subject ?? undefined;
|
|
345
|
+
}
|
|
346
|
+
function normalizePermissionCheckArgs(args) {
|
|
347
|
+
if (typeof args === "string") {
|
|
348
|
+
return {
|
|
349
|
+
scopeId: DEFAULT_SCOPE_SENTINEL,
|
|
350
|
+
permission: args,
|
|
351
|
+
resource: undefined,
|
|
352
|
+
ancestors: undefined,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
357
|
+
permission: args.permission,
|
|
358
|
+
resource: args.resource,
|
|
359
|
+
ancestors: normalizeAncestors(args.ancestors),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function normalizeAnyPermissionCheckArgs(args) {
|
|
363
|
+
if (Array.isArray(args)) {
|
|
364
|
+
return {
|
|
365
|
+
scopeId: DEFAULT_SCOPE_SENTINEL,
|
|
366
|
+
permissions: args,
|
|
367
|
+
resource: undefined,
|
|
368
|
+
ancestors: undefined,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
373
|
+
permissions: args.permissions,
|
|
374
|
+
resource: args.resource,
|
|
375
|
+
ancestors: args.ancestors,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function normalizeEffectivePermissionsArgs(args) {
|
|
379
|
+
return {
|
|
380
|
+
scopeId: args?.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
381
|
+
resource: args?.resource,
|
|
382
|
+
ancestors: normalizeAncestors(args?.ancestors),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function makeHasPermission(component) {
|
|
386
|
+
return async (ctx, args) => {
|
|
387
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
388
|
+
if (!tokenIdentifier)
|
|
389
|
+
return false;
|
|
390
|
+
const normalized = normalizePermissionCheckArgs(args);
|
|
391
|
+
const decision = await ctx.runQuery(component.checks.authorize, {
|
|
392
|
+
tokenIdentifier,
|
|
393
|
+
scopeId: normalized.scopeId,
|
|
394
|
+
permission: normalized.permission,
|
|
395
|
+
...resourceArgs(normalized.resource),
|
|
396
|
+
...ancestorArgs(normalized.ancestors),
|
|
397
|
+
});
|
|
398
|
+
return decision.allowed;
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function makeRequirePermission(component) {
|
|
402
|
+
const hasPermission = makeHasPermission(component);
|
|
403
|
+
return async (ctx, args) => {
|
|
404
|
+
if (await hasPermission(ctx, args))
|
|
405
|
+
return;
|
|
406
|
+
throw new ConvexError({ code: "ACCESS_DENIED", message: "Access denied" });
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function makeRequireAnyPermission(component) {
|
|
410
|
+
const hasPermission = makeHasPermission(component);
|
|
411
|
+
return async (ctx, args) => {
|
|
412
|
+
const normalized = normalizeAnyPermissionCheckArgs(args);
|
|
413
|
+
for (const permission of normalized.permissions) {
|
|
414
|
+
if (await hasPermission(ctx, { ...normalized, permission }))
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
throw new ConvexError({ code: "ACCESS_DENIED", message: "Access denied" });
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function makeGetEffectivePermissions(component) {
|
|
421
|
+
return async (ctx, args) => {
|
|
422
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
423
|
+
if (!tokenIdentifier)
|
|
424
|
+
return [];
|
|
425
|
+
const normalized = normalizeEffectivePermissionsArgs(args);
|
|
426
|
+
const result = await ctx.runQuery(component.queries.getEffectivePermissions, {
|
|
427
|
+
tokenIdentifier,
|
|
428
|
+
scopeId: normalized.scopeId,
|
|
429
|
+
...resourceArgs(normalized.resource),
|
|
430
|
+
...ancestorArgs(normalized.ancestors),
|
|
431
|
+
});
|
|
432
|
+
return result.permissions;
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function makeCheckPermissions(component) {
|
|
436
|
+
return async (ctx, checks) => {
|
|
437
|
+
if (checks.length > 50) {
|
|
438
|
+
throw new ConvexError({
|
|
439
|
+
code: "INVALID_PERMISSION_CHECKS",
|
|
440
|
+
message: "checkPermissions accepts at most 50 checks",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
444
|
+
if (!tokenIdentifier) {
|
|
445
|
+
return checks.map(() => ({
|
|
446
|
+
allowed: false,
|
|
447
|
+
reasonCode: "missing_identity",
|
|
448
|
+
effectiveRoleIds: [],
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
return await ctx.runQuery(component.checks.authorizeMany, {
|
|
452
|
+
tokenIdentifier,
|
|
453
|
+
checks: checks.map((check) => {
|
|
454
|
+
const normalized = normalizePermissionCheckArgs(check);
|
|
455
|
+
return {
|
|
456
|
+
scopeId: normalized.scopeId,
|
|
457
|
+
permission: normalized.permission,
|
|
458
|
+
...resourceArgs(normalized.resource),
|
|
459
|
+
...ancestorArgs(normalized.ancestors),
|
|
460
|
+
};
|
|
461
|
+
}),
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function makeFilterAuthorizedResources(component) {
|
|
466
|
+
return async (ctx, args) => {
|
|
467
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
468
|
+
if (!tokenIdentifier)
|
|
469
|
+
return [];
|
|
470
|
+
const scopeId = args.scopeId ?? DEFAULT_SCOPE_SENTINEL;
|
|
471
|
+
const allowed = [];
|
|
472
|
+
for (const item of args.resources) {
|
|
473
|
+
const ref = args.resource(item);
|
|
474
|
+
const ancestors = normalizeAncestors(args.ancestors?.(item), "filterAuthorizedResources");
|
|
475
|
+
const decision = await ctx.runQuery(component.checks.authorize, {
|
|
476
|
+
tokenIdentifier,
|
|
477
|
+
scopeId,
|
|
478
|
+
permission: args.permission,
|
|
479
|
+
resourceType: ref.type,
|
|
480
|
+
resourceId: ref.id,
|
|
481
|
+
...ancestorArgs(ancestors),
|
|
482
|
+
});
|
|
483
|
+
if (decision.allowed)
|
|
484
|
+
allowed.push(item);
|
|
485
|
+
}
|
|
486
|
+
return allowed;
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function makeListMyMemberships(component) {
|
|
490
|
+
return async (ctx) => {
|
|
491
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
492
|
+
if (!tokenIdentifier)
|
|
493
|
+
return [];
|
|
494
|
+
return await ctx.runQuery(component.queries.listMyMemberships, {
|
|
495
|
+
tokenIdentifier,
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function makeGetDeploymentEntryStatus(component) {
|
|
500
|
+
return async (ctx) => {
|
|
501
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
502
|
+
if (!tokenIdentifier) {
|
|
503
|
+
return { kind: "fallback", reason: "identity_missing" };
|
|
504
|
+
}
|
|
505
|
+
return await ctx.runQuery(component.queries.getDeploymentEntryStatus, {
|
|
506
|
+
tokenIdentifier,
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function makeListMyRoles(component) {
|
|
511
|
+
return async (ctx, args = {}) => {
|
|
512
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
513
|
+
if (!tokenIdentifier)
|
|
514
|
+
return [];
|
|
515
|
+
return await ctx.runQuery(component.queries.listMyRoles, {
|
|
516
|
+
tokenIdentifier,
|
|
517
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function makeListScopeMembers(component) {
|
|
522
|
+
return async (ctx, args = {}) => {
|
|
523
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
524
|
+
if (!tokenIdentifier)
|
|
525
|
+
return [];
|
|
526
|
+
return await ctx.runQuery(component.queries.listScopeMembers, {
|
|
527
|
+
tokenIdentifier,
|
|
528
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function makeListScopeMemberDirectory(component) {
|
|
533
|
+
return async (ctx, args = {}) => {
|
|
534
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
535
|
+
if (!tokenIdentifier)
|
|
536
|
+
return { members: [] };
|
|
537
|
+
const result = await ctx.runQuery(component.queries.listScopeMemberDirectory, {
|
|
538
|
+
tokenIdentifier,
|
|
539
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
540
|
+
cursor: args.cursor,
|
|
541
|
+
limit: args.limit,
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
members: result.members,
|
|
545
|
+
...(result.cursor ? { nextCursor: result.cursor } : {}),
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function makeGetScopeMemberDirectoryEntry(component) {
|
|
550
|
+
return async (ctx, args) => {
|
|
551
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
552
|
+
if (!tokenIdentifier)
|
|
553
|
+
return null;
|
|
554
|
+
return await ctx.runQuery(component.queries.getScopeMemberDirectoryEntry, {
|
|
555
|
+
tokenIdentifier,
|
|
556
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
557
|
+
principalId: args.principalId,
|
|
558
|
+
herculesAuthUserId: args.herculesAuthUserId,
|
|
559
|
+
});
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function makeListScopeRoles(component) {
|
|
563
|
+
return async (ctx, args = {}) => {
|
|
564
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
565
|
+
if (!tokenIdentifier)
|
|
566
|
+
return [];
|
|
567
|
+
return await ctx.runQuery(component.queries.listScopeRoles, {
|
|
568
|
+
tokenIdentifier,
|
|
569
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function makeListScopePermissions(component) {
|
|
574
|
+
return async (ctx, args = {}) => {
|
|
575
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
576
|
+
if (!tokenIdentifier)
|
|
577
|
+
return [];
|
|
578
|
+
return await ctx.runQuery(component.queries.listScopePermissions, {
|
|
579
|
+
tokenIdentifier,
|
|
580
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function makeListDirectSubjectsForResource(component) {
|
|
585
|
+
return async (ctx, args) => {
|
|
586
|
+
const tokenIdentifier = await getTokenIdentifier(ctx);
|
|
587
|
+
if (!tokenIdentifier)
|
|
588
|
+
return [];
|
|
589
|
+
return await ctx.runQuery(component.queries.listDirectSubjectsForResource, {
|
|
590
|
+
tokenIdentifier,
|
|
591
|
+
scopeId: args.scopeId ?? DEFAULT_SCOPE_SENTINEL,
|
|
592
|
+
resourceType: args.resourceType,
|
|
593
|
+
resourceId: args.resourceId,
|
|
594
|
+
permission: args.permission,
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async function ensureAuthorized(ctx, component, mode, access, callerArgs) {
|
|
599
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
600
|
+
// MED-01: short-circuit on missing identity before scope extraction so that
|
|
601
|
+
// unauthenticated callers cannot probe resource existence by observing
|
|
602
|
+
// INVALID_SCOPE_ARG vs RESOURCE_NOT_FOUND vs INVALID_RESOURCE_SCOPE.
|
|
603
|
+
if (!identity?.tokenIdentifier) {
|
|
604
|
+
throw new ConvexError({
|
|
605
|
+
code: mode === "permission" ? "ACCESS_DENIED" : "UNAUTHENTICATED",
|
|
606
|
+
message: mode === "permission" ? "Access denied" : "Authentication required",
|
|
607
|
+
reasonCode: "missing_identity",
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
let scopeId;
|
|
611
|
+
let resourceType;
|
|
612
|
+
let resourceId;
|
|
613
|
+
let ancestors;
|
|
614
|
+
if (mode === "permission") {
|
|
615
|
+
try {
|
|
616
|
+
const extracted = await (access?.scope ?? defaultScope)(ctx, callerArgs);
|
|
617
|
+
if (typeof extracted === "string") {
|
|
618
|
+
scopeId = extracted;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
scopeId = extracted.scopeId;
|
|
622
|
+
resourceType = extracted.resourceType;
|
|
623
|
+
resourceId = extracted.resourceId;
|
|
624
|
+
ancestors = extracted.ancestors;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
if (error instanceof ConvexError)
|
|
629
|
+
throw error;
|
|
630
|
+
throw new ConvexError({
|
|
631
|
+
code: "ACCESS_DENIED",
|
|
632
|
+
message: "scope extraction failed",
|
|
633
|
+
reasonCode: "scope_extract_failed",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const decision = await ctx.runQuery(component.checks.authorize, {
|
|
638
|
+
tokenIdentifier: identity.tokenIdentifier,
|
|
639
|
+
scopeId,
|
|
640
|
+
permission: mode === "permission" ? access?.permission : undefined,
|
|
641
|
+
resourceType,
|
|
642
|
+
resourceId,
|
|
643
|
+
...ancestorArgs(ancestors),
|
|
644
|
+
});
|
|
645
|
+
if (!decision.allowed) {
|
|
646
|
+
throw new ConvexError({
|
|
647
|
+
code: mode === "permission" ? "ACCESS_DENIED" : "UNAUTHENTICATED",
|
|
648
|
+
message: mode === "permission" ? "Access denied" : "Authentication required",
|
|
649
|
+
reasonCode: decision.reasonCode,
|
|
650
|
+
sourceVersion: decision.sourceVersion,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
//# sourceMappingURL=index.js.map
|