@percepta/create 3.4.0 → 3.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "3.4.0",
3
+ "version": "3.4.2",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,6 +7,7 @@
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./db": "./src/drizzle/db.ts",
10
+ "./principals": "./src/principals.ts",
10
11
  "./schema": "./src/drizzle/schema/index.ts"
11
12
  },
12
13
  "scripts": {
@@ -17,7 +18,7 @@
17
18
  "db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
18
19
  },
19
20
  "dependencies": {
20
- "@percepta/auth": "0.1.0",
21
+ "@percepta/auth": "0.1.1",
21
22
  "drizzle-orm": "^0.45.2"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,11 @@
1
+ import { listAuthPrincipals } from "@percepta/auth/drizzle";
2
+ import { db } from "./drizzle/db";
3
+ import { groups, users } from "./drizzle/schema";
4
+
5
+ export function listPrincipals() {
6
+ return listAuthPrincipals({
7
+ db,
8
+ groupsTable: groups,
9
+ usersTable: users,
10
+ });
11
+ }
@@ -28,7 +28,7 @@
28
28
  "pnpm": ">=9"
29
29
  },
30
30
  "devDependencies": {
31
- "@percepta/access-control": "0.6.0",
31
+ "@percepta/access-control": "0.6.1",
32
32
  "@types/node": "^24.1.0",
33
33
  "eslint": "^9.18.0",
34
34
  "rimraf": "^5.0.5",
@@ -98,6 +98,32 @@ src/
98
98
  | `pnpm db:setup-and-migrate` | Setup and migrate database |
99
99
  | `pnpm db:studio` | Setup/migrate the database, then open Drizzle Studio |
100
100
  | `pnpm db:seed` | Seed default shared-auth dev users and local access grants |
101
+ | `pnpm test:e2e:install` | Install the Chromium browser used by Playwright |
102
+ | `pnpm test:e2e` | Run Playwright e2e tests after local setup |
103
+ | `pnpm test:e2e:ui` | Run Playwright e2e tests in UI mode after local setup |
104
+
105
+ ## End-to-End Tests
106
+
107
+ This template includes focused Playwright coverage for seeded RBAC flows. Before
108
+ the first run, install the browser binary:
109
+
110
+ ```bash
111
+ pnpm test:e2e:install
112
+ ```
113
+
114
+ Then run:
115
+
116
+ ```bash
117
+ pnpm test:e2e
118
+ ```
119
+
120
+ The e2e script runs local setup first, then starts the Next.js dev server via
121
+ Playwright. To point the tests at an already-running app, set
122
+ `PLAYWRIGHT_BASE_URL`, for example:
123
+
124
+ ```bash
125
+ PLAYWRIGHT_BASE_URL=http://localhost:3000 pnpm test:e2e
126
+ ```
101
127
 
102
128
  ## Logging
103
129
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  This guide deploys __APP_TITLE__ to `https://__APP_NAME__.percepta-test.aitco.dev` using Ryvn. Tell Claude "deploy this app to percepta-test" and it should run the direct deploy helper below.
4
4
 
5
- This is the existing-environment deploy motion: `percepta-test` already owns the shared platform services, and this app is wired into them. Fresh-environment platform bootstrap is separate and should use a Ryvn blueprint or environment-specific platform rollout before app deploys run.
5
+ This is the existing-environment deploy motion: `percepta-test` already owns the shared platform services, and this app is wired into them. Fresh-environment platform bootstrap is separate and should use a Ryvn blueprint or environment-specific platform rollout before app deploys run. The `pnpm deploy:percepta-test` script delegates to the versioned `@percepta/deploy` CLI; this app owns only its Ryvn YAML and generated secrets env file.
6
6
 
7
7
  ## What's Already Scaffolded
8
8
 
@@ -18,7 +18,7 @@ These files deploy to `https://__APP_NAME__.percepta-test.aitco.dev`.
18
18
 
19
19
  The default deploy helper performs the existing-environment deploy motion: it assumes the target Ryvn environment already has the shared platform services installed, then wires this app into them. For `percepta-test`, that means shared Postgres, Inngest, the OTEL collector, the LGTM stack, and Langfuse must already exist before app deploy starts. Fresh-environment platform bootstrap is a separate motion and should be handled by a Ryvn blueprint or environment-specific platform rollout.
20
20
 
21
- The helper talks directly to Ryvn: it preflights the existing platform services, creates/updates the services, runs the GitHub Actions release workflows, creates the schema installation, approves the schema Terraform plan, creates or updates app-scoped Ryvn secrets, creates the web installation, waits for health, and verifies the health and app routes.
21
+ The `pnpm deploy:percepta-test` script delegates to the versioned `@percepta/deploy` CLI. The app owns only the Ryvn service/installation YAML and secrets env file. The helper talks directly to Ryvn: it preflights the existing platform services, creates/updates the services, runs the GitHub Actions release workflows, creates the schema installation, approves the schema Terraform plan, creates or updates app-scoped Ryvn secrets, creates the web installation, waits for health, and verifies the health and app routes.
22
22
 
23
23
  ## Deploying
24
24
 
@@ -0,0 +1,136 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { expect, test, type Page } from "@playwright/test";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const password = "password";
7
+
8
+ const users = {
9
+ appAdmin: "app-admin@example.com",
10
+ appUser: "app-user@example.com",
11
+ customerAdmin: "customer-admin@example.com",
12
+ nonAppUser: "non-user@example.com",
13
+ } as const;
14
+
15
+ test.describe("RBAC access", () => {
16
+ test("customer admin can manage role mappings but cannot enter the main app", async ({
17
+ page,
18
+ }) => {
19
+ test.setTimeout(60_000);
20
+ await signIn(page, users.customerAdmin, "/admin?tab=assignments");
21
+
22
+ await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
23
+ await expect(page.getByRole("link", { name: "Admin" })).toBeVisible();
24
+
25
+ const userAssignments = page.getByRole("button", {
26
+ name: "User assignments",
27
+ });
28
+ const adminAssignments = page.getByRole("button", {
29
+ name: "Admin assignments",
30
+ });
31
+
32
+ await expect(userAssignments).toHaveAttribute("aria-disabled", "false");
33
+ await expect(adminAssignments).toHaveAttribute("aria-disabled", "false");
34
+
35
+ await userAssignments.click();
36
+ await expect(page.getByPlaceholder("Search users or groups")).toBeVisible();
37
+
38
+ let shouldResetSeedData = false;
39
+ try {
40
+ await page.getByRole("option", { name: /App Non User/ }).click();
41
+ shouldResetSeedData = true;
42
+ await expect(userAssignments).toContainText("App Non User");
43
+ await expect(userAssignments).toHaveAttribute("aria-busy", "false");
44
+
45
+ await page.reload();
46
+ await expect(
47
+ page.getByRole("button", { name: "User assignments" }),
48
+ ).toContainText("App Non User");
49
+ } finally {
50
+ if (shouldResetSeedData) {
51
+ await resetSeedData();
52
+ }
53
+ }
54
+
55
+ await page.reload();
56
+ await expect(
57
+ page.getByRole("button", { name: "User assignments" }),
58
+ ).not.toContainText("App Non User");
59
+
60
+ await page.goto("/");
61
+ await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
62
+ 0,
63
+ );
64
+ await expectNotFound(page);
65
+ });
66
+
67
+ test("app admin can see RBAC and main app but cannot edit default role mappings", async ({
68
+ page,
69
+ }) => {
70
+ await signIn(page, users.appAdmin, "/admin?tab=assignments");
71
+
72
+ await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
73
+ await expect(
74
+ page
75
+ .getByText("Only customer admins can edit default application roles.")
76
+ .first(),
77
+ ).toBeVisible();
78
+
79
+ await expect(
80
+ page.getByRole("button", { name: "User assignments" }),
81
+ ).toHaveAttribute("aria-disabled", "true");
82
+ await expect(
83
+ page.getByRole("button", { name: "Admin assignments" }),
84
+ ).toHaveAttribute("aria-disabled", "true");
85
+
86
+ await page.goto("/");
87
+ await expect(
88
+ page.getByRole("heading", { name: /Welcome to/ }),
89
+ ).toBeVisible();
90
+ });
91
+
92
+ test("app user can enter the main app but cannot reach admin", async ({
93
+ page,
94
+ }) => {
95
+ await signIn(page, users.appUser, "/");
96
+
97
+ await expect(
98
+ page.getByRole("heading", { name: /Welcome to/ }),
99
+ ).toBeVisible();
100
+ await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0);
101
+
102
+ await page.goto("/admin");
103
+ await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
104
+ await expectNotFound(page);
105
+ });
106
+
107
+ test("non app user cannot enter the app", async ({ page }) => {
108
+ await signIn(page, users.nonAppUser, "/");
109
+
110
+ await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
111
+ 0,
112
+ );
113
+ await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
114
+ await expectNotFound(page);
115
+ });
116
+ });
117
+
118
+ async function signIn(page: Page, email: string, callbackUrl: string) {
119
+ await page.goto(
120
+ `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`,
121
+ );
122
+ await page.getByLabel("Email").fill(email);
123
+ await page.getByLabel("Password").fill(password);
124
+ await page.getByRole("button", { name: "Sign In" }).click();
125
+ await page.waitForURL((url) => url.pathname !== "/auth/signin");
126
+ }
127
+
128
+ async function expectNotFound(page: Page) {
129
+ await expect(
130
+ page.getByText(/This page could not be found|404/i).first(),
131
+ ).toBeVisible();
132
+ }
133
+
134
+ async function resetSeedData() {
135
+ await execFileAsync("pnpm", ["db:seed"], { cwd: process.cwd() });
136
+ }
@@ -91,4 +91,10 @@ export default tseslint.config(
91
91
  "n/no-process-env": "off",
92
92
  },
93
93
  },
94
+ {
95
+ files: ["playwright.config.ts"],
96
+ rules: {
97
+ "n/no-process-env": "off",
98
+ },
99
+ },
94
100
  );
@@ -12,6 +12,8 @@
12
12
 
13
13
  # testing
14
14
  /coverage
15
+ /playwright-report
16
+ /test-results
15
17
 
16
18
  # next.js
17
19
  /.next/
@@ -24,9 +24,12 @@
24
24
  "db:setup-readonly": "tsx ./scripts/setup-readonly-user.ts",
25
25
  "db:studio": "pnpm db:setup-and-migrate && drizzle-kit studio",
26
26
  "db:seed": "tsx ./scripts/seed.ts",
27
- "deploy:percepta-test": "tsx ./scripts/deploy-percepta-test.ts",
28
- "deploy:percepta-test:pr": "tsx ./scripts/open-ryvn-deploy-pr.ts",
27
+ "deploy:percepta-test": "percepta-deploy percepta-test --app __APP_NAME__ --repo __REPO_NAME__",
28
+ "deploy:percepta-test:pr": "percepta-deploy percepta-test pr --app __APP_NAME__ --database-schema __APP_NAME_SNAKE__",
29
29
  "test": "vitest run",
30
+ "test:e2e": "pnpm run setup && playwright test",
31
+ "test:e2e:install": "playwright install chromium",
32
+ "test:e2e:ui": "pnpm run setup && playwright test --ui",
30
33
  "test:watch": "vitest"
31
34
  },
32
35
  "dependencies": {
@@ -55,7 +58,7 @@
55
58
  "@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
56
59
  "@opentelemetry/sdk-node": "^0.203.0",
57
60
  "@__REPO_NAME__/auth": "workspace:*",
58
- "@percepta/access-control": "0.6.0",
61
+ "@percepta/access-control": "0.6.1",
59
62
  "@percepta/design": "0.3.2",
60
63
  "@percepta/logger": "0.0.6",
61
64
  "@percepta/next-utils": "0.1.0",
@@ -104,7 +107,9 @@
104
107
  "devDependencies": {
105
108
  "@eslint/js": "^9.18.0",
106
109
  "@next/eslint-plugin-next": "^15.3.5",
110
+ "@playwright/test": "^1.58.2",
107
111
  "@percepta/build": "0.4.0",
112
+ "@percepta/deploy": "0.1.0",
108
113
  "@tailwindcss/postcss": "^4.1.11",
109
114
  "@types/formidable": "^3.4.5",
110
115
  "@types/he": "^1.2.3",
@@ -0,0 +1,33 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000);
4
+ const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`;
5
+
6
+ export default defineConfig({
7
+ testDir: "./e2e",
8
+ fullyParallel: false,
9
+ forbidOnly: Boolean(process.env.CI),
10
+ retries: process.env.CI ? 2 : 0,
11
+ reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
12
+ use: {
13
+ baseURL,
14
+ trace: "on-first-retry",
15
+ },
16
+ webServer: process.env.PLAYWRIGHT_BASE_URL
17
+ ? undefined
18
+ : {
19
+ command: `pnpm dev -- --hostname 127.0.0.1 --port ${port}`,
20
+ env: {
21
+ SKIP_INNGEST_SYNC: "true",
22
+ },
23
+ reuseExistingServer: !process.env.CI,
24
+ timeout: 120_000,
25
+ url: baseURL,
26
+ },
27
+ projects: [
28
+ {
29
+ name: "chromium",
30
+ use: { ...devices["Desktop Chrome"] },
31
+ },
32
+ ],
33
+ });
@@ -1,17 +1,11 @@
1
- import { defineAccessManifest } from "@percepta/access-control";
1
+ import {
2
+ defineAccessManifest,
3
+ defineAccessRoleDefinitions,
4
+ } from "@percepta/access-control";
2
5
 
3
6
  const appRoles = [] as const;
4
7
 
5
- export const accessRoleDefinitions = [] as const satisfies ReadonlyArray<{
6
- readonly description: string;
7
- readonly editableBy: "app_admin" | "customer_admin";
8
- readonly label: string;
9
- readonly permissions: ReadonlyArray<{
10
- readonly description: string;
11
- readonly permission: string;
12
- }>;
13
- readonly role: (typeof appRoles)[number];
14
- }>;
8
+ export const accessRoleDefinitions = defineAccessRoleDefinitions(appRoles, []);
15
9
 
16
10
  export const accessManifest = defineAccessManifest({
17
11
  adminUI: {
@@ -1,7 +1,14 @@
1
- import type { ApplicationGrant, SubjectRef } from "@percepta/access-control";
1
+ import type {
2
+ AccessRoleDefinition,
3
+ ApplicationGrant,
4
+ SubjectRef,
5
+ } from "@percepta/access-control";
2
6
  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";
7
+ import {
8
+ PrincipalMultiInput,
9
+ type PrincipalOption,
10
+ } from "@percepta/access-control/react";
11
+ import { listPrincipals } from "@__REPO_NAME__/auth/principals";
5
12
  import {
6
13
  Badge,
7
14
  Table,
@@ -11,7 +18,6 @@ import {
11
18
  TableHeader,
12
19
  TableRow,
13
20
  } from "@percepta/design";
14
- import { isNull } from "drizzle-orm";
15
21
  import type { Metadata } from "next";
16
22
  import { revalidatePath } from "next/cache";
17
23
  import { notFound } from "next/navigation";
@@ -31,10 +37,6 @@ import {
31
37
  readAssignableRole,
32
38
  requireAnyAdminPermission,
33
39
  } from "./_lib/accessAdmin";
34
- import {
35
- PrincipalMultiInput,
36
- type PrincipalOption,
37
- } from "./_components/PrincipalMultiInput";
38
40
  import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
39
41
 
40
42
  export const metadata: Metadata = {
@@ -50,22 +52,10 @@ interface AdminPageProps {
50
52
  }>;
51
53
  }
52
54
 
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;
55
+ interface RoleDefinition extends AccessRoleDefinition {
64
56
  readonly source: "Application access" | "App RBAC";
65
57
  }
66
58
 
67
- type AppRoleDefinition = Omit<RoleDefinition, "source">;
68
-
69
59
  interface PrincipalAssignmentOption extends PrincipalOption {
70
60
  readonly effectiveAccess: boolean;
71
61
  readonly subject: SubjectRef;
@@ -98,7 +88,6 @@ interface PrincipalAccessRow {
98
88
  const roleDefinitions = [
99
89
  {
100
90
  description: "Can sign in to this application.",
101
- editableBy: "customer_admin",
102
91
  label: "User",
103
92
  permissions: [
104
93
  {
@@ -111,7 +100,6 @@ const roleDefinitions = [
111
100
  },
112
101
  {
113
102
  description: "Can sign in and manage application-specific roles.",
114
- editableBy: "customer_admin",
115
103
  label: "Admin",
116
104
  permissions: [
117
105
  {
@@ -126,7 +114,9 @@ const roleDefinitions = [
126
114
  role: "admin",
127
115
  source: "Application access",
128
116
  },
129
- ...(accessRoleDefinitions as readonly AppRoleDefinition[]).map(
117
+ ...(
118
+ accessRoleDefinitions as readonly AccessRoleDefinition<AccessAppRole>[]
119
+ ).map(
130
120
  (definition): RoleDefinition => ({
131
121
  ...definition,
132
122
  role: definition.role,
@@ -250,6 +240,7 @@ async function AssignmentsTab({
250
240
  <TableCell className="py-4 whitespace-normal">
251
241
  <PrincipalMultiInput
252
242
  action={row.updateAction}
243
+ ariaLabel={`${row.definition.label} assignments`}
253
244
  disabled={row.disabled}
254
245
  hiddenFields={row.hiddenFields}
255
246
  key={row.selectedSubjects.join("|")}
@@ -302,7 +293,11 @@ async function updateAssignableRoleAssignments(formData: FormData) {
302
293
  .filter((principal) => principal.roles.includes(role))
303
294
  .map((principal) => principal.subject);
304
295
 
305
- await syncAppRoleAssignments(role, currentSubjects, selectedSubjects);
296
+ await getAccessControl().app.setAppRoleSubjects(
297
+ role,
298
+ selectedSubjects,
299
+ permissions.canManageDefaultRoles ? undefined : { currentSubjects },
300
+ );
306
301
 
307
302
  revalidatePath("/admin");
308
303
  }
@@ -322,17 +317,18 @@ async function updateApplicationAccessAssignments(
322
317
  requireEffectiveAccess: false,
323
318
  });
324
319
 
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
- );
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
+ }
336
332
 
337
333
  revalidatePath("/admin");
338
334
  }
@@ -413,59 +409,24 @@ async function listRbacRoleAssignmentRows(
413
409
  async function listPrincipalAccessRows(
414
410
  permissions: AdminPermissions,
415
411
  ): 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),
412
+ const [principals, grants, roleSubjects] = await Promise.all([
413
+ listPrincipals(),
434
414
  getCustomerAccessControl().listApplicationGrants(
435
415
  accessManifest.appNamespace,
436
416
  ),
417
+ listAssignableRoleSubjects(),
437
418
  ]);
438
419
 
439
420
  const grantMap = buildGrantMap(grants);
440
421
  const access = getAccessControl();
441
422
  const application = accessManifest.application.ref();
442
423
 
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);
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);
469
430
  const grantState = grantMap.get(subject);
470
431
  const effectiveAccess = await access.permissions.can({
471
432
  permission: accessManifest.application.accessPermission,
@@ -474,30 +435,39 @@ async function listPrincipalAccessRows(
474
435
  });
475
436
 
476
437
  return {
477
- detail: `${group.source} group`,
478
- displayName: group.name,
438
+ detail: principal.detail,
439
+ displayName: principal.displayName,
479
440
  effectiveAccess,
480
441
  isAdmin: grantState?.has("admin") === true,
481
442
  isUser: grantState?.has("user") === true,
482
- principalType: "Group" as const,
483
- roles: await access.app.listAppRoles(subject),
443
+ principalType: principal.type,
444
+ roles: assignableRoles.filter(
445
+ (role) => roleSubjects.get(role)?.has(subject) === true,
446
+ ),
484
447
  subject,
485
448
  };
486
449
  }),
487
450
  );
488
451
 
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
452
  return permissions.canManageDefaultRoles
497
453
  ? rows
498
454
  : rows.filter((row) => row.effectiveAccess);
499
455
  }
500
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
+
501
471
  function buildGrantMap(
502
472
  grants: readonly ApplicationGrant[],
503
473
  ): Map<SubjectRef, Set<ApplicationGrant["relation"]>> {
@@ -516,14 +486,9 @@ function canEditAppRole(
516
486
  role: AccessAppRole,
517
487
  permissions: AdminPermissions,
518
488
  ): 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;
489
+ return (
490
+ appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
491
+ );
527
492
  }
528
493
 
529
494
  function getApplicationAccessRoleDefinition(
@@ -611,56 +576,6 @@ function validateSelectedPrincipals(
611
576
  }
612
577
  }
613
578
 
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
579
  function sortSubjectsByPrincipal(
665
580
  subjects: readonly SubjectRef[],
666
581
  principals: readonly PrincipalAccessRow[],