@percepta/create 3.1.5 → 3.2.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/dist/index.js +53 -46
- package/dist/index.js.map +1 -1
- package/dist/{init-OeK4Yk6_.js → init-CtCp7Tv2.js} +3 -3
- package/dist/init-CtCp7Tv2.js.map +1 -0
- package/dist/{status-DC8mvHZj.js → status-CKe4aKso.js} +2 -2
- package/dist/{status-DC8mvHZj.js.map → status-CKe4aKso.js.map} +1 -1
- package/dist/{sync-C5Pd32VM.js → sync-D1vkoofl.js} +2 -2
- package/dist/{sync-C5Pd32VM.js.map → sync-D1vkoofl.js.map} +1 -1
- package/dist/{upstream-F6m8zRBQ.js → upstream-D-LH_1z4.js} +2 -2
- package/dist/{upstream-F6m8zRBQ.js.map → upstream-D-LH_1z4.js.map} +1 -1
- package/package.json +2 -2
- package/template-versions.json +1 -1
- package/templates/monorepo/.github/workflows/access-control.yml +38 -0
- package/templates/monorepo/README.md +41 -2
- package/templates/monorepo/access/README.md +39 -0
- package/templates/monorepo/access/bootstrap-grants.yaml.example +9 -0
- package/templates/monorepo/access/dev-grants.yaml.example +19 -0
- package/templates/monorepo/access/dev-groups.yaml.example +8 -0
- package/templates/monorepo/access/reconcile.yaml.example +11 -0
- package/templates/monorepo/auth/README.md +26 -0
- package/templates/monorepo/auth/drizzle.config.ts +13 -0
- package/templates/monorepo/auth/package.json +32 -0
- package/templates/monorepo/auth/scripts/setup-database.ts +57 -0
- package/templates/monorepo/auth/src/auth.ts +77 -0
- package/templates/monorepo/auth/src/config/database.ts +31 -0
- package/templates/monorepo/auth/src/drizzle/db.ts +9 -0
- package/templates/monorepo/auth/src/drizzle/migrations/0000_shared_auth.sql +89 -0
- package/templates/monorepo/auth/src/drizzle/migrations/meta/_journal.json +13 -0
- package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/accounts.ts +1 -6
- package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/sessions.ts +1 -5
- package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/verifications.ts +0 -4
- package/templates/monorepo/auth/src/drizzle/schema/groups.ts +16 -0
- package/templates/monorepo/auth/src/drizzle/schema/index.ts +5 -0
- package/templates/monorepo/auth/src/drizzle/schema/users.ts +6 -0
- package/templates/monorepo/auth/src/index.ts +1 -0
- package/templates/monorepo/auth/src/scim/README.md +6 -0
- package/templates/monorepo/auth/tsconfig.json +12 -0
- package/templates/monorepo/package.json.template +18 -6
- package/templates/monorepo/pnpm-workspace.yaml +1 -0
- package/templates/webapp/AGENTS.md +13 -6
- package/templates/webapp/README.md +34 -18
- package/templates/webapp/agent-skills/access-control.md +301 -0
- package/templates/webapp/agent-skills/database.md +1 -1
- package/templates/webapp/docker-compose.yml +16 -0
- package/templates/webapp/env.example.template +9 -0
- package/templates/webapp/next.config.ts +1 -0
- package/templates/webapp/package.json.template +8 -4
- package/templates/webapp/scripts/seed.ts +87 -36
- package/templates/webapp/scripts/setup-database.ts +7 -1
- package/templates/webapp/scripts/start.sh +0 -9
- package/templates/webapp/src/access/access.manifest.ts +15 -0
- package/templates/webapp/src/access/schema.zed +7 -0
- package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +113 -0
- package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +85 -0
- package/templates/webapp/src/app/(app)/admin/groups/page.tsx +117 -0
- package/templates/webapp/src/app/(app)/admin/users/page.tsx +79 -0
- package/templates/webapp/src/app/(app)/layout.tsx +16 -2
- package/templates/webapp/src/app/(app)/page.tsx +1 -12
- package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +2 -5
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +2 -5
- package/templates/webapp/src/config/getEnvConfig.ts +8 -0
- package/templates/webapp/src/drizzle/db.ts +3 -4
- package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +1 -57
- package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +1 -347
- package/templates/webapp/src/drizzle/schema/index.ts +3 -4
- package/templates/webapp/src/lib/auth/index.ts +6 -81
- package/templates/webapp/src/server/api/root.ts +4 -1
- package/templates/webapp/src/server/api/routers/access.ts +13 -0
- package/templates/webapp/src/server/trpc.ts +42 -8
- package/templates/webapp/src/services/DatabaseService.ts +4 -5
- package/templates/webapp/src/services/access/AppAccessControl.ts +39 -0
- package/dist/init-OeK4Yk6_.js.map +0 -1
- package/templates/webapp/scripts/create-user.ts +0 -47
- package/templates/webapp/src/drizzle/schema/auth/users.ts +0 -38
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { auth, type BetterAuthSession } from "./auth";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# SCIM Placeholder
|
|
2
|
+
|
|
3
|
+
SCIM `/Users` and `/Groups` endpoints are intentionally a follow-up project.
|
|
4
|
+
When implemented, they should update the local `users`, `groups`, and
|
|
5
|
+
`group_members` projection through the access-control `groupSync` ingestion
|
|
6
|
+
contract so SpiceDB and Postgres stay aligned.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"declaration": false,
|
|
5
|
+
"declarationMap": false,
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"types": ["node"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["drizzle.config.ts", "src/**/*.ts"]
|
|
12
|
+
}
|
|
@@ -5,18 +5,30 @@
|
|
|
5
5
|
"description": "__APP_TITLE__",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"preinstall": "npx only-allow pnpm",
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"lint
|
|
13
|
-
"
|
|
8
|
+
"setup": "pnpm -r --filter './packages/*' --if-present run docker:up && pnpm run access:apply-local && pnpm run auth:db:setup-and-migrate && pnpm -r --filter './packages/*' --if-present run db:setup-and-migrate && pnpm -r --filter './packages/*' --if-present run db:seed",
|
|
9
|
+
"dev": "pnpm -r --parallel --if-present run dev",
|
|
10
|
+
"build": "pnpm -r --if-present run build",
|
|
11
|
+
"clean": "pnpm -r --if-present run clean",
|
|
12
|
+
"lint": "pnpm -r --parallel --no-bail --if-present run lint",
|
|
13
|
+
"lint:fix": "pnpm -r --no-bail --if-present run lint:fix",
|
|
14
|
+
"test": "pnpm -r --if-present run test",
|
|
15
|
+
"access:merge": "percepta-access-control merge --apps-dir \"$PWD/packages\" --out-dir access",
|
|
16
|
+
"access:validate": "percepta-access-control validate --apps-dir \"$PWD/packages\"",
|
|
17
|
+
"access:apply": "pnpm run access:merge && percepta-access-control apply --schema access/merged.zed --applications access/applications.generated.json",
|
|
18
|
+
"access:apply-local": "pnpm run access:merge && percepta-access-control apply --schema access/merged.zed --applications access/applications.generated.json --endpoint localhost:50051 --insecure --key dev-spicedb-token",
|
|
19
|
+
"access:seed-grants": "percepta-access-control seed-grants --fixture access/dev-grants.yaml",
|
|
20
|
+
"access:seed-groups": "percepta-access-control seed-groups",
|
|
21
|
+
"access:bootstrap-customer-admin": "percepta-access-control bootstrap-customer-admin",
|
|
22
|
+
"access:apply-bootstrap-grants": "percepta-access-control apply-bootstrap-grants --fixture access/bootstrap-grants.yaml",
|
|
23
|
+
"access:reconcile": "percepta-access-control reconcile --input access/reconcile.yaml",
|
|
24
|
+
"auth:db:setup-and-migrate": "pnpm --dir auth run db:setup-and-migrate"
|
|
14
25
|
},
|
|
15
26
|
"engines": {
|
|
16
27
|
"node": ">=20",
|
|
17
28
|
"pnpm": ">=9"
|
|
18
29
|
},
|
|
19
30
|
"devDependencies": {
|
|
31
|
+
"@percepta/access-control": "0.3.2",
|
|
20
32
|
"@types/node": "^24.1.0",
|
|
21
33
|
"eslint": "^9.18.0",
|
|
22
34
|
"rimraf": "^5.0.5",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Webapp Template
|
|
2
2
|
|
|
3
|
-
Next.js 15 full-stack application scaffolded from the Mosaic webapp template via `@percepta/create`. Uses React 19, TypeScript, Tailwind CSS v4, tRPC, Drizzle ORM, Better Auth, and Inngest.
|
|
3
|
+
Next.js 15 full-stack application scaffolded from the Mosaic webapp template via `@percepta/create`. Uses React 19, TypeScript, Tailwind CSS v4, tRPC, Drizzle ORM, Better Auth, SpiceDB access control, and Inngest.
|
|
4
4
|
|
|
5
5
|
## Build & Dev Commands
|
|
6
6
|
|
|
@@ -8,17 +8,21 @@ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via
|
|
|
8
8
|
- `pnpm build` — production build
|
|
9
9
|
- `pnpm lint` — run ESLint
|
|
10
10
|
- `pnpm test` — run Vitest tests
|
|
11
|
-
- `pnpm docker:up` / `pnpm docker:down` — start PostgreSQL and
|
|
11
|
+
- `pnpm docker:up` / `pnpm docker:down` — start PostgreSQL and SpiceDB, waiting for health, or stop them
|
|
12
|
+
- `pnpm access:validate` — validate access schema and manifest
|
|
13
|
+
- `pnpm access:apply-local` — apply merged customer access schema to local SpiceDB
|
|
14
|
+
- `pnpm auth:db:setup-and-migrate` — setup and migrate the shared customer auth database
|
|
12
15
|
- `pnpm inngest:dev` — start local Inngest dev server when working on background jobs
|
|
13
16
|
- `pnpm db:generate` — generate Drizzle migrations
|
|
14
17
|
- `pnpm db:migrate` — apply migrations
|
|
15
18
|
- `pnpm db:setup-and-migrate` — create DB + migrate
|
|
16
|
-
- `pnpm db:studio` — open Drizzle Studio
|
|
17
|
-
- `pnpm db:seed` — seed default dev
|
|
19
|
+
- `pnpm db:studio` — setup/migrate the database, then open Drizzle Studio
|
|
20
|
+
- `pnpm db:seed` — seed default shared-auth dev users and local access grants (admin@example.com and user@example.com / password)
|
|
18
21
|
|
|
19
22
|
**Package manager**: Always use `pnpm`, never `npm` or `yarn`.
|
|
20
23
|
|
|
21
|
-
Local development
|
|
24
|
+
Local development requires PostgreSQL and SpiceDB. Run Inngest locally when a workflow needs it. Do not run a local LGTM/Langfuse stack unless you are specifically debugging telemetry; those are wired by the Ryvn environment for deploys.
|
|
25
|
+
If local LLM calls are needed, `pnpm dev` loads shared provider keys from `~/.config/percepta/create.env` when it exists.
|
|
22
26
|
|
|
23
27
|
## Code Style
|
|
24
28
|
|
|
@@ -34,6 +38,7 @@ Local development only requires Postgres by default. Run Inngest locally when a
|
|
|
34
38
|
|
|
35
39
|
```
|
|
36
40
|
src/ # Application source
|
|
41
|
+
├── access/ # SpiceDB schema and manifest
|
|
37
42
|
├── app/ # Next.js App Router pages, layouts, and API route handlers
|
|
38
43
|
├── components/ # React components
|
|
39
44
|
├── config/ # Env config (clientEnvConfig, getEnvConfig)
|
|
@@ -45,6 +50,7 @@ src/ # Application source
|
|
|
45
50
|
│ ├── trpc.ts # Context & procedure builders
|
|
46
51
|
│ └── api/root.ts # Root appRouter
|
|
47
52
|
├── services/
|
|
53
|
+
│ ├── access/ # App access-control runtime binding
|
|
48
54
|
│ ├── inngest/ # Background job definitions
|
|
49
55
|
│ ├── langfuse/ # LLM observability
|
|
50
56
|
│ ├── llm/ # LLM provider selection and call helpers
|
|
@@ -194,6 +200,7 @@ Detailed how-to guides for each major stack component. Read the relevant guide w
|
|
|
194
200
|
| LLM Calls | [agent-skills/llm.md](agent-skills/llm.md) | Adding backend model calls, streaming, or structured generation |
|
|
195
201
|
| LLM Observability (Langfuse) | [agent-skills/langfuse.md](agent-skills/langfuse.md) | App uses LLMs and needs trace/eval monitoring |
|
|
196
202
|
| Database (Drizzle) | [agent-skills/database.md](agent-skills/database.md) | Adding tables, writing migrations, querying data |
|
|
203
|
+
| Access Control (SpiceDB) | [agent-skills/access-control.md](agent-skills/access-control.md) | Adding Zed policy, app roles, permissioned resources, or group sync |
|
|
197
204
|
| Deployment (Ryvn) | [agent-skills/ryvn.md](agent-skills/ryvn.md) | Ryvn overview and Percepta environment context |
|
|
198
205
|
| Deploy to percepta-test | [agent-skills/deploy.md](agent-skills/deploy.md) | Step-by-step deploy using the pre-scaffolded `deploy/ryvn/` IaC files |
|
|
199
206
|
| Build App (Oneshot) | [agent-skills/oneshot.md](agent-skills/oneshot.md) | Building a complete app from requirements end-to-end |
|
|
@@ -215,7 +222,7 @@ Client-side usage via `src/lib/trpc.ts`.
|
|
|
215
222
|
|
|
216
223
|
### Authentication
|
|
217
224
|
|
|
218
|
-
Better Auth configured in `src/lib/auth
|
|
225
|
+
Better Auth is configured in the customer monorepo's shared `@__REPO_NAME__/auth` package. The app imports it through `src/lib/auth/` for local session validation.
|
|
219
226
|
|
|
220
227
|
- **Server-side**: `auth.api.getSession({ headers: await headers() })` — get session in server components or tRPC context
|
|
221
228
|
- **Client-side**: `authClient.useSession()` — React hook from `src/lib/auth-client.ts`
|
|
@@ -7,6 +7,7 @@ A production-ready Next.js application with authentication, database, logging, b
|
|
|
7
7
|
- **Next.js 15** with App Router
|
|
8
8
|
- **Authentication** via Better Auth with email/password credentials
|
|
9
9
|
- **Database** with PostgreSQL, Drizzle ORM, and migrations
|
|
10
|
+
- **Access Control** with SpiceDB schema authoring and manifest validation
|
|
10
11
|
- **Logging** with Pino and structured safe/unsafe data separation
|
|
11
12
|
- **Background Jobs** with Inngest
|
|
12
13
|
- **LLM Calls** with a provider-backed `LLMService`
|
|
@@ -16,23 +17,21 @@ A production-ready Next.js application with authentication, database, logging, b
|
|
|
16
17
|
|
|
17
18
|
## Quick Start
|
|
18
19
|
|
|
19
|
-
### 1.
|
|
20
|
+
### 1. Configure Environment
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
pnpm docker:up
|
|
23
|
-
```
|
|
22
|
+
Copy `.env.example` to `.env.local` and configure any app-specific environment variables.
|
|
24
23
|
|
|
25
|
-
### 2.
|
|
24
|
+
### 2. Run Setup
|
|
26
25
|
|
|
27
26
|
```bash
|
|
28
|
-
pnpm
|
|
27
|
+
pnpm run setup
|
|
29
28
|
```
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
This starts local PostgreSQL and SpiceDB, applies the local access schema,
|
|
31
|
+
sets up the shared customer auth database, runs app migrations, and seeds dev
|
|
32
|
+
users.
|
|
34
33
|
|
|
35
|
-
###
|
|
34
|
+
### 3. Start Inngest When Using Background Jobs
|
|
36
35
|
|
|
37
36
|
```bash
|
|
38
37
|
pnpm inngest:dev
|
|
@@ -40,7 +39,7 @@ pnpm inngest:dev
|
|
|
40
39
|
|
|
41
40
|
Run this in a separate terminal when the app has Inngest functions or you want the local Inngest dashboard.
|
|
42
41
|
|
|
43
|
-
###
|
|
42
|
+
### 4. Start Development Server
|
|
44
43
|
|
|
45
44
|
```bash
|
|
46
45
|
pnpm dev
|
|
@@ -62,6 +61,7 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|
|
62
61
|
|
|
63
62
|
```
|
|
64
63
|
src/
|
|
64
|
+
├── access/ # SpiceDB schema and manifest
|
|
65
65
|
├── app/ # Next.js App Router pages, layouts, and API route handlers
|
|
66
66
|
├── components/ # React components
|
|
67
67
|
├── config/ # Environment configuration
|
|
@@ -72,6 +72,7 @@ src/
|
|
|
72
72
|
├── services/ # Business logic services
|
|
73
73
|
│ ├── inngest/ # Background job definitions
|
|
74
74
|
│ ├── langfuse/ # LLM observability
|
|
75
|
+
│ ├── access/ # App access-control runtime binding
|
|
75
76
|
│ ├── llm/ # LLM provider selection and call helpers
|
|
76
77
|
│ └── logger/ # Structured logging
|
|
77
78
|
└── utils/ # Utility functions
|
|
@@ -85,16 +86,18 @@ src/
|
|
|
85
86
|
| `pnpm build` | Build for production |
|
|
86
87
|
| `pnpm start` | Start production server |
|
|
87
88
|
| `pnpm lint` | Run ESLint |
|
|
88
|
-
| `pnpm docker:up` | Start PostgreSQL
|
|
89
|
-
| `pnpm docker:down` | Stop PostgreSQL
|
|
89
|
+
| `pnpm docker:up` | Start PostgreSQL and SpiceDB containers and wait until they are healthy |
|
|
90
|
+
| `pnpm docker:down` | Stop PostgreSQL and SpiceDB containers |
|
|
91
|
+
| `pnpm access:validate` | Validate the access manifest and schema |
|
|
92
|
+
| `pnpm access:apply-local` | Apply the merged customer access schema to local SpiceDB |
|
|
93
|
+
| `pnpm auth:db:setup-and-migrate` | Setup and migrate the shared customer auth database |
|
|
90
94
|
| `pnpm inngest:dev` | Start the local Inngest dev server for this app |
|
|
91
95
|
| `pnpm db:generate` | Generate Drizzle migrations |
|
|
92
96
|
| `pnpm db:migrate` | Run database migrations |
|
|
93
97
|
| `pnpm db:setup` | Create database and user |
|
|
94
98
|
| `pnpm db:setup-and-migrate` | Setup and migrate database |
|
|
95
|
-
| `pnpm db:studio` |
|
|
96
|
-
| `pnpm db:
|
|
97
|
-
| `pnpm db:seed` | Seed default dev user |
|
|
99
|
+
| `pnpm db:studio` | Setup/migrate the database, then open Drizzle Studio |
|
|
100
|
+
| `pnpm db:seed` | Seed default shared-auth dev users and local access grants |
|
|
98
101
|
|
|
99
102
|
## Logging
|
|
100
103
|
|
|
@@ -122,7 +125,7 @@ logger.error(
|
|
|
122
125
|
|
|
123
126
|
## Authentication
|
|
124
127
|
|
|
125
|
-
This app
|
|
128
|
+
This app consumes the customer monorepo's shared [Better Auth](https://better-auth.com) package, `@__REPO_NAME__/auth`. The app still serves local development auth routes, but the users, sessions, accounts, groups, and group memberships live in the shared customer auth database.
|
|
126
129
|
|
|
127
130
|
Required environment variables:
|
|
128
131
|
|
|
@@ -134,9 +137,14 @@ BETTER_AUTH_URL=http://localhost:3000
|
|
|
134
137
|
To create a dev user:
|
|
135
138
|
```bash
|
|
136
139
|
pnpm db:seed
|
|
137
|
-
# Creates admin@example.com / password
|
|
140
|
+
# Creates admin@example.com / password as an app admin
|
|
141
|
+
# Creates user@example.com / password as a non-admin app member
|
|
138
142
|
```
|
|
139
143
|
|
|
144
|
+
## Access Control
|
|
145
|
+
|
|
146
|
+
App permissions are authored in `src/access/schema.zed`; `src/access/access.manifest.ts` describes the app integration surface for admin UI, seed scripts, and resource inventories. For the full workflow, read [agent-skills/access-control.md](agent-skills/access-control.md).
|
|
147
|
+
|
|
140
148
|
## Environment Variables
|
|
141
149
|
|
|
142
150
|
### Application
|
|
@@ -169,6 +177,14 @@ Generate a secret key:
|
|
|
169
177
|
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
|
|
170
178
|
```
|
|
171
179
|
|
|
180
|
+
### Access Control
|
|
181
|
+
|
|
182
|
+
| Variable | Description | Default |
|
|
183
|
+
|----------|-------------|---------|
|
|
184
|
+
| `SPICEDB_ENDPOINT` | SpiceDB gRPC endpoint | `localhost:50051` |
|
|
185
|
+
| `SPICEDB_PRESHARED_KEY` | SpiceDB preshared key | `dev-spicedb-token` |
|
|
186
|
+
| `SPICEDB_INSECURE` | Use insecure local gRPC transport | `true` |
|
|
187
|
+
|
|
172
188
|
### Inngest (Background Jobs)
|
|
173
189
|
|
|
174
190
|
| Variable | Description |
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Access Control
|
|
2
|
+
|
|
3
|
+
Use this guide when adding permissioned resources, changing Zed policy, assigning app roles, debugging denied checks, or wiring identity/group sync.
|
|
4
|
+
|
|
5
|
+
## Mental Model
|
|
6
|
+
|
|
7
|
+
The customer monorepo has one shared identity layer and one shared SpiceDB deployment:
|
|
8
|
+
|
|
9
|
+
- `../../auth` owns Better Auth, `users`, `groups`, and `group_members`.
|
|
10
|
+
- `../../access` merges every app's Zed schema with the shared `core/*` schema and applies customer-level topology.
|
|
11
|
+
- This app owns only its app namespace, app-defined roles, and app resource relationships.
|
|
12
|
+
|
|
13
|
+
There are three separate authorization layers:
|
|
14
|
+
|
|
15
|
+
| Layer | Owner | Stored as |
|
|
16
|
+
|-------|-------|-----------|
|
|
17
|
+
| Customer admins | Customer bootstrap / Applications admin | `core/customer:main#admin@core/user:<users.id>` |
|
|
18
|
+
| Application access | Customer Applications admin | `core/application:<app>#owner/member@core/user:<users.id>` or `core/group:<groups.id>#member` |
|
|
19
|
+
| App roles/resources | This app's owner/admin UI and app code | `<app>/system:main#<role>@<subject>` and app resource relationships |
|
|
20
|
+
|
|
21
|
+
Customer admins do not automatically get application access. To enter an app, a user or group must be an application `owner` or `member`.
|
|
22
|
+
|
|
23
|
+
## Source Of Truth
|
|
24
|
+
|
|
25
|
+
- `src/access/schema.zed` is the policy source of truth. SpiceDB evaluates this directly.
|
|
26
|
+
- `src/access/access.manifest.ts` is integration metadata for TypeScript, admin UI, seed scripts, and resource inventories. It should describe what the app uses; it should not invent policy that is missing from Zed.
|
|
27
|
+
|
|
28
|
+
After changing `schema.zed` or `access.manifest.ts`, run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm access:validate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
PR CI runs the customer-level access validation, which merges every app schema
|
|
35
|
+
and checks the schema/manifest contract.
|
|
36
|
+
|
|
37
|
+
## Naming Rules
|
|
38
|
+
|
|
39
|
+
Every app-authored SpiceDB definition must live under this app's namespace:
|
|
40
|
+
|
|
41
|
+
```zed
|
|
42
|
+
definition __APP_NAME_SNAKE__/system {
|
|
43
|
+
relation application: core/application
|
|
44
|
+
relation admin: core/user | core/group#member
|
|
45
|
+
|
|
46
|
+
permission access_app = application->access
|
|
47
|
+
permission manage_roles = access_app & (application->owner + admin)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use canonical shared identity refs:
|
|
52
|
+
|
|
53
|
+
- Users: `core/user:<users.id>`
|
|
54
|
+
- Groups: `core/group:<groups.id>#member`
|
|
55
|
+
- Applications: `core/application:<appNamespace>`
|
|
56
|
+
- System singleton: `<appNamespace>/system:main`
|
|
57
|
+
|
|
58
|
+
Do not use email addresses, IdP external IDs, group slugs, or display names in SpiceDB refs. Resolve those values to shared `auth` table IDs first.
|
|
59
|
+
|
|
60
|
+
## Application Access
|
|
61
|
+
|
|
62
|
+
Application access means "can open this app at all." It comes from the customer-level `core/application` object.
|
|
63
|
+
|
|
64
|
+
The starter system permission is:
|
|
65
|
+
|
|
66
|
+
```zed
|
|
67
|
+
permission access_app = application->access
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
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
|
+
|
|
72
|
+
For resource permissions that should only be available inside the app, include `system->access_app` in the Zed expression or make the permission depend on another permission that does. If a permission is intentionally usable without app enrollment, list it in `externallyShareablePermissions` in the manifest.
|
|
73
|
+
|
|
74
|
+
## App-Defined Roles
|
|
75
|
+
|
|
76
|
+
App roles are static roles authored by the app builder in Zed and assigned by app owners/admins at runtime.
|
|
77
|
+
|
|
78
|
+
To add a role:
|
|
79
|
+
|
|
80
|
+
1. Add a relation to `<appNamespace>/system` in `schema.zed`.
|
|
81
|
+
2. Include the role in `system.assignableRoles` in `access.manifest.ts` if app owners may assign it.
|
|
82
|
+
3. Run `pnpm access:validate`.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
```zed
|
|
87
|
+
definition __APP_NAME_SNAKE__/system {
|
|
88
|
+
relation application: core/application
|
|
89
|
+
relation admin: core/user | core/group#member
|
|
90
|
+
relation hr_admin: core/user | core/group#member
|
|
91
|
+
|
|
92
|
+
permission access_app = application->access
|
|
93
|
+
permission manage_roles = access_app & (application->owner + admin)
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { defineAccessManifest } from "@percepta/access-control";
|
|
99
|
+
|
|
100
|
+
export const accessManifest = defineAccessManifest({
|
|
101
|
+
// ...
|
|
102
|
+
system: {
|
|
103
|
+
// ...
|
|
104
|
+
assignableRoles: ["admin", "hr_admin"],
|
|
105
|
+
},
|
|
106
|
+
} as const);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Role assignment is additive. Assigning `admin` and then `hr_admin` leaves both grants in place until each one is revoked.
|
|
110
|
+
|
|
111
|
+
## Permissioned Resources
|
|
112
|
+
|
|
113
|
+
Model app data as resource definitions when access varies by object or relationship.
|
|
114
|
+
|
|
115
|
+
Example for a people app:
|
|
116
|
+
|
|
117
|
+
```zed
|
|
118
|
+
definition __APP_NAME_SNAKE__/employee {
|
|
119
|
+
relation system: __APP_NAME_SNAKE__/system
|
|
120
|
+
relation employee_user: core/user
|
|
121
|
+
relation manager: __APP_NAME_SNAKE__/employee
|
|
122
|
+
|
|
123
|
+
permission view_basic = system->access_app
|
|
124
|
+
permission view_private = system->access_app & (employee_user + manager->view_private + system->hr_admin)
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Then describe the resource in `access.manifest.ts`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
resources: {
|
|
132
|
+
employee: {
|
|
133
|
+
assignableRelations: ["employee_user", "manager"],
|
|
134
|
+
inventory: listEmployeesForAccessReconcile,
|
|
135
|
+
label: "Employee",
|
|
136
|
+
permissionsUsedByApp: ["view_basic", "view_private"],
|
|
137
|
+
ref: (id) => `__APP_NAME_SNAKE__/employee:${id}`,
|
|
138
|
+
systemLinked: true,
|
|
139
|
+
type: "__APP_NAME_SNAKE__/employee",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Use `systemLinked: true` when the resource has a `relation system` and permissions depend on `system->...`. The access-control helpers call `touchResource()` when assigning resource relations, but resources created without relationships should call `touchResource()` after creation so app-level permissions can resolve.
|
|
145
|
+
|
|
146
|
+
If `access:reconcile` should repair missing system links for a resource type, provide an `inventory` callback that yields the live resource IDs from Postgres. The library cannot discover app resources by itself.
|
|
147
|
+
|
|
148
|
+
## Permissioned Postgres Tables
|
|
149
|
+
|
|
150
|
+
When a Drizzle table backs a SpiceDB resource, bind the table to the resource manifest once. Keep Zed as the permission source of truth; the table binding only records object identity, relationship columns, and which field groups require which permissions.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import {
|
|
154
|
+
createColumnPermissionChecks,
|
|
155
|
+
definePermissionedResourceTable,
|
|
156
|
+
} from "@percepta/access-control/drizzle";
|
|
157
|
+
import { userSubjectRef } from "@percepta/access-control";
|
|
158
|
+
import { accessManifest } from "@/access/access.manifest";
|
|
159
|
+
import { employees } from "@/drizzle/schema";
|
|
160
|
+
|
|
161
|
+
export const employeeAccess = definePermissionedResourceTable({
|
|
162
|
+
table: employees,
|
|
163
|
+
resource: accessManifest.resources.employee,
|
|
164
|
+
id: employees.id,
|
|
165
|
+
relations: {
|
|
166
|
+
employee_user: {
|
|
167
|
+
column: employees.userId,
|
|
168
|
+
subject: userSubjectRef,
|
|
169
|
+
},
|
|
170
|
+
manager: {
|
|
171
|
+
column: employees.managerEmployeeId,
|
|
172
|
+
subject: (employeeId: string) =>
|
|
173
|
+
accessManifest.resources.employee.ref(employeeId),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
fieldGroups: {
|
|
177
|
+
basic: {
|
|
178
|
+
read: "view_basic",
|
|
179
|
+
columns: [employees.id, employees.name, employees.title],
|
|
180
|
+
},
|
|
181
|
+
compensation: {
|
|
182
|
+
read: "view_compensation",
|
|
183
|
+
write: "edit_compensation",
|
|
184
|
+
columns: [employees.salary, employees.bonus],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
} as const);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Use `createColumnPermissionChecks()` near serializers, mutation handlers, exports, and custom filters/sorts whenever the selected columns are dynamic:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const checks = createColumnPermissionChecks(employeeAccess, {
|
|
194
|
+
access: "read",
|
|
195
|
+
columns: [employees.salary],
|
|
196
|
+
id: employeeId,
|
|
197
|
+
subject: userSubjectRef(session.user.id),
|
|
198
|
+
});
|
|
199
|
+
const [canReadSalary] = await getAccessControl().permissions.canMany(checks);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Do not model ordinary Postgres columns as SpiceDB objects. Model business permissions in Zed, group columns under those permissions in the table binding, then enforce the binding at the API/data boundary.
|
|
203
|
+
|
|
204
|
+
## App Code
|
|
205
|
+
|
|
206
|
+
Use the app access service instead of constructing raw SpiceDB clients in feature code:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { getAccessControl, toUserSubject } from "@/services/access/AppAccessControl";
|
|
210
|
+
|
|
211
|
+
const allowed = await getAccessControl().permissions.can({
|
|
212
|
+
permission: "view_private",
|
|
213
|
+
resource: `__APP_NAME_SNAKE__/employee:${employeeId}`,
|
|
214
|
+
subject: toUserSubject(session.user.id),
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
For tRPC procedures, use `protectedProcedure` for authentication-only calls, `appProcedure` for calls that should require app enrollment, and `requirePermission` for resource-specific authorization. Keep permission checks near the data load or mutation they protect.
|
|
219
|
+
|
|
220
|
+
Use `canStrong` after writes that must observe a recent revocation immediately. Normal `can` checks use the request cache and standard SpiceDB read consistency.
|
|
221
|
+
|
|
222
|
+
## Groups And Sync
|
|
223
|
+
|
|
224
|
+
The local source of truth for groups is the shared `auth` package:
|
|
225
|
+
|
|
226
|
+
- `groups` stores customer-global group IDs and upstream identifiers.
|
|
227
|
+
- `group_members` stores the last observed membership projection.
|
|
228
|
+
- SpiceDB group membership should be written through group sync/reconcile paths, not directly from app code.
|
|
229
|
+
|
|
230
|
+
Development fixtures can seed groups into the shared auth projection. Future SCIM/JIT sync should write the same projection first, then mirror to SpiceDB. `access:reconcile` repairs SpiceDB to match the projection; it does not call the IdP itself.
|
|
231
|
+
|
|
232
|
+
When assigning app roles or resource relations to a group, use the resolved shared group subject:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
await getAccessControl().app.assignAppRole(
|
|
236
|
+
"admin",
|
|
237
|
+
`core/group:${groupId}#member`,
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Shared Auth Boundary
|
|
242
|
+
|
|
243
|
+
This app imports Better Auth from `@__REPO_NAME__/auth` via `src/lib/auth/index.ts`. The app does not own `users`, `groups`, `group_members`, sessions, accounts, or verification tables.
|
|
244
|
+
|
|
245
|
+
When app code needs users for display, import from the shared auth package:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { db as authDb } from "@__REPO_NAME__/auth/db";
|
|
249
|
+
import { users } from "@__REPO_NAME__/auth/schema";
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Use `users.id` in SpiceDB refs. `users.external_id`, email, and group external IDs are ingestion lookup keys only.
|
|
253
|
+
|
|
254
|
+
The `users.role` column is not the app permission source of truth. If Better Auth admin features need it, update it deliberately for that UI. Do not rely on it for app access, app roles, or resource permissions.
|
|
255
|
+
|
|
256
|
+
## Debugging A Denied Check
|
|
257
|
+
|
|
258
|
+
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>#member` or `#owner`.
|
|
260
|
+
3. Confirm local topology exists:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
pnpm access:apply-local
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
4. Validate policy and manifest:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
pnpm access:validate
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
5. Check whether the permission expression depends on a missing `system` link or resource relationship. For `systemLinked` resources, call `touchResource()` or assign a relationship through the app access helper.
|
|
273
|
+
6. Check group membership in the shared `auth.group_members` projection before looking at SpiceDB. Reconcile only repairs what the projection says should exist.
|
|
274
|
+
7. If a recently revoked grant still appears allowed inside the same request, use the strong read path for the follow-up check and avoid reusing stale cached results after writes.
|
|
275
|
+
|
|
276
|
+
## Customer Merge Pipeline
|
|
277
|
+
|
|
278
|
+
In production, the customer monorepo owns the combined SpiceDB schema:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pnpm --dir ../.. run access:merge
|
|
282
|
+
pnpm --dir ../.. run access:validate
|
|
283
|
+
pnpm --dir ../.. run access:apply
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The merge step reads every app's `src/access/schema.zed` and `access.manifest.ts`, validates namespace boundaries, includes the library `core.zed`, and emits the customer-level app registry. App packages should not apply production schema fragments independently.
|
|
287
|
+
|
|
288
|
+
Treat `pnpm --dir ../.. run access:apply` like a database migration step in the
|
|
289
|
+
customer deploy pipeline, not like an ordinary PR check. PR CI should merge and
|
|
290
|
+
validate; trusted deploy jobs should apply the already-validated merged schema to
|
|
291
|
+
each environment's SpiceDB before deploying app code that depends on new
|
|
292
|
+
relations or permissions.
|
|
293
|
+
|
|
294
|
+
For additive changes, apply the schema first, then deploy the app. For
|
|
295
|
+
destructive changes, use expand/contract:
|
|
296
|
+
|
|
297
|
+
1. Add the new relation or permission while keeping the old one.
|
|
298
|
+
2. Deploy app code that dual-writes or dual-reads as needed.
|
|
299
|
+
3. Backfill relationships idempotently with `TOUCH` writes or `access:reconcile`.
|
|
300
|
+
4. Switch permission expressions and app checks to the new shape.
|
|
301
|
+
5. Stop old writes, delete old relationships, then remove the old relation in a later schema apply.
|
|
@@ -10,7 +10,7 @@ Create a new schema file alongside the existing ones:
|
|
|
10
10
|
|
|
11
11
|
```typescript
|
|
12
12
|
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
13
|
-
import { users } from "
|
|
13
|
+
import { users } from "@__REPO_NAME__/auth/schema";
|
|
14
14
|
|
|
15
15
|
export const documents = pgTable("documents", {
|
|
16
16
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
services:
|
|
2
|
+
spicedb:
|
|
3
|
+
image: authzed/spicedb:v1.47.1
|
|
4
|
+
command:
|
|
5
|
+
- serve
|
|
6
|
+
- --datastore-engine
|
|
7
|
+
- memory
|
|
8
|
+
- --grpc-preshared-key
|
|
9
|
+
- dev-spicedb-token
|
|
10
|
+
ports:
|
|
11
|
+
- "50051:50051"
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
|
|
14
|
+
interval: 2s
|
|
15
|
+
timeout: 2s
|
|
16
|
+
retries: 30
|
|
17
|
+
|
|
2
18
|
postgres:
|
|
3
19
|
image: postgres:16
|
|
4
20
|
ports:
|
|
@@ -14,10 +14,19 @@ DATABASE_USE_SSL=false
|
|
|
14
14
|
# Authentication (Better Auth)
|
|
15
15
|
BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
|
|
16
16
|
BETTER_AUTH_URL=http://localhost:3000
|
|
17
|
+
# Optional override for the shared customer auth database. Defaults to the
|
|
18
|
+
# customer monorepo database generated in ../../auth.
|
|
19
|
+
# AUTH_DATABASE_NAME=
|
|
20
|
+
# AUTH_DATABASE_URL=
|
|
17
21
|
|
|
18
22
|
# Security
|
|
19
23
|
ENCRYPTION_SECRET_KEY=generate-with-node-e-console-log-require-crypto-randomBytes-16-toString-hex
|
|
20
24
|
|
|
25
|
+
# Access Control (SpiceDB)
|
|
26
|
+
SPICEDB_ENDPOINT=localhost:50051
|
|
27
|
+
SPICEDB_PRESHARED_KEY=dev-spicedb-token
|
|
28
|
+
SPICEDB_INSECURE=true
|
|
29
|
+
|
|
21
30
|
# Inngest (Background Jobs)
|
|
22
31
|
INNGEST_BASE_URL=http://localhost:8288
|
|
23
32
|
# INNGEST_SIGNING_KEY=
|