@percepta/create 3.2.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 +9 -6
- package/templates/monorepo/access/dev-grants.yaml.example +3 -8
- package/templates/monorepo/auth/README.md +4 -3
- package/templates/monorepo/auth/package.json +2 -5
- package/templates/monorepo/auth/scripts/setup-database.ts +2 -48
- package/templates/monorepo/auth/src/auth.ts +10 -40
- package/templates/monorepo/auth/src/config/database.ts +9 -25
- package/templates/monorepo/auth/src/drizzle/db.ts +3 -4
- package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +2 -23
- package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +2 -16
- package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +2 -11
- package/templates/monorepo/auth/src/drizzle/schema/groups.ts +1 -1
- package/templates/monorepo/auth/src/drizzle/schema/users.ts +1 -1
- 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
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Scaffold and manage Mosaic packages.
|
|
|
8
8
|
npx @percepta/create
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
That's it. The CLI prompts you for the package type, repo name, and package name as needed. Defaults yield a running app — sign in as `admin@example.com` / `password`.
|
|
11
|
+
That's it. The CLI prompts you for the package type, repo name, and package name as needed. Defaults yield a running app — sign in as `app-admin@example.com` / `password`.
|
|
12
12
|
|
|
13
13
|
## Options (mostly for automation)
|
|
14
14
|
|
|
@@ -43,11 +43,11 @@ The bare command above is the canonical UX. The flags below exist for tests and
|
|
|
43
43
|
When you scaffold a webapp (the default flow), `create` automatically runs:
|
|
44
44
|
|
|
45
45
|
1. `pnpm install` (at the monorepo root)
|
|
46
|
-
2. `pnpm run setup` — Docker Compose Postgres + Drizzle migrations + seed
|
|
46
|
+
2. `pnpm run setup` — Docker Compose Postgres + Drizzle migrations + seed users
|
|
47
47
|
3. `pnpm dev` — Next.js dev server
|
|
48
48
|
4. Opens the served URL in your default browser
|
|
49
49
|
|
|
50
|
-
Sign in as `admin@example.com` / `password` to start building.
|
|
50
|
+
Sign in as `app-admin@example.com` / `password` to start building.
|
|
51
51
|
|
|
52
52
|
To bail out of the auto-run and get manual next-steps instead, pass `--skip-install`. Then you can run install / setup / dev yourself when ready.
|
|
53
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percepta/create",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Scaffold a new Mosaic package",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@types/node": "^24.1.0",
|
|
39
39
|
"@types/validate-npm-package-name": "^4.0.2",
|
|
40
40
|
"vitest": "^4.0.0",
|
|
41
|
-
"@percepta/build": "
|
|
41
|
+
"@percepta/build": "1.1.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=18.0.0"
|
|
@@ -41,7 +41,7 @@ packages/
|
|
|
41
41
|
Application builders define app-local Zed schemas in each package's
|
|
42
42
|
`src/access/` directory. The root access scripts merge those schemas with the
|
|
43
43
|
shared `core/*` schema and apply customer-owned grants such
|
|
44
|
-
as application
|
|
44
|
+
as application admins, application users, and bootstrap customer admins.
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
pnpm access:merge
|
|
@@ -53,8 +53,10 @@ PR CI merges the customer schema and runs static schema/manifest validation.
|
|
|
53
53
|
`access:apply` is reserved for trusted deploy jobs and should run once per
|
|
54
54
|
target environment with that environment's SpiceDB credentials.
|
|
55
55
|
|
|
56
|
-
For local development,
|
|
57
|
-
|
|
56
|
+
For local development, `pnpm run setup` seeds the generated app's default dev
|
|
57
|
+
users and access grants. For additional local grants, copy the example fixture
|
|
58
|
+
files in `access/`, fill in customer-global user/group IDs from `auth/`, then
|
|
59
|
+
run:
|
|
58
60
|
|
|
59
61
|
```bash
|
|
60
62
|
pnpm access:seed-grants
|
|
@@ -63,9 +65,10 @@ pnpm access:bootstrap-customer-admin -- --subject core/user:<user-id>
|
|
|
63
65
|
|
|
64
66
|
## Shared Auth
|
|
65
67
|
|
|
66
|
-
The `auth/` workspace
|
|
67
|
-
`users`, `groups`, and `group_members`. Apps should
|
|
68
|
-
identity layer instead of creating app-local users or
|
|
68
|
+
The `auth/` workspace wires the customer-global Better Auth schema from
|
|
69
|
+
`@percepta/auth`, including `users`, `groups`, and `group_members`. Apps should
|
|
70
|
+
consume this shared identity layer instead of creating app-local users or
|
|
71
|
+
groups.
|
|
69
72
|
|
|
70
73
|
## Adding a new package
|
|
71
74
|
|
|
@@ -5,15 +5,10 @@ customerAdmins:
|
|
|
5
5
|
|
|
6
6
|
applications:
|
|
7
7
|
- appNamespace: "people_app"
|
|
8
|
-
|
|
8
|
+
admins:
|
|
9
9
|
- userId: "00000000-0000-0000-0000-000000000000"
|
|
10
|
-
|
|
11
|
-
- groupId: "11111111-1111-1111-1111-111111111111"
|
|
12
|
-
|
|
13
|
-
appRoles:
|
|
14
|
-
- appNamespace: "people_app"
|
|
15
|
-
role: "admin"
|
|
16
|
-
subjects:
|
|
10
|
+
users:
|
|
17
11
|
- groupId: "11111111-1111-1111-1111-111111111111"
|
|
18
12
|
|
|
13
|
+
appRoles: []
|
|
19
14
|
resourceRelations: []
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Shared Auth
|
|
2
2
|
|
|
3
|
-
This workspace
|
|
4
|
-
customer monorepo should import this package for
|
|
5
|
-
group table references instead of creating
|
|
3
|
+
This workspace wires the customer-global Better Auth database schema from
|
|
4
|
+
`@percepta/auth`. Apps in the customer monorepo should import this package for
|
|
5
|
+
session validation and user / group table references instead of creating
|
|
6
|
+
app-local auth tables.
|
|
6
7
|
|
|
7
8
|
Import auth as `@__APP_NAME__/auth`, the database handle as
|
|
8
9
|
`@__APP_NAME__/auth/db`, and table definitions as `@__APP_NAME__/auth/schema`
|
|
@@ -17,14 +17,11 @@
|
|
|
17
17
|
"db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@percepta/
|
|
21
|
-
"
|
|
22
|
-
"drizzle-orm": "^0.45.2",
|
|
23
|
-
"pg": "^8.16.3"
|
|
20
|
+
"@percepta/auth": "0.1.0",
|
|
21
|
+
"drizzle-orm": "^0.45.2"
|
|
24
22
|
},
|
|
25
23
|
"devDependencies": {
|
|
26
24
|
"@types/node": "^24.1.0",
|
|
27
|
-
"@types/pg": "^8.15.4",
|
|
28
25
|
"drizzle-kit": "^0.31.4",
|
|
29
26
|
"tsx": "^4.20.3",
|
|
30
27
|
"typescript": "^5.8.3"
|
|
@@ -1,54 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { setupAuthDatabase } from "@percepta/auth";
|
|
2
2
|
import { getAuthDatabaseConfig } from "../src/config/database";
|
|
3
3
|
|
|
4
|
-
const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
|
|
5
|
-
|
|
6
4
|
async function main(): Promise<void> {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (url != null && url.length > 0) {
|
|
10
|
-
console.log("AUTH_DATABASE_URL is set; skipping CREATE DATABASE.");
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
console.log(`Setting up shared auth database: ${database}`);
|
|
15
|
-
console.log(`Host: ${host}:${port}`);
|
|
16
|
-
console.log(`User: ${user}`);
|
|
17
|
-
|
|
18
|
-
if (SHARED_DATABASES.has(database)) {
|
|
19
|
-
console.log(`${database} is a shared infra-managed database; skipping.`);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const adminClient = new Pool({
|
|
24
|
-
database: "postgres",
|
|
25
|
-
host,
|
|
26
|
-
password,
|
|
27
|
-
port,
|
|
28
|
-
user,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const result = await adminClient.query(
|
|
33
|
-
"SELECT 1 FROM pg_database WHERE datname = $1",
|
|
34
|
-
[database],
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
if (result.rows.length === 0) {
|
|
38
|
-
await adminClient.query(
|
|
39
|
-
`CREATE DATABASE ${escapePgIdentifier(database)}`,
|
|
40
|
-
);
|
|
41
|
-
console.log(`Database ${database} created successfully.`);
|
|
42
|
-
} else {
|
|
43
|
-
console.log(`Database ${database} already exists.`);
|
|
44
|
-
}
|
|
45
|
-
} finally {
|
|
46
|
-
await adminClient.end();
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function escapePgIdentifier(identifier: string): string {
|
|
51
|
-
return `"${identifier.replaceAll('"', '""')}"`;
|
|
5
|
+
await setupAuthDatabase({ config: getAuthDatabaseConfig() });
|
|
52
6
|
}
|
|
53
7
|
|
|
54
8
|
void main().catch((error) => {
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
3
|
-
import { admin } from "better-auth/plugins";
|
|
1
|
+
import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
|
|
4
2
|
import { db } from "./drizzle/db";
|
|
5
3
|
import { accounts } from "./drizzle/schema/auth/accounts";
|
|
6
4
|
import { sessions } from "./drizzle/schema/auth/sessions";
|
|
@@ -27,51 +25,23 @@ function getSecret(): string {
|
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
function createAuth() {
|
|
30
|
-
return
|
|
28
|
+
return createPerceptaAuth({
|
|
31
29
|
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
account: accounts,
|
|
39
|
-
verification: verifications,
|
|
40
|
-
},
|
|
41
|
-
}),
|
|
42
|
-
emailAndPassword: {
|
|
43
|
-
enabled: true,
|
|
44
|
-
},
|
|
45
|
-
plugins: [admin()],
|
|
46
|
-
advanced: {
|
|
47
|
-
database: {
|
|
48
|
-
generateId: false,
|
|
49
|
-
},
|
|
30
|
+
database: db,
|
|
31
|
+
schema: {
|
|
32
|
+
user: users,
|
|
33
|
+
session: sessions,
|
|
34
|
+
account: accounts,
|
|
35
|
+
verification: verifications,
|
|
50
36
|
},
|
|
37
|
+
secret: getSecret(),
|
|
51
38
|
});
|
|
52
39
|
}
|
|
53
40
|
|
|
54
|
-
type Auth = ReturnType<typeof createAuth>;
|
|
55
|
-
|
|
56
|
-
let authInstance: Auth | undefined;
|
|
57
|
-
|
|
58
|
-
function getAuth(): Auth {
|
|
59
|
-
authInstance ??= createAuth();
|
|
60
|
-
return authInstance;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
41
|
/**
|
|
64
42
|
* Lazy proxy so app builds can import the shared auth package without requiring
|
|
65
43
|
* runtime secrets until Better Auth is actually used.
|
|
66
44
|
*/
|
|
67
|
-
export const auth
|
|
68
|
-
get(_target, prop, receiver) {
|
|
69
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
70
|
-
return Reflect.get(getAuth(), prop, receiver);
|
|
71
|
-
},
|
|
72
|
-
has(_target, prop) {
|
|
73
|
-
return Reflect.has(getAuth(), prop);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
45
|
+
export const auth = createLazyAuth(createAuth);
|
|
76
46
|
|
|
77
47
|
export type BetterAuthSession = typeof auth.$Infer.Session;
|
|
@@ -1,31 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
createAuthDatabaseConnectionString,
|
|
3
|
+
readAuthDatabaseConfig,
|
|
4
|
+
type AuthDatabaseConfig,
|
|
5
|
+
} from "@percepta/auth";
|
|
6
|
+
|
|
7
|
+
export type { AuthDatabaseConfig } from "@percepta/auth";
|
|
9
8
|
|
|
10
9
|
export function getAuthDatabaseConfig(): AuthDatabaseConfig {
|
|
11
|
-
return {
|
|
12
|
-
database: process.env.AUTH_DATABASE_NAME ?? "__DB_NAME__",
|
|
13
|
-
host: process.env.DATABASE_HOST ?? "localhost",
|
|
14
|
-
password: process.env.DATABASE_PASSWORD ?? "postgres",
|
|
15
|
-
port: Number(process.env.DATABASE_PORT ?? 5434),
|
|
16
|
-
url: process.env.AUTH_DATABASE_URL,
|
|
17
|
-
user: process.env.DATABASE_USERNAME ?? "postgres",
|
|
18
|
-
};
|
|
10
|
+
return readAuthDatabaseConfig({ defaultDatabaseName: "__DB_NAME__" });
|
|
19
11
|
}
|
|
20
12
|
|
|
21
13
|
export function getAuthDatabaseConnectionString(): string {
|
|
22
|
-
|
|
23
|
-
if (url != null && url.length > 0) {
|
|
24
|
-
return url;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
`postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}` +
|
|
29
|
-
`@${host}:${port}/${encodeURIComponent(database)}`
|
|
30
|
-
);
|
|
14
|
+
return createAuthDatabaseConnectionString(getAuthDatabaseConfig());
|
|
31
15
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Pool } from "pg";
|
|
1
|
+
import { createAuthDatabase } from "@percepta/auth/drizzle";
|
|
3
2
|
import { getAuthDatabaseConnectionString } from "../config/database";
|
|
4
3
|
import * as schema from "./schema";
|
|
5
4
|
|
|
6
|
-
export const client =
|
|
5
|
+
export const { client, db } = createAuthDatabase({
|
|
7
6
|
connectionString: getAuthDatabaseConnectionString(),
|
|
7
|
+
schema,
|
|
8
8
|
});
|
|
9
|
-
export const db: NodePgDatabase<typeof schema> = drizzle(client, { schema });
|
|
@@ -1,28 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createAccountsTable } from "@percepta/auth/drizzle";
|
|
2
2
|
import { users } from "../users";
|
|
3
3
|
|
|
4
|
-
export const accounts =
|
|
5
|
-
id: text("id")
|
|
6
|
-
.$defaultFn(() => crypto.randomUUID())
|
|
7
|
-
.primaryKey(),
|
|
8
|
-
userId: uuid("user_id")
|
|
9
|
-
.notNull()
|
|
10
|
-
.references(() => users.id, { onDelete: "cascade" }),
|
|
11
|
-
accountId: text("account_id").notNull(),
|
|
12
|
-
providerId: text("provider_id").notNull(),
|
|
13
|
-
accessToken: text("access_token"),
|
|
14
|
-
refreshToken: text("refresh_token"),
|
|
15
|
-
expiresAt: integer("expires_at"),
|
|
16
|
-
accessTokenExpiresAt: timestamp("access_token_expires_at", { mode: "date" }),
|
|
17
|
-
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
|
18
|
-
mode: "date",
|
|
19
|
-
}),
|
|
20
|
-
scope: text("scope"),
|
|
21
|
-
idToken: text("id_token"),
|
|
22
|
-
password: text("password"),
|
|
23
|
-
createdAt: timestamp("created_at", { mode: "date" }).notNull(),
|
|
24
|
-
updatedAt: timestamp("updated_at", { mode: "date" }).notNull(),
|
|
25
|
-
});
|
|
4
|
+
export const accounts = createAccountsTable({ usersTable: users });
|
|
26
5
|
|
|
27
6
|
export type Account = typeof accounts.$inferSelect;
|
|
28
7
|
export type NewAccount = typeof accounts.$inferInsert;
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createSessionsTable } from "@percepta/auth/drizzle";
|
|
2
2
|
import { users } from "../users";
|
|
3
3
|
|
|
4
|
-
export const sessions =
|
|
5
|
-
id: text("id")
|
|
6
|
-
.$defaultFn(() => crypto.randomUUID())
|
|
7
|
-
.primaryKey(),
|
|
8
|
-
userId: uuid("user_id")
|
|
9
|
-
.notNull()
|
|
10
|
-
.references(() => users.id, { onDelete: "cascade" }),
|
|
11
|
-
token: text("token").notNull().unique(),
|
|
12
|
-
expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
|
|
13
|
-
ipAddress: text("ip_address"),
|
|
14
|
-
userAgent: text("user_agent"),
|
|
15
|
-
impersonatedBy: text("impersonated_by"),
|
|
16
|
-
createdAt: timestamp("created_at", { mode: "date" }).notNull(),
|
|
17
|
-
updatedAt: timestamp("updated_at", { mode: "date" }).notNull(),
|
|
18
|
-
});
|
|
4
|
+
export const sessions = createSessionsTable({ usersTable: users });
|
|
19
5
|
|
|
20
6
|
export type Session = typeof sessions.$inferSelect;
|
|
21
7
|
export type NewSession = typeof sessions.$inferInsert;
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createVerificationsTable } from "@percepta/auth/drizzle";
|
|
2
2
|
|
|
3
|
-
export const verifications =
|
|
4
|
-
id: text("id")
|
|
5
|
-
.$defaultFn(() => crypto.randomUUID())
|
|
6
|
-
.primaryKey(),
|
|
7
|
-
identifier: text("identifier").notNull(),
|
|
8
|
-
value: text("value").notNull(),
|
|
9
|
-
expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
|
|
10
|
-
createdAt: timestamp("created_at", { mode: "date" }),
|
|
11
|
-
updatedAt: timestamp("updated_at", { mode: "date" }),
|
|
12
|
-
});
|
|
3
|
+
export const verifications = createVerificationsTable();
|
|
13
4
|
|
|
14
5
|
export type Verification = typeof verifications.$inferSelect;
|
|
15
6
|
export type NewVerification = typeof verifications.$inferInsert;
|
|
@@ -17,7 +17,7 @@ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via
|
|
|
17
17
|
- `pnpm db:migrate` — apply migrations
|
|
18
18
|
- `pnpm db:setup-and-migrate` — create DB + migrate
|
|
19
19
|
- `pnpm db:studio` — setup/migrate the database, then open Drizzle Studio
|
|
20
|
-
- `pnpm db:seed` — seed
|
|
20
|
+
- `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas (all use password)
|
|
21
21
|
|
|
22
22
|
**Package manager**: Always use `pnpm`, never `npm` or `yarn`.
|
|
23
23
|
|
|
@@ -109,15 +109,14 @@ Also includes composite components: `ButtonWithDropdown`, `Combobox`, `IconButto
|
|
|
109
109
|
|
|
110
110
|
### @percepta/build — Shared Build Configs
|
|
111
111
|
|
|
112
|
-
Provides centralized
|
|
112
|
+
Provides centralized formatter, TypeScript, bundler, and Vitest configuration.
|
|
113
113
|
|
|
114
114
|
```js
|
|
115
115
|
// eslint.config.mjs
|
|
116
|
-
import
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
];
|
|
116
|
+
import eslint from "@eslint/js";
|
|
117
|
+
import tseslint from "typescript-eslint";
|
|
118
|
+
|
|
119
|
+
export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended);
|
|
121
120
|
```
|
|
122
121
|
|
|
123
122
|
```json
|
|
@@ -134,11 +134,13 @@ BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
|
|
|
134
134
|
BETTER_AUTH_URL=http://localhost:3000
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
To create
|
|
137
|
+
To create dev users:
|
|
138
138
|
```bash
|
|
139
139
|
pnpm db:seed
|
|
140
|
-
# Creates admin@example.com / password as
|
|
141
|
-
# Creates
|
|
140
|
+
# Creates customer-admin@example.com / password as a customer admin
|
|
141
|
+
# Creates app-admin@example.com / password as an app admin
|
|
142
|
+
# Creates app-user@example.com / password as an app user
|
|
143
|
+
# Creates non-user@example.com / password with no app access
|
|
142
144
|
```
|
|
143
145
|
|
|
144
146
|
## Access Control
|
|
@@ -15,10 +15,10 @@ There are three separate authorization layers:
|
|
|
15
15
|
| Layer | Owner | Stored as |
|
|
16
16
|
|-------|-------|-----------|
|
|
17
17
|
| Customer admins | Customer bootstrap / Applications admin | `core/customer:main#admin@core/user:<users.id>` |
|
|
18
|
-
|
|
|
19
|
-
| App roles/resources | This app's
|
|
18
|
+
| Default application roles | Customer Applications admin | `core/application:<app>#admin/user@core/user:<users.id>` or `core/group:<groups.id>#member` |
|
|
19
|
+
| App roles/resources | This app's admin UI and app code | `<app>/system:main#<role>@<subject>` and app resource relationships |
|
|
20
20
|
|
|
21
|
-
Customer admins do not automatically get application access.
|
|
21
|
+
Customer admins do not automatically get application access. They can manage default app roles, but to enter the app as a user, a user or group must be an application `admin` or `user`.
|
|
22
22
|
|
|
23
23
|
## Source Of Truth
|
|
24
24
|
|
|
@@ -40,11 +40,7 @@ Every app-authored SpiceDB definition must live under this app's namespace:
|
|
|
40
40
|
|
|
41
41
|
```zed
|
|
42
42
|
definition __APP_NAME_SNAKE__/system {
|
|
43
|
-
|
|
44
|
-
relation admin: core/user | core/group#member
|
|
45
|
-
|
|
46
|
-
permission access_app = application->access
|
|
47
|
-
permission manage_roles = access_app & (application->owner + admin)
|
|
43
|
+
// Add application-specific roles and permissions here.
|
|
48
44
|
}
|
|
49
45
|
```
|
|
50
46
|
|
|
@@ -61,36 +57,33 @@ Do not use email addresses, IdP external IDs, group slugs, or display names in S
|
|
|
61
57
|
|
|
62
58
|
Application access means "can open this app at all." It comes from the customer-level `core/application` object.
|
|
63
59
|
|
|
64
|
-
The
|
|
60
|
+
The core application permission is:
|
|
65
61
|
|
|
66
62
|
```zed
|
|
67
|
-
permission
|
|
63
|
+
permission app_access = user + admin
|
|
68
64
|
```
|
|
69
65
|
|
|
70
66
|
The generated app gates the protected app layout with `canAccessApplication()`. Keep that gate in place for authenticated app pages. In tRPC, `protectedProcedure` means authenticated, `appProcedure` adds the default app-access gate, and `requirePermission` should be used for resource-specific checks.
|
|
71
67
|
|
|
72
|
-
For resource permissions that should only be available inside the app,
|
|
68
|
+
For resource permissions that should only be available inside the app, keep the API route behind `appProcedure` or another `canAccessApplication()` check, then let the resource permission model the object-specific rule. If a permission is intentionally usable without app enrollment, list it in `externallyShareablePermissions` in the manifest.
|
|
73
69
|
|
|
74
70
|
## App-Defined Roles
|
|
75
71
|
|
|
76
|
-
App roles are static roles authored by the app builder in Zed and assigned by app
|
|
72
|
+
App roles are static roles authored by the app builder in Zed and assigned by app admins at runtime.
|
|
77
73
|
|
|
78
74
|
To add a role:
|
|
79
75
|
|
|
80
76
|
1. Add a relation to `<appNamespace>/system` in `schema.zed`.
|
|
81
|
-
2. Include the role in `system.assignableRoles` in `access.manifest.ts` if app
|
|
77
|
+
2. Include the role in `system.assignableRoles` in `access.manifest.ts` if app admins may assign it.
|
|
82
78
|
3. Run `pnpm access:validate`.
|
|
83
79
|
|
|
84
80
|
Example:
|
|
85
81
|
|
|
86
82
|
```zed
|
|
87
83
|
definition __APP_NAME_SNAKE__/system {
|
|
88
|
-
relation application: core/application
|
|
89
|
-
relation admin: core/user | core/group#member
|
|
90
84
|
relation hr_admin: core/user | core/group#member
|
|
91
85
|
|
|
92
|
-
permission
|
|
93
|
-
permission manage_roles = access_app & (application->owner + admin)
|
|
86
|
+
permission manage_hr = hr_admin
|
|
94
87
|
}
|
|
95
88
|
```
|
|
96
89
|
|
|
@@ -101,12 +94,12 @@ export const accessManifest = defineAccessManifest({
|
|
|
101
94
|
// ...
|
|
102
95
|
system: {
|
|
103
96
|
// ...
|
|
104
|
-
assignableRoles: ["
|
|
97
|
+
assignableRoles: ["hr_admin"],
|
|
105
98
|
},
|
|
106
99
|
} as const);
|
|
107
100
|
```
|
|
108
101
|
|
|
109
|
-
Role assignment is additive. Assigning
|
|
102
|
+
Role assignment is additive. Assigning one app role and then another leaves both grants in place until each one is revoked.
|
|
110
103
|
|
|
111
104
|
## Permissioned Resources
|
|
112
105
|
|
|
@@ -120,8 +113,8 @@ definition __APP_NAME_SNAKE__/employee {
|
|
|
120
113
|
relation employee_user: core/user
|
|
121
114
|
relation manager: __APP_NAME_SNAKE__/employee
|
|
122
115
|
|
|
123
|
-
permission view_basic = system->
|
|
124
|
-
permission view_private =
|
|
116
|
+
permission view_basic = employee_user + manager->view_basic + system->hr_admin
|
|
117
|
+
permission view_private = employee_user + manager->view_private + system->hr_admin
|
|
125
118
|
}
|
|
126
119
|
```
|
|
127
120
|
|
|
@@ -256,7 +249,7 @@ The `users.role` column is not the app permission source of truth. If Better Aut
|
|
|
256
249
|
## Debugging A Denied Check
|
|
257
250
|
|
|
258
251
|
1. Confirm the user can authenticate and has the expected shared `users.id` in the auth DB.
|
|
259
|
-
2. Confirm application access first: the user or one of their groups needs `core/application:<app>#
|
|
252
|
+
2. Confirm application access first: the user or one of their groups needs `core/application:<app>#user` or `#admin`.
|
|
260
253
|
3. Confirm local topology exists:
|
|
261
254
|
|
|
262
255
|
```bash
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import eslint from "@eslint/js";
|
|
2
3
|
import nextPlugin from "@next/eslint-plugin-next";
|
|
3
|
-
import createEslintConfig from "@percepta/build/eslint";
|
|
4
4
|
import nodePlugin from "eslint-plugin-n";
|
|
5
|
+
import reactPlugin from "eslint-plugin-react";
|
|
6
|
+
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
|
7
|
+
import globals from "globals";
|
|
8
|
+
import tseslint from "typescript-eslint";
|
|
5
9
|
|
|
6
|
-
export default
|
|
7
|
-
...createEslintConfig({
|
|
8
|
-
type: "react",
|
|
9
|
-
dirname: import.meta.dirname,
|
|
10
|
-
}),
|
|
10
|
+
export default tseslint.config(
|
|
11
11
|
{
|
|
12
12
|
ignores: [
|
|
13
13
|
"pnpm-lock.yaml",
|
|
@@ -18,15 +18,43 @@ export default [
|
|
|
18
18
|
"terraform/**",
|
|
19
19
|
],
|
|
20
20
|
},
|
|
21
|
+
eslint.configs.recommended,
|
|
22
|
+
...tseslint.configs.recommended,
|
|
23
|
+
{
|
|
24
|
+
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
|
25
|
+
languageOptions: {
|
|
26
|
+
ecmaVersion: "latest",
|
|
27
|
+
globals: {
|
|
28
|
+
...globals.browser,
|
|
29
|
+
...globals.node,
|
|
30
|
+
},
|
|
31
|
+
parserOptions: {
|
|
32
|
+
ecmaFeatures: {
|
|
33
|
+
jsx: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
sourceType: "module",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
21
39
|
{
|
|
22
40
|
plugins: {
|
|
23
41
|
"@next/next": nextPlugin,
|
|
42
|
+
react: reactPlugin,
|
|
43
|
+
"react-hooks": reactHooksPlugin,
|
|
24
44
|
},
|
|
25
45
|
rules: {
|
|
26
46
|
...nextPlugin.configs.recommended.rules,
|
|
27
47
|
...nextPlugin.configs["core-web-vitals"].rules,
|
|
48
|
+
...reactPlugin.configs.recommended.rules,
|
|
49
|
+
...reactHooksPlugin.configs.recommended.rules,
|
|
50
|
+
"react/jsx-uses-react": "off",
|
|
28
51
|
"react/react-in-jsx-scope": "off",
|
|
29
52
|
},
|
|
53
|
+
settings: {
|
|
54
|
+
react: {
|
|
55
|
+
version: "detect",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
30
58
|
},
|
|
31
59
|
{
|
|
32
60
|
files: ["src/**/*"],
|
|
@@ -63,4 +91,4 @@ export default [
|
|
|
63
91
|
"n/no-process-env": "off",
|
|
64
92
|
},
|
|
65
93
|
},
|
|
66
|
-
|
|
94
|
+
);
|