diff --git a/README.md b/README.md index e99a2ef..06d8508 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,10 @@ only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). > **Status.** This README describes the target architecture. Built today (see `todo.md`): > the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, -> filters, pagination, forms — extracted from `html-css-foundation/`), and the **plugin host** -> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding). The -> **Ory integration** (Kratos/Keto/Hydra + Postgres) and **auth** are the roadmap; sections marked +> filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host** +> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the +> **Ory stack** wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL +> namespaces). Hydra and the **auth** wiring that consumes these are the roadmap; sections marked > _(planned)_ are not built yet. ## The MVP — "clone, one command, hack on a plugin" _(planned)_ @@ -367,7 +368,7 @@ session cookie. ``` **Keto is the single source of truth for roles.** Coarse roles are Keto relations -(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto. +(e.g. `role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the app reads the roles from Keto and refreshes a **derived projection**: a read-only copy written onto the identity's `metadata_admin` for the tokenizer to see, @@ -474,7 +475,7 @@ src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) -ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS) + storage init (postgres/init/init.sql: one DB per service) +ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource) + storage init (postgres/init/init.sql: one DB per service) plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) diff --git a/compose.yml b/compose.yml index eddde3e..4c8405c 100644 --- a/compose.yml +++ b/compose.yml @@ -57,5 +57,31 @@ services: command: serve -c /etc/config/kratos/kratos.yml --watch-courier restart: unless-stopped + # Ory Keto — authorization (ReBAC). Permission model in ory/keto/namespaces.keto.ts (OPL). + # DSN is the per-service `keto` DB (init.sql). The web app calls its read/write APIs (config.ts). + keto-migrate: + image: oryd/keto:v26.2.0 + depends_on: + postgres: + condition: service_healthy + environment: + DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable + volumes: + - ./ory/keto:/etc/config/keto:ro + command: -c /etc/config/keto/keto.yml migrate up -y + restart: on-failure + + keto: + image: oryd/keto:v26.2.0 + depends_on: + keto-migrate: + condition: service_completed_successfully + environment: + DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable + volumes: + - ./ory/keto:/etc/config/keto:ro + command: serve -c /etc/config/keto/keto.yml + restart: unless-stopped + volumes: pgdata: diff --git a/ory/keto/keto.yml b/ory/keto/keto.yml new file mode 100644 index 0000000..b33ea12 --- /dev/null +++ b/ory/keto/keto.yml @@ -0,0 +1,19 @@ +# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare +# fine-grained check (README: three tiers of "may I?"). The permission model lives in +# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web +# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports +# config.ts points at. +namespaces: + location: file:///etc/config/keto/namespaces.keto.ts + +serve: + read: + host: 0.0.0.0 + port: 4466 + write: + host: 0.0.0.0 + port: 4467 + +log: + level: info + format: text diff --git a/ory/keto/namespaces.keto.ts b/ory/keto/namespaces.keto.ts new file mode 100644 index 0000000..a4d5821 --- /dev/null +++ b/ory/keto/namespaces.keto.ts @@ -0,0 +1,47 @@ +// Ory Permission Language (OPL) — the authorization model Keto enforces. Keto parses +// this file (referenced by keto.yml `namespaces.location`); the `@ory/keto-namespace-types` +// import is for the author's editor only and is ignored at load. Subject ids are Kratos +// identity ids (== the JWT `sub`). +import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types" + +// A human identity. Subjects are written as `user:`. +class User implements Namespace {} + +// A subject set: a named collection of users (and nested groups), resolved transitively. +// The admin "Groups" screen (§5) manages membership; checks expand it automatically. +class Group implements Namespace { + related: { + members: (User | SubjectSet)[] + } +} + +// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads +// `role:#members@user:` from Keto and projects the result into the token +// (README: Login → session JWT). A group can hold a role, so members can be users or groups. +class Role implements Namespace { + related: { + members: (User | SubjectSet)[] + } +} + +// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare +// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer. +// Grants accept a user directly or any member of a group. +class Resource implements Namespace { + related: { + owners: (User | SubjectSet)[] + editors: (User | SubjectSet)[] + viewers: (User | SubjectSet)[] + } + + permits = { + view: (ctx: Context): boolean => + this.related.viewers.includes(ctx.subject) || + this.related.editors.includes(ctx.subject) || + this.related.owners.includes(ctx.subject), + edit: (ctx: Context): boolean => + this.related.editors.includes(ctx.subject) || + this.related.owners.includes(ctx.subject), + delete: (ctx: Context): boolean => this.related.owners.includes(ctx.subject), + } +} diff --git a/src/keto.test.ts b/src/keto.test.ts new file mode 100644 index 0000000..a93fbeb --- /dev/null +++ b/src/keto.test.ts @@ -0,0 +1,62 @@ +// Guards the Ory Keto config (§3): image pinned to an exact version (AGENTS.md), +// migrations run before the server (keto-migrate → keto), the DSN targets the keto +// database, read/write APIs serve on the ports config.ts points at, and the OPL +// declares the role/group/resource namespaces. Real boot is verified by running the +// stack; this catches edits. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); +const compose = read("compose.yml"); +const ketoYml = read("ory/keto/keto.yml"); +const opl = read("ory/keto/namespaces.keto.ts"); + +test("compose pins both keto services to one exact version", () => { + const tags = [...compose.matchAll(/image:\s*oryd\/keto:(\S+)/g)].map((m) => m[1]); + assert.equal(tags.length, 2, "keto + keto-migrate both present"); + assert.equal(tags[0], tags[1], "both pinned to the same version"); + const tag = tags[0]!; + assert.match(tag, /^v\d+\.\d+\.\d+$/, `${tag} is an exact vMAJOR.MINOR.PATCH`); + assert.doesNotMatch(tag, /latest|[\^~*]/, `${tag} is exact, not floating`); +}); + +test("keto migrations run once before the server starts", () => { + assert.match(compose, /migrate\s+up\s+-y/, "keto-migrate runs migrations"); + assert.ok((compose.match(/condition:\s*service_completed_successfully/g) ?? []).length >= 2, + "keto waits for keto-migrate (alongside kratos's gate)"); +}); + +test("keto DSN targets the per-service keto database", () => { + const dsns = [...compose.matchAll(/DSN:\s*(\S+)/g)].map((m) => m[1]); + const ketoDsns = dsns.filter((d) => /\/keto\b/.test(d!)); + assert.ok(ketoDsns.length >= 2, "both keto services point DSN at the keto DB"); + for (const dsn of ketoDsns) assert.match(dsn!, /@postgres:5432\/keto\b/, `${dsn} hits the keto DB`); +}); + +test("keto serves read/write on the ports config.ts targets", () => { + // config.ts defaults: ketoReadUrl=http://keto:4466, ketoWriteUrl=http://keto:4467. + assert.match(ketoYml, /read:\s*\n\s*host:[^\n]*\n\s*port:\s*4466/, "read API on 4466"); + assert.match(ketoYml, /write:\s*\n\s*host:[^\n]*\n\s*port:\s*4467/, "write API on 4467"); +}); + +test("keto loads the OPL namespaces from the mounted file", () => { + assert.match(ketoYml, /location:\s*file:\/\/\/etc\/config\/keto\/namespaces\.keto\.ts/, + "namespaces come from the committed OPL"); +}); + +test("the OPL declares role, group and a resource namespace over user subjects", () => { + for (const ns of ["User", "Group", "Role", "Resource"]) + assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`); + // role + group are subject sets read at login → JWT roles claim (README). + assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/, + "Role has a members relation"); + assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/, + "Group has a members relation"); +}); + +test("the resource namespace exposes fine-grained permissions (permits)", () => { + // README's third tier: the rare live Keto check. owners ⊇ editors ⊇ viewers. + assert.match(opl, /class Resource[\s\S]*permits\s*=\s*{[\s\S]*view:[\s\S]*edit:[\s\S]*delete:/, + "Resource permits view/edit/delete"); +}); diff --git a/src/kratos.test.ts b/src/kratos.test.ts index 1d77016..fd351f6 100644 --- a/src/kratos.test.ts +++ b/src/kratos.test.ts @@ -27,7 +27,7 @@ test("migrations run once before the server starts", () => { }); test("kratos DSN targets the per-service kratos database", () => { - const dsns = [...compose.matchAll(/DSN:\s*(\S+)/g)].map((m) => m[1]); + const dsns = [...compose.matchAll(/DSN:\s*(\S+)/g)].map((m) => m[1]).filter((d) => /\/kratos\b/.test(d!)); assert.ok(dsns.length >= 2, "both kratos services set DSN"); for (const dsn of dsns) assert.match(dsn!, /@postgres:5432\/kratos\b/, `${dsn} hits the kratos DB`); }); diff --git a/todo.md b/todo.md index 673b811..fe251f2 100644 --- a/todo.md +++ b/todo.md @@ -64,7 +64,7 @@ everything via Docker. - [x] Kratos session settings (cookie name, lifespan, sliding refresh). → `ory/kratos/kratos.yml` adds a `session` block: branded cookie `name: plainpages_session` (`persistent: true`, `same_site: Lax`), `lifespan: 720h` (30d "stay signed in" backbone the app re-mints the ~10m JWT off, §4), and sliding refresh via `earliest_possible_extend: 24h` (an active session extends back to full lifespan only once within 24h of expiry — no DB write per request). Tests-first (`kratos.test.ts`: cookie name + lifespan + extend window). Boot-verified: kratos serves `/health/ready` 200 with the block; a real browser registration (one-off `--dev` kratos, since Secure cookies don't ride plain http — that's the line-69 split) issued `Set-Cookie: plainpages_session=…; Max-Age=2591999; Expires=…; HttpOnly; SameSite=Lax` — name/persistent/lifespan all as configured; torn down. typecheck + 123 units green. - [x] Kratos tokenizer template `plainpages`: claims `{ sub, email, roles }`, `ttl ≈ 10m`, `jwks_url` signer, `claims_mapper_url` (Jsonnet reading `metadata_admin.roles`). → `ory/kratos/kratos.yml` adds `session.whoami.tokenizer.templates.plainpages`: `ttl: 10m`, `subject_source: id` (sub = identity id), `claims_mapper_url`/`jwks_url` pointing at the mounted config dir. `ory/kratos/tokenizer/plainpages.jsonnet` is the claims mapper — `email` from `session.identity.traits.email`, `roles` from the `metadata_admin` projection (§4 refreshes it from Keto at login; absent on a fresh identity ⇒ `[]`, defensive `objectHas`). `sub` is fixed to the identity id by Kratos (`subject_source`), not the mapper. The JWKS signing key referenced by `jwks_url` is generated/mounted by the next §3 item — Kratos loads it lazily at tokenize time, so this boots clean. Tests-first (`kratos.test.ts`: template ttl/subject_source/urls + mapper email/roles-from-metadata_admin). Boot-verified: kratos serves `/admin/health/ready` 200 with the tokenizer wired (config schema accepts the block); torn down. typecheck + 125 units green. - [x] Generate + mount the JWT signing JWKS; document key rotation. → `src/gen-jwks.ts` (`generateJwks()` + CLI) mints an **ES256** EC P-256 signing key as a JWK Set — Ory's recommended alg and the verifier's preferred (`src/jwt.ts`). The committed `ory/kratos/tokenizer/jwks.json` is the **dev throwaway** (like the cookie/cipher secrets in `kratos.yml`), already mounted via `./ory/kratos:/etc/config/kratos:ro` at the `jwks_url` the tokenizer template points to — so a clean clone signs out of the box. Regenerate/rotate: `docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json` (also `npm run gen-jwks`). README documents prod override (mount a real key or `…_JWKS_URL=base64://…`) + zero-downtime rotation (Kratos signs with the first key, app verifies by `kid` (§4) → prepend new, keep old ~one 10m TTL, drop). Tests-first (`gen-jwks.test.ts`: generator shape + unique kid, committed key validity, **round-trip** — a JWS signed with a generated key verifies through `verifyJws`). Boot-verified the full chain end-to-end: live Kratos registered an identity (API flow), `whoami?tokenize_as=plainpages` returned a real JWT signed with our `kid`, `verifyJws` validated it against the committed public half, claims `{sub, email, roles:[]}` + exp−iat = 600s (10m); torn down. typecheck + 128 units green. -- [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). +- [x] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). → `compose.yml` adds `keto`/`keto-migrate` pinned to `oryd/keto:v26.2.0` (Ory's unified versioning — same train as kratos; verified latest stable); `keto-migrate` runs `migrate up -y` against the per-service `keto` DB after postgres is healthy, `keto` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/keto/keto.yml` serves read on 4466 + write on 4467 (the ports `config.ts` already targets), DSN via env, loads the OPL from the mounted file. `ory/keto/namespaces.keto.ts` is the OPL model: `User` (subject = Kratos id), `Group`/`Role` as subject sets with `members` (the coarse roles read at login → JWT, README), and a fine-grained `Resource` with `permits` view/edit/delete over owner ⊇ editor ⊇ viewer (README's third "may I?" tier). OPL stays out of tsconfig `include` (Keto-dialect, like the jsonnets). README: Status note + Layout updated, the role tuple example fixed to `#members` to match the OPL. Tests-first (`keto.test.ts`: version pin + migrate-before-serve + DSN→keto DB + read/write ports + OPL namespaces/permits). Fixed a pre-existing kratos test that over-asserted *every* compose DSN was kratos's (now scoped to kratos DSNs). Boot-verified the whole model live: migrate exits 0, read API ready, then over the write/read APIs — `role:admin#members@user:alice` checks allowed; `Resource:doc1` owner→delete/view allowed, viewer→view allowed but delete denied, stranger denied; and a transitive `Group:eng members ⊆ Role:editor` resolved `user:erin`→editor; torn down. typecheck + 135 units green. - [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. - [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering. - [ ] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box.