@invect/rbac 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/dist/backend/index.cjs +1365 -0
  4. package/dist/backend/index.cjs.map +1 -0
  5. package/dist/backend/index.d.ts +3 -0
  6. package/dist/backend/index.d.ts.map +1 -0
  7. package/dist/backend/index.mjs +1363 -0
  8. package/dist/backend/index.mjs.map +1 -0
  9. package/dist/backend/plugin.d.ts +60 -0
  10. package/dist/backend/plugin.d.ts.map +1 -0
  11. package/dist/frontend/components/AccessControlPage.d.ts +2 -0
  12. package/dist/frontend/components/AccessControlPage.d.ts.map +1 -0
  13. package/dist/frontend/components/FlowAccessPanel.d.ts +10 -0
  14. package/dist/frontend/components/FlowAccessPanel.d.ts.map +1 -0
  15. package/dist/frontend/components/ShareButton.d.ts +9 -0
  16. package/dist/frontend/components/ShareButton.d.ts.map +1 -0
  17. package/dist/frontend/components/ShareFlowModal.d.ts +12 -0
  18. package/dist/frontend/components/ShareFlowModal.d.ts.map +1 -0
  19. package/dist/frontend/components/TeamsPage.d.ts +5 -0
  20. package/dist/frontend/components/TeamsPage.d.ts.map +1 -0
  21. package/dist/frontend/components/UserMenuSection.d.ts +14 -0
  22. package/dist/frontend/components/UserMenuSection.d.ts.map +1 -0
  23. package/dist/frontend/components/access-control/AccessControlPage.d.ts +2 -0
  24. package/dist/frontend/components/access-control/AccessControlPage.d.ts.map +1 -0
  25. package/dist/frontend/components/access-control/AccessTable.d.ts +17 -0
  26. package/dist/frontend/components/access-control/AccessTable.d.ts.map +1 -0
  27. package/dist/frontend/components/access-control/FlowDetailPanel.d.ts +11 -0
  28. package/dist/frontend/components/access-control/FlowDetailPanel.d.ts.map +1 -0
  29. package/dist/frontend/components/access-control/FormDialog.d.ts +7 -0
  30. package/dist/frontend/components/access-control/FormDialog.d.ts.map +1 -0
  31. package/dist/frontend/components/access-control/MemberCombobox.d.ts +8 -0
  32. package/dist/frontend/components/access-control/MemberCombobox.d.ts.map +1 -0
  33. package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts +9 -0
  34. package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts.map +1 -0
  35. package/dist/frontend/components/access-control/PrincipalCombobox.d.ts +11 -0
  36. package/dist/frontend/components/access-control/PrincipalCombobox.d.ts.map +1 -0
  37. package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts +11 -0
  38. package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts.map +1 -0
  39. package/dist/frontend/components/access-control/index.d.ts +4 -0
  40. package/dist/frontend/components/access-control/index.d.ts.map +1 -0
  41. package/dist/frontend/components/access-control/types.d.ts +36 -0
  42. package/dist/frontend/components/access-control/types.d.ts.map +1 -0
  43. package/dist/frontend/components/access-control/useUsers.d.ts +3 -0
  44. package/dist/frontend/components/access-control/useUsers.d.ts.map +1 -0
  45. package/dist/frontend/hooks/useFlowAccess.d.ts +15 -0
  46. package/dist/frontend/hooks/useFlowAccess.d.ts.map +1 -0
  47. package/dist/frontend/hooks/useScopes.d.ts +15 -0
  48. package/dist/frontend/hooks/useScopes.d.ts.map +1 -0
  49. package/dist/frontend/hooks/useTeams.d.ts +25 -0
  50. package/dist/frontend/hooks/useTeams.d.ts.map +1 -0
  51. package/dist/frontend/index.cjs +2928 -0
  52. package/dist/frontend/index.cjs.map +1 -0
  53. package/dist/frontend/index.d.ts +23 -0
  54. package/dist/frontend/index.d.ts.map +1 -0
  55. package/dist/frontend/index.mjs +2899 -0
  56. package/dist/frontend/index.mjs.map +1 -0
  57. package/dist/frontend/providers/RbacProvider.d.ts +33 -0
  58. package/dist/frontend/providers/RbacProvider.d.ts.map +1 -0
  59. package/dist/frontend/stores/accessControlStore.d.ts +49 -0
  60. package/dist/frontend/stores/accessControlStore.d.ts.map +1 -0
  61. package/dist/frontend/types.d.ts +95 -0
  62. package/dist/frontend/types.d.ts.map +1 -0
  63. package/dist/shared/types.cjs +0 -0
  64. package/dist/shared/types.d.ts +172 -0
  65. package/dist/shared/types.d.ts.map +1 -0
  66. package/dist/shared/types.mjs +1 -0
  67. package/package.json +107 -0
@@ -0,0 +1,1365 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/backend/plugin.ts
3
+ /**
4
+ * Resolve team IDs for a user from the rbac_team_members table.
5
+ * Use this in a custom `mapUser` for the auth plugin to populate
6
+ * `identity.teamIds` automatically.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { resolveTeamIds } from '@invect/rbac/backend';
11
+ *
12
+ * betterAuthPlugin({
13
+ * auth,
14
+ * mapUser: async (user, session) => ({
15
+ * id: user.id,
16
+ * name: user.name ?? undefined,
17
+ * role: user.role === 'admin' ? 'admin' : 'user',
18
+ * teamIds: await resolveTeamIds(db, user.id),
19
+ * }),
20
+ * });
21
+ * ```
22
+ */
23
+ async function resolveTeamIds(db, userId) {
24
+ return (await db.query("SELECT team_id FROM rbac_team_members WHERE user_id = ?", [userId])).map((r) => r.team_id);
25
+ }
26
+ const FLOW_RESOURCE_TYPES = new Set([
27
+ "flow",
28
+ "flow-version",
29
+ "flow-run",
30
+ "node-execution"
31
+ ]);
32
+ const FLOW_PERMISSION_LEVELS = {
33
+ viewer: 1,
34
+ operator: 2,
35
+ editor: 3,
36
+ owner: 4
37
+ };
38
+ function isFlowAccessPermission(value) {
39
+ return value === "viewer" || value === "operator" || value === "editor" || value === "owner";
40
+ }
41
+ function toFlowAccessPermission(value) {
42
+ return isFlowAccessPermission(value) ? value : null;
43
+ }
44
+ function getHigherPermission(left, right) {
45
+ if (!left) return right;
46
+ if (!right) return left;
47
+ return FLOW_PERMISSION_LEVELS[right] > FLOW_PERMISSION_LEVELS[left] ? right : left;
48
+ }
49
+ function createInClause(count) {
50
+ return Array.from({ length: count }, () => "?").join(", ");
51
+ }
52
+ function mapActionToRequiredPermission(action) {
53
+ if (action.includes("delete") || action.includes("share") || action.includes("admin")) return "owner";
54
+ if (action.includes("update") || action.includes("write") || action.includes("create")) return "editor";
55
+ if (action.includes("run") || action.includes("execute")) return "operator";
56
+ return "viewer";
57
+ }
58
+ function normalizeFlowAccessRecord(row) {
59
+ return {
60
+ id: String(row.id),
61
+ flowId: String(row.flowId ?? row.flow_id),
62
+ userId: row.userId ?? row.user_id ? String(row.userId ?? row.user_id) : null,
63
+ teamId: row.teamId ?? row.team_id ? String(row.teamId ?? row.team_id) : null,
64
+ permission: String(row.permission),
65
+ grantedBy: row.grantedBy ?? row.granted_by ? String(row.grantedBy ?? row.granted_by) : null,
66
+ grantedAt: String(row.grantedAt ?? row.granted_at),
67
+ expiresAt: row.expiresAt ?? row.expires_at ? String(row.expiresAt ?? row.expires_at) : null
68
+ };
69
+ }
70
+ function normalizeScopeAccessRecord(row) {
71
+ return {
72
+ id: String(row.id),
73
+ scopeId: String(row.scopeId ?? row.scope_id),
74
+ userId: row.userId ?? row.user_id ? String(row.userId ?? row.user_id) : null,
75
+ teamId: row.teamId ?? row.team_id ? String(row.teamId ?? row.team_id) : null,
76
+ permission: String(row.permission),
77
+ grantedBy: row.grantedBy ?? row.granted_by ? String(row.grantedBy ?? row.granted_by) : null,
78
+ grantedAt: String(row.grantedAt ?? row.granted_at)
79
+ };
80
+ }
81
+ function normalizeTeamRow(row) {
82
+ return {
83
+ id: row.id,
84
+ name: row.name,
85
+ description: row.description,
86
+ parentId: row.parent_id ?? null,
87
+ createdBy: row.created_by,
88
+ createdAt: row.created_at,
89
+ updatedAt: row.updated_at
90
+ };
91
+ }
92
+ async function getFlowScopeId(db, flowId) {
93
+ return (await db.query("SELECT scope_id FROM flows WHERE id = ?", [flowId]))[0]?.scope_id ?? null;
94
+ }
95
+ async function getAncestorScopeIds(db, scopeId) {
96
+ return (await db.query(`WITH RECURSIVE ancestors AS (
97
+ SELECT id, parent_id FROM rbac_teams WHERE id = ?
98
+ UNION ALL
99
+ SELECT parent.id, parent.parent_id
100
+ FROM rbac_teams parent
101
+ INNER JOIN ancestors current ON parent.id = current.parent_id
102
+ )
103
+ SELECT id FROM ancestors`, [scopeId])).map((row) => row.id);
104
+ }
105
+ async function getDescendantScopeIds(db, scopeId) {
106
+ return (await db.query(`WITH RECURSIVE descendants AS (
107
+ SELECT id FROM rbac_teams WHERE id = ?
108
+ UNION ALL
109
+ SELECT child.id
110
+ FROM rbac_teams child
111
+ INNER JOIN descendants current ON child.parent_id = current.id
112
+ )
113
+ SELECT id FROM descendants`, [scopeId])).map((row) => row.id);
114
+ }
115
+ async function listScopeAccessForScopeIds(db, scopeIds, userId, teamIds) {
116
+ if (scopeIds.length === 0) return [];
117
+ const params = [...scopeIds, userId];
118
+ let sql = `SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at FROM rbac_scope_access WHERE (scope_id IN (${createInClause(scopeIds.length)}) AND user_id = ?)`;
119
+ if (teamIds.length > 0) {
120
+ sql += ` OR (scope_id IN (${createInClause(scopeIds.length)}) AND team_id IN (${createInClause(teamIds.length)}))`;
121
+ params.push(...scopeIds, ...teamIds);
122
+ }
123
+ return (await db.query(sql, params)).map(normalizeScopeAccessRecord);
124
+ }
125
+ async function listDirectFlowAccessForIdentity(db, flowId, userId, teamIds) {
126
+ const params = [flowId, userId];
127
+ let sql = "SELECT id, flow_id, user_id, team_id, permission, granted_by, granted_at, expires_at FROM flow_access WHERE (flow_id = ? AND user_id = ?)";
128
+ if (teamIds.length > 0) {
129
+ sql += ` OR (flow_id = ? AND team_id IN (${createInClause(teamIds.length)}))`;
130
+ params.push(flowId, ...teamIds);
131
+ }
132
+ const rows = await db.query(sql, params);
133
+ const now = Date.now();
134
+ return rows.map(normalizeFlowAccessRecord).filter((record) => !record.expiresAt || new Date(record.expiresAt).getTime() > now);
135
+ }
136
+ async function getEffectiveFlowAccessRecords(db, flowId, userId, teamIds) {
137
+ const directRecords = (await listDirectFlowAccessForIdentity(db, flowId, userId, teamIds)).map((record) => ({
138
+ ...record,
139
+ source: "direct"
140
+ }));
141
+ const scopeId = await getFlowScopeId(db, flowId);
142
+ if (!scopeId) return directRecords;
143
+ const ancestorIds = await getAncestorScopeIds(db, scopeId);
144
+ const inheritedRows = await listScopeAccessForScopeIds(db, ancestorIds, userId, teamIds);
145
+ if (inheritedRows.length === 0) return directRecords;
146
+ const scopeRows = await db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(ancestorIds.length)})`, ancestorIds);
147
+ const scopeNames = new Map(scopeRows.map((row) => [row.id, row.name]));
148
+ return [...directRecords, ...inheritedRows.map((record) => ({
149
+ id: record.id,
150
+ flowId,
151
+ userId: record.userId,
152
+ teamId: record.teamId,
153
+ permission: record.permission,
154
+ grantedBy: record.grantedBy,
155
+ grantedAt: record.grantedAt,
156
+ expiresAt: null,
157
+ source: "inherited",
158
+ scopeId: record.scopeId,
159
+ scopeName: scopeNames.get(record.scopeId) ?? null
160
+ }))];
161
+ }
162
+ async function getEffectiveFlowPermission(db, flowId, identity) {
163
+ const records = await getEffectiveFlowAccessRecords(db, flowId, identity.id, identity.teamIds ?? []);
164
+ let highest = null;
165
+ for (const record of records) highest = getHigherPermission(highest, record.permission);
166
+ return highest;
167
+ }
168
+ async function getCurrentUserAccessibleFlows(db, identity) {
169
+ const rows = await db.query("SELECT id FROM flows");
170
+ const permissions = {};
171
+ await Promise.all(rows.map(async (row) => {
172
+ permissions[row.id] = await getEffectiveFlowPermission(db, row.id, identity);
173
+ }));
174
+ return {
175
+ flowIds: rows.map((row) => row.id).filter((flowId) => permissions[flowId]),
176
+ permissions
177
+ };
178
+ }
179
+ async function getScopePath(db, scopeId) {
180
+ if (!scopeId) return [];
181
+ return (await db.query(`WITH RECURSIVE ancestors AS (
182
+ SELECT id, name, parent_id, 0 AS depth FROM rbac_teams WHERE id = ?
183
+ UNION ALL
184
+ SELECT parent.id, parent.name, parent.parent_id, current.depth + 1
185
+ FROM rbac_teams parent
186
+ INNER JOIN ancestors current ON parent.id = current.parent_id
187
+ )
188
+ SELECT id, name FROM ancestors ORDER BY depth DESC`, [scopeId])).map((row) => row.name);
189
+ }
190
+ async function listAllScopeAccessForScopeIds(db, scopeIds) {
191
+ if (scopeIds.length === 0) return [];
192
+ return (await db.query(`SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at
193
+ FROM rbac_scope_access
194
+ WHERE scope_id IN (${createInClause(scopeIds.length)})`, scopeIds)).map(normalizeScopeAccessRecord);
195
+ }
196
+ async function listAllDirectFlowAccess(db, flowId) {
197
+ const rows = await db.query("SELECT id, flow_id, user_id, team_id, permission, granted_by, granted_at, expires_at FROM flow_access WHERE flow_id = ?", [flowId]);
198
+ const now = Date.now();
199
+ return rows.map(normalizeFlowAccessRecord).filter((record) => !record.expiresAt || new Date(record.expiresAt).getTime() > now);
200
+ }
201
+ async function listAllEffectiveFlowAccessForPreview(db, flowId, overrideScopeId) {
202
+ const direct = await listAllDirectFlowAccess(db, flowId);
203
+ const scopeId = overrideScopeId === void 0 ? await getFlowScopeId(db, flowId) : overrideScopeId;
204
+ if (!scopeId) return direct;
205
+ const inherited = await listAllScopeAccessForScopeIds(db, await getAncestorScopeIds(db, scopeId));
206
+ return [...direct, ...inherited];
207
+ }
208
+ async function listAllEffectiveFlowAccessRecords(db, flowId) {
209
+ const directRecords = (await listAllDirectFlowAccess(db, flowId)).map((record) => ({
210
+ ...record,
211
+ source: "direct"
212
+ }));
213
+ const scopeId = await getFlowScopeId(db, flowId);
214
+ if (!scopeId) return directRecords;
215
+ const ancestorIds = await getAncestorScopeIds(db, scopeId);
216
+ const inheritedRows = await listAllScopeAccessForScopeIds(db, ancestorIds);
217
+ if (inheritedRows.length === 0) return directRecords;
218
+ const scopeRows = await db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(ancestorIds.length)})`, ancestorIds);
219
+ const scopeNames = new Map(scopeRows.map((row) => [row.id, row.name]));
220
+ const teamIds = [...new Set(inheritedRows.map((r) => r.teamId).filter((id) => !!id))];
221
+ const teamMembersByTeamId = /* @__PURE__ */ new Map();
222
+ if (teamIds.length > 0) {
223
+ const memberRows = await db.query(`SELECT team_id, user_id FROM rbac_team_members WHERE team_id IN (${createInClause(teamIds.length)})`, teamIds);
224
+ for (const row of memberRows) {
225
+ const members = teamMembersByTeamId.get(row.team_id) ?? [];
226
+ members.push(row.user_id);
227
+ teamMembersByTeamId.set(row.team_id, members);
228
+ }
229
+ }
230
+ const expandedInherited = [];
231
+ for (const record of inheritedRows) {
232
+ const scopeName = scopeNames.get(record.scopeId) ?? null;
233
+ if (record.teamId) {
234
+ const members = teamMembersByTeamId.get(record.teamId) ?? [];
235
+ for (const userId of members) expandedInherited.push({
236
+ id: `${record.id}:${userId}`,
237
+ flowId,
238
+ userId,
239
+ teamId: null,
240
+ permission: record.permission,
241
+ grantedBy: record.grantedBy,
242
+ grantedAt: record.grantedAt,
243
+ expiresAt: null,
244
+ source: "inherited",
245
+ scopeId: record.scopeId,
246
+ scopeName
247
+ });
248
+ } else expandedInherited.push({
249
+ id: record.id,
250
+ flowId,
251
+ userId: record.userId,
252
+ teamId: null,
253
+ permission: record.permission,
254
+ grantedBy: record.grantedBy,
255
+ grantedAt: record.grantedAt,
256
+ expiresAt: null,
257
+ source: "inherited",
258
+ scopeId: record.scopeId,
259
+ scopeName
260
+ });
261
+ }
262
+ return [...directRecords, ...expandedInherited];
263
+ }
264
+ function buildAccessKey(record) {
265
+ if (record.userId) return `user:${record.userId}`;
266
+ if (record.teamId) return `team:${record.teamId}`;
267
+ return "unknown";
268
+ }
269
+ async function resolveAccessChangeNames(db, entries) {
270
+ const userIds = Array.from(new Set(entries.map((entry) => entry.userId).filter(Boolean)));
271
+ const teamIds = Array.from(new Set(entries.map((entry) => entry.teamId).filter(Boolean)));
272
+ const [userRows, teamRows] = await Promise.all([userIds.length > 0 ? db.query(`SELECT id, name, email FROM user WHERE id IN (${createInClause(userIds.length)})`, userIds) : Promise.resolve([]), teamIds.length > 0 ? db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(teamIds.length)})`, teamIds) : Promise.resolve([])]);
273
+ const userMap = new Map(userRows.map((row) => [row.id, row.name || row.email || row.id]));
274
+ const teamMap = new Map(teamRows.map((row) => [row.id, row.name]));
275
+ return entries.map((entry) => ({
276
+ userId: entry.userId ?? void 0,
277
+ teamId: entry.teamId ?? void 0,
278
+ name: entry.userId ? userMap.get(entry.userId) ?? entry.userId : entry.teamId ? teamMap.get(entry.teamId) ?? entry.teamId : "Unknown",
279
+ permission: entry.permission,
280
+ source: entry.source
281
+ }));
282
+ }
283
+ function rbacPlugin(options = {}) {
284
+ const { useFlowAccessTable = true, adminPermission = "flow:read", enableTeams = true } = options;
285
+ const teamsSchema = enableTeams ? {
286
+ flows: { fields: { scope_id: {
287
+ type: "string",
288
+ required: false,
289
+ references: {
290
+ table: "rbac_teams",
291
+ field: "id",
292
+ onDelete: "set null"
293
+ },
294
+ index: true
295
+ } } },
296
+ rbac_teams: { fields: {
297
+ id: {
298
+ type: "string",
299
+ primaryKey: true
300
+ },
301
+ name: {
302
+ type: "string",
303
+ required: true
304
+ },
305
+ description: {
306
+ type: "text",
307
+ required: false
308
+ },
309
+ parent_id: {
310
+ type: "string",
311
+ required: false,
312
+ references: {
313
+ table: "rbac_teams",
314
+ field: "id",
315
+ onDelete: "set null"
316
+ },
317
+ index: true
318
+ },
319
+ created_by: {
320
+ type: "string",
321
+ required: false,
322
+ references: {
323
+ table: "user",
324
+ field: "id"
325
+ }
326
+ },
327
+ created_at: {
328
+ type: "date",
329
+ required: true,
330
+ defaultValue: "now()"
331
+ },
332
+ updated_at: {
333
+ type: "date",
334
+ required: false
335
+ }
336
+ } },
337
+ rbac_team_members: { fields: {
338
+ id: {
339
+ type: "string",
340
+ primaryKey: true
341
+ },
342
+ team_id: {
343
+ type: "string",
344
+ required: true,
345
+ references: {
346
+ table: "rbac_teams",
347
+ field: "id",
348
+ onDelete: "cascade"
349
+ },
350
+ index: true
351
+ },
352
+ user_id: {
353
+ type: "string",
354
+ required: true,
355
+ references: {
356
+ table: "user",
357
+ field: "id",
358
+ onDelete: "cascade"
359
+ },
360
+ index: true
361
+ },
362
+ created_at: {
363
+ type: "date",
364
+ required: true,
365
+ defaultValue: "now()"
366
+ }
367
+ } },
368
+ rbac_scope_access: { fields: {
369
+ id: {
370
+ type: "string",
371
+ primaryKey: true
372
+ },
373
+ scope_id: {
374
+ type: "string",
375
+ required: true,
376
+ references: {
377
+ table: "rbac_teams",
378
+ field: "id",
379
+ onDelete: "cascade"
380
+ },
381
+ index: true
382
+ },
383
+ user_id: {
384
+ type: "string",
385
+ required: false,
386
+ index: true
387
+ },
388
+ team_id: {
389
+ type: "string",
390
+ required: false,
391
+ index: true
392
+ },
393
+ permission: {
394
+ type: "string",
395
+ required: true,
396
+ defaultValue: "viewer",
397
+ typeAnnotation: "FlowAccessPermission"
398
+ },
399
+ granted_by: {
400
+ type: "string",
401
+ required: false
402
+ },
403
+ granted_at: {
404
+ type: "date",
405
+ required: true,
406
+ defaultValue: "now()"
407
+ }
408
+ } }
409
+ } : {};
410
+ let capturedDbApi = null;
411
+ const ui = {
412
+ sidebar: [{
413
+ label: "Access Control",
414
+ icon: "Shield",
415
+ path: "/access",
416
+ permission: adminPermission
417
+ }, ...enableTeams ? [] : []],
418
+ pages: [{
419
+ path: "/access",
420
+ componentId: "rbac.AccessControlPage",
421
+ title: "Access Control"
422
+ }, ...enableTeams ? [] : []],
423
+ panelTabs: [{
424
+ context: "flowEditor",
425
+ label: "Access",
426
+ componentId: "rbac.FlowAccessPanel",
427
+ permission: "flow:read"
428
+ }],
429
+ headerActions: [{
430
+ context: "flowHeader",
431
+ componentId: "rbac.ShareButton",
432
+ permission: "flow:update"
433
+ }]
434
+ };
435
+ return {
436
+ id: "rbac",
437
+ name: "Role-Based Access Control",
438
+ schema: teamsSchema,
439
+ requiredTables: [
440
+ "user",
441
+ "session",
442
+ ...enableTeams ? [
443
+ "rbac_teams",
444
+ "rbac_team_members",
445
+ "rbac_scope_access"
446
+ ] : []
447
+ ],
448
+ setupInstructions: "The RBAC plugin requires better-auth tables (user, session). Make sure @invect/user-auth is configured, then run `npx invect generate` followed by `npx drizzle-kit push`.",
449
+ init: async (ctx) => {
450
+ if (!ctx.hasPlugin("better-auth")) ctx.logger.warn("RBAC plugin requires the @invect/user-auth plugin. RBAC will work with reduced functionality (no session resolution). Make sure betterAuthPlugin() is registered before rbacPlugin().");
451
+ ctx.logger.info("RBAC plugin initialized", { useFlowAccessTable });
452
+ },
453
+ endpoints: [
454
+ {
455
+ method: "GET",
456
+ path: "/rbac/me",
457
+ isPublic: false,
458
+ handler: async (ctx) => {
459
+ if (!capturedDbApi && enableTeams) capturedDbApi = ctx.database;
460
+ const identity = ctx.identity;
461
+ const permissions = ctx.core.getPermissions(identity);
462
+ const resolvedRole = identity ? ctx.core.getResolvedRole(identity) : null;
463
+ return {
464
+ status: 200,
465
+ body: {
466
+ identity: identity ? {
467
+ id: identity.id,
468
+ name: identity.name,
469
+ role: identity.role,
470
+ resolvedRole
471
+ } : null,
472
+ permissions,
473
+ isAuthenticated: !!identity
474
+ }
475
+ };
476
+ }
477
+ },
478
+ {
479
+ method: "GET",
480
+ path: "/rbac/roles",
481
+ isPublic: false,
482
+ permission: "flow:read",
483
+ handler: async (ctx) => {
484
+ return {
485
+ status: 200,
486
+ body: { roles: ctx.core.getAvailableRoles() }
487
+ };
488
+ }
489
+ },
490
+ {
491
+ method: "GET",
492
+ path: "/rbac/ui-manifest",
493
+ isPublic: true,
494
+ handler: async (_ctx) => {
495
+ return {
496
+ status: 200,
497
+ body: {
498
+ id: "rbac",
499
+ ...ui
500
+ }
501
+ };
502
+ }
503
+ },
504
+ {
505
+ method: "GET",
506
+ path: "/rbac/flows/:flowId/access",
507
+ permission: "flow:read",
508
+ handler: async (ctx) => {
509
+ const flowId = ctx.params.flowId;
510
+ if (!flowId) return {
511
+ status: 400,
512
+ body: { error: "Missing flowId parameter" }
513
+ };
514
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
515
+ status: 501,
516
+ body: {
517
+ error: "Not Implemented",
518
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
519
+ }
520
+ };
521
+ if (!ctx.identity) return {
522
+ status: 401,
523
+ body: {
524
+ error: "Unauthorized",
525
+ message: "Authentication required"
526
+ }
527
+ };
528
+ if (!(ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity))) return {
529
+ status: 403,
530
+ body: {
531
+ error: "Forbidden",
532
+ message: "No access to this flow"
533
+ }
534
+ };
535
+ return {
536
+ status: 200,
537
+ body: { access: await ctx.core.listFlowAccess(flowId) }
538
+ };
539
+ }
540
+ },
541
+ {
542
+ method: "POST",
543
+ path: "/rbac/flows/:flowId/access",
544
+ permission: "flow:read",
545
+ handler: async (ctx) => {
546
+ const flowId = ctx.params.flowId;
547
+ if (!flowId) return {
548
+ status: 400,
549
+ body: { error: "Missing flowId parameter" }
550
+ };
551
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
552
+ status: 501,
553
+ body: {
554
+ error: "Not Implemented",
555
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
556
+ }
557
+ };
558
+ const { userId, teamId, permission, expiresAt } = ctx.body;
559
+ if (!userId && !teamId) return {
560
+ status: 400,
561
+ body: { error: "Either userId or teamId must be provided" }
562
+ };
563
+ if (!ctx.identity) return {
564
+ status: 401,
565
+ body: {
566
+ error: "Unauthorized",
567
+ message: "Authentication required"
568
+ }
569
+ };
570
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
571
+ status: 403,
572
+ body: {
573
+ error: "Forbidden",
574
+ message: "Owner access is required to manage sharing"
575
+ }
576
+ };
577
+ if (!permission || ![
578
+ "owner",
579
+ "editor",
580
+ "operator",
581
+ "viewer"
582
+ ].includes(permission)) return {
583
+ status: 400,
584
+ body: { error: "permission must be one of: owner, editor, operator, viewer" }
585
+ };
586
+ return {
587
+ status: 201,
588
+ body: await ctx.core.grantFlowAccess({
589
+ flowId,
590
+ userId,
591
+ teamId,
592
+ permission,
593
+ grantedBy: ctx.identity?.id,
594
+ expiresAt
595
+ })
596
+ };
597
+ }
598
+ },
599
+ {
600
+ method: "DELETE",
601
+ path: "/rbac/flows/:flowId/access/:accessId",
602
+ permission: "flow:read",
603
+ handler: async (ctx) => {
604
+ const { flowId, accessId } = ctx.params;
605
+ if (!flowId || !accessId) return {
606
+ status: 400,
607
+ body: { error: "Missing flowId or accessId parameter" }
608
+ };
609
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
610
+ status: 501,
611
+ body: {
612
+ error: "Not Implemented",
613
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
614
+ }
615
+ };
616
+ if (!ctx.identity) return {
617
+ status: 401,
618
+ body: {
619
+ error: "Unauthorized",
620
+ message: "Authentication required"
621
+ }
622
+ };
623
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
624
+ status: 403,
625
+ body: {
626
+ error: "Forbidden",
627
+ message: "Owner access is required to manage sharing"
628
+ }
629
+ };
630
+ await ctx.core.revokeFlowAccess(accessId);
631
+ return {
632
+ status: 204,
633
+ body: null
634
+ };
635
+ }
636
+ },
637
+ {
638
+ method: "GET",
639
+ path: "/rbac/flows/accessible",
640
+ isPublic: false,
641
+ handler: async (ctx) => {
642
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
643
+ status: 501,
644
+ body: {
645
+ error: "Not Implemented",
646
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
647
+ }
648
+ };
649
+ const identity = ctx.identity;
650
+ if (!identity) return {
651
+ status: 401,
652
+ body: {
653
+ error: "Unauthorized",
654
+ message: "Authentication required"
655
+ }
656
+ };
657
+ if (ctx.core.getPermissions(identity).includes("admin:*")) return {
658
+ status: 200,
659
+ body: {
660
+ flowIds: [],
661
+ permissions: {},
662
+ isAdmin: true
663
+ }
664
+ };
665
+ const { flowIds, permissions } = await getCurrentUserAccessibleFlows(ctx.database, identity);
666
+ return {
667
+ status: 200,
668
+ body: {
669
+ flowIds,
670
+ permissions,
671
+ isAdmin: false
672
+ }
673
+ };
674
+ }
675
+ },
676
+ {
677
+ method: "GET",
678
+ path: "/rbac/flows/:flowId/effective-access",
679
+ permission: "flow:read",
680
+ handler: async (ctx) => {
681
+ const flowId = ctx.params.flowId;
682
+ if (!flowId) return {
683
+ status: 400,
684
+ body: { error: "Missing flowId parameter" }
685
+ };
686
+ if (!ctx.identity) return {
687
+ status: 401,
688
+ body: {
689
+ error: "Unauthorized",
690
+ message: "Authentication required"
691
+ }
692
+ };
693
+ if (!(ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity))) return {
694
+ status: 403,
695
+ body: {
696
+ error: "Forbidden",
697
+ message: "No access to this flow"
698
+ }
699
+ };
700
+ const records = await listAllEffectiveFlowAccessRecords(ctx.database, flowId);
701
+ return {
702
+ status: 200,
703
+ body: {
704
+ flowId,
705
+ scopeId: await getFlowScopeId(ctx.database, flowId),
706
+ records
707
+ }
708
+ };
709
+ }
710
+ },
711
+ {
712
+ method: "PUT",
713
+ path: "/rbac/flows/:flowId/scope",
714
+ permission: "flow:update",
715
+ handler: async (ctx) => {
716
+ const flowId = ctx.params.flowId;
717
+ const { scopeId } = ctx.body;
718
+ if (!flowId) return {
719
+ status: 400,
720
+ body: { error: "Missing flowId parameter" }
721
+ };
722
+ if (!ctx.identity) return {
723
+ status: 401,
724
+ body: {
725
+ error: "Unauthorized",
726
+ message: "Authentication required"
727
+ }
728
+ };
729
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
730
+ status: 403,
731
+ body: {
732
+ error: "Forbidden",
733
+ message: "Owner access is required to move flows"
734
+ }
735
+ };
736
+ if (scopeId) {
737
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [scopeId])).length === 0) return {
738
+ status: 404,
739
+ body: { error: "Scope not found" }
740
+ };
741
+ }
742
+ await ctx.database.execute("UPDATE flows SET scope_id = ? WHERE id = ?", [scopeId ?? null, flowId]);
743
+ return {
744
+ status: 200,
745
+ body: {
746
+ success: true,
747
+ flowId,
748
+ scopeId: scopeId ?? null
749
+ }
750
+ };
751
+ }
752
+ },
753
+ ...enableTeams ? [
754
+ {
755
+ method: "GET",
756
+ path: "/rbac/scopes/tree",
757
+ isPublic: false,
758
+ handler: async (ctx) => {
759
+ if (!ctx.identity) return {
760
+ status: 401,
761
+ body: { error: "Unauthorized" }
762
+ };
763
+ const [teamRows, flowRows, memberRows, accessRows, teamRoleRows] = await Promise.all([
764
+ ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams ORDER BY name"),
765
+ ctx.database.query("SELECT id, name, scope_id FROM flows ORDER BY name"),
766
+ ctx.database.query("SELECT team_id, COUNT(*) AS member_count FROM rbac_team_members GROUP BY team_id"),
767
+ ctx.database.query("SELECT scope_id, COUNT(*) AS access_count FROM rbac_scope_access GROUP BY scope_id"),
768
+ ctx.database.query("SELECT scope_id, permission FROM rbac_scope_access WHERE team_id = scope_id AND team_id IS NOT NULL")
769
+ ]);
770
+ const memberCounts = new Map(memberRows.map((row) => [row.team_id, Number(row.member_count)]));
771
+ const accessCounts = new Map(accessRows.map((row) => [row.scope_id, Number(row.access_count)]));
772
+ const teamPermissions = new Map(teamRoleRows.map((row) => [row.scope_id, row.permission]));
773
+ const nodeMap = /* @__PURE__ */ new Map();
774
+ for (const row of teamRows) nodeMap.set(row.id, {
775
+ ...normalizeTeamRow(row),
776
+ children: [],
777
+ flows: [],
778
+ directAccessCount: accessCounts.get(row.id) ?? 0,
779
+ memberCount: memberCounts.get(row.id) ?? 0,
780
+ teamPermission: teamPermissions.get(row.id) ?? null
781
+ });
782
+ const roots = [];
783
+ for (const node of nodeMap.values()) if (node.parentId && nodeMap.has(node.parentId)) nodeMap.get(node.parentId)?.children.push(node);
784
+ else roots.push(node);
785
+ const unscopedFlows = [];
786
+ for (const flow of flowRows) {
787
+ const mapped = {
788
+ id: flow.id,
789
+ name: flow.name,
790
+ scopeId: flow.scope_id
791
+ };
792
+ if (flow.scope_id && nodeMap.has(flow.scope_id)) nodeMap.get(flow.scope_id)?.flows.push(mapped);
793
+ else unscopedFlows.push(mapped);
794
+ }
795
+ return {
796
+ status: 200,
797
+ body: {
798
+ scopes: roots,
799
+ unscopedFlows
800
+ }
801
+ };
802
+ }
803
+ },
804
+ {
805
+ method: "GET",
806
+ path: "/rbac/scopes/:scopeId/access",
807
+ isPublic: false,
808
+ handler: async (ctx) => {
809
+ if (!ctx.identity) return {
810
+ status: 401,
811
+ body: { error: "Unauthorized" }
812
+ };
813
+ return {
814
+ status: 200,
815
+ body: { access: (await ctx.database.query("SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at FROM rbac_scope_access WHERE scope_id = ?", [ctx.params.scopeId])).map(normalizeScopeAccessRecord) }
816
+ };
817
+ }
818
+ },
819
+ {
820
+ method: "POST",
821
+ path: "/rbac/scopes/:scopeId/access",
822
+ isPublic: false,
823
+ handler: async (ctx) => {
824
+ if (!ctx.identity) return {
825
+ status: 401,
826
+ body: { error: "Unauthorized" }
827
+ };
828
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
829
+ status: 403,
830
+ body: {
831
+ error: "Forbidden",
832
+ message: "Admin access required"
833
+ }
834
+ };
835
+ const { scopeId } = ctx.params;
836
+ const { userId, teamId, permission } = ctx.body;
837
+ if (!scopeId) return {
838
+ status: 400,
839
+ body: { error: "Missing scopeId parameter" }
840
+ };
841
+ if (!userId && !teamId) return {
842
+ status: 400,
843
+ body: { error: "Either userId or teamId must be provided" }
844
+ };
845
+ if (userId && teamId) return {
846
+ status: 400,
847
+ body: { error: "Provide either userId or teamId, not both" }
848
+ };
849
+ if (teamId && teamId !== scopeId) return {
850
+ status: 400,
851
+ body: { error: "Teams can only hold a role on their own scope" }
852
+ };
853
+ if (!isFlowAccessPermission(permission)) return {
854
+ status: 400,
855
+ body: { error: "permission must be one of: owner, editor, operator, viewer" }
856
+ };
857
+ const existing = await ctx.database.query("SELECT id FROM rbac_scope_access WHERE scope_id = ? AND user_id IS ? AND team_id IS ?", [
858
+ scopeId,
859
+ userId ?? null,
860
+ teamId ?? null
861
+ ]);
862
+ const now = (/* @__PURE__ */ new Date()).toISOString();
863
+ if (existing[0]) {
864
+ await ctx.database.execute("UPDATE rbac_scope_access SET permission = ?, granted_by = ?, granted_at = ? WHERE id = ?", [
865
+ permission,
866
+ ctx.identity.id,
867
+ now,
868
+ existing[0].id
869
+ ]);
870
+ return {
871
+ status: 200,
872
+ body: {
873
+ id: existing[0].id,
874
+ scopeId,
875
+ userId: userId ?? null,
876
+ teamId: teamId ?? null,
877
+ permission,
878
+ grantedBy: ctx.identity.id,
879
+ grantedAt: now
880
+ }
881
+ };
882
+ }
883
+ const id = crypto.randomUUID();
884
+ await ctx.database.execute("INSERT INTO rbac_scope_access (id, scope_id, user_id, team_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
885
+ id,
886
+ scopeId,
887
+ userId ?? null,
888
+ teamId ?? null,
889
+ permission,
890
+ ctx.identity.id,
891
+ now
892
+ ]);
893
+ return {
894
+ status: 201,
895
+ body: {
896
+ id,
897
+ scopeId,
898
+ userId: userId ?? null,
899
+ teamId: teamId ?? null,
900
+ permission,
901
+ grantedBy: ctx.identity.id,
902
+ grantedAt: now
903
+ }
904
+ };
905
+ }
906
+ },
907
+ {
908
+ method: "DELETE",
909
+ path: "/rbac/scopes/:scopeId/access/:accessId",
910
+ isPublic: false,
911
+ handler: async (ctx) => {
912
+ if (!ctx.identity) return {
913
+ status: 401,
914
+ body: { error: "Unauthorized" }
915
+ };
916
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
917
+ status: 403,
918
+ body: {
919
+ error: "Forbidden",
920
+ message: "Admin access required"
921
+ }
922
+ };
923
+ await ctx.database.execute("DELETE FROM rbac_scope_access WHERE id = ?", [ctx.params.accessId]);
924
+ return {
925
+ status: 204,
926
+ body: null
927
+ };
928
+ }
929
+ },
930
+ {
931
+ method: "POST",
932
+ path: "/rbac/preview-move",
933
+ isPublic: false,
934
+ handler: async (ctx) => {
935
+ if (!ctx.identity) return {
936
+ status: 401,
937
+ body: { error: "Unauthorized" }
938
+ };
939
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
940
+ status: 403,
941
+ body: {
942
+ error: "Forbidden",
943
+ message: "Admin access required"
944
+ }
945
+ };
946
+ const { type, id, targetScopeId } = ctx.body;
947
+ if (!type || !id) return {
948
+ status: 400,
949
+ body: { error: "type and id are required" }
950
+ };
951
+ let affectedFlowIds = [];
952
+ let itemName = id;
953
+ if (type === "flow") {
954
+ const rows = await ctx.database.query("SELECT id, name FROM flows WHERE id = ?", [id]);
955
+ if (!rows[0]) return {
956
+ status: 404,
957
+ body: { error: "Flow not found" }
958
+ };
959
+ itemName = rows[0].name;
960
+ affectedFlowIds = [id];
961
+ } else {
962
+ const scopeRows = await ctx.database.query("SELECT id, name FROM rbac_teams WHERE id = ?", [id]);
963
+ if (!scopeRows[0]) return {
964
+ status: 404,
965
+ body: { error: "Scope not found" }
966
+ };
967
+ itemName = scopeRows[0].name;
968
+ const descendantScopeIds = await getDescendantScopeIds(ctx.database, id);
969
+ if (targetScopeId && descendantScopeIds.includes(targetScopeId)) return {
970
+ status: 400,
971
+ body: { error: "Cannot move a scope into itself or its descendant" }
972
+ };
973
+ affectedFlowIds = (await ctx.database.query(`SELECT id FROM flows WHERE scope_id IN (${createInClause(descendantScopeIds.length)})`, descendantScopeIds)).map((row) => row.id);
974
+ }
975
+ const targetPath = await getScopePath(ctx.database, targetScopeId ?? null);
976
+ const targetAncestorIds = targetScopeId ? await getAncestorScopeIds(ctx.database, targetScopeId) : [];
977
+ const targetScopeAccess = await listAllScopeAccessForScopeIds(ctx.database, targetAncestorIds);
978
+ const gainedEntries = /* @__PURE__ */ new Map();
979
+ let unchanged = 0;
980
+ for (const flowId of affectedFlowIds) {
981
+ const currentRecords = await listAllEffectiveFlowAccessForPreview(ctx.database, flowId);
982
+ const currentPermissions = /* @__PURE__ */ new Map();
983
+ for (const record of currentRecords) {
984
+ const permission = toFlowAccessPermission(record.permission);
985
+ if (!permission) continue;
986
+ const key = buildAccessKey(record);
987
+ currentPermissions.set(key, getHigherPermission(currentPermissions.get(key) ?? null, permission) ?? permission);
988
+ }
989
+ for (const record of targetScopeAccess) {
990
+ const key = buildAccessKey(record);
991
+ const existingPermission = currentPermissions.get(key) ?? null;
992
+ if (existingPermission && FLOW_PERMISSION_LEVELS[existingPermission] >= FLOW_PERMISSION_LEVELS[record.permission]) {
993
+ unchanged += 1;
994
+ continue;
995
+ }
996
+ gainedEntries.set(key, {
997
+ userId: record.userId,
998
+ teamId: record.teamId,
999
+ permission: record.permission,
1000
+ source: `from ${targetPath.at(-1) ?? "root"}`
1001
+ });
1002
+ }
1003
+ }
1004
+ const gained = await resolveAccessChangeNames(ctx.database, Array.from(gainedEntries.values()));
1005
+ return {
1006
+ status: 200,
1007
+ body: {
1008
+ item: {
1009
+ id,
1010
+ name: itemName,
1011
+ type
1012
+ },
1013
+ target: {
1014
+ id: targetScopeId ?? null,
1015
+ name: targetPath.at(-1) ?? "Unscoped",
1016
+ path: targetPath
1017
+ },
1018
+ affectedFlows: affectedFlowIds.length,
1019
+ accessChanges: {
1020
+ gained,
1021
+ unchanged
1022
+ }
1023
+ }
1024
+ };
1025
+ }
1026
+ },
1027
+ {
1028
+ method: "GET",
1029
+ path: "/rbac/teams",
1030
+ isPublic: false,
1031
+ handler: async (ctx) => {
1032
+ if (!ctx.identity) return {
1033
+ status: 401,
1034
+ body: { error: "Unauthorized" }
1035
+ };
1036
+ return {
1037
+ status: 200,
1038
+ body: { teams: (await ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams ORDER BY name")).map((r) => normalizeTeamRow(r)) }
1039
+ };
1040
+ }
1041
+ },
1042
+ {
1043
+ method: "POST",
1044
+ path: "/rbac/teams",
1045
+ isPublic: false,
1046
+ handler: async (ctx) => {
1047
+ if (!ctx.identity) return {
1048
+ status: 401,
1049
+ body: { error: "Unauthorized" }
1050
+ };
1051
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1052
+ status: 403,
1053
+ body: {
1054
+ error: "Forbidden",
1055
+ message: "Admin access required"
1056
+ }
1057
+ };
1058
+ const { name, description, parentId } = ctx.body;
1059
+ if (!name?.trim()) return {
1060
+ status: 400,
1061
+ body: { error: "Team name is required" }
1062
+ };
1063
+ if (parentId) {
1064
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [parentId])).length === 0) return {
1065
+ status: 404,
1066
+ body: { error: "Parent scope not found" }
1067
+ };
1068
+ }
1069
+ const id = crypto.randomUUID();
1070
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1071
+ await ctx.database.execute("INSERT INTO rbac_teams (id, name, description, parent_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
1072
+ id,
1073
+ name.trim(),
1074
+ description?.trim() || null,
1075
+ parentId ?? null,
1076
+ ctx.identity.id,
1077
+ now,
1078
+ now
1079
+ ]);
1080
+ return {
1081
+ status: 201,
1082
+ body: {
1083
+ id,
1084
+ name: name.trim(),
1085
+ description: description?.trim() || null,
1086
+ parentId: parentId ?? null,
1087
+ createdBy: ctx.identity.id,
1088
+ createdAt: now,
1089
+ updatedAt: now
1090
+ }
1091
+ };
1092
+ }
1093
+ },
1094
+ {
1095
+ method: "PUT",
1096
+ path: "/rbac/teams/:teamId",
1097
+ isPublic: false,
1098
+ handler: async (ctx) => {
1099
+ if (!ctx.identity) return {
1100
+ status: 401,
1101
+ body: { error: "Unauthorized" }
1102
+ };
1103
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1104
+ status: 403,
1105
+ body: {
1106
+ error: "Forbidden",
1107
+ message: "Admin access required"
1108
+ }
1109
+ };
1110
+ const { teamId } = ctx.params;
1111
+ const { name, description, parentId } = ctx.body;
1112
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [teamId])).length === 0) return {
1113
+ status: 404,
1114
+ body: { error: "Team not found" }
1115
+ };
1116
+ if (parentId === teamId) return {
1117
+ status: 400,
1118
+ body: { error: "A scope cannot be its own parent" }
1119
+ };
1120
+ if (parentId) {
1121
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [parentId])).length === 0) return {
1122
+ status: 404,
1123
+ body: { error: "Parent scope not found" }
1124
+ };
1125
+ if ((await getDescendantScopeIds(ctx.database, teamId)).includes(parentId)) return {
1126
+ status: 400,
1127
+ body: { error: "Cannot move a scope into itself or its descendant" }
1128
+ };
1129
+ }
1130
+ const updates = [];
1131
+ const values = [];
1132
+ if (name !== void 0) {
1133
+ if (!name.trim()) return {
1134
+ status: 400,
1135
+ body: { error: "Team name cannot be empty" }
1136
+ };
1137
+ updates.push("name = ?");
1138
+ values.push(name.trim());
1139
+ }
1140
+ if (description !== void 0) {
1141
+ updates.push("description = ?");
1142
+ values.push(description?.trim() || null);
1143
+ }
1144
+ if (parentId !== void 0) {
1145
+ updates.push("parent_id = ?");
1146
+ values.push(parentId ?? null);
1147
+ }
1148
+ if (updates.length === 0) return {
1149
+ status: 400,
1150
+ body: { error: "No fields to update" }
1151
+ };
1152
+ updates.push("updated_at = ?");
1153
+ values.push((/* @__PURE__ */ new Date()).toISOString());
1154
+ values.push(teamId);
1155
+ await ctx.database.execute(`UPDATE rbac_teams SET ${updates.join(", ")} WHERE id = ?`, values);
1156
+ return {
1157
+ status: 200,
1158
+ body: { success: true }
1159
+ };
1160
+ }
1161
+ },
1162
+ {
1163
+ method: "DELETE",
1164
+ path: "/rbac/teams/:teamId",
1165
+ isPublic: false,
1166
+ handler: async (ctx) => {
1167
+ if (!ctx.identity) return {
1168
+ status: 401,
1169
+ body: { error: "Unauthorized" }
1170
+ };
1171
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1172
+ status: 403,
1173
+ body: {
1174
+ error: "Forbidden",
1175
+ message: "Admin access required"
1176
+ }
1177
+ };
1178
+ const { teamId } = ctx.params;
1179
+ const teams = await ctx.database.query("SELECT id, parent_id FROM rbac_teams WHERE id = ?", [teamId]);
1180
+ if (teams.length === 0) return {
1181
+ status: 404,
1182
+ body: { error: "Team not found" }
1183
+ };
1184
+ const parentId = teams[0].parent_id ?? null;
1185
+ await ctx.database.execute("UPDATE flows SET scope_id = ? WHERE scope_id = ?", [parentId, teamId]);
1186
+ await ctx.database.execute("DELETE FROM rbac_teams WHERE id = ?", [teamId]);
1187
+ return {
1188
+ status: 204,
1189
+ body: null
1190
+ };
1191
+ }
1192
+ },
1193
+ {
1194
+ method: "GET",
1195
+ path: "/rbac/teams/:teamId",
1196
+ isPublic: false,
1197
+ handler: async (ctx) => {
1198
+ if (!ctx.identity) return {
1199
+ status: 401,
1200
+ body: { error: "Unauthorized" }
1201
+ };
1202
+ const { teamId } = ctx.params;
1203
+ const teams = await ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams WHERE id = ?", [teamId]);
1204
+ if (teams.length === 0) return {
1205
+ status: 404,
1206
+ body: { error: "Team not found" }
1207
+ };
1208
+ const members = await ctx.database.query("SELECT id, team_id, user_id, created_at FROM rbac_team_members WHERE team_id = ?", [teamId]);
1209
+ const team = teams[0];
1210
+ return {
1211
+ status: 200,
1212
+ body: {
1213
+ ...normalizeTeamRow(team),
1214
+ members: members.map((m) => ({
1215
+ id: m.id,
1216
+ teamId: m.team_id,
1217
+ userId: m.user_id,
1218
+ createdAt: m.created_at
1219
+ }))
1220
+ }
1221
+ };
1222
+ }
1223
+ },
1224
+ {
1225
+ method: "POST",
1226
+ path: "/rbac/teams/:teamId/members",
1227
+ isPublic: false,
1228
+ handler: async (ctx) => {
1229
+ if (!ctx.identity) return {
1230
+ status: 401,
1231
+ body: { error: "Unauthorized" }
1232
+ };
1233
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1234
+ status: 403,
1235
+ body: {
1236
+ error: "Forbidden",
1237
+ message: "Admin access required"
1238
+ }
1239
+ };
1240
+ const { teamId } = ctx.params;
1241
+ const { userId } = ctx.body;
1242
+ if (!userId?.trim()) return {
1243
+ status: 400,
1244
+ body: { error: "userId is required" }
1245
+ };
1246
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [teamId])).length === 0) return {
1247
+ status: 404,
1248
+ body: { error: "Team not found" }
1249
+ };
1250
+ if ((await ctx.database.query("SELECT id FROM rbac_team_members WHERE team_id = ? AND user_id = ?", [teamId, userId.trim()])).length > 0) return {
1251
+ status: 409,
1252
+ body: { error: "User is already a member of this team" }
1253
+ };
1254
+ const id = crypto.randomUUID();
1255
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1256
+ await ctx.database.execute("INSERT INTO rbac_team_members (id, team_id, user_id, created_at) VALUES (?, ?, ?, ?)", [
1257
+ id,
1258
+ teamId,
1259
+ userId.trim(),
1260
+ now
1261
+ ]);
1262
+ return {
1263
+ status: 201,
1264
+ body: {
1265
+ id,
1266
+ teamId,
1267
+ userId: userId.trim(),
1268
+ createdAt: now
1269
+ }
1270
+ };
1271
+ }
1272
+ },
1273
+ {
1274
+ method: "DELETE",
1275
+ path: "/rbac/teams/:teamId/members/:userId",
1276
+ isPublic: false,
1277
+ handler: async (ctx) => {
1278
+ if (!ctx.identity) return {
1279
+ status: 401,
1280
+ body: { error: "Unauthorized" }
1281
+ };
1282
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1283
+ status: 403,
1284
+ body: {
1285
+ error: "Forbidden",
1286
+ message: "Admin access required"
1287
+ }
1288
+ };
1289
+ const { teamId, userId } = ctx.params;
1290
+ await ctx.database.execute("DELETE FROM rbac_team_members WHERE team_id = ? AND user_id = ?", [teamId, userId]);
1291
+ return {
1292
+ status: 204,
1293
+ body: null
1294
+ };
1295
+ }
1296
+ },
1297
+ {
1298
+ method: "GET",
1299
+ path: "/rbac/my-teams",
1300
+ isPublic: false,
1301
+ handler: async (ctx) => {
1302
+ if (!ctx.identity) return {
1303
+ status: 401,
1304
+ body: { error: "Unauthorized" }
1305
+ };
1306
+ return {
1307
+ status: 200,
1308
+ body: { teams: (await ctx.database.query("SELECT t.id, t.name, t.description, t.parent_id, t.created_by, t.created_at, t.updated_at FROM rbac_teams t INNER JOIN rbac_team_members tm ON t.id = tm.team_id WHERE tm.user_id = ? ORDER BY t.name", [ctx.identity.id])).map((r) => normalizeTeamRow(r)) }
1309
+ };
1310
+ }
1311
+ }
1312
+ ] : []
1313
+ ],
1314
+ hooks: {
1315
+ onRequest: enableTeams ? async (_request, context) => {
1316
+ if (!context.identity || !capturedDbApi) return;
1317
+ if (context.identity.teamIds && context.identity.teamIds.length > 0) return;
1318
+ try {
1319
+ const rows = await capturedDbApi.query("SELECT team_id FROM rbac_team_members WHERE user_id = ?", [context.identity.id]);
1320
+ if (rows.length > 0) context.identity = {
1321
+ ...context.identity,
1322
+ teamIds: rows.map((r) => r.team_id)
1323
+ };
1324
+ } catch {}
1325
+ } : void 0,
1326
+ onAuthorize: async (context) => {
1327
+ if (!useFlowAccessTable) return;
1328
+ const { identity, resource, action } = context;
1329
+ if (!identity || !resource?.id) return;
1330
+ if (!FLOW_RESOURCE_TYPES.has(resource.type)) return;
1331
+ if (identity.permissions?.includes("admin:*") || identity.role === "admin") return { allowed: true };
1332
+ const database = context.database ?? capturedDbApi;
1333
+ if (!database) return;
1334
+ const effectivePermission = await getEffectiveFlowPermission(database, resource.id, identity);
1335
+ if (!effectivePermission) return { allowed: false };
1336
+ const requiredPermission = mapActionToRequiredPermission(action);
1337
+ return { allowed: FLOW_PERMISSION_LEVELS[effectivePermission] >= FLOW_PERMISSION_LEVELS[requiredPermission] };
1338
+ },
1339
+ afterFlowRun: async (_context) => {}
1340
+ },
1341
+ $ERROR_CODES: {
1342
+ "rbac:no_access": {
1343
+ message: "You do not have access to this flow.",
1344
+ status: 403
1345
+ },
1346
+ "rbac:insufficient_permission": {
1347
+ message: "Your access level is insufficient for this operation.",
1348
+ status: 403
1349
+ },
1350
+ "rbac:auth_required": {
1351
+ message: "Authentication is required. Please sign in.",
1352
+ status: 401
1353
+ },
1354
+ "rbac:plugin_missing": {
1355
+ message: "The RBAC plugin requires the @invect/user-auth plugin.",
1356
+ status: 500
1357
+ }
1358
+ }
1359
+ };
1360
+ }
1361
+ //#endregion
1362
+ exports.rbacPlugin = rbacPlugin;
1363
+ exports.resolveTeamIds = resolveTeamIds;
1364
+
1365
+ //# sourceMappingURL=index.cjs.map