@percepta/create 3.3.0 → 3.4.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/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/auth/package.json +2 -1
- package/templates/monorepo/auth/src/principals.ts +11 -0
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/AGENTS.md +6 -7
- package/templates/webapp/README.md +31 -3
- package/templates/webapp/agent-skills/access-control.md +15 -22
- package/templates/webapp/e2e/rbac.spec.ts +136 -0
- package/templates/webapp/eslint.config.mjs +41 -7
- package/templates/webapp/gitignore.template +2 -0
- package/templates/webapp/package.json.template +8 -1
- package/templates/webapp/playwright.config.ts +33 -0
- package/templates/webapp/scripts/seed.ts +71 -24
- package/templates/webapp/src/access/access.manifest.ts +9 -2
- 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/_lib/accessAdmin.ts +62 -0
- package/templates/webapp/src/app/(admin)/admin/page.tsx +598 -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 +23 -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,598 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessRoleDefinition,
|
|
3
|
+
ApplicationGrant,
|
|
4
|
+
SubjectRef,
|
|
5
|
+
} from "@percepta/access-control";
|
|
6
|
+
import { groupSubjectRef } from "@percepta/access-control";
|
|
7
|
+
import {
|
|
8
|
+
PrincipalMultiInput,
|
|
9
|
+
type PrincipalOption,
|
|
10
|
+
} from "@percepta/access-control/react";
|
|
11
|
+
import { listPrincipals } from "@__REPO_NAME__/auth/principals";
|
|
12
|
+
import {
|
|
13
|
+
Badge,
|
|
14
|
+
Table,
|
|
15
|
+
TableBody,
|
|
16
|
+
TableCell,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableHeader,
|
|
19
|
+
TableRow,
|
|
20
|
+
} from "@percepta/design";
|
|
21
|
+
import type { Metadata } from "next";
|
|
22
|
+
import { revalidatePath } from "next/cache";
|
|
23
|
+
import { notFound } from "next/navigation";
|
|
24
|
+
import {
|
|
25
|
+
accessManifest,
|
|
26
|
+
accessRoleDefinitions,
|
|
27
|
+
} from "../../../access/access.manifest";
|
|
28
|
+
import {
|
|
29
|
+
getAccessControl,
|
|
30
|
+
getCustomerAccessControl,
|
|
31
|
+
toUserSubject,
|
|
32
|
+
} from "../../../services/access/AppAccessControl";
|
|
33
|
+
import {
|
|
34
|
+
type AccessAppRole,
|
|
35
|
+
type AdminPermissions,
|
|
36
|
+
assignableRoles,
|
|
37
|
+
readAssignableRole,
|
|
38
|
+
requireAnyAdminPermission,
|
|
39
|
+
} from "./_lib/accessAdmin";
|
|
40
|
+
import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
|
|
41
|
+
|
|
42
|
+
export const metadata: Metadata = {
|
|
43
|
+
title: "RBAC - __APP_TITLE__",
|
|
44
|
+
description: "RBAC - __APP_TITLE__",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ApplicationAccessRole = ApplicationGrant["relation"];
|
|
48
|
+
|
|
49
|
+
interface AdminPageProps {
|
|
50
|
+
readonly searchParams?: Promise<{
|
|
51
|
+
readonly tab?: string | string[];
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RoleDefinition extends AccessRoleDefinition {
|
|
56
|
+
readonly source: "Application access" | "App RBAC";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PrincipalAssignmentOption extends PrincipalOption {
|
|
60
|
+
readonly effectiveAccess: boolean;
|
|
61
|
+
readonly subject: SubjectRef;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RbacRoleAssignmentRow {
|
|
65
|
+
readonly definition: RoleDefinition;
|
|
66
|
+
readonly disabled: boolean;
|
|
67
|
+
readonly disabledReason?: string;
|
|
68
|
+
readonly hiddenFields?: readonly {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly value: string;
|
|
71
|
+
}[];
|
|
72
|
+
readonly options: readonly PrincipalAssignmentOption[];
|
|
73
|
+
readonly selectedSubjects: readonly SubjectRef[];
|
|
74
|
+
readonly updateAction: (formData: FormData) => Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PrincipalAccessRow {
|
|
78
|
+
readonly detail: string;
|
|
79
|
+
readonly displayName: string;
|
|
80
|
+
readonly effectiveAccess: boolean;
|
|
81
|
+
readonly isAdmin: boolean;
|
|
82
|
+
readonly isUser: boolean;
|
|
83
|
+
readonly principalType: "Group" | "User";
|
|
84
|
+
readonly roles: readonly AccessAppRole[];
|
|
85
|
+
readonly subject: SubjectRef;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const roleDefinitions = [
|
|
89
|
+
{
|
|
90
|
+
description: "Can sign in to this application.",
|
|
91
|
+
label: "User",
|
|
92
|
+
permissions: [
|
|
93
|
+
{
|
|
94
|
+
description: "Enter the application.",
|
|
95
|
+
permission: accessManifest.application.accessPermission,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
role: "user",
|
|
99
|
+
source: "Application access",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
description: "Can sign in and manage application-specific roles.",
|
|
103
|
+
label: "Admin",
|
|
104
|
+
permissions: [
|
|
105
|
+
{
|
|
106
|
+
description: "Enter the application.",
|
|
107
|
+
permission: accessManifest.application.accessPermission,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
description: "Assign and revoke app-defined roles.",
|
|
111
|
+
permission: accessManifest.application.manageAppRolesPermission,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
role: "admin",
|
|
115
|
+
source: "Application access",
|
|
116
|
+
},
|
|
117
|
+
...(
|
|
118
|
+
accessRoleDefinitions as readonly AccessRoleDefinition<AccessAppRole>[]
|
|
119
|
+
).map(
|
|
120
|
+
(definition): RoleDefinition => ({
|
|
121
|
+
...definition,
|
|
122
|
+
role: definition.role,
|
|
123
|
+
source: "App RBAC",
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
128
|
+
const appRoleDefinitionMap = new Map<AccessAppRole, RoleDefinition>(
|
|
129
|
+
roleDefinitions
|
|
130
|
+
.filter((definition) => definition.source === "App RBAC")
|
|
131
|
+
.map((definition) => [definition.role as AccessAppRole, definition]),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|
135
|
+
const permissions = await requireAnyAdminPermission();
|
|
136
|
+
const params = await searchParams;
|
|
137
|
+
const tab = readAdminTab(params?.tab);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="space-y-6">
|
|
141
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
142
|
+
<div>
|
|
143
|
+
<h1 className="text-2xl font-semibold text-foreground">RBAC</h1>
|
|
144
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
145
|
+
{accessManifest.application.displayName}
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
149
|
+
<div className="font-medium text-foreground">
|
|
150
|
+
{accessManifest.application.displayName}
|
|
151
|
+
</div>
|
|
152
|
+
<div className="text-muted-foreground">
|
|
153
|
+
{accessManifest.appNamespace}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<AdminTabs activeTab={tab} />
|
|
159
|
+
|
|
160
|
+
{tab === "roles" ? (
|
|
161
|
+
<RolesTab />
|
|
162
|
+
) : (
|
|
163
|
+
<AssignmentsTab permissions={permissions} />
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function RolesTab() {
|
|
170
|
+
return (
|
|
171
|
+
<div className="rounded-md border border-border">
|
|
172
|
+
<Table>
|
|
173
|
+
<TableHeader>
|
|
174
|
+
<TableRow>
|
|
175
|
+
<TableHead>Role</TableHead>
|
|
176
|
+
<TableHead>Permissions</TableHead>
|
|
177
|
+
</TableRow>
|
|
178
|
+
</TableHeader>
|
|
179
|
+
<TableBody>
|
|
180
|
+
{roleDefinitions.map((role) => (
|
|
181
|
+
<TableRow
|
|
182
|
+
key={`${role.source}:${role.role}`}
|
|
183
|
+
className="align-top"
|
|
184
|
+
>
|
|
185
|
+
<TableCell className="py-4 whitespace-normal">
|
|
186
|
+
<div className="font-medium text-foreground">{role.label}</div>
|
|
187
|
+
<div className="text-muted-foreground">{role.description}</div>
|
|
188
|
+
</TableCell>
|
|
189
|
+
<TableCell className="py-4 whitespace-normal">
|
|
190
|
+
<div className="flex flex-wrap gap-2">
|
|
191
|
+
{role.permissions.map((permission) => (
|
|
192
|
+
<Badge
|
|
193
|
+
key={permission.permission}
|
|
194
|
+
title={permission.description}
|
|
195
|
+
variant="outline"
|
|
196
|
+
>
|
|
197
|
+
{permission.permission}
|
|
198
|
+
</Badge>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
</TableCell>
|
|
202
|
+
</TableRow>
|
|
203
|
+
))}
|
|
204
|
+
</TableBody>
|
|
205
|
+
</Table>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function AssignmentsTab({
|
|
211
|
+
permissions,
|
|
212
|
+
}: {
|
|
213
|
+
readonly permissions: AdminPermissions;
|
|
214
|
+
}) {
|
|
215
|
+
const rows = await listRbacRoleAssignmentRows(permissions);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div className="rounded-md border border-border">
|
|
219
|
+
<Table>
|
|
220
|
+
<TableHeader>
|
|
221
|
+
<TableRow>
|
|
222
|
+
<TableHead className="w-72">Role</TableHead>
|
|
223
|
+
<TableHead>Users and Groups</TableHead>
|
|
224
|
+
</TableRow>
|
|
225
|
+
</TableHeader>
|
|
226
|
+
<TableBody>
|
|
227
|
+
{rows.map((row) => (
|
|
228
|
+
<TableRow
|
|
229
|
+
key={`${row.definition.source}:${row.definition.role}`}
|
|
230
|
+
className="align-top"
|
|
231
|
+
>
|
|
232
|
+
<TableCell className="py-4 whitespace-normal">
|
|
233
|
+
<div className="font-medium text-foreground">
|
|
234
|
+
{row.definition.label}
|
|
235
|
+
</div>
|
|
236
|
+
<div className="text-muted-foreground">
|
|
237
|
+
{row.definition.description}
|
|
238
|
+
</div>
|
|
239
|
+
</TableCell>
|
|
240
|
+
<TableCell className="py-4 whitespace-normal">
|
|
241
|
+
<PrincipalMultiInput
|
|
242
|
+
action={row.updateAction}
|
|
243
|
+
ariaLabel={`${row.definition.label} assignments`}
|
|
244
|
+
disabled={row.disabled}
|
|
245
|
+
hiddenFields={row.hiddenFields}
|
|
246
|
+
key={row.selectedSubjects.join("|")}
|
|
247
|
+
options={row.options}
|
|
248
|
+
selectedSubjects={row.selectedSubjects}
|
|
249
|
+
/>
|
|
250
|
+
{row.disabledReason == null ? null : (
|
|
251
|
+
<div className="mt-2 text-xs text-muted-foreground">
|
|
252
|
+
{row.disabledReason}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</TableCell>
|
|
256
|
+
</TableRow>
|
|
257
|
+
))}
|
|
258
|
+
</TableBody>
|
|
259
|
+
</Table>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function updateUserAssignments(formData: FormData) {
|
|
265
|
+
"use server";
|
|
266
|
+
|
|
267
|
+
await updateApplicationAccessAssignments(formData, "user");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function updateAdminAssignments(formData: FormData) {
|
|
271
|
+
"use server";
|
|
272
|
+
|
|
273
|
+
await updateApplicationAccessAssignments(formData, "admin");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function updateAssignableRoleAssignments(formData: FormData) {
|
|
277
|
+
"use server";
|
|
278
|
+
|
|
279
|
+
const permissions = await requireAnyAdminPermission();
|
|
280
|
+
const role = readAssignableRole(formData);
|
|
281
|
+
|
|
282
|
+
if (!canEditAppRole(role, permissions)) {
|
|
283
|
+
notFound();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
287
|
+
const selectedSubjects = readPrincipalSubjects(formData);
|
|
288
|
+
validateSelectedPrincipals(selectedSubjects, principals, {
|
|
289
|
+
requireEffectiveAccess: true,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const currentSubjects = principals
|
|
293
|
+
.filter((principal) => principal.roles.includes(role))
|
|
294
|
+
.map((principal) => principal.subject);
|
|
295
|
+
|
|
296
|
+
await getAccessControl().app.setAppRoleSubjects(
|
|
297
|
+
role,
|
|
298
|
+
selectedSubjects,
|
|
299
|
+
permissions.canManageDefaultRoles ? undefined : { currentSubjects },
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
revalidatePath("/admin");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function updateApplicationAccessAssignments(
|
|
306
|
+
formData: FormData,
|
|
307
|
+
relation: ApplicationGrant["relation"],
|
|
308
|
+
) {
|
|
309
|
+
const permissions = await requireAnyAdminPermission();
|
|
310
|
+
if (!permissions.canManageDefaultRoles) {
|
|
311
|
+
notFound();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
315
|
+
const selectedSubjects = readPrincipalSubjects(formData);
|
|
316
|
+
validateSelectedPrincipals(selectedSubjects, principals, {
|
|
317
|
+
requireEffectiveAccess: false,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const customerAccess = getCustomerAccessControl();
|
|
321
|
+
if (relation === "user") {
|
|
322
|
+
await customerAccess.setApplicationUsers(
|
|
323
|
+
accessManifest.appNamespace,
|
|
324
|
+
selectedSubjects,
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
await customerAccess.setApplicationAdmins(
|
|
328
|
+
accessManifest.appNamespace,
|
|
329
|
+
selectedSubjects,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
revalidatePath("/admin");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function listRbacRoleAssignmentRows(
|
|
337
|
+
permissions: AdminPermissions,
|
|
338
|
+
): Promise<readonly RbacRoleAssignmentRow[]> {
|
|
339
|
+
const principals = await listPrincipalAccessRows(permissions);
|
|
340
|
+
const principalOptions = principals.map(toPrincipalOption);
|
|
341
|
+
|
|
342
|
+
const applicationRows: readonly RbacRoleAssignmentRow[] = [
|
|
343
|
+
{
|
|
344
|
+
definition: getApplicationAccessRoleDefinition("user"),
|
|
345
|
+
disabled: !permissions.canManageDefaultRoles,
|
|
346
|
+
disabledReason: permissions.canManageDefaultRoles
|
|
347
|
+
? undefined
|
|
348
|
+
: "Only customer admins can edit default application roles.",
|
|
349
|
+
options: principalOptions,
|
|
350
|
+
selectedSubjects: sortSubjectsByPrincipal(
|
|
351
|
+
principals
|
|
352
|
+
.filter((principal) => principal.isUser)
|
|
353
|
+
.map((principal) => principal.subject),
|
|
354
|
+
principals,
|
|
355
|
+
),
|
|
356
|
+
updateAction: updateUserAssignments,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
definition: getApplicationAccessRoleDefinition("admin"),
|
|
360
|
+
disabled: !permissions.canManageDefaultRoles,
|
|
361
|
+
disabledReason: permissions.canManageDefaultRoles
|
|
362
|
+
? undefined
|
|
363
|
+
: "Only customer admins can edit default application roles.",
|
|
364
|
+
options: principalOptions,
|
|
365
|
+
selectedSubjects: sortSubjectsByPrincipal(
|
|
366
|
+
principals
|
|
367
|
+
.filter((principal) => principal.isAdmin)
|
|
368
|
+
.map((principal) => principal.subject),
|
|
369
|
+
principals,
|
|
370
|
+
),
|
|
371
|
+
updateAction: updateAdminAssignments,
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const appRoleRows = assignableRoles.map((role): RbacRoleAssignmentRow => {
|
|
376
|
+
const canEditRole = canEditAppRole(role, permissions);
|
|
377
|
+
const selectedSubjects = sortSubjectsByPrincipal(
|
|
378
|
+
principals
|
|
379
|
+
.filter((principal) => principal.roles.includes(role))
|
|
380
|
+
.map((principal) => principal.subject),
|
|
381
|
+
principals,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
definition: getAppRoleDefinition(role),
|
|
386
|
+
disabled: !canEditRole,
|
|
387
|
+
disabledReason: canEditRole
|
|
388
|
+
? undefined
|
|
389
|
+
: "You do not have permission to edit this role.",
|
|
390
|
+
hiddenFields: [{ name: "role", value: role }],
|
|
391
|
+
options: principalOptions.map((option) =>
|
|
392
|
+
option.effectiveAccess
|
|
393
|
+
? option
|
|
394
|
+
: {
|
|
395
|
+
...option,
|
|
396
|
+
disabled: true,
|
|
397
|
+
disabledReason:
|
|
398
|
+
"Grant application access before assigning app roles.",
|
|
399
|
+
},
|
|
400
|
+
),
|
|
401
|
+
selectedSubjects,
|
|
402
|
+
updateAction: updateAssignableRoleAssignments,
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return [...applicationRows, ...appRoleRows];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function listPrincipalAccessRows(
|
|
410
|
+
permissions: AdminPermissions,
|
|
411
|
+
): Promise<readonly PrincipalAccessRow[]> {
|
|
412
|
+
const [principals, grants, roleSubjects] = await Promise.all([
|
|
413
|
+
listPrincipals(),
|
|
414
|
+
getCustomerAccessControl().listApplicationGrants(
|
|
415
|
+
accessManifest.appNamespace,
|
|
416
|
+
),
|
|
417
|
+
listAssignableRoleSubjects(),
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
const grantMap = buildGrantMap(grants);
|
|
421
|
+
const access = getAccessControl();
|
|
422
|
+
const application = accessManifest.application.ref();
|
|
423
|
+
|
|
424
|
+
const rows = await Promise.all(
|
|
425
|
+
principals.map(async (principal) => {
|
|
426
|
+
const subject =
|
|
427
|
+
principal.type === "User"
|
|
428
|
+
? toUserSubject(principal.id)
|
|
429
|
+
: groupSubjectRef(principal.id);
|
|
430
|
+
const grantState = grantMap.get(subject);
|
|
431
|
+
const effectiveAccess = await access.permissions.can({
|
|
432
|
+
permission: accessManifest.application.accessPermission,
|
|
433
|
+
resource: application,
|
|
434
|
+
subject,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
detail: principal.detail,
|
|
439
|
+
displayName: principal.displayName,
|
|
440
|
+
effectiveAccess,
|
|
441
|
+
isAdmin: grantState?.has("admin") === true,
|
|
442
|
+
isUser: grantState?.has("user") === true,
|
|
443
|
+
principalType: principal.type,
|
|
444
|
+
roles: assignableRoles.filter(
|
|
445
|
+
(role) => roleSubjects.get(role)?.has(subject) === true,
|
|
446
|
+
),
|
|
447
|
+
subject,
|
|
448
|
+
};
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return permissions.canManageDefaultRoles
|
|
453
|
+
? rows
|
|
454
|
+
: rows.filter((row) => row.effectiveAccess);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function listAssignableRoleSubjects(): Promise<
|
|
458
|
+
ReadonlyMap<AccessAppRole, ReadonlySet<SubjectRef>>
|
|
459
|
+
> {
|
|
460
|
+
const app = getAccessControl().app;
|
|
461
|
+
const entries = await Promise.all(
|
|
462
|
+
assignableRoles.map(async (role) => [
|
|
463
|
+
role,
|
|
464
|
+
new Set(await app.listAppRoleSubjects(role)),
|
|
465
|
+
] as const),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
return new Map(entries);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function buildGrantMap(
|
|
472
|
+
grants: readonly ApplicationGrant[],
|
|
473
|
+
): Map<SubjectRef, Set<ApplicationGrant["relation"]>> {
|
|
474
|
+
const grantMap = new Map<SubjectRef, Set<ApplicationGrant["relation"]>>();
|
|
475
|
+
|
|
476
|
+
for (const grant of grants) {
|
|
477
|
+
const subjectGrants = grantMap.get(grant.subject) ?? new Set();
|
|
478
|
+
subjectGrants.add(grant.relation);
|
|
479
|
+
grantMap.set(grant.subject, subjectGrants);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return grantMap;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function canEditAppRole(
|
|
486
|
+
role: AccessAppRole,
|
|
487
|
+
permissions: AdminPermissions,
|
|
488
|
+
): boolean {
|
|
489
|
+
return (
|
|
490
|
+
appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function getApplicationAccessRoleDefinition(
|
|
495
|
+
role: ApplicationAccessRole,
|
|
496
|
+
): RoleDefinition {
|
|
497
|
+
const definition = roleDefinitions.find(
|
|
498
|
+
(candidate) =>
|
|
499
|
+
candidate.source === "Application access" && candidate.role === role,
|
|
500
|
+
);
|
|
501
|
+
if (definition == null) {
|
|
502
|
+
throw new Error(`Missing application access role definition: ${role}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return definition;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function getAppRoleDefinition(role: AccessAppRole): RoleDefinition {
|
|
509
|
+
const definition = appRoleDefinitionMap.get(role);
|
|
510
|
+
if (definition == null) {
|
|
511
|
+
throw new Error(`Missing app role definition: ${role}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return definition;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function toPrincipalOption(
|
|
518
|
+
principal: PrincipalAccessRow,
|
|
519
|
+
): PrincipalAssignmentOption {
|
|
520
|
+
return {
|
|
521
|
+
detail: principal.detail,
|
|
522
|
+
effectiveAccess: principal.effectiveAccess,
|
|
523
|
+
label: principal.displayName,
|
|
524
|
+
subject: principal.subject,
|
|
525
|
+
type: principal.principalType,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function readPrincipalSubjects(formData: FormData): readonly SubjectRef[] {
|
|
530
|
+
const subjects: SubjectRef[] = [];
|
|
531
|
+
const seenSubjects = new Set<string>();
|
|
532
|
+
|
|
533
|
+
for (const value of formData.getAll("subjects")) {
|
|
534
|
+
if (typeof value !== "string" || !isPrincipalSubject(value)) {
|
|
535
|
+
throw new Error("Invalid principal subject.");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!seenSubjects.has(value)) {
|
|
539
|
+
subjects.push(value);
|
|
540
|
+
seenSubjects.add(value);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return subjects;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isPrincipalSubject(subject: string): subject is SubjectRef {
|
|
548
|
+
return (
|
|
549
|
+
subject.startsWith("core/user:") ||
|
|
550
|
+
/^core\/group:[^#]+#member$/.test(subject)
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function validateSelectedPrincipals(
|
|
555
|
+
selectedSubjects: readonly SubjectRef[],
|
|
556
|
+
principals: readonly PrincipalAccessRow[],
|
|
557
|
+
{
|
|
558
|
+
requireEffectiveAccess,
|
|
559
|
+
}: {
|
|
560
|
+
readonly requireEffectiveAccess: boolean;
|
|
561
|
+
},
|
|
562
|
+
) {
|
|
563
|
+
const principalBySubject = new Map(
|
|
564
|
+
principals.map((principal) => [principal.subject, principal]),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
for (const subject of selectedSubjects) {
|
|
568
|
+
const principal = principalBySubject.get(subject);
|
|
569
|
+
if (principal == null) {
|
|
570
|
+
throw new Error("Unknown principal subject.");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (requireEffectiveAccess && !principal.effectiveAccess) {
|
|
574
|
+
throw new Error("Principal must have application access.");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function sortSubjectsByPrincipal(
|
|
580
|
+
subjects: readonly SubjectRef[],
|
|
581
|
+
principals: readonly PrincipalAccessRow[],
|
|
582
|
+
): readonly SubjectRef[] {
|
|
583
|
+
const sortKeyBySubject = new Map(
|
|
584
|
+
principals.map((principal) => [
|
|
585
|
+
principal.subject,
|
|
586
|
+
`${principal.principalType}:${principal.displayName}:${principal.detail}`,
|
|
587
|
+
]),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
return [...subjects].sort((a, b) =>
|
|
591
|
+
(sortKeyBySubject.get(a) ?? a).localeCompare(sortKeyBySubject.get(b) ?? b),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function readAdminTab(tab: string | string[] | undefined): AdminTab {
|
|
596
|
+
const value = Array.isArray(tab) ? tab[0] : tab;
|
|
597
|
+
return value === "assignments" ? "assignments" : "roles";
|
|
598
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { notFound, redirect } from "next/navigation";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import Header from "../../components/Header";
|
|
4
|
+
import { getServerSession } from "../../lib/auth";
|
|
5
|
+
import {
|
|
6
|
+
canManageAppRoles,
|
|
7
|
+
canManageDefaultRoles,
|
|
8
|
+
} from "../../services/access/AppAccessControl";
|
|
9
|
+
|
|
10
|
+
export default async function AdminLayout({
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
const session = await getServerSession();
|
|
16
|
+
|
|
17
|
+
if (session?.user == null) {
|
|
18
|
+
redirect("/auth/signin");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const [canManageDefaults, canManageRoles] = await Promise.all([
|
|
22
|
+
canManageDefaultRoles(session.user.id),
|
|
23
|
+
canManageAppRoles(session.user.id),
|
|
24
|
+
]);
|
|
25
|
+
const showAdminLink = canManageDefaults || canManageRoles;
|
|
26
|
+
|
|
27
|
+
if (!showAdminLink) {
|
|
28
|
+
notFound();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<Header showAdminLink={showAdminLink} />
|
|
34
|
+
<main>
|
|
35
|
+
<div className="py-8">
|
|
36
|
+
<div className="mx-auto max-w-6xl">
|
|
37
|
+
<div className="rounded-lg bg-white p-8">{children}</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</main>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -2,7 +2,11 @@ import { notFound, redirect } from "next/navigation";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import Header from "../../components/Header";
|
|
4
4
|
import { getServerSession } from "../../lib/auth";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
canAccessApplication,
|
|
7
|
+
canManageAppRoles,
|
|
8
|
+
canManageDefaultRoles,
|
|
9
|
+
} from "../../services/access/AppAccessControl";
|
|
6
10
|
|
|
7
11
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
|
8
12
|
const session = await getServerSession();
|
|
@@ -11,14 +15,20 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
|
|
11
15
|
redirect("/auth/signin");
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
const canAccessApp = await
|
|
18
|
+
const [canAccessApp, canManageDefaults, canManageRoles] = await Promise.all([
|
|
19
|
+
canAccessApplication(session.user.id),
|
|
20
|
+
canManageDefaultRoles(session.user.id),
|
|
21
|
+
canManageAppRoles(session.user.id),
|
|
22
|
+
]);
|
|
23
|
+
const showAdminLink = canManageDefaults || canManageRoles;
|
|
24
|
+
|
|
15
25
|
if (!canAccessApp) {
|
|
16
26
|
notFound();
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
return (
|
|
20
30
|
<>
|
|
21
|
-
<Header />
|
|
31
|
+
<Header showAdminLink={showAdminLink} />
|
|
22
32
|
<main>
|
|
23
33
|
<div className="py-8">
|
|
24
34
|
<div className="mx-auto max-w-6xl">
|
|
@@ -23,7 +23,7 @@ type Credentials = z.infer<typeof CREDENTIALS_SCHEMA>;
|
|
|
23
23
|
// credentials. In dev we prefill with the seeded user from `pnpm db:seed` so a
|
|
24
24
|
// fresh scaffold is one click away from authed.
|
|
25
25
|
const DEFAULTS: Credentials = IS_DEV
|
|
26
|
-
? { email: "admin@example.com", password: "password" }
|
|
26
|
+
? { email: "app-admin@example.com", password: "password" }
|
|
27
27
|
: { email: "", password: "" };
|
|
28
28
|
|
|
29
29
|
export function CredentialsSignInForm() {
|
|
@@ -8,9 +8,8 @@ export default function GlobalError({
|
|
|
8
8
|
reset: () => void;
|
|
9
9
|
}) {
|
|
10
10
|
try {
|
|
11
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
12
|
const { faro } = require("@grafana/faro-web-sdk");
|
|
13
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
14
13
|
faro.api?.pushError(error);
|
|
15
14
|
} catch {
|
|
16
15
|
// Faro may not be initialized yet — don't let reporting break the error page
|