diff --git a/AGENTS.md b/AGENTS.md index 63adb3b..424dea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,17 +86,19 @@ them. Revisit only if the stated reason stops holding. `tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks in place *and* copies across unchanged. Never commit real plugins/config into the root mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise). -- **Authorization vocabulary: `Identity` → `Group` → `Permission`, and there is no `Role`.** Keto - ships no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Identity` matches - Kratos, which owns that record. `Permission` follows RBAC, where a permission is one operation - ("read shifts") and a role is a *bundle* of them; a route gates on one operation, so it gates on - a permission, and a bundle is just a group with several grants (groups nest). Ory's own - "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier. -- **UI labels stay in ordinary words — the menu says "Users", not "Identities".** The model uses - domain vocabulary; labels use the reader's, per Nielsen's heuristic #2 (match between system and - the real world). This is not a rename of an Ory concept: Ory's own docs state it uses "identity" - *interchangeably* with "users"/"accounts". Same split as `chrome.user`/`ShellUser` (the avatar - view-model) versus `SessionIdentity`/`ctx.identity` (the entity). +- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships + no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC, + where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route + gates on one operation, so it gates on a permission, and a bundle is just a group with several + grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the + separate per-row tier. +- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record + an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and + "accounts" — so this is house style, not a renamed concept, and "user" is the word readers + already know (Nielsen's heuristic #2: match between the system and the real world). One note in + README → Auth records the mapping so nobody has to rediscover it. The single exception is the + `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's + name — don't rename that one. - **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), @@ -131,12 +133,12 @@ docker compose -f compose.yml up --build -d # production running **building plugins** comes first, then **configuring and securing** the system (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are deliberately deferred — they're not top of mind when starting out. Concretely: Overview → - Identities, groups & permissions → Building plugins → menu/blocks/interactivity → + Users, groups & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place it by this value (how early an adopter needs it), not by where it sits in the stack. - **Identities, groups & permissions precedes Building plugins** because a manifest's + **Users, groups & permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable without the model, and operators need it as much as plugin authors. It is the one home for that model — the plugin and auth sections link to it rather than restating it. diff --git a/README.md b/README.md index da80e57..2b0ed4a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [Overview](#overview) - [how it compares](#how-it-compares) -- [Identities, groups & permissions](#identities-groups--permissions) +- [Users, groups & permissions](#users-groups--permissions) - [a worked example](#a-worked-example) - [granting a permission](#granting-a-permission) - [fine-grained, per-row access](#fine-grained-per-row-access) @@ -198,35 +198,40 @@ server-rendered** design system, **[optional auth](#auth-sessions--access)** (an public or gated), **no app database**, and a **framework-light TypeScript** core with no build step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection. -## Identities, groups & permissions +## Users, groups & permissions -Authorization here is two hops: an **identity** — directly, or through a **group** — is granted a +Authorization here is two hops: a **user** — directly, or through a **group** — is granted a **permission**, and that permission's *name* is exactly the string a plugin gates on. - **Group** answers *who* — a reusable set of people. Optional: a permission can be granted - straight to an identity. + straight to a user. - **Permission** answers *what* — its **name is the string** you write in a manifest's `permission:` gate. -- **A relation tuple** is the grant: `Permission:#granted@identity:`, or +- **A relation tuple** is the grant: `Permission:#granted@user:`, or `@Group:#members`. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | -| **Identity** | Kratos | who you are | `identity:0198f2c1-…` | +| **User** | Kratos | who you are | `user:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | | **Permission** | Keto | what you may do | `Permission: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 +Users live in Kratos; every authorization edge is a Keto relation tuple. The app itself 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`, `Permission` and `Resource` have no upstream counterpart to match, so they use -the ordinary words. +transitively, through nested groups). + +> **Ory calls a user an "identity".** Kratos owns that record and names it so: its API is +> `/admin/identities`, and a session carries `session.identity`. Plainpages says **user** +> everywhere, because that is the word readers already know — and Ory's own documentation states +> it uses "identity" interchangeably with "users" and "accounts". You will meet Ory's spelling in +> exactly two places: the Kratos API itself, and the `Identity` type in `src/auth/kratos-admin.ts` +> that mirrors it. > **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is > a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a @@ -250,7 +255,7 @@ Alice works support and leads scheduling; Bob works support; Carol administers t carol ───────────────────────────────────────────────> Permission:admin ``` -At login the host asks Keto which permissions the identity holds, walking those arrows +At login the host asks Keto which permissions the user holds, walking those arrows transitively, and bakes the answer into the session JWT (see [Login and the session JWT](#login-and-the-session-jwt)): @@ -538,12 +543,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.identity` may be `null` (use it to show a "go to dashboard" link to +handler is a **public** page, so `ctx.user` 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.identity` is non-null; +For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null; branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a single permission — 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`.) @@ -561,13 +566,13 @@ request: ```ts interface RequestContext { chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell - identity: SessionIdentity | null; // { id, email, permissions } from the verified session JWT, or null + user: User | null; // { id, email, permissions } 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; - permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check + permissions: string[]; // user?.permissions ?? [] — 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; verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie @@ -659,7 +664,7 @@ accident of a forgotten gate**. `public` and `permission` are **mutually exclusi 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.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) +`ctx.user` 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.permissions` is empty (read a permission 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, @@ -667,7 +672,7 @@ while the actual shifts list stays behind `scheduling:read`. The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their names are a shared global namespace, and the fine-grained per-row tier are all covered in -[Identities, groups & permissions](#identities-groups--permissions). +[Users, groups & permissions](#users-groups--permissions). Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them, feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is @@ -1040,7 +1045,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam ``` **Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g. -`Permission:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the +`Permission:admin#granted@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 permissions 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 @@ -1097,7 +1102,7 @@ deactivate the user, or use a direct user-permission change, for an instant effe ### Three tiers of "may I?" -[Identities, groups & permissions](#identities-groups--permissions) covers *what* the entities are; this is where each +[Users, groups & permissions](#users-groups--permissions) covers *what* the entities are; this is where each **kind** of rule belongs. ``` diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index 977a00d..1069e14 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 SessionIdentity } from "#plugin-api"; +import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } 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: SessionIdentity; } +interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } 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 79d34c0..f1962ca 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: `identity:${uid(n)}` }); + ({ namespace: "Group", object: group, relation: "members", subject_id: `user:${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(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` }); + assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${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 ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); + for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad); - assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` }); + assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${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: "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(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(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: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; + const options = [{ label: "ada@example.com", value: `user:${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: `identity:${uid(1)}` }, // already a member → excluded - { label: "grace@example.com", value: `identity:${uid(2)}` }, + { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded + { label: "grace@example.com", value: `user:${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), [`identity:${uid(2)}`, "group:ops"]); + assert.deepEqual(m.add.options.map((o) => o.value), [`user:${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 cd8c881..2f5e00b 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 SessionIdentity } from "#plugin-api"; +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 { 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 — `identity:` or `group:` (see parseSubject). +// is the form value that round-trips it — `user:` or `group:` (see parseSubject). export interface MemberView { - kind: "group" | "identity"; + kind: "group" | "user"; label: string; subject: string; } @@ -35,7 +35,7 @@ export interface MemberView { // One option in a member