@percepta/create 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/templates/monorepo/README.md +5 -3
- package/templates/monorepo/access/dev-grants.yaml.example +3 -8
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/AGENTS.md +6 -7
- package/templates/webapp/README.md +5 -3
- package/templates/webapp/agent-skills/access-control.md +15 -22
- package/templates/webapp/eslint.config.mjs +35 -7
- package/templates/webapp/package.json.template +4 -1
- package/templates/webapp/scripts/seed.ts +71 -24
- package/templates/webapp/src/access/access.manifest.ts +14 -1
- package/templates/webapp/src/access/schema.zed +1 -5
- package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +248 -0
- package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
- package/templates/webapp/src/app/(admin)/admin/page.tsx +683 -0
- package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
- package/templates/webapp/src/app/(app)/layout.tsx +13 -3
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
- package/templates/webapp/src/app/global-error.tsx +1 -2
- package/templates/webapp/src/components/Header.tsx +23 -2
- package/templates/webapp/src/server/api/routers/access.ts +2 -2
- package/templates/webapp/src/services/access/AppAccessControl.ts +49 -0
- package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
- package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
- package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
- package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
- package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
- package/templates/webapp/src/app/(app)/admin/users/page.tsx +0 -79
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import type { ApplicationGrant, SubjectRef } from "@percepta/access-control";
|
|
2
|
+
import { groupSubjectRef } from "@percepta/access-control";
|
|
3
|
+
import { db as authDb } from "@__REPO_NAME__/auth/db";
|
|
4
|
+
import { groups, users } from "@__REPO_NAME__/auth/schema";
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from "@percepta/design";
|
|
14
|
+
import { isNull } from "drizzle-orm";
|
|
15
|
+
import type { Metadata } from "next";
|
|
16
|
+
import { revalidatePath } from "next/cache";
|
|
17
|
+
import { notFound } from "next/navigation";
|
|
18
|
+
import {
|
|
19
|
+
accessManifest,
|
|
20
|
+
accessRoleDefinitions,
|
|
21
|
+
} from "../../../access/access.manifest";
|
|
22
|
+
import {
|
|
23
|
+
getAccessControl,
|
|
24
|
+
getCustomerAccessControl,
|
|
25
|
+
toUserSubject,
|
|
26
|
+
} from "../../../services/access/AppAccessControl";
|
|
27
|
+
import {
|
|
28
|
+
type AccessAppRole,
|
|
29
|
+
type AdminPermissions,
|
|
30
|
+
assignableRoles,
|
|
31
|
+
readAssignableRole,
|
|
32
|
+
requireAnyAdminPermission,
|
|
33
|
+
} from "./_lib/accessAdmin";
|
|
34
|
+
import {
|
|
35
|
+
PrincipalMultiInput,
|
|
36
|
+
type PrincipalOption,
|
|
37
|
+
} from "./_components/PrincipalMultiInput";
|
|
38
|
+
import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
|
|
39
|
+
|
|
40
|
+
export const metadata: Metadata = {
|
|
41
|
+
title: "RBAC - __APP_TITLE__",
|
|
42
|
+
description: "RBAC - __APP_TITLE__",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ApplicationAccessRole = ApplicationGrant["relation"];
|
|
46
|
+
|
|
47
|
+
interface AdminPageProps {
|
|
48
|
+
readonly searchParams?: Promise<{
|
|
49
|
+
readonly tab?: string | string[];
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PermissionDefinition {
|
|
54
|
+
readonly description: string;
|
|
55
|
+
readonly permission: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RoleDefinition {
|
|
59
|
+
readonly description: string;
|
|
60
|
+
readonly editableBy: "app_admin" | "customer_admin";
|
|
61
|
+
readonly label: string;
|
|
62
|
+
readonly permissions: readonly PermissionDefinition[];
|
|
63
|
+
readonly role: string;
|
|
64
|
+
readonly source: "Application access" | "App RBAC";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type AppRoleDefinition = Omit<RoleDefinition, "source">;
|
|
68
|
+
|
|
69
|
+
interface PrincipalAssignmentOption extends PrincipalOption {
|
|
70
|
+
readonly effectiveAccess: boolean;
|
|
71
|
+
readonly subject: SubjectRef;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RbacRoleAssignmentRow {
|
|
75
|
+
readonly definition: RoleDefinition;
|
|
76
|
+
readonly disabled: boolean;
|
|
77
|
+
readonly disabledReason?: string;
|
|
78
|
+
readonly hiddenFields?: readonly {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly value: string;
|
|
81
|
+
}[];
|
|
82
|
+
readonly options: readonly PrincipalAssignmentOption[];
|
|
83
|
+
readonly selectedSubjects: readonly SubjectRef[];
|
|
84
|
+
readonly updateAction: (formData: FormData) => Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface PrincipalAccessRow {
|
|
88
|
+
readonly detail: string;
|
|
89
|
+
readonly displayName: string;
|
|
90
|
+
readonly effectiveAccess: boolean;
|
|
91
|
+
readonly isAdmin: boolean;
|
|
92
|
+
readonly isUser: boolean;
|
|
93
|
+
readonly principalType: "Group" | "User";
|
|
94
|
+
readonly roles: readonly AccessAppRole[];
|
|
95
|
+
readonly subject: SubjectRef;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const roleDefinitions = [
|
|
99
|
+
{
|
|
100
|
+
description: "Can sign in to this application.",
|
|
101
|
+
editableBy: "customer_admin",
|
|
102
|
+
label: "User",
|
|
103
|
+
permissions: [
|
|
104
|
+
{
|
|
105
|
+
description: "Enter the application.",
|
|
106
|
+
permission: accessManifest.application.accessPermission,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
role: "user",
|
|
110
|
+
source: "Application access",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
description: "Can sign in and manage application-specific roles.",
|
|
114
|
+
editableBy: "customer_admin",
|
|
115
|
+
label: "Admin",
|
|
116
|
+
permissions: [
|
|
117
|
+
{
|
|
118
|
+
description: "Enter the application.",
|
|
119
|
+
permission: accessManifest.application.accessPermission,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
description: "Assign and revoke app-defined roles.",
|
|
123
|
+
permission: accessManifest.application.manageAppRolesPermission,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
role: "admin",
|
|
127
|
+
source: "Application access",
|
|
128
|
+
},
|
|
129
|
+
...(accessRoleDefinitions as readonly AppRoleDefinition[]).map(
|
|
130
|
+
(definition): RoleDefinition => ({
|
|
131
|
+
...definition,
|
|
132
|
+
role: definition.role,
|
|
133
|
+
source: "App RBAC",
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
138
|
+
const appRoleDefinitionMap = new Map<AccessAppRole, RoleDefinition>(
|
|
139
|
+
roleDefinitions
|
|
140
|
+
.filter((definition) => definition.source === "App RBAC")
|
|
141
|
+
.map((definition) => [definition.role as AccessAppRole, definition]),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|
145
|
+
const permissions = await requireAnyAdminPermission();
|
|
146
|
+
const params = await searchParams;
|
|
147
|
+
const tab = readAdminTab(params?.tab);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="space-y-6">
|
|
151
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
152
|
+
<div>
|
|
153
|
+
<h1 className="text-2xl font-semibold text-foreground">RBAC</h1>
|
|
154
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
155
|
+
{accessManifest.application.displayName}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
159
|
+
<div className="font-medium text-foreground">
|
|
160
|
+
{accessManifest.application.displayName}
|
|
161
|
+
</div>
|
|
162
|
+
<div className="text-muted-foreground">
|
|
163
|
+
{accessManifest.appNamespace}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<AdminTabs activeTab={tab} />
|
|
169
|
+
|
|
170
|
+
{tab === "roles" ? (
|
|
171
|
+
<RolesTab />
|
|
172
|
+
) : (
|
|
173
|
+
<AssignmentsTab permissions={permissions} />
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function RolesTab() {
|
|
180
|
+
return (
|
|
181
|
+
<div className="rounded-md border border-border">
|
|
182
|
+
<Table>
|
|
183
|
+
<TableHeader>
|
|
184
|
+
<TableRow>
|
|
185
|
+
<TableHead>Role</TableHead>
|
|
186
|
+
<TableHead>Permissions</TableHead>
|
|
187
|
+
</TableRow>
|
|
188
|
+
</TableHeader>
|
|
189
|
+
<TableBody>
|
|
190
|
+
{roleDefinitions.map((role) => (
|
|
191
|
+
<TableRow
|
|
192
|
+
key={`${role.source}:${role.role}`}
|
|
193
|
+
className="align-top"
|
|
194
|
+
>
|
|
195
|
+
<TableCell className="py-4 whitespace-normal">
|
|
196
|
+
<div className="font-medium text-foreground">{role.label}</div>
|
|
197
|
+
<div className="text-muted-foreground">{role.description}</div>
|
|
198
|
+
</TableCell>
|
|
199
|
+
<TableCell className="py-4 whitespace-normal">
|
|
200
|
+
<div className="flex flex-wrap gap-2">
|
|
201
|
+
{role.permissions.map((permission) => (
|
|
202
|
+
<Badge
|
|
203
|
+
key={permission.permission}
|
|
204
|
+
title={permission.description}
|
|
205
|
+
variant="outline"
|
|
206
|
+
>
|
|
207
|
+
{permission.permission}
|
|
208
|
+
</Badge>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
</TableCell>
|
|
212
|
+
</TableRow>
|
|
213
|
+
))}
|
|
214
|
+
</TableBody>
|
|
215
|
+
</Table>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function AssignmentsTab({
|
|
221
|
+
permissions,
|
|
222
|
+
}: {
|
|
223
|
+
readonly permissions: AdminPermissions;
|
|
224
|
+
}) {
|
|
225
|
+
const rows = await listRbacRoleAssignmentRows(permissions);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className="rounded-md border border-border">
|
|
229
|
+
<Table>
|
|
230
|
+
<TableHeader>
|
|
231
|
+
<TableRow>
|
|
232
|
+
<TableHead className="w-72">Role</TableHead>
|
|
233
|
+
<TableHead>Users and Groups</TableHead>
|
|
234
|
+
</TableRow>
|
|
235
|
+
</TableHeader>
|
|
236
|
+
<TableBody>
|
|
237
|
+
{rows.map((row) => (
|
|
238
|
+
<TableRow
|
|
239
|
+
key={`${row.definition.source}:${row.definition.role}`}
|
|
240
|
+
className="align-top"
|
|
241
|
+
>
|
|
242
|
+
<TableCell className="py-4 whitespace-normal">
|
|
243
|
+
<div className="font-medium text-foreground">
|
|
244
|
+
{row.definition.label}
|
|
245
|
+
</div>
|
|
246
|
+
<div className="text-muted-foreground">
|
|
247
|
+
{row.definition.description}
|
|
248
|
+
</div>
|
|
249
|
+
</TableCell>
|
|
250
|
+
<TableCell className="py-4 whitespace-normal">
|
|
251
|
+
<PrincipalMultiInput
|
|
252
|
+
action={row.updateAction}
|
|
253
|
+
disabled={row.disabled}
|
|
254
|
+
hiddenFields={row.hiddenFields}
|
|
255
|
+
key={row.selectedSubjects.join("|")}
|
|
256
|
+
options={row.options}
|
|
257
|
+
selectedSubjects={row.selectedSubjects}
|
|
258
|
+
/>
|
|
259
|
+
{row.disabledReason == null ? null : (
|
|
260
|
+
<div className="mt-2 text-xs text-muted-foreground">
|
|
261
|
+
{row.disabledReason}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
</TableCell>
|
|
265
|
+
</TableRow>
|
|
266
|
+
))}
|
|
267
|
+
</TableBody>
|
|
268
|
+
</Table>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function updateUserAssignments(formData: FormData) {
|
|
274
|
+
"use server";
|
|
275
|
+
|
|
276
|
+
await updateApplicationAccessAssignments(formData, "user");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function updateAdminAssignments(formData: FormData) {
|
|
280
|
+
"use server";
|
|
281
|
+
|
|
282
|
+
await updateApplicationAccessAssignments(formData, "admin");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function updateAssignableRoleAssignments(formData: FormData) {
|
|
286
|
+
"use server";
|
|
287
|
+
|
|
288
|
+
const permissions = await requireAnyAdminPermission();
|
|
289
|
+
const role = readAssignableRole(formData);
|
|
290
|
+
|
|
291
|
+
if (!canEditAppRole(role, permissions)) {
|
|
292
|
+
notFound();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
296
|
+
const selectedSubjects = readPrincipalSubjects(formData);
|
|
297
|
+
validateSelectedPrincipals(selectedSubjects, principals, {
|
|
298
|
+
requireEffectiveAccess: true,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const currentSubjects = principals
|
|
302
|
+
.filter((principal) => principal.roles.includes(role))
|
|
303
|
+
.map((principal) => principal.subject);
|
|
304
|
+
|
|
305
|
+
await syncAppRoleAssignments(role, currentSubjects, selectedSubjects);
|
|
306
|
+
|
|
307
|
+
revalidatePath("/admin");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function updateApplicationAccessAssignments(
|
|
311
|
+
formData: FormData,
|
|
312
|
+
relation: ApplicationGrant["relation"],
|
|
313
|
+
) {
|
|
314
|
+
const permissions = await requireAnyAdminPermission();
|
|
315
|
+
if (!permissions.canManageDefaultRoles) {
|
|
316
|
+
notFound();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
320
|
+
const selectedSubjects = readPrincipalSubjects(formData);
|
|
321
|
+
validateSelectedPrincipals(selectedSubjects, principals, {
|
|
322
|
+
requireEffectiveAccess: false,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const currentSubjects = principals
|
|
326
|
+
.filter((principal) =>
|
|
327
|
+
relation === "user" ? principal.isUser : principal.isAdmin,
|
|
328
|
+
)
|
|
329
|
+
.map((principal) => principal.subject);
|
|
330
|
+
|
|
331
|
+
await syncApplicationAccessAssignments(
|
|
332
|
+
relation,
|
|
333
|
+
currentSubjects,
|
|
334
|
+
selectedSubjects,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
revalidatePath("/admin");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function listRbacRoleAssignmentRows(
|
|
341
|
+
permissions: AdminPermissions,
|
|
342
|
+
): Promise<readonly RbacRoleAssignmentRow[]> {
|
|
343
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
344
|
+
const principalOptions = principals.map(toPrincipalOption);
|
|
345
|
+
|
|
346
|
+
const applicationRows: readonly RbacRoleAssignmentRow[] = [
|
|
347
|
+
{
|
|
348
|
+
definition: getApplicationAccessRoleDefinition("user"),
|
|
349
|
+
disabled: !permissions.canManageDefaultRoles,
|
|
350
|
+
disabledReason: permissions.canManageDefaultRoles
|
|
351
|
+
? undefined
|
|
352
|
+
: "Only customer admins can edit default application roles.",
|
|
353
|
+
options: principalOptions,
|
|
354
|
+
selectedSubjects: sortSubjectsByPrincipal(
|
|
355
|
+
principals
|
|
356
|
+
.filter((principal) => principal.isUser)
|
|
357
|
+
.map((principal) => principal.subject),
|
|
358
|
+
principals,
|
|
359
|
+
),
|
|
360
|
+
updateAction: updateUserAssignments,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
definition: getApplicationAccessRoleDefinition("admin"),
|
|
364
|
+
disabled: !permissions.canManageDefaultRoles,
|
|
365
|
+
disabledReason: permissions.canManageDefaultRoles
|
|
366
|
+
? undefined
|
|
367
|
+
: "Only customer admins can edit default application roles.",
|
|
368
|
+
options: principalOptions,
|
|
369
|
+
selectedSubjects: sortSubjectsByPrincipal(
|
|
370
|
+
principals
|
|
371
|
+
.filter((principal) => principal.isAdmin)
|
|
372
|
+
.map((principal) => principal.subject),
|
|
373
|
+
principals,
|
|
374
|
+
),
|
|
375
|
+
updateAction: updateAdminAssignments,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
const appRoleRows = assignableRoles.map((role): RbacRoleAssignmentRow => {
|
|
380
|
+
const canEditRole = canEditAppRole(role, permissions);
|
|
381
|
+
const selectedSubjects = sortSubjectsByPrincipal(
|
|
382
|
+
principals
|
|
383
|
+
.filter((principal) => principal.roles.includes(role))
|
|
384
|
+
.map((principal) => principal.subject),
|
|
385
|
+
principals,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
definition: getAppRoleDefinition(role),
|
|
390
|
+
disabled: !canEditRole,
|
|
391
|
+
disabledReason: canEditRole
|
|
392
|
+
? undefined
|
|
393
|
+
: "You do not have permission to edit this role.",
|
|
394
|
+
hiddenFields: [{ name: "role", value: role }],
|
|
395
|
+
options: principalOptions.map((option) =>
|
|
396
|
+
option.effectiveAccess
|
|
397
|
+
? option
|
|
398
|
+
: {
|
|
399
|
+
...option,
|
|
400
|
+
disabled: true,
|
|
401
|
+
disabledReason:
|
|
402
|
+
"Grant application access before assigning app roles.",
|
|
403
|
+
},
|
|
404
|
+
),
|
|
405
|
+
selectedSubjects,
|
|
406
|
+
updateAction: updateAssignableRoleAssignments,
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return [...applicationRows, ...appRoleRows];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function listPrincipalAccessRows(
|
|
414
|
+
permissions: AdminPermissions,
|
|
415
|
+
): Promise<readonly PrincipalAccessRow[]> {
|
|
416
|
+
const [userRows, groupRows, grants] = await Promise.all([
|
|
417
|
+
authDb
|
|
418
|
+
.select({
|
|
419
|
+
email: users.email,
|
|
420
|
+
id: users.id,
|
|
421
|
+
name: users.name,
|
|
422
|
+
})
|
|
423
|
+
.from(users)
|
|
424
|
+
.orderBy(users.email),
|
|
425
|
+
authDb
|
|
426
|
+
.select({
|
|
427
|
+
id: groups.id,
|
|
428
|
+
name: groups.name,
|
|
429
|
+
source: groups.source,
|
|
430
|
+
})
|
|
431
|
+
.from(groups)
|
|
432
|
+
.where(isNull(groups.deletedAt))
|
|
433
|
+
.orderBy(groups.name),
|
|
434
|
+
getCustomerAccessControl().listApplicationGrants(
|
|
435
|
+
accessManifest.appNamespace,
|
|
436
|
+
),
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
const grantMap = buildGrantMap(grants);
|
|
440
|
+
const access = getAccessControl();
|
|
441
|
+
const application = accessManifest.application.ref();
|
|
442
|
+
|
|
443
|
+
const userAccessRows = await Promise.all(
|
|
444
|
+
userRows.map(async (user) => {
|
|
445
|
+
const subject = toUserSubject(user.id);
|
|
446
|
+
const grantState = grantMap.get(subject);
|
|
447
|
+
const effectiveAccess = await access.permissions.can({
|
|
448
|
+
permission: accessManifest.application.accessPermission,
|
|
449
|
+
resource: application,
|
|
450
|
+
subject,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
detail: user.email,
|
|
455
|
+
displayName: user.name,
|
|
456
|
+
effectiveAccess,
|
|
457
|
+
isAdmin: grantState?.has("admin") === true,
|
|
458
|
+
isUser: grantState?.has("user") === true,
|
|
459
|
+
principalType: "User" as const,
|
|
460
|
+
roles: await access.app.listAppRoles(subject),
|
|
461
|
+
subject,
|
|
462
|
+
};
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const groupAccessRows = await Promise.all(
|
|
467
|
+
groupRows.map(async (group) => {
|
|
468
|
+
const subject = groupSubjectRef(group.id);
|
|
469
|
+
const grantState = grantMap.get(subject);
|
|
470
|
+
const effectiveAccess = await access.permissions.can({
|
|
471
|
+
permission: accessManifest.application.accessPermission,
|
|
472
|
+
resource: application,
|
|
473
|
+
subject,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
detail: `${group.source} group`,
|
|
478
|
+
displayName: group.name,
|
|
479
|
+
effectiveAccess,
|
|
480
|
+
isAdmin: grantState?.has("admin") === true,
|
|
481
|
+
isUser: grantState?.has("user") === true,
|
|
482
|
+
principalType: "Group" as const,
|
|
483
|
+
roles: await access.app.listAppRoles(subject),
|
|
484
|
+
subject,
|
|
485
|
+
};
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const rows = [...userAccessRows, ...groupAccessRows].sort((a, b) => {
|
|
490
|
+
const typeCompare = a.principalType.localeCompare(b.principalType);
|
|
491
|
+
return typeCompare === 0
|
|
492
|
+
? a.displayName.localeCompare(b.displayName)
|
|
493
|
+
: typeCompare;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return permissions.canManageDefaultRoles
|
|
497
|
+
? rows
|
|
498
|
+
: rows.filter((row) => row.effectiveAccess);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function buildGrantMap(
|
|
502
|
+
grants: readonly ApplicationGrant[],
|
|
503
|
+
): Map<SubjectRef, Set<ApplicationGrant["relation"]>> {
|
|
504
|
+
const grantMap = new Map<SubjectRef, Set<ApplicationGrant["relation"]>>();
|
|
505
|
+
|
|
506
|
+
for (const grant of grants) {
|
|
507
|
+
const subjectGrants = grantMap.get(grant.subject) ?? new Set();
|
|
508
|
+
subjectGrants.add(grant.relation);
|
|
509
|
+
grantMap.set(grant.subject, subjectGrants);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return grantMap;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function canEditAppRole(
|
|
516
|
+
role: AccessAppRole,
|
|
517
|
+
permissions: AdminPermissions,
|
|
518
|
+
): boolean {
|
|
519
|
+
const roleDefinition = appRoleDefinitionMap.get(role);
|
|
520
|
+
if (roleDefinition == null) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return roleDefinition.editableBy === "customer_admin"
|
|
525
|
+
? permissions.canManageDefaultRoles
|
|
526
|
+
: permissions.canManageAppRoles;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function getApplicationAccessRoleDefinition(
|
|
530
|
+
role: ApplicationAccessRole,
|
|
531
|
+
): RoleDefinition {
|
|
532
|
+
const definition = roleDefinitions.find(
|
|
533
|
+
(candidate) =>
|
|
534
|
+
candidate.source === "Application access" && candidate.role === role,
|
|
535
|
+
);
|
|
536
|
+
if (definition == null) {
|
|
537
|
+
throw new Error(`Missing application access role definition: ${role}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return definition;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function getAppRoleDefinition(role: AccessAppRole): RoleDefinition {
|
|
544
|
+
const definition = appRoleDefinitionMap.get(role);
|
|
545
|
+
if (definition == null) {
|
|
546
|
+
throw new Error(`Missing app role definition: ${role}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return definition;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function toPrincipalOption(
|
|
553
|
+
principal: PrincipalAccessRow,
|
|
554
|
+
): PrincipalAssignmentOption {
|
|
555
|
+
return {
|
|
556
|
+
detail: principal.detail,
|
|
557
|
+
effectiveAccess: principal.effectiveAccess,
|
|
558
|
+
label: principal.displayName,
|
|
559
|
+
subject: principal.subject,
|
|
560
|
+
type: principal.principalType,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function readPrincipalSubjects(formData: FormData): readonly SubjectRef[] {
|
|
565
|
+
const subjects: SubjectRef[] = [];
|
|
566
|
+
const seenSubjects = new Set<string>();
|
|
567
|
+
|
|
568
|
+
for (const value of formData.getAll("subjects")) {
|
|
569
|
+
if (typeof value !== "string" || !isPrincipalSubject(value)) {
|
|
570
|
+
throw new Error("Invalid principal subject.");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!seenSubjects.has(value)) {
|
|
574
|
+
subjects.push(value);
|
|
575
|
+
seenSubjects.add(value);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return subjects;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isPrincipalSubject(subject: string): subject is SubjectRef {
|
|
583
|
+
return (
|
|
584
|
+
subject.startsWith("core/user:") ||
|
|
585
|
+
/^core\/group:[^#]+#member$/.test(subject)
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function validateSelectedPrincipals(
|
|
590
|
+
selectedSubjects: readonly SubjectRef[],
|
|
591
|
+
principals: readonly PrincipalAccessRow[],
|
|
592
|
+
{
|
|
593
|
+
requireEffectiveAccess,
|
|
594
|
+
}: {
|
|
595
|
+
readonly requireEffectiveAccess: boolean;
|
|
596
|
+
},
|
|
597
|
+
) {
|
|
598
|
+
const principalBySubject = new Map(
|
|
599
|
+
principals.map((principal) => [principal.subject, principal]),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
for (const subject of selectedSubjects) {
|
|
603
|
+
const principal = principalBySubject.get(subject);
|
|
604
|
+
if (principal == null) {
|
|
605
|
+
throw new Error("Unknown principal subject.");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (requireEffectiveAccess && !principal.effectiveAccess) {
|
|
609
|
+
throw new Error("Principal must have application access.");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function syncApplicationAccessAssignments(
|
|
615
|
+
relation: ApplicationAccessRole,
|
|
616
|
+
currentSubjects: readonly SubjectRef[],
|
|
617
|
+
selectedSubjects: readonly SubjectRef[],
|
|
618
|
+
) {
|
|
619
|
+
const currentSet = new Set(currentSubjects);
|
|
620
|
+
const selectedSet = new Set(selectedSubjects);
|
|
621
|
+
const customerAccess = getCustomerAccessControl();
|
|
622
|
+
const appNamespace = accessManifest.appNamespace;
|
|
623
|
+
|
|
624
|
+
await Promise.all([
|
|
625
|
+
...subjectsMinus(selectedSet, currentSet).map((subject) =>
|
|
626
|
+
relation === "user"
|
|
627
|
+
? customerAccess.assignApplicationUser(appNamespace, subject)
|
|
628
|
+
: customerAccess.assignApplicationAdmin(appNamespace, subject),
|
|
629
|
+
),
|
|
630
|
+
...subjectsMinus(currentSet, selectedSet).map((subject) =>
|
|
631
|
+
relation === "user"
|
|
632
|
+
? customerAccess.revokeApplicationUser(appNamespace, subject)
|
|
633
|
+
: customerAccess.revokeApplicationAdmin(appNamespace, subject),
|
|
634
|
+
),
|
|
635
|
+
]);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function syncAppRoleAssignments(
|
|
639
|
+
role: AccessAppRole,
|
|
640
|
+
currentSubjects: readonly SubjectRef[],
|
|
641
|
+
selectedSubjects: readonly SubjectRef[],
|
|
642
|
+
) {
|
|
643
|
+
const currentSet = new Set(currentSubjects);
|
|
644
|
+
const selectedSet = new Set(selectedSubjects);
|
|
645
|
+
const app = getAccessControl().app;
|
|
646
|
+
|
|
647
|
+
await Promise.all([
|
|
648
|
+
...subjectsMinus(selectedSet, currentSet).map((subject) =>
|
|
649
|
+
app.assignAppRole(role, subject),
|
|
650
|
+
),
|
|
651
|
+
...subjectsMinus(currentSet, selectedSet).map((subject) =>
|
|
652
|
+
app.revokeAppRole(role, subject),
|
|
653
|
+
),
|
|
654
|
+
]);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function subjectsMinus(
|
|
658
|
+
left: ReadonlySet<SubjectRef>,
|
|
659
|
+
right: ReadonlySet<SubjectRef>,
|
|
660
|
+
): SubjectRef[] {
|
|
661
|
+
return Array.from(left).filter((subject) => !right.has(subject));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function sortSubjectsByPrincipal(
|
|
665
|
+
subjects: readonly SubjectRef[],
|
|
666
|
+
principals: readonly PrincipalAccessRow[],
|
|
667
|
+
): readonly SubjectRef[] {
|
|
668
|
+
const sortKeyBySubject = new Map(
|
|
669
|
+
principals.map((principal) => [
|
|
670
|
+
principal.subject,
|
|
671
|
+
`${principal.principalType}:${principal.displayName}:${principal.detail}`,
|
|
672
|
+
]),
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
return [...subjects].sort((a, b) =>
|
|
676
|
+
(sortKeyBySubject.get(a) ?? a).localeCompare(sortKeyBySubject.get(b) ?? b),
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function readAdminTab(tab: string | string[] | undefined): AdminTab {
|
|
681
|
+
const value = Array.isArray(tab) ? tab[0] : tab;
|
|
682
|
+
return value === "assignments" ? "assignments" : "roles";
|
|
683
|
+
}
|