Rename the Keto User namespace to Identity, matching Kratos
CI / full-gate (push) Successful in 2m34s

This commit is contained in:
2026-08-03 12:19:12 +02:00
parent ca3d49ec45
commit f7d70cfff8
37 changed files with 220 additions and 212 deletions
+17 -10
View File
@@ -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:<name>#members@user:<id>`, or `@Group:<name>#members`.
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#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<string, string>; // 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