Add Keto service + migrate (todo §3); OPL role/group/resource namespaces, fine-grained resource permits
This commit is contained in:
11
README.md
11
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`):
|
> **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,
|
> 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**
|
> filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host**
|
||||||
> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding). The
|
> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the
|
||||||
> **Ory integration** (Kratos/Keto/Hydra + Postgres) and **auth** are the roadmap; sections marked
|
> **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.
|
> _(planned)_ are not built yet.
|
||||||
|
|
||||||
## The MVP — "clone, one command, hack on a plugin" _(planned)_
|
## 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
|
**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
|
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
|
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,
|
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)
|
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)
|
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)
|
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)
|
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)
|
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
|
||||||
e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
|
||||||
|
|||||||
26
compose.yml
26
compose.yml
@@ -57,5 +57,31 @@ services:
|
|||||||
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
19
ory/keto/keto.yml
Normal file
19
ory/keto/keto.yml
Normal 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
|
||||||
47
ory/keto/namespaces.keto.ts
Normal file
47
ory/keto/namespaces.keto.ts
Normal 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
62
src/keto.test.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ test("migrations run once before the server starts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("kratos DSN targets the per-service kratos database", () => {
|
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");
|
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`);
|
for (const dsn of dsns) assert.match(dsn!, /@postgres:5432\/kratos\b/, `${dsn} hits the kratos DB`);
|
||||||
});
|
});
|
||||||
|
|||||||
2
todo.md
2
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 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] 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.
|
- [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.
|
- [ ] `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.
|
- [ ] 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.
|
- [ ] **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user