Add Keto service + migrate (todo §3); OPL role/group/resource namespaces, fine-grained resource permits

This commit is contained in:
2026-06-17 15:12:01 +02:00
parent 6640dfc84e
commit fa87280f46
7 changed files with 162 additions and 7 deletions

View File

@@ -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)

View File

@@ -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:

19
ory/keto/keto.yml Normal file
View File

@@ -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

View File

@@ -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:<kratos-identity-id>`.
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<Group, "members">)[]
}
}
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
// `role:<name>#members@user:<id>` 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<Group, "members">)[]
}
}
// 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<Group, "members">)[]
editors: (User | SubjectSet<Group, "members">)[]
viewers: (User | SubjectSet<Group, "members">)[]
}
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),
}
}

62
src/keto.test.ts Normal file
View File

@@ -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");
});

View File

@@ -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`);
});

View File

@@ -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:[]}` + expiat = 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.