Rename the Keto User namespace to Identity, matching Kratos
CI / full-gate (push) Successful in 2m34s
CI / full-gate (push) Successful in 2m34s
This commit is contained in:
@@ -206,18 +206,25 @@ separate "permission" object to define, register, or wire up.
|
|||||||
|
|
||||||
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
|
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
|
||||||
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
|
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
|
||||||
- **A relation tuple** is the grant: `Role:<name>#members@user:<id>`, or `@Group:<name>#members`.
|
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#members`.
|
||||||
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
|
- **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 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| **User** | Kratos | who you are | the identity behind `user:0198f2c1-…` |
|
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
|
||||||
| **Group** | Keto | who — a reusable set | `Group:support` |
|
| **Group** | Keto | who — a reusable set | `Group:support` |
|
||||||
| **Role** | Keto | what you may do | `Role:scheduling:read` |
|
| **Role** | Keto | what you may do | `Role: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
|
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
|
||||||
stores none of it — it is [stateless](#stateless). The model is `ory/keto/namespaces.keto.ts`.
|
stores none of it — it is [stateless](#stateless).
|
||||||
|
|
||||||
|
**Keto ships no entities of its own.** Its entire model is one primitive —
|
||||||
|
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
|
||||||
|
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
|
||||||
|
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
|
||||||
|
record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the
|
||||||
|
ordinary words.
|
||||||
|
|
||||||
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
|
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
|
||||||
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
|
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
|
||||||
@@ -527,12 +534,12 @@ export default definePlugin({
|
|||||||
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
|
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.user` may be `null` (use it to show a "go to dashboard" link to
|
handler is a **public** page, so `ctx.identity` may be `null` (use it to show a "go to dashboard" link to
|
||||||
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
|
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.user` is non-null;
|
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null;
|
||||||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
||||||
single role — there's no second dashboard to fall back to, so a user lacking it would land on a
|
single role — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||||
403. (Both slots answer `GET` and `HEAD`.)
|
403. (Both slots answer `GET` and `HEAD`.)
|
||||||
@@ -550,15 +557,15 @@ 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, roles } 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;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
|
||||||
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||||
url: URL;
|
url: URL;
|
||||||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
|
||||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -647,7 +654,7 @@ accident of a forgotten gate**. `public` and `role` are **mutually exclusive**
|
|||||||
both is contradictory and discovery refuses the plugin at boot.
|
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.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
||||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||||
@@ -1028,7 +1035,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
||||||
`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the
|
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
|
||||||
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
||||||
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
|
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
|
||||||
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
||||||
@@ -1651,7 +1658,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
|
|
||||||
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
||||||
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
||||||
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
|
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint
|
||||||
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
||||||
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
||||||
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
|
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
|
||||||
|
|||||||
@@ -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 User } from "#plugin-api";
|
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { 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: User; }
|
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; }
|
||||||
|
|
||||||
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
|
|||||||
@@ -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: `user:${uid(n)}` });
|
({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${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(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` });
|
assert.deepEqual(parseSubject(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` });
|
||||||
assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
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 ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
|
for (const bad of ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
|
||||||
|
|
||||||
assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` });
|
assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` });
|
||||||
assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
assert.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: "user", label: "ada@example.com", subject: `user:${uid(1)}` });
|
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` });
|
||||||
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` });
|
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` });
|
||||||
assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" });
|
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: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
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: `user:${uid(1)}` }, // already a member → excluded
|
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
{ label: "grace@example.com", value: `identity:${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), [`user:${uid(2)}`, "group:ops"]);
|
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 User } from "#plugin-api";
|
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { 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 — `user:<id>` or `group:<name>` (see parseSubject).
|
// is the form value that round-trips it — `identity:<id>` or `group:<name>` (see parseSubject).
|
||||||
export interface MemberView {
|
export interface MemberView {
|
||||||
kind: "group" | "user";
|
kind: "group" | "identity";
|
||||||
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; // `user:<id>` | `group:<name>`
|
value: string; // `identity:<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) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
|
if (value.slice(0, sep) === "identity") return UUID.test(rest) ? { subject_id: `identity:${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("user:") ? subjectId.slice("user:".length) : subjectId;
|
const id = subjectId.startsWith("identity:") ? subjectId.slice("identity:".length) : subjectId;
|
||||||
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
|
return { kind: "identity", 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: `user:${it.id}` });
|
userOptions.push({ label: email, value: `identity:${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: User; }
|
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: SessionIdentity; }
|
||||||
|
|
||||||
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 = (role: string, n: number): RelationTuple =>
|
const userTuple = (role: string, n: number): RelationTuple =>
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
|
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
|
||||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
const groupTuple = (role: string, group: string): RelationTuple =>
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||||
|
|
||||||
@@ -26,14 +26,14 @@ test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over
|
|||||||
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(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
|
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
|
||||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("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: `user:${uid(n)}` }, type: "leaf" });
|
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `identity:${uid(n)}` }, type: "leaf" });
|
||||||
const tree: ExpandTree = {
|
const tree: ExpandTree = {
|
||||||
children: [
|
children: [
|
||||||
leaf(1), // direct
|
leaf(1), // direct
|
||||||
@@ -71,7 +71,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
assert.equal(m.title, "New role");
|
assert.equal(m.title, "New role");
|
||||||
assert.equal(m.form.action, "/admin/roles");
|
assert.equal(m.form.action, "/admin/roles");
|
||||||
@@ -89,8 +89,8 @@ test("buildRoleFormModel: a create form with a required name field + member opti
|
|||||||
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
test("buildRoleDetailModel: 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: `user:${uid(1)}` }, // already a member → excluded
|
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
{ label: "grace@example.com", value: `identity:${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("buildRoleDetailModel: members → rows, add-options exclude current member
|
|||||||
assert.equal(m.members.rows.length, 2);
|
assert.equal(m.members.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
assert.equal(m.add.action, "/admin/roles/admin/members");
|
||||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||||
assert.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/roles/admin/delete");
|
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 User } from "#plugin-api";
|
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
import { ADMIN_ROLE, ADMIN_ROLES_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("user:")) ids.add(subjectId.slice("user:".length));
|
if (subjectId?.startsWith("identity:")) ids.add(subjectId.slice("identity:".length));
|
||||||
node.children?.forEach(walk);
|
node.children?.forEach(walk);
|
||||||
};
|
};
|
||||||
walk(tree);
|
walk(tree);
|
||||||
@@ -231,11 +231,11 @@ export function buildRoleDetailModel(opts: {
|
|||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
|
||||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
// user's live tokens (a re-mint then re-reads roles 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("user:")) revoke(member.slice("user:".length));
|
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
// A role 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: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||||
return expandToEffectiveUsers(tree)
|
return expandToEffectiveUsers(tree)
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
.map((id) => ({ label: emailById.get(id) ?? `identity:${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: User; }
|
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
||||||
|
|
||||||
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
@@ -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_ROLE && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = roleMemberTuple(name, member);
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ 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 User } from "#plugin-api";
|
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
|
||||||
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||||
|
|
||||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["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?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | 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, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 User } from "#plugin-api";
|
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||||
|
|
||||||
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
|
export const ADMIN_ROLE = "admin"; // the role 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 `role: "admin"`, so the
|
// The admin gate: a signed-in admin only. Each route already declares `role: "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): User {
|
export function requireAdmin(ctx: RequestContext): SessionIdentity {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
|
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -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 User } from "#plugin-api";
|
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } 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: User; }
|
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
||||||
|
|
||||||
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
|
// Resolve the shared deps, then run `inner`. The route's `role: "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,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin group membership body, captured into the shell content slot. Config:
|
Admin group membership body, captured into the shell content slot. Config:
|
||||||
group { name }
|
group { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
|
||||||
add { action, options: {label,value}[] } action = add-member endpoint
|
add { action, options: {label,value}[] } action = add-member endpoint
|
||||||
del { action } delete the whole group
|
del { action } delete the whole group
|
||||||
csrfToken, error?
|
csrfToken, error?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role detail body, captured into the shell content slot. Config:
|
Admin role detail body, captured into the shell content slot. Config:
|
||||||
role { name }
|
role { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||||
effective { label }[] users who hold the role (expand)
|
effective { label }[] users who hold the role (expand)
|
||||||
add { action, options: {label,value}[] } action = assign endpoint
|
add { action, options: {label,value}[] } action = assign endpoint
|
||||||
del { action } delete the whole role
|
del { action } delete the whole role
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCs
|
|||||||
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, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.user may be null here, so read the role via can() (zero I/O).
|
// else a prompt to sign in. ctx.identity may be null here, so read the role 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" },
|
||||||
|
|||||||
@@ -4,23 +4,23 @@
|
|||||||
// 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 human identity. Subjects are written as `user:<kratos-identity-id>`.
|
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
|
||||||
class User implements Namespace {}
|
class Identity implements Namespace {}
|
||||||
|
|
||||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
||||||
// The admin "Groups" screen manages membership; checks expand it automatically.
|
// The admin "Groups" screen manages membership; checks expand it automatically.
|
||||||
class Group implements Namespace {
|
class Group implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
members: (Identity | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
||||||
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
|
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token
|
||||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
||||||
class Role implements Namespace {
|
class Role implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
members: (Identity | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ class Role implements Namespace {
|
|||||||
// Grants accept a user 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: (User | SubjectSet<Group, "members">)[]
|
owners: (Identity | SubjectSet<Group, "members">)[]
|
||||||
editors: (User | SubjectSet<Group, "members">)[]
|
editors: (Identity | SubjectSet<Group, "members">)[]
|
||||||
viewers: (User | SubjectSet<Group, "members">)[]
|
viewers: (Identity | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
|
|||||||
@@ -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("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
assert.deepEqual(roleTuple(id, "admin"), {
|
assert.deepEqual(roleTuple(id, "admin"), {
|
||||||
namespace: "Role",
|
namespace: "Role",
|
||||||
object: "admin",
|
object: "admin",
|
||||||
relation: "members",
|
relation: "members",
|
||||||
subject_id: `user:${id}`,
|
subject_id: `identity:${id}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
|||||||
assert.equal(puts.length, 2); // one grant per role
|
assert.equal(puts.length, 2); // one grant per role
|
||||||
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: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
|
||||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
||||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export function identityPayload(email: string, password: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
||||||
export function roleTuple(identityId: string, role: string) {
|
export function roleTuple(identityId: string, role: string) {
|
||||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
||||||
|
|||||||
@@ -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 User } from "../http/context.ts";
|
import { buildContext, type RequestContext, type SessionIdentity } 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: User | null, url = "/"): RequestContext {
|
function ctxFor(user: SessionIdentity | 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), { user });
|
return buildContext(req, new ServerResponse(req), { identity: user });
|
||||||
}
|
}
|
||||||
|
|
||||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["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: "user:u1" }); // subject is the signed-in user
|
assert.deepEqual(asked, { ...tuple, subject_id: "identity: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
@@ -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, User } from "../http/context.ts";
|
import type { RequestContext, SessionIdentity } 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): User {
|
export function requireSession(ctx: RequestContext): SessionIdentity {
|
||||||
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
||||||
return ctx.user;
|
return ctx.identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
// Coarse role 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.user) return false;
|
if (!ctx.identity) return false;
|
||||||
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
|
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
|
import { authenticate, claimsToIdentity, 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("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
|
||||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||||
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
|
||||||
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||||
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
||||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
|
test("resolveSession classifies the cookie; authenticate is its fail-closed identity 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 user = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
const identity = { email: "a@b.c", id: "u1", roles: ["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, user });
|
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
||||||
// 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, user: null });
|
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: 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, user: null });
|
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, identity: null });
|
||||||
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
|
assert.deepEqual(await resolveSession("other=1", 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(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, identity: null });
|
||||||
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
|
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, identity: 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 }), user);
|
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity);
|
||||||
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, user: null });
|
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: 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", roles: ["admin"] });
|
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["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
@@ -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 role gate denies.
|
// (anonymous), so the route renders signed-out and the role gate denies.
|
||||||
import type { User } from "../http/context.ts";
|
import type { SessionIdentity } 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); roles defaults to [] and
|
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
|
||||||
// keeps only string entries (defensive).
|
// keeps only string entries (defensive).
|
||||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
||||||
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 claimsToUser(payload: Record<string, unknown>): User {
|
|||||||
|
|
||||||
// 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<User> {
|
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> {
|
||||||
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 = claimsToUser(verified.payload);
|
const user = claimsToIdentity(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 roles from Keto, or a cleared session).
|
// resolveSession routes it through the re-mint (fresh roles 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
|
||||||
user: User | null;
|
identity: SessionIdentity | 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, user: null };
|
if (!token) return { expired: false, identity: null };
|
||||||
try {
|
try {
|
||||||
return { expired: false, user: await verifyToken(token, jwks, options) };
|
return { expired: false, identity: await verifyToken(token, jwks, options) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { expired: err instanceof TokenError && err.expired, user: null };
|
return { expired: err instanceof TokenError && err.expired, identity: 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<User | null> {
|
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> {
|
||||||
return (await resolveSession(cookieHeader, jwks, options)).user;
|
return (await resolveSession(cookieHeader, jwks, options)).identity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
const USER = "identity: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: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
|
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity: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 () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
|
|||||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||||
|
|
||||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
|
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
|
||||||
|
|
||||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
@@ -49,11 +49,11 @@ test("readRoles returns roles held directly OR transitively (enumerate defined r
|
|||||||
// 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: [role("editor", { subject_id: "user:other" })] };
|
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
|
||||||
return { nextPageToken: "p2", tuples: [
|
return { nextPageToken: "p2", tuples: [
|
||||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||||
role("admin", { subject_id: `user:${ID}` }),
|
role("admin", { subject_id: `identity:${ID}` }),
|
||||||
role("viewer", { subject_id: "user:stranger" }),
|
role("viewer", { subject_id: "identity: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.
|
||||||
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
|
|||||||
|
|
||||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
// TTL lapsed but the Kratos session lives → re-read roles 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.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["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.user, null);
|
assert.equal(dead.identity, null);
|
||||||
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
|
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -6,7 +6,7 @@
|
|||||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, 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 { User } from "../http/context.ts";
|
import type { SessionIdentity } 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";
|
||||||
@@ -37,13 +37,13 @@ export interface CompletedLogin {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
|
||||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
||||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
// namespace) and asks Keto to resolve each membership, so a role 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; role count is small, so the per-role checks are cheap and run in parallel.
|
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
||||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||||
const subject_id = `user:${identityId}`;
|
const subject_id = `identity:${identityId}`;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
do {
|
do {
|
||||||
@@ -76,7 +76,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
|||||||
|
|
||||||
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
|
||||||
user: User | null;
|
identity: SessionIdentity | 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), user: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
@@ -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.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
if (ctx.identity && (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.user
|
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity
|
||||||
// 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.user?.id ?? "" });
|
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" });
|
||||||
return { redirect: flow?.logoutUrl ?? "/login" };
|
return { redirect: flow?.logoutUrl ?? "/login" };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -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.user }, view: "board" }),
|
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, 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.user
|
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity
|
||||||
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.user/roles, which the gate reads.
|
// JWT middleware: a verified session cookie populates ctx.identity/roles, 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 role-gated route; no cookie / expired token → sign in", async (t) => {
|
test("a verified session JWT authorizes a role-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, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["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: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["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.user
|
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.identity
|
||||||
// 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: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${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: `user:${ada}` }];
|
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${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=user:${grace}`);
|
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=identity:${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 === `user:${grace}`));
|
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `identity:${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=user:${grace}`)).status, 400);
|
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=eng&member=user:${grace}`)).status, 400); // already exists
|
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists
|
||||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
assert.equal((await post("/admin/groups", `name=x&member=identity:${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=user:${grace}`);
|
await post("/admin/groups/eng/members", `_csrf=${token}&member=identity:${grace}`);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
|
||||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${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 role whose only direct member is ada.
|
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
||||||
const tuples: RelationTuple[] = [
|
const tuples: RelationTuple[] = [
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
|
||||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
|
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${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/roles/new")).text(), /Create role/);
|
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
|
||||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
|
||||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
|
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role 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/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
|
||||||
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403);
|
assert.equal((await post("/admin/roles", `name=x&member=identity:${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 === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && 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/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`);
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||||
|
|
||||||
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
// Delete the role: 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 === "Role" && tp.object === "editor"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
||||||
|
|
||||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
|
||||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
assert.equal((await post("/admin/roles/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/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
|
||||||
|
|
||||||
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
||||||
|
|||||||
+14
-14
@@ -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 User } from "./context.ts";
|
import { buildContext, type RequestContext, type SessionIdentity } 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.user/roles; absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; 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.user }, view: "home" };
|
return { data: { chrome: ctx.chrome, user: ctx.identity }, 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.user) return { redirect: loginRedirect(ctx), status: 303 };
|
if (!ctx.identity) 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, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
|
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, 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.user/roles; none/invalid ⇒ anonymous.
|
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; 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 roles from Keto, re-tokenize,
|
// clients), silently re-mint it — "stay signed in": re-read roles 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: User | null = null;
|
let user: SessionIdentity | 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.user;
|
user = auth.identity;
|
||||||
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.user;
|
user = reminted.identity;
|
||||||
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, user }));
|
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: 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, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
const ctx = buildContext(req, res, { chrome, identity: 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, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||||
// 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 role gets the 403 page.
|
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
||||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.user.id });
|
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id });
|
||||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 User } from "./context.ts";
|
import { buildContext, type SessionIdentity } 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.user, null);
|
assert.equal(ctx.identity, null);
|
||||||
assert.deepEqual(ctx.roles, []);
|
assert.deepEqual(ctx.roles, []);
|
||||||
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 roles from it", () => {
|
test("buildContext threads the user and derives roles from it", () => {
|
||||||
const { req, res } = reqRes("/");
|
const { req, res } = reqRes("/");
|
||||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
||||||
const ctx = buildContext(req, res, { user });
|
const ctx = buildContext(req, res, { identity: user });
|
||||||
assert.equal(ctx.user, user);
|
assert.equal(ctx.identity, user);
|
||||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -5,11 +5,11 @@ 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 `user` (null until then). The host's single handler argument.
|
// middleware supplies `identity` (null until then). The host's single handler argument.
|
||||||
|
|
||||||
// The authenticated user, projected from verified session JWT claims:
|
// The authenticated Kratos identity, projected from verified session JWT claims:
|
||||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||||
export interface User {
|
export interface SessionIdentity {
|
||||||
email: string;
|
email: string;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
@@ -19,6 +19,8 @@ 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.
|
||||||
@@ -27,12 +29,11 @@ export interface RequestContext {
|
|||||||
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;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
roles: string[]; // identity?.roles ?? [] — 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;
|
|
||||||
// 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;
|
||||||
@@ -43,10 +44,10 @@ 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;
|
||||||
log?: Log;
|
log?: Log;
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
system?: SystemCapabilities;
|
system?: SystemCapabilities;
|
||||||
user?: User | null;
|
|
||||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,20 +63,20 @@ 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 user = options.user ?? null;
|
const identity = options.identity ?? 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,
|
||||||
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,
|
||||||
roles: user?.roles ?? [],
|
roles: identity?.roles ?? [],
|
||||||
...(options.system ? { system: options.system } : {}),
|
...(options.system ? { system: options.system } : {}),
|
||||||
url,
|
url,
|
||||||
user,
|
|
||||||
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
@@ -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 role/group/resource namespaces. Version pinning is
|
// points at, and the OPL declares the identity/role/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 role, group and a resource namespace over user subjects", () => {
|
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
|
||||||
for (const ns of ["User", "Group", "Role", "Resource"])
|
for (const ns of ["Identity", "Group", "Role", "Resource"])
|
||||||
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
||||||
// role + group are subject sets read at login → JWT roles claim (README).
|
// role + group are subject sets read at login → JWT roles claim (README).
|
||||||
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
|
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
export { definePlugin } from "./plugin.ts";
|
export { definePlugin } from "./plugin.ts";
|
||||||
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||||
export type { RequestContext, User } from "../http/context.ts";
|
export type { RequestContext, SessionIdentity } 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";
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
|||||||
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
test("a role 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],
|
||||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
identity: { email: "ada@x.io", id: "u1", roles: ["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 role holder sees the Dashboard link + plugin nav; current path opens the
|
|||||||
});
|
});
|
||||||
|
|
||||||
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], user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["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
@@ -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 { User } from "../http/context.ts";
|
import type { SessionIdentity } 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[];
|
||||||
user?: User | null;
|
identity?: SessionIdentity | 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.user ? [[DASHBOARD_NAV]] : [];
|
const fragments: NavNode[][] = opts.identity ? [[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 roles = opts.user?.roles ?? [];
|
const roles = opts.identity?.roles ?? [];
|
||||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||||
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.user),
|
user: shellUser(opts.identity),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
|
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["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
@@ -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 { User } from "../http/context.ts";
|
import type { SessionIdentity } 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[]; user?: User | null } = {}) {
|
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; identity?: SessionIdentity | 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",
|
||||||
user: opts.user ?? null,
|
identity: opts.identity ?? null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
identity: { email: "a@b.c", id: "u1", roles: ["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");
|
||||||
|
|||||||
@@ -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 { User } from "../http/context.ts";
|
import type { SessionIdentity } 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(user: User | null | undefined): ShellUser {
|
export function shellUser(identity: SessionIdentity | null | undefined): ShellUser {
|
||||||
if (!user) return { email: "", initials: "G", name: "Guest" };
|
if (!identity) return { email: "", initials: "G", name: "Guest" };
|
||||||
const local = user.email.split("@")[0] || user.email;
|
const local = identity.email.split("@")[0] || identity.email;
|
||||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
return { email: identity.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;
|
||||||
user?: User | null;
|
identity?: SessionIdentity | 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.user),
|
user: shellUser(opts.identity),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- [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. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply". Follow-up: the *threat* model turned out not to be the part that was unclear — the **authorization** model was. README gained a top-level [Users, groups & roles](README.md#users-groups--roles) section (entity table, worked graph, per-route can/cannot walkthrough, the "a per-row grant never widens a coarse gate" trap), placed before Building plugins because a manifest's `role:` gate is unreadable without it.)
|
- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply". Follow-up: the *threat* model turned out not to be the part that was unclear — the **authorization** model was. README gained a top-level [Users, groups & roles](README.md#users-groups--roles) section (entity table, worked graph, per-route can/cannot walkthrough, the "a per-row grant never widens a coarse gate" trap), placed before Building plugins because a manifest's `role:` gate is unreadable without it.)
|
||||||
- [ ] Add i18n support.
|
- [ ] Add i18n support.
|
||||||
- [ ] Decide whether the Keto `User` namespace should follow Kratos and become `Identity`. Kratos never says "user" — it is `/admin/identities`, `identity.traits`, `session.identity` — but our OPL declares `class User` with subjects `user:<kratos-identity-id>`, and the code already mixes both (`login.ts` passes `identityId`, `context.ts` exports `User`). Aligning means renaming the namespace *and* the `user:` subject prefix, which rewrites every relation tuple in Keto's Postgres — a data migration, not a code change. Raised 2026-08-03 while renaming the coarse gate to `role`; deliberately left out of that change because it is a different and much heavier class of edit.
|
- [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, roles }`, 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.)
|
||||||
- [ ] 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user