From f7d70cfff89f08972a3b620a0a9bc9c4b73ca8c9 Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 12:19:12 +0200 Subject: [PATCH] Rename the Keto User namespace to Identity, matching Kratos --- README.md | 27 +++++---- examples/plugins/admin/admin-clients.ts | 4 +- examples/plugins/admin/admin-groups.test.ts | 20 +++---- examples/plugins/admin/admin-groups.ts | 18 +++--- examples/plugins/admin/admin-roles.test.ts | 16 +++--- examples/plugins/admin/admin-roles.ts | 14 ++--- examples/plugins/admin/admin-shared.test.ts | 12 ++-- examples/plugins/admin/admin-shared.ts | 4 +- examples/plugins/admin/admin-users.ts | 4 +- .../views/partials/group-detail-body.ejs | 2 +- .../admin/views/partials/role-detail-body.ejs | 2 +- examples/plugins/scheduling/shifts.test.ts | 4 +- examples/plugins/scheduling/shifts.ts | 2 +- ory/keto/namespaces.keto.ts | 16 +++--- src/auth/bootstrap.test.ts | 10 ++-- src/auth/bootstrap.ts | 4 +- src/auth/guards.test.ts | 10 ++-- src/auth/guards.ts | 12 ++-- src/auth/jwt-middleware.test.ts | 36 ++++++------ src/auth/jwt-middleware.ts | 20 +++---- src/auth/keto-client.test.ts | 4 +- src/auth/login.test.ts | 12 ++-- src/auth/login.ts | 12 ++-- src/auth/routes.ts | 6 +- src/http/app.test.ts | 56 +++++++++---------- src/http/app.ts | 28 +++++----- src/http/context.test.ts | 10 ++-- src/http/context.ts | 19 ++++--- src/keto.test.ts | 6 +- src/plugin-host/plugin-api.ts | 2 +- src/ui/chrome.test.ts | 4 +- src/ui/chrome.ts | 10 ++-- src/ui/dashboard.test.ts | 2 +- src/ui/dashboard.ts | 6 +- src/ui/shell-context.test.ts | 2 +- src/ui/shell-context.ts | 14 ++--- todo.md | 2 +- 37 files changed, 220 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index dd97b29..fa41009 100644 --- a/README.md +++ b/README.md @@ -206,18 +206,25 @@ separate "permission" object to define, register, or wire up. - **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user. - **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate. -- **A relation tuple** is the grant: `Role:#members@user:`, or `@Group:#members`. +- **A relation tuple** is the grant: `Role:#members@identity:`, or `@Group:#members`. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | -| **User** | Kratos | who you are | the identity behind `user:0198f2c1-…` | +| **Identity** | Kratos | who you are | `identity:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | | **Role** | Keto | what you may do | `Role:scheduling:read` | | **Resource** | Keto | which specific row | `Resource:shift-4471` | Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself -stores none of it — it is [stateless](#stateless). The model is `ory/keto/namespaces.keto.ts`. +stores none of it — it is [stateless](#stateless). + +**Keto ships no entities of its own.** Its entire model is one primitive — +`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in +`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including +transitively, through nested groups). `Identity` is named to match Kratos, which owns that +record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the +ordinary words. > **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits` > block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a @@ -527,12 +534,12 @@ export default definePlugin({ Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home` -handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to +handler is a **public** page, so `ctx.identity` may be `null` (use it to show a "go to dashboard" link to a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on `/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link points there. -For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null; +For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null; branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a single role — there's no second dashboard to fall back to, so a user lacking it would land on a 403. (Both slots answer `GET` and `HEAD`.) @@ -550,15 +557,15 @@ request: ```ts interface RequestContext { chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell + identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null log: Log; // request-scoped logger, in this request's trace params: Record; // path params from the route match, e.g. /things/:id → { id } query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; - roles: string[]; // user?.roles ?? [] — coarse gate without a null-check + roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them url: URL; - user: User | null; // { id, email, roles } from the verified session JWT, or null verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie } ``` @@ -647,7 +654,7 @@ accident of a forgotten gate**. `public` and `role` are **mutually exclusive** both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor -`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) +`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` **Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, @@ -1028,7 +1035,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam ``` **Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g. -`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the +`Role:admin#members@identity: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_public` for the tokenizer to see, which the template @@ -1651,7 +1658,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *. auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) - jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint + jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index 1069e14..977a00d 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -5,7 +5,7 @@ // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. -import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; +import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api"; import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; @@ -235,7 +235,7 @@ function readClientInput(form: URLSearchParams): ClientInput { // Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the // Hydra capability (else a themed 503). Each route below is a thin handler over these. -interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } +interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; } function withClients(inner: (deps: ClientsDeps) => Promise): RouteHandler { return async (ctx) => { diff --git a/examples/plugins/admin/admin-groups.test.ts b/examples/plugins/admin/admin-groups.test.ts index f1962ca..79d34c0 100644 --- a/examples/plugins/admin/admin-groups.test.ts +++ b/examples/plugins/admin/admin-groups.test.ts @@ -18,7 +18,7 @@ import type { RelationTuple } from "#plugin-api"; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const userTuple = (group: string, n: number): RelationTuple => - ({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` }); + ({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${uid(n)}` }); const groupTuple = (group: string, child: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } }); @@ -28,12 +28,12 @@ test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/ }); test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => { - assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` }); + assert.deepEqual(parseSubject(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` }); assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } }); // Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly. - for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); + for (const bad of ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); - assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` }); + assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` }); assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); assert.equal(memberTuple("design", "bad"), null); }); @@ -48,8 +48,8 @@ test("groupsFromTuples collapses membership tuples → distinct groups + member test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => { const emails = new Map([[uid(1), "ada@example.com"]]); - assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` }); - assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` }); + assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` }); + assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` }); assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" }); }); @@ -76,7 +76,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t }); test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => { - const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; + const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options }); assert.equal(m.title, "New group"); assert.equal(m.form.action, "/admin/groups"); @@ -96,8 +96,8 @@ test("buildGroupFormModel: a create form with a required name field + member opt test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => { const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())]; const candidates = [ - { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded - { label: "grace@example.com", value: `user:${uid(2)}` }, + { label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded + { label: "grace@example.com", value: `identity:${uid(2)}` }, { label: "design (group)", value: "group:design" }, // already a member → excluded { label: "eng (group)", value: "group:eng" }, // the group itself → excluded { label: "ops (group)", value: "group:ops" }, @@ -107,6 +107,6 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe assert.equal(m.members.rows.length, 2); assert.equal(m.members.action, "/admin/groups/eng/members/delete"); assert.equal(m.add.action, "/admin/groups/eng/members"); - assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]); + assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]); assert.equal(m.delete.action, "/admin/groups/eng/delete"); }); diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 2f5e00b..cd8c881 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -6,7 +6,7 @@ // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // each returning a RouteResult. -import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api"; +import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api"; import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; @@ -25,9 +25,9 @@ export interface GroupView { } // A member's view model: a user (label = email) or a nested group (label = group name). `subject` -// is the form value that round-trips it — `user:` or `group:` (see parseSubject). +// is the form value that round-trips it — `identity:` or `group:` (see parseSubject). export interface MemberView { - kind: "group" | "user"; + kind: "group" | "identity"; label: string; subject: string; } @@ -35,7 +35,7 @@ export interface MemberView { // One option in a member