@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,851 @@
|
|
|
1
|
+
import { internalMutationGeneric, makeFunctionReference, mutationGeneric, } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { accessProjectionSyncPayloadSchema, } from "../shared/sync";
|
|
4
|
+
import schema from "./schema";
|
|
5
|
+
const internalMutation = internalMutationGeneric;
|
|
6
|
+
// applySync is the parent-facing entry point (the app's HTTP sync route calls
|
|
7
|
+
// it via runMutation); it must be public-in-component to be exported to the
|
|
8
|
+
// parent. The expiry mutations below stay internal: they are scheduled and run
|
|
9
|
+
// entirely inside the component.
|
|
10
|
+
const mutation = mutationGeneric;
|
|
11
|
+
// Exact-identity expiry mutations: scheduled at expiresAt so the reactive query
|
|
12
|
+
// is invalidated when a time-bound binding lapses. The runtime readers also
|
|
13
|
+
// fail closed on the timestamp, so a delayed schedule never over-grants.
|
|
14
|
+
const expireRoleBindingReference = makeFunctionReference("sync:expireRoleBinding");
|
|
15
|
+
const expirePermissionBindingReference = makeFunctionReference("sync:expirePermissionBinding");
|
|
16
|
+
// Convex transactions have document-count limits. Reject an oversized aggregate
|
|
17
|
+
// with a clear payload failure rather than letting the mutation abort opaquely.
|
|
18
|
+
const MAX_SNAPSHOT_DOCUMENTS = 16_000;
|
|
19
|
+
function bindingAppliesTo(binding) {
|
|
20
|
+
if ("appliesTo" in binding &&
|
|
21
|
+
(binding.appliesTo === "self" || binding.appliesTo === "self_and_descendants")) {
|
|
22
|
+
return binding.appliesTo;
|
|
23
|
+
}
|
|
24
|
+
return "self";
|
|
25
|
+
}
|
|
26
|
+
// The args validator is intentionally loose (the producer ships either payload
|
|
27
|
+
// kind); real validation is the zod parse below. Accept the v3 top-level shape.
|
|
28
|
+
const syncPayloadArgs = {
|
|
29
|
+
type: v.union(v.literal("access.projection.snapshot"), v.literal("access.projection.event")),
|
|
30
|
+
schemaVersion: v.number(),
|
|
31
|
+
eventId: v.string(),
|
|
32
|
+
sourceVersion: v.number(),
|
|
33
|
+
mode: v.optional(v.union(v.literal("initialize"), v.literal("reset"))),
|
|
34
|
+
expectedIssuer: v.optional(v.string()),
|
|
35
|
+
catalog: v.optional(v.any()),
|
|
36
|
+
users: v.optional(v.any()),
|
|
37
|
+
scopes: v.optional(v.array(v.any())),
|
|
38
|
+
};
|
|
39
|
+
export const applySync = mutation({
|
|
40
|
+
args: syncPayloadArgs,
|
|
41
|
+
handler: async (ctx, rawArgs) => {
|
|
42
|
+
if (rawArgs.schemaVersion !== 3) {
|
|
43
|
+
return { ok: false, status: "unsupported_schema" };
|
|
44
|
+
}
|
|
45
|
+
// A bootstrap/reset aggregate must carry the default scope (the zod also
|
|
46
|
+
// checks default-first/exactly-one, but this returns a precise status the
|
|
47
|
+
// reconciler keys on before paying for a full parse).
|
|
48
|
+
if (rawArgs.type === "access.projection.snapshot" &&
|
|
49
|
+
!(rawArgs.scopes ?? []).some((entry) => entry !== null &&
|
|
50
|
+
typeof entry === "object" &&
|
|
51
|
+
"scope" in entry &&
|
|
52
|
+
entry.scope !== null &&
|
|
53
|
+
typeof entry.scope === "object" &&
|
|
54
|
+
"kind" in entry.scope &&
|
|
55
|
+
entry.scope.kind === "default")) {
|
|
56
|
+
return { ok: false, status: "default_scope_required" };
|
|
57
|
+
}
|
|
58
|
+
const parsed = accessProjectionSyncPayloadSchema.safeParse(rawArgs);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
return { ok: false, status: "invalid_payload" };
|
|
61
|
+
}
|
|
62
|
+
const payload = parsed.data;
|
|
63
|
+
const state = await ctx.db.query("sync_state").unique();
|
|
64
|
+
// Idempotency: a re-delivered event/snapshot with the same eventId is a
|
|
65
|
+
// no-op ack (the version must match what we recorded for that eventId).
|
|
66
|
+
if (state?.lastEventId === payload.eventId) {
|
|
67
|
+
if (state.sourceVersion !== payload.sourceVersion) {
|
|
68
|
+
return { ok: false, status: "invalid_payload" };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
status: "duplicate",
|
|
73
|
+
acknowledgedVersion: state.sourceVersion,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (payload.type === "access.projection.event") {
|
|
77
|
+
if (!state) {
|
|
78
|
+
return { ok: false, status: "not_ready", currentVersion: 0 };
|
|
79
|
+
}
|
|
80
|
+
// Contract point 1: events apply strictly at currentVersion + 1.
|
|
81
|
+
const expectedVersion = state.sourceVersion + 1;
|
|
82
|
+
if (payload.sourceVersion !== expectedVersion) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
status: "version_gap",
|
|
86
|
+
currentVersion: state.sourceVersion,
|
|
87
|
+
expectedVersion,
|
|
88
|
+
receivedVersion: payload.sourceVersion,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// initialize requires a clean mirror; reset requires an existing one.
|
|
94
|
+
if (payload.mode === "initialize" && state) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
status: "reset_required",
|
|
98
|
+
currentVersion: state.sourceVersion,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (payload.mode === "reset" && !state) {
|
|
102
|
+
return { ok: false, status: "not_ready", currentVersion: 0 };
|
|
103
|
+
}
|
|
104
|
+
if (state && state.expectedIssuer !== payload.expectedIssuer) {
|
|
105
|
+
return { ok: false, status: "issuer_mismatch" };
|
|
106
|
+
}
|
|
107
|
+
if (payload.mode === "reset" && state && payload.sourceVersion < state.sourceVersion) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
status: "version_gap",
|
|
111
|
+
currentVersion: state.sourceVersion,
|
|
112
|
+
expectedVersion: state.sourceVersion + 1,
|
|
113
|
+
receivedVersion: payload.sourceVersion,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (payload.type === "access.projection.snapshot") {
|
|
119
|
+
if (snapshotDocumentCount(payload) > MAX_SNAPSHOT_DOCUMENTS) {
|
|
120
|
+
return { ok: false, status: "invalid_payload" };
|
|
121
|
+
}
|
|
122
|
+
// Whole-aggregate atomic install. A Convex mutation is a single
|
|
123
|
+
// transaction, so no scope becomes visible until the entire snapshot
|
|
124
|
+
// commits (contract point 2).
|
|
125
|
+
await replaceProjection(payload, now);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
await applyEvent(payload, now);
|
|
129
|
+
}
|
|
130
|
+
const nextState = {
|
|
131
|
+
sourceVersion: payload.sourceVersion,
|
|
132
|
+
expectedIssuer: payload.type === "access.projection.snapshot"
|
|
133
|
+
? payload.expectedIssuer
|
|
134
|
+
: state.expectedIssuer,
|
|
135
|
+
lastEventId: payload.eventId,
|
|
136
|
+
lastSyncedAt: Date.now(),
|
|
137
|
+
};
|
|
138
|
+
if (state) {
|
|
139
|
+
await ctx.db.replace(state._id, nextState);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
await ctx.db.insert("sync_state", nextState);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
status: "applied",
|
|
147
|
+
acknowledgedVersion: payload.sourceVersion,
|
|
148
|
+
};
|
|
149
|
+
// ── snapshot install ──────────────────────────────────────────────────
|
|
150
|
+
async function replaceProjection(snapshot, now) {
|
|
151
|
+
// Clear children-before-parents is unnecessary (no DB FKs), so order only
|
|
152
|
+
// for clarity. Every table is wiped before re-install.
|
|
153
|
+
await clearTable("permission_bindings");
|
|
154
|
+
await clearTable("role_bindings");
|
|
155
|
+
await clearTable("role_permission_overrides");
|
|
156
|
+
await clearTable("role_permissions");
|
|
157
|
+
await clearTable("permissions");
|
|
158
|
+
await clearTable("roles");
|
|
159
|
+
await clearTable("principal_memberships");
|
|
160
|
+
await clearTable("principals");
|
|
161
|
+
await clearTable("organizations");
|
|
162
|
+
await clearTable("scopes");
|
|
163
|
+
await clearTable("users");
|
|
164
|
+
// Deployment-wide catalog (NEVER per-scope). Catalog roles carry no
|
|
165
|
+
// accessScopeId; permissions are pinned to the default scope id for
|
|
166
|
+
// lookup symmetry.
|
|
167
|
+
const defaultScopeId = snapshot.scopes[0].scope.accessScopeId;
|
|
168
|
+
for (const role of snapshot.catalog.roles) {
|
|
169
|
+
await ctx.db.insert("roles", {
|
|
170
|
+
roleId: role.roleId,
|
|
171
|
+
key: role.key,
|
|
172
|
+
source: role.source,
|
|
173
|
+
name: role.name,
|
|
174
|
+
baseWildcard: role.baseWildcard,
|
|
175
|
+
updatedAt: role.updatedAt,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const permission of snapshot.catalog.permissions) {
|
|
179
|
+
await ctx.db.insert("permissions", {
|
|
180
|
+
accessScopeId: defaultScopeId,
|
|
181
|
+
permissionId: permission.permissionId,
|
|
182
|
+
key: permission.key,
|
|
183
|
+
resourceType: permission.resourceType,
|
|
184
|
+
action: permission.action,
|
|
185
|
+
classification: permission.classification,
|
|
186
|
+
tenantAssignable: permission.tenantAssignable,
|
|
187
|
+
updatedAt: permission.updatedAt,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (const rolePermission of snapshot.catalog.rolePermissions) {
|
|
191
|
+
await ctx.db.insert("role_permissions", {
|
|
192
|
+
roleId: rolePermission.roleId,
|
|
193
|
+
permissionId: rolePermission.permissionId,
|
|
194
|
+
effect: rolePermission.effect,
|
|
195
|
+
updatedAt: rolePermission.updatedAt,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
// Deployment-wide users.
|
|
199
|
+
for (const user of snapshot.users) {
|
|
200
|
+
await ctx.db.insert("users", { ...user });
|
|
201
|
+
}
|
|
202
|
+
// Per-scope runtime state.
|
|
203
|
+
for (const scopeEntry of snapshot.scopes) {
|
|
204
|
+
await insertScope(scopeEntry.scope);
|
|
205
|
+
for (const principal of scopeEntry.principals) {
|
|
206
|
+
await ctx.db.insert("principals", {
|
|
207
|
+
accessScopeId: scopeEntry.scope.accessScopeId,
|
|
208
|
+
...principal,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
for (const membership of scopeEntry.principalMemberships) {
|
|
212
|
+
await ctx.db.insert("principal_memberships", {
|
|
213
|
+
accessScopeId: scopeEntry.scope.accessScopeId,
|
|
214
|
+
...membership,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// E4 (cross-scope escalation fence): write every embedded row pinned to
|
|
218
|
+
// the ENCLOSING scope, never the nested accessScopeId the row carries.
|
|
219
|
+
// The zod parse already rejects a mismatched nested id, but pinning here
|
|
220
|
+
// is the second, in-depth layer so a scope-A block can never land a row
|
|
221
|
+
// in scope B even if the parse fence is bypassed.
|
|
222
|
+
const enclosingScopeId = scopeEntry.scope.accessScopeId;
|
|
223
|
+
for (const role of scopeEntry.roles) {
|
|
224
|
+
await ctx.db.insert("roles", {
|
|
225
|
+
roleId: role.roleId,
|
|
226
|
+
key: role.key,
|
|
227
|
+
source: role.source,
|
|
228
|
+
name: role.name,
|
|
229
|
+
baseWildcard: role.baseWildcard,
|
|
230
|
+
accessScopeId: enclosingScopeId,
|
|
231
|
+
updatedAt: role.updatedAt,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
for (const override of scopeEntry.rolePermissionOverrides) {
|
|
235
|
+
await ctx.db.insert("role_permission_overrides", {
|
|
236
|
+
...override,
|
|
237
|
+
accessScopeId: enclosingScopeId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
for (const binding of scopeEntry.roleBindings) {
|
|
241
|
+
if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
await ctx.db.insert("role_bindings", {
|
|
245
|
+
...binding,
|
|
246
|
+
accessScopeId: enclosingScopeId,
|
|
247
|
+
appliesTo: bindingAppliesTo(binding),
|
|
248
|
+
});
|
|
249
|
+
await scheduleRoleBindingExpiration(binding);
|
|
250
|
+
}
|
|
251
|
+
for (const binding of scopeEntry.permissionBindings) {
|
|
252
|
+
if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
await ctx.db.insert("permission_bindings", {
|
|
256
|
+
...binding,
|
|
257
|
+
accessScopeId: enclosingScopeId,
|
|
258
|
+
appliesTo: bindingAppliesTo(binding),
|
|
259
|
+
});
|
|
260
|
+
await schedulePermissionBindingExpiration(binding);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ── event application ─────────────────────────────────────────────────
|
|
265
|
+
async function applyEvent(event, now) {
|
|
266
|
+
if (event.catalog)
|
|
267
|
+
await applyCatalogDelta(event.catalog);
|
|
268
|
+
if (event.users)
|
|
269
|
+
await applyUserDelta(event.users);
|
|
270
|
+
for (const scopeDelta of event.scopes ?? []) {
|
|
271
|
+
await applyScopeDelta(scopeDelta, now);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function applyCatalogDelta(catalog) {
|
|
275
|
+
const defaultScopeId = await getDefaultScopeId();
|
|
276
|
+
for (const change of catalog.changes) {
|
|
277
|
+
if (change.operation === "delete") {
|
|
278
|
+
switch (change.entityType) {
|
|
279
|
+
case "role":
|
|
280
|
+
await deleteCatalogRole(change.roleId);
|
|
281
|
+
break;
|
|
282
|
+
case "permission":
|
|
283
|
+
await deletePermission(change.permissionId);
|
|
284
|
+
break;
|
|
285
|
+
case "role_permission":
|
|
286
|
+
await deleteRolePermission(change.roleId, change.permissionId);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
switch (change.entityType) {
|
|
292
|
+
case "role": {
|
|
293
|
+
const role = catalog.roles.find((r) => r.roleId === change.roleId);
|
|
294
|
+
await upsertCatalogRole({
|
|
295
|
+
roleId: role.roleId,
|
|
296
|
+
key: role.key,
|
|
297
|
+
source: role.source,
|
|
298
|
+
name: role.name,
|
|
299
|
+
baseWildcard: role.baseWildcard,
|
|
300
|
+
updatedAt: role.updatedAt,
|
|
301
|
+
});
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
case "permission": {
|
|
305
|
+
const permission = catalog.permissions.find((p) => p.permissionId === change.permissionId);
|
|
306
|
+
await upsertPermission({
|
|
307
|
+
accessScopeId: defaultScopeId,
|
|
308
|
+
permissionId: permission.permissionId,
|
|
309
|
+
key: permission.key,
|
|
310
|
+
resourceType: permission.resourceType,
|
|
311
|
+
action: permission.action,
|
|
312
|
+
classification: permission.classification,
|
|
313
|
+
tenantAssignable: permission.tenantAssignable,
|
|
314
|
+
updatedAt: permission.updatedAt,
|
|
315
|
+
});
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case "role_permission": {
|
|
319
|
+
const rolePermission = catalog.rolePermissions.find((rp) => rp.roleId === change.roleId && rp.permissionId === change.permissionId);
|
|
320
|
+
await upsertRolePermission({
|
|
321
|
+
roleId: rolePermission.roleId,
|
|
322
|
+
permissionId: rolePermission.permissionId,
|
|
323
|
+
effect: rolePermission.effect,
|
|
324
|
+
updatedAt: rolePermission.updatedAt,
|
|
325
|
+
});
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function applyUserDelta(delta) {
|
|
332
|
+
for (const change of delta.changes) {
|
|
333
|
+
if (change.operation === "delete") {
|
|
334
|
+
await deleteUser(change.herculesAuthUserId);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const user = delta.users.find((u) => u.herculesAuthUserId === change.herculesAuthUserId);
|
|
338
|
+
await upsertUser({ ...user });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function applyScopeDelta(scope, now) {
|
|
342
|
+
const accessScopeId = scope.accessScopeId;
|
|
343
|
+
if (scope.scope)
|
|
344
|
+
await upsertScope(scope.scope);
|
|
345
|
+
for (const change of scope.changes) {
|
|
346
|
+
if (change.operation === "delete") {
|
|
347
|
+
switch (change.entityType) {
|
|
348
|
+
case "scope":
|
|
349
|
+
await deleteScope(change.accessScopeId);
|
|
350
|
+
break;
|
|
351
|
+
case "principal":
|
|
352
|
+
await deletePrincipal(accessScopeId, change.principalId);
|
|
353
|
+
break;
|
|
354
|
+
case "principal_membership":
|
|
355
|
+
await deleteMembership(accessScopeId, change.groupPrincipalId, change.memberPrincipalId);
|
|
356
|
+
break;
|
|
357
|
+
case "role":
|
|
358
|
+
await deleteTenantRole(change.roleId);
|
|
359
|
+
break;
|
|
360
|
+
case "role_permission_override":
|
|
361
|
+
// E4: delete keyed on the ENCLOSING scope, never the change's own
|
|
362
|
+
// accessScopeId (which could name a foreign scope).
|
|
363
|
+
await deleteOverride(accessScopeId, change.roleId, change.permissionId);
|
|
364
|
+
break;
|
|
365
|
+
case "role_binding":
|
|
366
|
+
await deleteRoleBinding(change.bindingId);
|
|
367
|
+
break;
|
|
368
|
+
case "permission_binding":
|
|
369
|
+
await deletePermissionBinding(change.bindingId);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
switch (change.entityType) {
|
|
375
|
+
// A `scope` upsert change is satisfied by `scope.scope` above; the
|
|
376
|
+
// integrity rule guarantees the metadata row is present.
|
|
377
|
+
case "scope":
|
|
378
|
+
break;
|
|
379
|
+
case "principal": {
|
|
380
|
+
const principal = scope.principals.find((p) => p.principalId === change.principalId);
|
|
381
|
+
await upsertPrincipal(accessScopeId, principal);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "principal_membership": {
|
|
385
|
+
const membership = scope.principalMemberships.find((m) => m.groupPrincipalId === change.groupPrincipalId &&
|
|
386
|
+
m.memberPrincipalId === change.memberPrincipalId);
|
|
387
|
+
await upsertMembership(accessScopeId, membership);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case "role": {
|
|
391
|
+
const role = scope.roles.find((r) => r.roleId === change.roleId);
|
|
392
|
+
await upsertTenantRole({
|
|
393
|
+
roleId: role.roleId,
|
|
394
|
+
key: role.key,
|
|
395
|
+
source: role.source,
|
|
396
|
+
name: role.name,
|
|
397
|
+
baseWildcard: role.baseWildcard,
|
|
398
|
+
// E4: pin to the ENCLOSING scope, never the row's own
|
|
399
|
+
// accessScopeId.
|
|
400
|
+
accessScopeId,
|
|
401
|
+
updatedAt: role.updatedAt,
|
|
402
|
+
});
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case "role_permission_override": {
|
|
406
|
+
const override = scope.rolePermissionOverrides.find((o) => o.accessScopeId === change.accessScopeId &&
|
|
407
|
+
o.roleId === change.roleId &&
|
|
408
|
+
o.permissionId === change.permissionId);
|
|
409
|
+
// E4: write the override pinned to the ENCLOSING scope.
|
|
410
|
+
await upsertOverride({ ...override, accessScopeId });
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case "role_binding": {
|
|
414
|
+
const binding = scope.roleBindings.find((b) => b.bindingId === change.bindingId);
|
|
415
|
+
// E4: write the binding pinned to the ENCLOSING scope.
|
|
416
|
+
await upsertRoleBinding({ ...binding, accessScopeId }, now);
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "permission_binding": {
|
|
420
|
+
const binding = scope.permissionBindings.find((b) => b.bindingId === change.bindingId);
|
|
421
|
+
// E4: write the binding pinned to the ENCLOSING scope.
|
|
422
|
+
await upsertPermissionBinding({ ...binding, accessScopeId }, now);
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ── table helpers ─────────────────────────────────────────────────────
|
|
429
|
+
async function clearTable(table) {
|
|
430
|
+
for (const row of await ctx.db.query(table).collect()) {
|
|
431
|
+
await ctx.db.delete(row._id);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function getDefaultScopeId() {
|
|
435
|
+
const defaultScope = await ctx.db
|
|
436
|
+
.query("scopes")
|
|
437
|
+
.withIndex("by_kind", (q) => q.eq("kind", "default"))
|
|
438
|
+
.unique();
|
|
439
|
+
// The mirror always holds exactly one default scope (snapshot installs it
|
|
440
|
+
// before any event applies). Fall back to empty string defensively.
|
|
441
|
+
return defaultScope?.accessScopeId ?? "";
|
|
442
|
+
}
|
|
443
|
+
async function insertScope(scope) {
|
|
444
|
+
await ctx.db.insert("scopes", { ...scope });
|
|
445
|
+
if (scope.kind === "org" || scope.kind === "suite") {
|
|
446
|
+
await ctx.db.insert("organizations", organizationFromScope(scope));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function upsertScope(scope) {
|
|
450
|
+
const existing = await ctx.db
|
|
451
|
+
.query("scopes")
|
|
452
|
+
.withIndex("by_scope_id", (q) => q.eq("accessScopeId", scope.accessScopeId))
|
|
453
|
+
.unique();
|
|
454
|
+
if (existing)
|
|
455
|
+
await ctx.db.replace(existing._id, { ...scope });
|
|
456
|
+
else
|
|
457
|
+
await ctx.db.insert("scopes", { ...scope });
|
|
458
|
+
const organization = await ctx.db
|
|
459
|
+
.query("organizations")
|
|
460
|
+
.withIndex("by_scope_id", (q) => q.eq("accessScopeId", scope.accessScopeId))
|
|
461
|
+
.unique();
|
|
462
|
+
if (scope.kind !== "org" && scope.kind !== "suite") {
|
|
463
|
+
if (organization)
|
|
464
|
+
await ctx.db.delete(organization._id);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const row = organizationFromScope(scope);
|
|
468
|
+
if (organization)
|
|
469
|
+
await ctx.db.replace(organization._id, row);
|
|
470
|
+
else
|
|
471
|
+
await ctx.db.insert("organizations", row);
|
|
472
|
+
}
|
|
473
|
+
async function deleteScope(accessScopeId) {
|
|
474
|
+
const scope = await ctx.db
|
|
475
|
+
.query("scopes")
|
|
476
|
+
.withIndex("by_scope_id", (q) => q.eq("accessScopeId", accessScopeId))
|
|
477
|
+
.unique();
|
|
478
|
+
const organization = await ctx.db
|
|
479
|
+
.query("organizations")
|
|
480
|
+
.withIndex("by_scope_id", (q) => q.eq("accessScopeId", accessScopeId))
|
|
481
|
+
.unique();
|
|
482
|
+
if (organization)
|
|
483
|
+
await ctx.db.delete(organization._id);
|
|
484
|
+
if (scope)
|
|
485
|
+
await ctx.db.delete(scope._id);
|
|
486
|
+
}
|
|
487
|
+
async function upsertUser(user) {
|
|
488
|
+
const existing = await ctx.db
|
|
489
|
+
.query("users")
|
|
490
|
+
.withIndex("by_auth_user_id", (q) => q.eq("herculesAuthUserId", user.herculesAuthUserId))
|
|
491
|
+
.unique();
|
|
492
|
+
if (existing && existing.updatedAt >= user.updatedAt)
|
|
493
|
+
return;
|
|
494
|
+
if (existing)
|
|
495
|
+
await ctx.db.replace(existing._id, user);
|
|
496
|
+
else
|
|
497
|
+
await ctx.db.insert("users", user);
|
|
498
|
+
}
|
|
499
|
+
async function deleteUser(herculesAuthUserId) {
|
|
500
|
+
const user = await ctx.db
|
|
501
|
+
.query("users")
|
|
502
|
+
.withIndex("by_auth_user_id", (q) => q.eq("herculesAuthUserId", herculesAuthUserId))
|
|
503
|
+
.unique();
|
|
504
|
+
if (user)
|
|
505
|
+
await ctx.db.delete(user._id);
|
|
506
|
+
}
|
|
507
|
+
async function upsertPrincipal(accessScopeId, principal) {
|
|
508
|
+
const existing = await ctx.db
|
|
509
|
+
.query("principals")
|
|
510
|
+
.withIndex("by_principal_id", (q) => q.eq("principalId", principal.principalId))
|
|
511
|
+
.unique();
|
|
512
|
+
const row = { accessScopeId, ...principal };
|
|
513
|
+
if (existing)
|
|
514
|
+
await ctx.db.replace(existing._id, row);
|
|
515
|
+
else
|
|
516
|
+
await ctx.db.insert("principals", row);
|
|
517
|
+
}
|
|
518
|
+
async function deletePrincipal(accessScopeId, principalId) {
|
|
519
|
+
const principal = await ctx.db
|
|
520
|
+
.query("principals")
|
|
521
|
+
.withIndex("by_principal_id", (q) => q.eq("principalId", principalId))
|
|
522
|
+
.unique();
|
|
523
|
+
if (!principal)
|
|
524
|
+
return;
|
|
525
|
+
// Cascade: the principal's bindings (as subject) and memberships.
|
|
526
|
+
for (const binding of await ctx.db
|
|
527
|
+
.query("role_bindings")
|
|
528
|
+
.withIndex("by_subject_principal", (q) => q.eq("subjectPrincipalId", principalId))
|
|
529
|
+
.collect()) {
|
|
530
|
+
await ctx.db.delete(binding._id);
|
|
531
|
+
}
|
|
532
|
+
for (const binding of await ctx.db
|
|
533
|
+
.query("permission_bindings")
|
|
534
|
+
.withIndex("by_subject_principal", (q) => q.eq("subjectPrincipalId", principalId))
|
|
535
|
+
.collect()) {
|
|
536
|
+
await ctx.db.delete(binding._id);
|
|
537
|
+
}
|
|
538
|
+
for (const membership of await ctx.db
|
|
539
|
+
.query("principal_memberships")
|
|
540
|
+
.withIndex("by_group", (q) => q.eq("accessScopeId", accessScopeId).eq("groupPrincipalId", principalId))
|
|
541
|
+
.collect()) {
|
|
542
|
+
await ctx.db.delete(membership._id);
|
|
543
|
+
}
|
|
544
|
+
for (const membership of await ctx.db
|
|
545
|
+
.query("principal_memberships")
|
|
546
|
+
.withIndex("by_member", (q) => q.eq("accessScopeId", accessScopeId).eq("memberPrincipalId", principalId))
|
|
547
|
+
.collect()) {
|
|
548
|
+
await ctx.db.delete(membership._id);
|
|
549
|
+
}
|
|
550
|
+
await ctx.db.delete(principal._id);
|
|
551
|
+
}
|
|
552
|
+
async function upsertMembership(accessScopeId, membership) {
|
|
553
|
+
const existing = await ctx.db
|
|
554
|
+
.query("principal_memberships")
|
|
555
|
+
.withIndex("by_group_member", (q) => q
|
|
556
|
+
.eq("accessScopeId", accessScopeId)
|
|
557
|
+
.eq("groupPrincipalId", membership.groupPrincipalId)
|
|
558
|
+
.eq("memberPrincipalId", membership.memberPrincipalId))
|
|
559
|
+
.unique();
|
|
560
|
+
const row = { accessScopeId, ...membership };
|
|
561
|
+
if (existing)
|
|
562
|
+
await ctx.db.replace(existing._id, row);
|
|
563
|
+
else
|
|
564
|
+
await ctx.db.insert("principal_memberships", row);
|
|
565
|
+
}
|
|
566
|
+
async function deleteMembership(accessScopeId, groupPrincipalId, memberPrincipalId) {
|
|
567
|
+
const membership = await ctx.db
|
|
568
|
+
.query("principal_memberships")
|
|
569
|
+
.withIndex("by_group_member", (q) => q
|
|
570
|
+
.eq("accessScopeId", accessScopeId)
|
|
571
|
+
.eq("groupPrincipalId", groupPrincipalId)
|
|
572
|
+
.eq("memberPrincipalId", memberPrincipalId))
|
|
573
|
+
.unique();
|
|
574
|
+
if (membership)
|
|
575
|
+
await ctx.db.delete(membership._id);
|
|
576
|
+
}
|
|
577
|
+
async function upsertCatalogRole(role) {
|
|
578
|
+
const existing = await ctx.db
|
|
579
|
+
.query("roles")
|
|
580
|
+
.withIndex("by_role_id", (q) => q.eq("roleId", role.roleId))
|
|
581
|
+
.unique();
|
|
582
|
+
// Catalog roles carry NO accessScopeId.
|
|
583
|
+
if (existing)
|
|
584
|
+
await ctx.db.replace(existing._id, role);
|
|
585
|
+
else
|
|
586
|
+
await ctx.db.insert("roles", role);
|
|
587
|
+
}
|
|
588
|
+
async function upsertTenantRole(role) {
|
|
589
|
+
const existing = await ctx.db
|
|
590
|
+
.query("roles")
|
|
591
|
+
.withIndex("by_role_id", (q) => q.eq("roleId", role.roleId))
|
|
592
|
+
.unique();
|
|
593
|
+
if (existing)
|
|
594
|
+
await ctx.db.replace(existing._id, role);
|
|
595
|
+
else
|
|
596
|
+
await ctx.db.insert("roles", role);
|
|
597
|
+
}
|
|
598
|
+
async function deleteRoleEverywhere(roleId) {
|
|
599
|
+
const role = await ctx.db
|
|
600
|
+
.query("roles")
|
|
601
|
+
.withIndex("by_role_id", (q) => q.eq("roleId", roleId))
|
|
602
|
+
.unique();
|
|
603
|
+
if (!role)
|
|
604
|
+
return;
|
|
605
|
+
// Cascade base mappings + per-scope overrides keyed on this role.
|
|
606
|
+
for (const row of await ctx.db
|
|
607
|
+
.query("role_permissions")
|
|
608
|
+
.withIndex("by_role", (q) => q.eq("roleId", roleId))
|
|
609
|
+
.collect()) {
|
|
610
|
+
await ctx.db.delete(row._id);
|
|
611
|
+
}
|
|
612
|
+
for (const row of await ctx.db
|
|
613
|
+
.query("role_permission_overrides")
|
|
614
|
+
.withIndex("by_role", (q) => q.eq("roleId", roleId))
|
|
615
|
+
.collect()) {
|
|
616
|
+
await ctx.db.delete(row._id);
|
|
617
|
+
}
|
|
618
|
+
// Cascade bindings: role bindings of this role, and permission bindings
|
|
619
|
+
// with this role as subject.
|
|
620
|
+
for (const binding of await ctx.db
|
|
621
|
+
.query("role_bindings")
|
|
622
|
+
.withIndex("by_role", (q) => q.eq("roleId", roleId))
|
|
623
|
+
.collect()) {
|
|
624
|
+
await ctx.db.delete(binding._id);
|
|
625
|
+
}
|
|
626
|
+
for (const binding of await ctx.db
|
|
627
|
+
.query("permission_bindings")
|
|
628
|
+
.withIndex("by_subject_role", (q) => q.eq("subjectRoleId", roleId))
|
|
629
|
+
.collect()) {
|
|
630
|
+
await ctx.db.delete(binding._id);
|
|
631
|
+
}
|
|
632
|
+
await ctx.db.delete(role._id);
|
|
633
|
+
}
|
|
634
|
+
async function deleteCatalogRole(roleId) {
|
|
635
|
+
await deleteRoleEverywhere(roleId);
|
|
636
|
+
}
|
|
637
|
+
async function deleteTenantRole(roleId) {
|
|
638
|
+
await deleteRoleEverywhere(roleId);
|
|
639
|
+
}
|
|
640
|
+
async function upsertPermission(permission) {
|
|
641
|
+
const existing = await ctx.db
|
|
642
|
+
.query("permissions")
|
|
643
|
+
.withIndex("by_permission_id", (q) => q.eq("permissionId", permission.permissionId))
|
|
644
|
+
.unique();
|
|
645
|
+
if (existing)
|
|
646
|
+
await ctx.db.replace(existing._id, permission);
|
|
647
|
+
else
|
|
648
|
+
await ctx.db.insert("permissions", permission);
|
|
649
|
+
}
|
|
650
|
+
async function deletePermission(permissionId) {
|
|
651
|
+
const permission = await ctx.db
|
|
652
|
+
.query("permissions")
|
|
653
|
+
.withIndex("by_permission_id", (q) => q.eq("permissionId", permissionId))
|
|
654
|
+
.unique();
|
|
655
|
+
if (!permission)
|
|
656
|
+
return;
|
|
657
|
+
// Cascade base mappings, overrides, and permission bindings on this perm.
|
|
658
|
+
for (const row of await ctx.db
|
|
659
|
+
.query("role_permissions")
|
|
660
|
+
.withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
|
|
661
|
+
.collect()) {
|
|
662
|
+
await ctx.db.delete(row._id);
|
|
663
|
+
}
|
|
664
|
+
for (const row of await ctx.db
|
|
665
|
+
.query("role_permission_overrides")
|
|
666
|
+
.withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
|
|
667
|
+
.collect()) {
|
|
668
|
+
await ctx.db.delete(row._id);
|
|
669
|
+
}
|
|
670
|
+
for (const binding of await ctx.db
|
|
671
|
+
.query("permission_bindings")
|
|
672
|
+
.withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
|
|
673
|
+
.collect()) {
|
|
674
|
+
await ctx.db.delete(binding._id);
|
|
675
|
+
}
|
|
676
|
+
await ctx.db.delete(permission._id);
|
|
677
|
+
}
|
|
678
|
+
async function upsertRolePermission(rolePermission) {
|
|
679
|
+
const existing = await ctx.db
|
|
680
|
+
.query("role_permissions")
|
|
681
|
+
.withIndex("by_role_permission", (q) => q.eq("roleId", rolePermission.roleId).eq("permissionId", rolePermission.permissionId))
|
|
682
|
+
.unique();
|
|
683
|
+
if (existing)
|
|
684
|
+
await ctx.db.replace(existing._id, rolePermission);
|
|
685
|
+
else
|
|
686
|
+
await ctx.db.insert("role_permissions", rolePermission);
|
|
687
|
+
}
|
|
688
|
+
async function deleteRolePermission(roleId, permissionId) {
|
|
689
|
+
const row = await ctx.db
|
|
690
|
+
.query("role_permissions")
|
|
691
|
+
.withIndex("by_role_permission", (q) => q.eq("roleId", roleId).eq("permissionId", permissionId))
|
|
692
|
+
.unique();
|
|
693
|
+
if (row)
|
|
694
|
+
await ctx.db.delete(row._id);
|
|
695
|
+
}
|
|
696
|
+
async function upsertOverride(override) {
|
|
697
|
+
const existing = await ctx.db
|
|
698
|
+
.query("role_permission_overrides")
|
|
699
|
+
.withIndex("by_scope_role_permission", (q) => q
|
|
700
|
+
.eq("accessScopeId", override.accessScopeId)
|
|
701
|
+
.eq("roleId", override.roleId)
|
|
702
|
+
.eq("permissionId", override.permissionId))
|
|
703
|
+
.unique();
|
|
704
|
+
if (existing)
|
|
705
|
+
await ctx.db.replace(existing._id, override);
|
|
706
|
+
else
|
|
707
|
+
await ctx.db.insert("role_permission_overrides", override);
|
|
708
|
+
}
|
|
709
|
+
async function deleteOverride(accessScopeId, roleId, permissionId) {
|
|
710
|
+
const row = await ctx.db
|
|
711
|
+
.query("role_permission_overrides")
|
|
712
|
+
.withIndex("by_scope_role_permission", (q) => q
|
|
713
|
+
.eq("accessScopeId", accessScopeId)
|
|
714
|
+
.eq("roleId", roleId)
|
|
715
|
+
.eq("permissionId", permissionId))
|
|
716
|
+
.unique();
|
|
717
|
+
if (row)
|
|
718
|
+
await ctx.db.delete(row._id);
|
|
719
|
+
}
|
|
720
|
+
async function upsertRoleBinding(binding, now) {
|
|
721
|
+
const existing = await ctx.db
|
|
722
|
+
.query("role_bindings")
|
|
723
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", binding.bindingId))
|
|
724
|
+
.unique();
|
|
725
|
+
if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
|
|
726
|
+
if (existing)
|
|
727
|
+
await ctx.db.delete(existing._id);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const normalized = { ...binding, appliesTo: bindingAppliesTo(binding) };
|
|
731
|
+
if (existing)
|
|
732
|
+
await ctx.db.replace(existing._id, normalized);
|
|
733
|
+
else
|
|
734
|
+
await ctx.db.insert("role_bindings", normalized);
|
|
735
|
+
await scheduleRoleBindingExpiration(binding);
|
|
736
|
+
}
|
|
737
|
+
async function deleteRoleBinding(bindingId) {
|
|
738
|
+
const binding = await ctx.db
|
|
739
|
+
.query("role_bindings")
|
|
740
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
|
|
741
|
+
.unique();
|
|
742
|
+
if (binding)
|
|
743
|
+
await ctx.db.delete(binding._id);
|
|
744
|
+
}
|
|
745
|
+
async function upsertPermissionBinding(binding, now) {
|
|
746
|
+
const existing = await ctx.db
|
|
747
|
+
.query("permission_bindings")
|
|
748
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", binding.bindingId))
|
|
749
|
+
.unique();
|
|
750
|
+
if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
|
|
751
|
+
if (existing)
|
|
752
|
+
await ctx.db.delete(existing._id);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const normalized = { ...binding, appliesTo: bindingAppliesTo(binding) };
|
|
756
|
+
if (existing)
|
|
757
|
+
await ctx.db.replace(existing._id, normalized);
|
|
758
|
+
else
|
|
759
|
+
await ctx.db.insert("permission_bindings", normalized);
|
|
760
|
+
await schedulePermissionBindingExpiration(binding);
|
|
761
|
+
}
|
|
762
|
+
async function deletePermissionBinding(bindingId) {
|
|
763
|
+
const binding = await ctx.db
|
|
764
|
+
.query("permission_bindings")
|
|
765
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
|
|
766
|
+
.unique();
|
|
767
|
+
if (binding)
|
|
768
|
+
await ctx.db.delete(binding._id);
|
|
769
|
+
}
|
|
770
|
+
async function scheduleRoleBindingExpiration(binding) {
|
|
771
|
+
if (binding.expiresAt === undefined)
|
|
772
|
+
return;
|
|
773
|
+
await ctx.scheduler.runAt(binding.expiresAt, expireRoleBindingReference, {
|
|
774
|
+
bindingId: binding.bindingId,
|
|
775
|
+
expiresAt: binding.expiresAt,
|
|
776
|
+
updatedAt: binding.updatedAt,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
async function schedulePermissionBindingExpiration(binding) {
|
|
780
|
+
if (binding.expiresAt === undefined)
|
|
781
|
+
return;
|
|
782
|
+
await ctx.scheduler.runAt(binding.expiresAt, expirePermissionBindingReference, {
|
|
783
|
+
bindingId: binding.bindingId,
|
|
784
|
+
expiresAt: binding.expiresAt,
|
|
785
|
+
updatedAt: binding.updatedAt,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
export const expireRoleBinding = internalMutation({
|
|
791
|
+
args: { bindingId: v.string(), expiresAt: v.number(), updatedAt: v.number() },
|
|
792
|
+
handler: async (ctx, args) => {
|
|
793
|
+
const binding = await ctx.db
|
|
794
|
+
.query("role_bindings")
|
|
795
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", args.bindingId))
|
|
796
|
+
.unique();
|
|
797
|
+
if (!binding || binding.expiresAt !== args.expiresAt || binding.updatedAt !== args.updatedAt) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (args.expiresAt > Date.now()) {
|
|
801
|
+
await ctx.scheduler.runAt(args.expiresAt, expireRoleBindingReference, args);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
await ctx.db.delete(binding._id);
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
export const expirePermissionBinding = internalMutation({
|
|
808
|
+
args: { bindingId: v.string(), expiresAt: v.number(), updatedAt: v.number() },
|
|
809
|
+
handler: async (ctx, args) => {
|
|
810
|
+
const binding = await ctx.db
|
|
811
|
+
.query("permission_bindings")
|
|
812
|
+
.withIndex("by_binding_id", (q) => q.eq("bindingId", args.bindingId))
|
|
813
|
+
.unique();
|
|
814
|
+
if (!binding || binding.expiresAt !== args.expiresAt || binding.updatedAt !== args.updatedAt) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (args.expiresAt > Date.now()) {
|
|
818
|
+
await ctx.scheduler.runAt(args.expiresAt, expirePermissionBindingReference, args);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
await ctx.db.delete(binding._id);
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
function organizationFromScope(scope) {
|
|
825
|
+
return {
|
|
826
|
+
accessScopeId: scope.accessScopeId,
|
|
827
|
+
name: scope.name,
|
|
828
|
+
status: scope.status,
|
|
829
|
+
accountEntryMode: scope.accountEntryMode,
|
|
830
|
+
updatedAt: scope.updatedAt,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function snapshotDocumentCount(snapshot) {
|
|
834
|
+
let count = snapshot.catalog.roles.length +
|
|
835
|
+
snapshot.catalog.permissions.length +
|
|
836
|
+
snapshot.catalog.rolePermissions.length +
|
|
837
|
+
snapshot.users.length;
|
|
838
|
+
for (const scope of snapshot.scopes) {
|
|
839
|
+
count +=
|
|
840
|
+
1 + // scope (+ possibly an organization row, counted generously below)
|
|
841
|
+
1 +
|
|
842
|
+
scope.principals.length +
|
|
843
|
+
scope.principalMemberships.length +
|
|
844
|
+
scope.roles.length +
|
|
845
|
+
scope.rolePermissionOverrides.length +
|
|
846
|
+
scope.roleBindings.length +
|
|
847
|
+
scope.permissionBindings.length;
|
|
848
|
+
}
|
|
849
|
+
return count;
|
|
850
|
+
}
|
|
851
|
+
//# sourceMappingURL=sync.js.map
|