@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 +1 -1
- 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/README.md +26 -0
- package/templates/webapp/agent-skills/deploy.md +1 -1
- package/templates/webapp/deploy/README.md +1 -1
- package/templates/webapp/e2e/rbac.spec.ts +136 -0
- package/templates/webapp/eslint.config.mjs +6 -0
- package/templates/webapp/gitignore.template +2 -0
- package/templates/webapp/package.json.template +8 -3
- package/templates/webapp/playwright.config.ts +33 -0
- package/templates/webapp/src/access/access.manifest.ts +5 -11
- package/templates/webapp/src/app/(admin)/admin/page.tsx +64 -149
- package/templates/webapp/src/services/access/AppAccessControl.ts +5 -31
- package/templates/webapp/scripts/deploy-percepta-test.ts +0 -1112
- package/templates/webapp/scripts/open-ryvn-deploy-pr.ts +0 -497
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +0 -248
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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": "
|
|
28
|
-
"deploy:percepta-test:pr": "
|
|
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.
|
|
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 {
|
|
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 = []
|
|
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 {
|
|
1
|
+
import type {
|
|
2
|
+
AccessRoleDefinition,
|
|
3
|
+
ApplicationGrant,
|
|
4
|
+
SubjectRef,
|
|
5
|
+
} from "@percepta/access-control";
|
|
2
6
|
import { groupSubjectRef } from "@percepta/access-control";
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 [
|
|
417
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
const subject =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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:
|
|
478
|
-
displayName:
|
|
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:
|
|
483
|
-
roles:
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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[],
|