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