Say user throughout, noting Ory's identity naming in the docs
CI / full-gate (push) Successful in 2m32s

This commit is contained in:
2026-08-03 17:13:20 +02:00
parent 076d1f6926
commit 9097552065
37 changed files with 259 additions and 251 deletions
+15 -13
View File
@@ -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 `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 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). mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
- **Authorization vocabulary: `Identity``Group``Permission`, and there is no `Role`.** Keto - **Authorization vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships
ships no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Identity` matches no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
Kratos, which owns that record. `Permission` follows RBAC, where a permission is one operation where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
("read shifts") and a role is a *bundle* of them; a route gates on one operation, so it gates on gates on one operation, so it gates on a permission, and a bundle is just a group with several
a permission, and a bundle is just a group with several grants (groups nest). Ory's own grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier. separate per-row tier.
- **UI labels stay in ordinary words — the menu says "Users", not "Identities".** The model uses - **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
domain vocabulary; labels use the reader's, per Nielsen's heuristic #2 (match between system and an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
the real world). This is not a rename of an Ory concept: Ory's own docs state it uses "identity" "accounts" — so this is house style, not a renamed concept, and "user" is the word readers
*interchangeably* with "users"/"accounts". Same split as `chrome.user`/`ShellUser` (the avatar already know (Nielsen's heuristic #2: match between the system and the real world). One note in
view-model) versus `SessionIdentity`/`ctx.identity` (the entity). 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 - **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`: `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), 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 running **building plugins** comes first, then **configuring and securing** the system
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview → 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 Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place 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. 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 `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 authors. It is the one home for that model — the plugin and auth sections link to it rather
than restating it. than restating it.
+24 -19
View File
@@ -69,7 +69,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Overview](#overview) - [Overview](#overview)
- [how it compares](#how-it-compares) - [how it compares](#how-it-compares)
- [Identities, groups & permissions](#identities-groups--permissions) - [Users, groups & permissions](#users-groups--permissions)
- [a worked example](#a-worked-example) - [a worked example](#a-worked-example)
- [granting a permission](#granting-a-permission) - [granting a permission](#granting-a-permission)
- [fine-grained, per-row access](#fine-grained-per-row-access) - [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 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. 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. **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 - **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** answers *what* — its **name is the string** you write in a manifest's
`permission:` gate. `permission:` gate.
- **A relation tuple** is the grant: `Permission:<name>#granted@identity:<id>`, or - **A relation tuple** is the grant: `Permission:<name>#granted@user:<id>`, or
`@Group:<name>#members`. `@Group:<name>#members`.
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
| Entity | Lives in | Answers | Example | | 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` | | **Group** | Keto | who — a reusable set | `Group:support` |
| **Permission** | Keto | what you may do | `Permission:scheduling:read` | | **Permission** | Keto | what you may do | `Permission:scheduling:read` |
| **Resource** | Keto | which specific row | `Resource:shift-4471` | | **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). stores none of it — it is [stateless](#stateless).
**Keto ships no entities of its own.** Its entire model is one primitive — **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 `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 `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 transitively, through nested groups).
record. `Group`, `Permission` and `Resource` have no upstream counterpart to match, so they use
the ordinary words. > **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 > **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 > 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 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 transitively, and bakes the answer into the session JWT (see [Login and the session
JWT](#login-and-the-session-jwt)): 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 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 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` 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 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 `/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
points there. 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 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 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`.) 403. (Both slots answer `GET` and `HEAD`.)
@@ -561,13 +566,13 @@ request:
```ts ```ts
interface RequestContext { interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell 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 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 } params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
query: URLSearchParams; // alias of url.searchParams query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; 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 system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL; url: URL;
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie 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. 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 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 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` 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, **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 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 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, 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 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. **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 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 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 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?" ### 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. **kind** of rule belongs.
``` ```
+2 -2
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // 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. // 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 { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.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 // 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. // 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<RouteResult>): RouteHandler { function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => { return async (ctx) => {
+10 -10
View File
@@ -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 uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => 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 => const groupTuple = (group: string, child: string): RelationTuple =>
({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } }); ({ 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)", () => { 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" } }); 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. // 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.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); 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", () => { 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"]]); 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", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` });
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` }); 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" }); 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", () => { 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 }); const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group"); assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups"); 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", () => { 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 members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())];
const candidates = [ const candidates = [
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${uid(2)}` }, { label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "design (group)", value: "group:design" }, // already a member → excluded { label: "design (group)", value: "group:design" }, // already a member → excluded
{ label: "eng (group)", value: "group:eng" }, // the group itself → excluded { label: "eng (group)", value: "group:eng" }, // the group itself → excluded
{ label: "ops (group)", value: "group:ops" }, { 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.rows.length, 2);
assert.equal(m.members.action, "/admin/groups/eng/members/delete"); assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members"); 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"); assert.equal(m.delete.action, "/admin/groups/eng/delete");
}); });
+9 -9
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // 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 { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.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` // 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:<id>` or `group:<name>` (see parseSubject). // is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
export interface MemberView { export interface MemberView {
kind: "group" | "identity"; kind: "group" | "user";
label: string; label: string;
subject: string; subject: string;
} }
@@ -35,7 +35,7 @@ export interface MemberView {
// One option in a member <select>. // One option in a member <select>.
export interface MemberOption { export interface MemberOption {
label: string; label: string;
value: string; // `identity:<id>` | `group:<name>` value: string; // `user:<id>` | `group:<name>`
} }
export function isValidGroupName(name: string): boolean { export function isValidGroupName(name: string): boolean {
@@ -51,7 +51,7 @@ export function parseSubject(value: string): { subject_id: string } | { subject_
if (!rest) return null; if (!rest) return null;
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only // Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name. // ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
if (value.slice(0, sep) === "identity") return UUID.test(rest) ? { subject_id: `identity:${rest}` } : null; if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null; if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
return null; return null;
} }
@@ -72,8 +72,8 @@ export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView { export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` }; if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
const subjectId = tuple.subject_id ?? ""; const subjectId = tuple.subject_id ?? "";
const id = subjectId.startsWith("identity:") ? subjectId.slice("identity:".length) : subjectId; const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
return { kind: "identity", label: emailById.get(id) ?? subjectId, subject: subjectId }; return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
} }
// ---- list view model ---- // ---- list view model ----
@@ -267,7 +267,7 @@ export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmi
const trait = it.traits?.["email"]; const trait = it.traits?.["email"];
const email = typeof trait === "string" ? trait : it.id; const email = typeof trait === "string" ? trait : it.id;
emailById.set(it.id, email); emailById.set(it.id, email);
userOptions.push({ label: email, value: `identity:${it.id}` }); userOptions.push({ label: email, value: `user:${it.id}` });
} }
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] }; return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
@@ -281,7 +281,7 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and // Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these. // Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: SessionIdentity; } interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler { function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => { return async (ctx) => {
@@ -18,7 +18,7 @@ import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (permission: string, n: number): RelationTuple => const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${uid(n)}` }); ({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple => const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }); ({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
@@ -26,14 +26,14 @@ test("isValidRoleName + permissionGrantTuple map the form value to a Permission
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok); for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad); for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(permissionGrantTuple("editor", `identity:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${uid(2)}` }); assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad); for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
}); });
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => { test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live). // The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `identity:${uid(n)}` }, type: "leaf" }); const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = { const tree: ExpandTree = {
children: [ children: [
leaf(1), // direct leaf(1), // direct
@@ -71,7 +71,7 @@ test("buildPermissionsListModel filters by search, sorts, paginates; the name li
}); });
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => { test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
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 = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options }); const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission"); assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions"); assert.equal(m.form.action, "/admin/permissions");
@@ -89,8 +89,8 @@ test("buildPermissionFormModel: a create form with a required name field + membe
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => { test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())]; const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [ const candidates = [
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded { label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${uid(2)}` }, { label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded { label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" }, { label: "ops (group)", value: "group:ops" },
]; ];
@@ -100,7 +100,7 @@ test("buildPermissionDetailModel: members → rows, add-options exclude current
assert.equal(m.members.rows.length, 2); assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/permissions/admin/members/delete"); assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members"); assert.equal(m.add.action, "/admin/permissions/admin/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.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]); assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/permissions/admin/delete"); assert.equal(m.delete.action, "/admin/permissions/admin/delete");
}); });
+11 -11
View File
@@ -8,7 +8,7 @@
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on // Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded. // ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api"; import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { import {
type GroupView, type GroupView,
@@ -54,7 +54,7 @@ export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): str
const walk = (node?: ExpandTree | null): void => { const walk = (node?: ExpandTree | null): void => {
if (!node) return; if (!node) return;
const subjectId = node.tuple?.subject_id; const subjectId = node.tuple?.subject_id;
if (subjectId?.startsWith("identity:")) ids.add(subjectId.slice("identity:".length)); if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
node.children?.forEach(walk); node.children?.forEach(walk);
}; };
walk(tree); walk(tree);
@@ -231,11 +231,11 @@ export function buildPermissionDetailModel(opts: {
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// instant-revoke: a permission change for a `identity:<id>` member must take effect now, so revoke that // instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is // user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke. // transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void { function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length)); if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
} }
// A permission exists exactly while it has ≥1 member (Keto has no create-object). // A permission exists exactly while it has ≥1 member (Keto has no create-object).
@@ -250,13 +250,13 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
if (!hasMembers) return []; if (!hasMembers) return [];
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH }); const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree) return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` })) .map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
} }
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and // Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these. // Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; } interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler { function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => { return async (ctx) => {
@@ -283,7 +283,7 @@ const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?:
}; };
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action). // The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => { const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin); const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED }); const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
const members = tuples.map((t) => memberView(t, emailById)); const members = tuples.map((t) => memberView(t, emailById));
@@ -319,7 +319,7 @@ export const rolesCreate = withRoles(async (deps) => {
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {})); export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand). // GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name)); export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens. // POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => { export const rolesAddMember = withRoleName(async (deps, name) => {
@@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted. // GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => { export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
const base = detailHref(name); const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
@@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
export const rolesDelete = withRoleName(async (deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED }); await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name }); ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_PERMISSIONS_BASE }; return { redirect: ADMIN_PERMISSIONS_BASE };
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps; const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim(); const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = permissionGrantTuple(name, member); const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); } if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
+5 -5
View File
@@ -6,19 +6,19 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", permissions: ["admin"] }; const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users"); const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse, chrome: CHROME, user: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
} }
+2 -2
View File
@@ -3,7 +3,7 @@
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens; // (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel. // everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api"; import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
@@ -32,7 +32,7 @@ export const ADMIN_NAV: NavNode = {
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the // The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): SessionIdentity { export function requireAdmin(ctx: RequestContext): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required"); if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
return user; return user;
+2 -2
View File
@@ -4,7 +4,7 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate // models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG). // — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api"; import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
@@ -266,7 +266,7 @@ function readUserInput(form: URLSearchParams): UserInput {
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and // Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// the Kratos capability (else a themed 503). Each route below is a thin handler over these. // the Kratos capability (else a themed 503). Each route below is a thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; } interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the // Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
+1 -1
View File
@@ -16,7 +16,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse, chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true), permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
} }
+1 -1
View File
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data // gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.identity may be null here, so read the permission via can() (zero I/O). // else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
export function overview(): RouteHandler { export function overview(): RouteHandler {
return (ctx) => ({ return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
+12 -11
View File
@@ -4,35 +4,36 @@
// identity ids (== the JWT `sub`). // identity ids (== the JWT `sub`).
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types" import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`. // A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
class Identity implements Namespace {} // throughout. Subjects are written as `user:<kratos-identity-id>`.
class User implements Namespace {}
// A named set of identities (and nested groups), resolved transitively. The admin "Groups" // A named set of users (and nested groups), resolved transitively. The admin "Groups"
// screen manages membership; checks expand it automatically. // screen manages membership; checks expand it automatically.
class Group implements Namespace { class Group implements Namespace {
related: { related: {
members: (Identity | SubjectSet<Group, "members">)[] members: (User | SubjectSet<Group, "members">)[]
} }
} }
// A coarse permission — an operation a route or menu item gates on, and the source of truth // A coarse permission — an operation a route or menu item gates on, and the source of truth
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@identity:<id>` // for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
// from Keto and projects the result into the token (README: Login → session JWT). A group can // from Keto and projects the result into the token (README: Login → session JWT). A group can
// hold a permission, so grants go to an identity or to a whole group. // hold a permission, so grants go to a user or to a whole group.
class Permission implements Namespace { class Permission implements Namespace {
related: { related: {
granted: (Identity | SubjectSet<Group, "members">)[] granted: (User | SubjectSet<Group, "members">)[]
} }
} }
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare // A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer. // live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
// Grants accept an identity directly or any member of a group. // Grants accept a user directly or any member of a group.
class Resource implements Namespace { class Resource implements Namespace {
related: { related: {
owners: (Identity | SubjectSet<Group, "members">)[] owners: (User | SubjectSet<Group, "members">)[]
editors: (Identity | SubjectSet<Group, "members">)[] editors: (User | SubjectSet<Group, "members">)[]
viewers: (Identity | SubjectSet<Group, "members">)[] viewers: (User | SubjectSet<Group, "members">)[]
} }
permits = { permits = {
+5 -5
View File
@@ -20,13 +20,13 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin"); assert.equal(body.credentials.password.config.password, "admin");
}); });
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => { test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
const id = randomUUID(); const id = randomUUID();
assert.deepEqual(permissionTuple(id, "admin"), { assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission", namespace: "Permission",
object: "admin", object: "admin",
relation: "granted", relation: "granted",
subject_id: `identity:${id}`, subject_id: `user:${id}`,
}); });
}); });
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
assert.equal(puts.length, 2); // one grant per permission assert.equal(puts.length, 2); // one grant per permission
assert.ok(puts.every((p) => p.method === "PUT")); assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [ assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` }, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` }, { namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
]); ]);
}); });
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
}); });
assert.deepEqual(result, { created: false, id, permissions: ["admin"] }); assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` }); assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
}); });
test("seedAdmin fails loud on an unexpected Kratos error", async () => { test("seedAdmin fails loud on an unexpected Kratos error", async () => {
+3 -3
View File
@@ -22,10 +22,10 @@ export function identityPayload(email: string, password: string) {
}; };
} }
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>` // Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions. // (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(identityId: string, permission: string) { export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` }; return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
} }
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`) // The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
+5 -5
View File
@@ -2,17 +2,17 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http"; import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { test } from "node:test"; import { test } from "node:test";
import { buildContext, type RequestContext, type SessionIdentity } from "../http/context.ts"; import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { can, check, GuardError, requireSession } from "./guards.ts"; import { can, check, GuardError, requireSession } from "./guards.ts";
import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { KetoClient, RelationTuple } from "./keto-client.ts";
function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext { function ctxFor(user: User | null, url = "/"): RequestContext {
const req = new IncomingMessage(new Socket()); const req = new IncomingMessage(new Socket());
req.url = url; req.url = url;
return buildContext(req, new ServerResponse(req), { identity: user }); return buildContext(req, new ServerResponse(req), { user });
} }
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] }; const alice: User = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => { test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice); assert.equal(requireSession(ctxFor(alice)), alice);
@@ -44,7 +44,7 @@ test("check asks Keto with the current user as subject; anonymous is denied with
const tuple = { namespace: "Resource", object: "doc1", relation: "view" }; const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
assert.equal(await check(keto, ctxFor(alice), tuple), true); assert.equal(await check(keto, ctxFor(alice), tuple), true);
assert.deepEqual(asked, { ...tuple, subject_id: "identity:u1" }); // subject is the signed-in user assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
asked = undefined; asked = undefined;
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
+6 -6
View File
@@ -3,7 +3,7 @@
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, SessionIdentity } from "../http/context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts"; import { localPath } from "../http/safe-url.ts";
@@ -32,9 +32,9 @@ export class GuardError extends Error {
} }
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept). // Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
export function requireSession(ctx: RequestContext): SessionIdentity { export function requireSession(ctx: RequestContext): User {
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx)); if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.identity; return ctx.user;
} }
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false. // Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
@@ -49,6 +49,6 @@ export async function check(
ctx: RequestContext, ctx: RequestContext,
tuple: { namespace: string; object: string; relation: string }, tuple: { namespace: string; object: string; relation: string },
): Promise<boolean> { ): Promise<boolean> {
if (!ctx.identity) return false; if (!ctx.user) return false;
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` }); return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
} }
+18 -18
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto"; import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test"; import { test } from "node:test";
import { staticJwks } from "./jwks.ts"; import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToIdentity, resolveSession, verifyToken } from "./jwt-middleware.ts"; import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts"; import { SESSION_COOKIE } from "./login.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url"); const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
@@ -59,31 +59,31 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/); await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
}); });
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => { test("claimsToUser requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/); assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/); assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it) assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]); assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
}); });
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => { test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`; const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] }; const user = { email: "a@b.c", id: "u1", permissions: ["admin"] };
// A valid token → the user, not expired. // A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity }); assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
// Present but past exp → the re-mint trigger (expired flagged, no user). // Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: null }); assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip). // No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, identity: null }); assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, identity: null }); assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, identity: null }); assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, identity: null }); assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag. // authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity); assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null); assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
}); });
@@ -94,7 +94,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
// Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear). // Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/); await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null }); assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched. // A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] }); assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW }); await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
+10 -10
View File
@@ -3,7 +3,7 @@
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the // check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the permission gate denies. // (anonymous), so the route renders signed-out and the permission gate denies.
import type { SessionIdentity } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts"; import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts"; import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts"; import { decodeJws, verifyJws } from "./jwt.ts";
@@ -61,7 +61,7 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer // Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and // always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
// keeps only string entries (defensive). // keeps only string entries (defensive).
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity { export function claimsToUser(payload: Record<string, unknown>): User {
const sub = payload["sub"]; const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"]; const email = payload["email"];
@@ -72,13 +72,13 @@ export function claimsToIdentity(payload: Record<string, unknown>): SessionIdent
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate // Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
// claims, project the User. Throws TokenError / the underlying verify error on any failure. // claims, project the User. Throws TokenError / the underlying verify error on any failure.
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> { export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
const jwk = await jwks.getKey(header.kid); const jwk = await jwks.getKey(header.kid);
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`); if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options); validateClaims(verified.payload, options);
const user = claimsToIdentity(verified.payload); const user = claimsToUser(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so // Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session). // resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true); if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
@@ -87,7 +87,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
export interface SessionAuth { export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
identity: SessionIdentity | null; user: User | null;
} }
// The request middleware: read our session cookie, verify it → the User (fail-closed: any // The request middleware: read our session cookie, verify it → the User (fail-closed: any
@@ -96,15 +96,15 @@ export interface SessionAuth {
// expired session, never for anonymous or garbage requests. // expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> { export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE]; const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return { expired: false, identity: null }; if (!token) return { expired: false, user: null };
try { try {
return { expired: false, identity: await verifyToken(token, jwks, options) }; return { expired: false, user: await verifyToken(token, jwks, options) };
} catch (err) { } catch (err) {
return { expired: err instanceof TokenError && err.expired, identity: null }; return { expired: err instanceof TokenError && err.expired, user: null };
} }
} }
// Convenience for callers that don't re-mint: just the User, or null. // Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> { export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).identity; return (await resolveSession(cookieHeader, jwks, options)).user;
} }
+2 -2
View File
@@ -8,7 +8,7 @@ import { createKetoClient, KetoError } from "./keto-client.ts";
const READ = "http://keto:4466"; const READ = "http://keto:4466";
const WRITE = "http://keto:4467"; const WRITE = "http://keto:4467";
const USER = "identity:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response { function res(status: number, body?: unknown): Response {
const h = new Headers(); const h = new Headers();
@@ -35,7 +35,7 @@ test("check GETs the read API and returns the allowed boolean (true and false)",
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`)); assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict. // A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false })); const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false); assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
}); });
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => { test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
+3 -3
View File
@@ -33,7 +33,7 @@ export interface RecoveryCode {
export interface KratosAdmin { export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>; createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>; createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>; deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>; getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>; listIdentities(opts?: ListOptions): Promise<IdentityList>;
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the // Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly. // user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(identityId, opts = {}) { async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId }; const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn; if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" }); const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res); if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
+7 -7
View File
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts"; import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55"; const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` }); const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false, check: async () => false,
@@ -49,11 +49,11 @@ test("readPermissions returns permissions held directly OR transitively (enumera
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped. // subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => { listRelations: async (q) => {
listQ.push(q); listQ.push(q);
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] }; if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "user:other" })] };
return { nextPageToken: "p2", tuples: [ return { nextPageToken: "p2", tuples: [
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }), permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
permission("admin", { subject_id: `identity:${ID}` }), permission("admin", { subject_id: `user:${ID}` }),
permission("viewer", { subject_id: "identity:stranger" }), permission("viewer", { subject_id: "user:stranger" }),
] }; ] };
}, },
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer. // Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
@@ -79,7 +79,7 @@ test("completeLogin: read permissions → project onto metadata_public → token
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) }); const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s"); const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] }); assert.deepEqual(out, { email: "admin@plainpages.local", userId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
}); });
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie. // TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s"); const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] }); assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/); assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous. // Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined); const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
assert.equal(dead.identity, null); assert.equal(dead.user, null);
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/); assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
}); });
+13 -13
View File
@@ -6,7 +6,7 @@
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie // 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper // Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto. // reads only the identity, never Keto.
import type { SessionIdentity } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "../http/cookie.ts"; import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "../logger.ts"; import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
@@ -32,18 +32,18 @@ export interface LoginDeps {
export interface CompletedLogin { export interface CompletedLogin {
email: string | null; email: string | null;
identityId: string; userId: string;
jwt: string; jwt: string;
permissions: string[]; permissions: string[];
} }
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a // The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission // group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT — // namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per // matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// request; permission count is small, so the per-permission checks are cheap and run in parallel. // request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> { export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
const subject_id = `identity:${identityId}`; const subject_id = `user:${userId}`;
const names = new Set<string>(); const names = new Set<string>();
let pageToken: string | undefined; let pageToken: string | undefined;
do { do {
@@ -59,24 +59,24 @@ export async function readPermissions(keto: KetoClient, identityId: string): Pro
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> { export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {}); const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
if (!session?.identity) return null; if (!session?.identity) return null;
const identityId = session.identity.id; const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null; const email = typeof emailTrait === "string" ? emailTrait : null;
const permissions = await readPermissions(deps.keto, identityId); const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions }); await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS }); const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized; const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT"); if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint currentLog()?.info("session minted", { permissions: permissions.join(","), sub: userId }); // login or TTL re-mint
return { email, identityId, jwt, permissions }; return { email, userId, jwt, permissions };
} }
export interface Reminted { export interface Reminted {
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
identity: SessionIdentity | null; user: User | null;
} }
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but // Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
@@ -86,8 +86,8 @@ export interface Reminted {
// anonymous instead of re-hitting Ory on every one. // anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), identity: null }; if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+3 -3
View File
@@ -44,7 +44,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
const pathname = ctx.url.pathname; const pathname = ctx.url.pathname;
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app // Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.) // dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.identity && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" }; if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie; const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow"); const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to // Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
@@ -75,7 +75,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=). // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname }; if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose // Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity // `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/ // is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing // registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500. // login (mint the JWT from the live session), honouring return_to — never a 500.
@@ -218,7 +218,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
} }
const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {}); const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {});
ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies })); ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" }); ctx.log.info("logout", { sub: ctx.user?.id ?? "" });
return { redirect: flow?.logoutUrl ?? "/login" }; return { redirect: flow?.logoutUrl ?? "/login" };
}; };
} }
+28 -28
View File
@@ -105,7 +105,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
const portal: Plugin = { const portal: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, view: "board" }), dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
home: () => ({ data: { brand: "Acme" }, view: "welcome" }), home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
id: "portal", id: "portal",
}; };
@@ -125,7 +125,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
assert.equal(board.status, 200); assert.equal(board.status, 200);
const html = await board.text(); const html = await board.text();
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
}); });
@@ -516,7 +516,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303); assert.equal(ok.status, 303);
}); });
// JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads. // JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file). // The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => { test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
@@ -569,7 +569,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" }); const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session); const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "identity:u1" }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`; const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response. // Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
@@ -727,7 +727,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
}); });
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => { test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.identity // After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration // is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the // flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
// JWT from the live session), preserving return_to — never fall through to the catch-all 500. // JWT from the live session), preserving return_to — never fall through to the catch-all 500.
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
let projected: unknown; let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session); const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } }); const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${identity.id}` }] }) }); const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => { const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
@@ -1178,7 +1178,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } }, { id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } }, { id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
]; ];
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${ada}` }]; const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples); const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
@@ -1192,26 +1192,26 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail. // Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/groups/new")).text(), /Create group/); assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=identity:${grace}`); const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/groups/design"); assert.equal(created.headers.get("location"), "/admin/groups/design");
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `identity:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=identity:${grace}`)).status, 400); assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
assert.equal((await post("/admin/groups", `name=x&member=identity:${grace}`)).status, 403); assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.equal(tuples.length, before); assert.equal(tuples.length, before);
// Detail: lists the current member by email. // Detail: lists the current member by email.
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/); assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
// Add a member, then remove it. // Add a member, then remove it.
await post("/admin/groups/eng/members", `_csrf=${token}&member=identity:${grace}`); await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`); await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`)); assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/); assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
@@ -1237,8 +1237,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
]; ];
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada. // grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
const tuples: RelationTuple[] = [ const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` }, { namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` }, { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
]; ];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. // Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({ const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1262,17 +1262,17 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
// Create: a valid post writes the first-member tuple and redirects to the detail. // Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/); assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`); const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/permissions/viewer"); assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`)); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400); assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403); assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
assert.equal(tuples.length, before); assert.equal(tuples.length, before);
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not // Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
@@ -1293,8 +1293,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate. // Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`); await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`); await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true); assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
@@ -1305,11 +1305,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1). // Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" }); tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400); assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin")); assert.ok(tuples.some((tp) => tp.object === "admin"));
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400); assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1")); assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500. // An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404); assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
+14 -14
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts"; import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type SessionIdentity } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts"; import { buildDashboardModel } from "../ui/dashboard.ts";
@@ -40,7 +40,7 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
@@ -124,7 +124,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data)); await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
return null; return null;
} }
return { data: { chrome: ctx.chrome, user: ctx.identity }, view: "home" }; return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
}; };
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign // The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
// handler renders against its own views, same path as a plugin route. Else the built-in // handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts. // mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
if (!ctx.identity) return { redirect: loginRedirect(ctx), status: 303 }; if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (dashboardPlugin) {
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data)); await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null; return null;
} }
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, nav: ctx.chrome.nav }) }, view: "index" }; return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -186,19 +186,19 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous. // Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory // If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize, // clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces // and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory. // (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: SessionIdentity | null = null; let user: User | null = null;
if (jwks) { if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions); const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.identity; user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) { if (!user && auth.expired && keto && kratos && kratosAdmin) {
try { try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies }); const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.identity; user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie); res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) { } catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of // Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
@@ -223,10 +223,10 @@ export function createApp(options: AppOptions = {}): Server {
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler, // ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu). // or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined; let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: user })); const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest hooks and the landing routes. // base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, identity: user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) { if (anyRequestHooks) {
@@ -245,12 +245,12 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) }); const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.permissions)) { if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the permission gets the 403 page. // return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id }); reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" })); sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return; return;
} }
+5 -5
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http"; import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { test } from "node:test"; import { test } from "node:test";
import { buildContext, type SessionIdentity } from "./context.ts"; import { buildContext, type User } from "./context.ts";
import { createLogger } from "../logger.ts"; import { createLogger } from "../logger.ts";
// A req/res pair without a live server — enough to build and inspect a context. // A req/res pair without a live server — enough to build and inspect a context.
@@ -22,7 +22,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
assert.equal(ctx.query.get("q"), "ann"); assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2"); assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.identity, null); assert.equal(ctx.user, null);
assert.deepEqual(ctx.permissions, []); assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {}); assert.deepEqual(ctx.params, {});
}); });
@@ -35,9 +35,9 @@ test("buildContext threads path params supplied by the router", () => {
test("buildContext threads the user and derives permissions from it", () => { test("buildContext threads the user and derives permissions from it", () => {
const { req, res } = reqRes("/"); const { req, res } = reqRes("/");
const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] }; const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { identity: user }); const ctx = buildContext(req, res, { user });
assert.equal(ctx.identity, user); assert.equal(ctx.user, user);
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
}); });
+10 -10
View File
@@ -5,11 +5,12 @@ import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once // The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the JWT // per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `identity` (null until then). The host's single handler argument. // middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated Kratos identity, projected from verified session JWT claims: // The signed-in user, projected from verified session JWT claims. Ory calls this record an
// "identity" (see README); Plainpages says user throughout.
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token. // `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
export interface SessionIdentity { export interface User {
email: string; email: string;
id: string; id: string;
permissions: string[]; permissions: string[];
@@ -19,21 +20,20 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its // Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise). // page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome; chrome: PageChrome;
// The signed-in Kratos identity, or null when anonymous.
identity: SessionIdentity | null;
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path. // requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log; log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; res: ServerResponse;
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined // Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it. // unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities; system?: SystemCapabilities;
url: URL; url: URL;
user: User | null; // the signed-in user, or null when anonymous
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF // Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body. // cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
verifyCsrf(submitted: string | null | undefined): boolean; verifyCsrf(submitted: string | null | undefined): boolean;
@@ -44,7 +44,7 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
identity?: SessionIdentity | null; user?: User | null;
log?: Log; log?: Log;
params?: Record<string, string>; params?: Record<string, string>;
system?: SystemCapabilities; system?: SystemCapabilities;
@@ -63,18 +63,18 @@ export function buildContext(
options: BuildContextOptions = {}, options: BuildContextOptions = {},
): RequestContext { ): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const identity = options.identity ?? null; const user = options.user ?? null;
const buildChrome = options.chrome; const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
identity, user,
log: options.log ?? SILENT_LOG, log: options.log ?? SILENT_LOG,
params: options.params ?? {}, params: options.params ?? {},
query: url.searchParams, query: url.searchParams,
req, req,
res, res,
permissions: identity?.permissions ?? [], permissions: user?.permissions ?? [],
...(options.system ? { system: options.system } : {}), ...(options.system ? { system: options.system } : {}),
url, url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
+3 -3
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate → // Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts // keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the identity/permission/group/resource namespaces. Version pinning is // points at, and the OPL declares the user/permission/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits. // in compose.test.ts. Real boot is verified by running the stack; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -35,8 +35,8 @@ test("keto loads the OPL namespaces from the mounted file", () => {
"namespaces come from the committed OPL"); "namespaces come from the committed OPL");
}); });
test("the OPL declares permission, group and a resource namespace over identity subjects", () => { test("the OPL declares permission, group and a resource namespace over user subjects", () => {
for (const ns of ["Identity", "Group", "Permission", "Resource"]) for (const ns of ["User", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`); assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// permission + group are subject sets read at login → JWT permissions claim (README). // permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/, assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
+1 -1
View File
@@ -6,7 +6,7 @@
export { definePlugin } from "./plugin.ts"; export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, SessionIdentity } from "../http/context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts";
+2 -2
View File
@@ -48,7 +48,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({ const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] }, user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
}); });
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!; const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
}); });
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => { test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } }); const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
+5 -5
View File
@@ -5,7 +5,7 @@
// admin plugin is installed) — run through composeNav (override + per-user filter) and // admin plugin is installed) — run through composeNav (override + per-user filter) and
// current-marked for the request path. // current-marked for the request path.
import type { SessionIdentity } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
@@ -29,17 +29,17 @@ export interface ChromeOptions {
currentPath?: string; // request pathname; the matching nav leaf is marked current currentPath?: string; // request pathname; the matching nav leaf is marked current
menu: MenuConfig; menu: MenuConfig;
plugins?: Plugin[]; plugins?: Plugin[];
identity?: SessionIdentity | null; user?: User | null;
} }
export function buildPluginChrome(opts: ChromeOptions): PageChrome { export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an // The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin // anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin). // section, when present, is just another plugin's nav fragment (examples/plugins/admin).
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : []; const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav); for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const permissions = opts.identity?.permissions ?? []; const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions); const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) { if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
@@ -56,7 +56,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname). // Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login", signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
...(b.theme != null ? { theme: b.theme } : {}), ...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.identity), user: shellUser(opts.user),
}; };
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }]; const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => { test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV }); const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard"); assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig"); assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+3 -3
View File
@@ -4,12 +4,12 @@
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built // this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// once per request by the host, so the dashboard shows the exact same menu as every other page. // once per request by the host, so the dashboard shows the exact same menu as every other page.
import type { SessionIdentity } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; identity?: SessionIdentity | null } = {}) { export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
return { return {
nav: opts.nav ?? [], nav: opts.nav ?? [],
shell: buildShellContext({ shell: buildShellContext({
@@ -17,7 +17,7 @@ export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfi
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU, menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard", title: "Dashboard",
identity: opts.identity ?? null, user: opts.user ?? null,
}), }),
}; };
} }
+1 -1
View File
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx", signInHref: "/login?return_to=%2Fx",
title: "Users", title: "Users",
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] }, user: { email: "a@b.c", id: "u1", permissions: ["admin"] },
}); });
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" }); assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark"); assert.equal(full.theme, "dark");
+7 -7
View File
@@ -5,7 +5,7 @@
// the profile shows the email's local part as the name with the full email beneath, initials from // the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest". // the local part; anonymous ⇒ "Guest".
import type { SessionIdentity } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser { export interface ShellUser {
@@ -24,10 +24,10 @@ export interface ShellModel {
user: ShellUser; user: ShellUser;
} }
export function shellUser(identity: SessionIdentity | null | undefined): ShellUser { export function shellUser(user: User | null | undefined): ShellUser {
if (!identity) return { email: "", initials: "G", name: "Guest" }; if (!user) return { email: "", initials: "G", name: "Guest" };
const local = identity.email.split("@")[0] || identity.email; const local = user.email.split("@")[0] || user.email;
return { email: identity.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local }; return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
} }
export function buildShellContext(opts: { export function buildShellContext(opts: {
@@ -36,7 +36,7 @@ export function buildShellContext(opts: {
menu: MenuConfig; menu: MenuConfig;
signInHref?: string; signInHref?: string;
title: string; title: string;
identity?: SessionIdentity | null; user?: User | null;
}): ShellModel { }): ShellModel {
const b = opts.menu.branding; const b = opts.menu.branding;
return { return {
@@ -46,6 +46,6 @@ export function buildShellContext(opts: {
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}), ...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}), ...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title, title: opts.title,
user: shellUser(opts.identity), user: shellUser(opts.user),
}; };
} }
+2 -2
View File
@@ -15,9 +15,9 @@
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Identities, groups & permissions](README.md#identities-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.) - [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
- [ ] Add i18n support. - [ ] Add i18n support.
- [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:<kratos-id>`, and the session type `User``SessionIdentity` with `ctx.user``ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, permissions }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.) - [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one. - [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer. - [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.