Add Keto fetch client (todo §4); createKetoClient(): check / list / expand relations + write / delete tuples
This commit is contained in:
@@ -498,6 +498,7 @@ src/static.ts Static file serving (path-traversal protection) + routePubl
|
||||
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
|
||||
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4)
|
||||
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_admin update (login role projection, §4)
|
||||
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
||||
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
|
||||
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
||||
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||
|
||||
106
src/keto-client.test.ts
Normal file
106
src/keto-client.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Keto client (§4): typed fetch wrappers over Ory Keto's read (check/list/expand) and
|
||||
// write (write/delete tuple) APIs. Guards the request contracts (URLs, ports, method,
|
||||
// query/body shape, subject_id vs subject_set) and the result mapping (allowed bool, the
|
||||
// next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards (§4).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createKetoClient, KetoError } from "./keto-client.ts";
|
||||
|
||||
const READ = "http://keto:4466";
|
||||
const WRITE = "http://keto:4467";
|
||||
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
|
||||
function res(status: number, body?: unknown): Response {
|
||||
const h = new Headers();
|
||||
if (body !== undefined) h.set("content-type", "application/json");
|
||||
return new Response(body === undefined ? null : JSON.stringify(body), { status, headers: h });
|
||||
}
|
||||
|
||||
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
|
||||
const calls: { body: string | undefined; method: string; url: string }[] = [];
|
||||
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({ body: init?.body as string | undefined, method: init?.method ?? "GET", url: String(input) });
|
||||
return handler(String(input), init);
|
||||
}) as typeof fetch;
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl: READ, writeUrl: WRITE });
|
||||
|
||||
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
||||
const allow = recorder(() => res(200, { allowed: true }));
|
||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
|
||||
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
||||
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
||||
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.
|
||||
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);
|
||||
});
|
||||
|
||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { allowed: true }));
|
||||
await keto(fetchImpl).check(
|
||||
{ namespace: "Resource", object: "doc1", relation: "view", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
|
||||
{ maxDepth: 5 },
|
||||
);
|
||||
const url = calls[0]!.url;
|
||||
assert.match(url, /subject_set\.namespace=Group&subject_set\.object=eng&subject_set\.relation=members/);
|
||||
assert.match(url, /max-depth=5/);
|
||||
});
|
||||
|
||||
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
||||
await assert.rejects(
|
||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
||||
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
||||
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
|
||||
assert.deepEqual(out.tuples, tuples);
|
||||
assert.equal(out.nextPageToken, "NEXT");
|
||||
const url = calls[0]!.url;
|
||||
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
||||
assert.match(url, /namespace=Role&object=admin&relation=members/);
|
||||
assert.match(url, /page_size=10&page_token=CUR/);
|
||||
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
||||
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
||||
assert.deepEqual(empty, { nextPageToken: null, tuples: [] });
|
||||
});
|
||||
|
||||
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
||||
const tree = { children: [{ subject_id: USER, type: "leaf" }], subject_set: { namespace: "Role", object: "admin", relation: "members" }, type: "union" };
|
||||
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
||||
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
|
||||
assert.deepEqual(out, tree);
|
||||
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
|
||||
});
|
||||
|
||||
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
||||
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
|
||||
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
||||
await keto(fetchImpl).writeTuple(tuple);
|
||||
assert.equal(calls[0]!.method, "PUT");
|
||||
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), tuple);
|
||||
await assert.rejects(
|
||||
keto((async () => res(500, "boom")) as typeof fetch).writeTuple(tuple),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 500,
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(204));
|
||||
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
|
||||
assert.equal(calls[0]!.method, "DELETE");
|
||||
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
||||
await assert.rejects(
|
||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||
);
|
||||
});
|
||||
128
src/keto-client.ts
Normal file
128
src/keto-client.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Keto client (todo §4): typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
|
||||
// `check` a permission, `listRelations`/`expand` to inspect them (read API), and
|
||||
// `writeTuple`/`deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no
|
||||
// SDK dep (AGENTS.md); `fetchImpl`-injectable like the kratos clients. read/write split
|
||||
// onto the two ports config.ts targets (ketoReadUrl 4466 / ketoWriteUrl 4467). The login
|
||||
// role projection (§4) reads roles via this; guards' live `check` (§4) calls `check`.
|
||||
|
||||
// A subject set: a relation on another object (e.g. Group:eng#members), resolved
|
||||
// transitively. The other Keto subject form is a direct `subject_id` string.
|
||||
export interface SubjectSet {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
}
|
||||
|
||||
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
|
||||
export interface RelationTuple {
|
||||
namespace: string;
|
||||
object: string;
|
||||
relation: string;
|
||||
subject_id?: string;
|
||||
subject_set?: SubjectSet;
|
||||
}
|
||||
|
||||
// Any subset of a tuple's fields filters a list query; the rest paginate.
|
||||
export type RelationQuery = Partial<RelationTuple> & { pageSize?: number; pageToken?: string };
|
||||
|
||||
export interface RelationList {
|
||||
nextPageToken: string | null; // keyset cursor for the next page; null on the last page
|
||||
tuples: RelationTuple[];
|
||||
}
|
||||
|
||||
// Keto's expand tree: a node is a set operation (union/…) or a leaf, with the resolved
|
||||
// subject(s). Shape kept loose — callers walk it as needed (§5 "effective access" view).
|
||||
export interface ExpandTree {
|
||||
children?: ExpandTree[];
|
||||
subject_id?: string;
|
||||
subject_set?: SubjectSet;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Carries the HTTP status so a caller can branch (parallels KratosError).
|
||||
export class KetoError extends Error {
|
||||
body: string;
|
||||
status: number;
|
||||
constructor(message: string, status: number, body: string) {
|
||||
super(message);
|
||||
this.body = body;
|
||||
this.name = "KetoError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface KetoClient {
|
||||
check(tuple: RelationTuple, opts?: { maxDepth?: number }): Promise<boolean>;
|
||||
deleteTuple(tuple: RelationTuple): Promise<void>;
|
||||
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
|
||||
listRelations(query?: RelationQuery): Promise<RelationList>;
|
||||
writeTuple(tuple: RelationTuple): Promise<void>;
|
||||
}
|
||||
|
||||
// namespace/object/relation + the chosen subject form → query params (Keto's read API and
|
||||
// tuple delete both filter this way; subject sets use dotted `subject_set.*` keys).
|
||||
function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (t.namespace !== undefined) p.set("namespace", t.namespace);
|
||||
if (t.object !== undefined) p.set("object", t.object);
|
||||
if (t.relation !== undefined) p.set("relation", t.relation);
|
||||
if (t.subject_id !== undefined) p.set("subject_id", t.subject_id);
|
||||
if (t.subject_set) {
|
||||
p.set("subject_set.namespace", t.subject_set.namespace);
|
||||
p.set("subject_set.object", t.subject_set.object);
|
||||
p.set("subject_set.relation", t.subject_set.relation);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
|
||||
const read = config.readUrl.replace(/\/+$/, "");
|
||||
const write = config.writeUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
const tuples = `${write}/admin/relation-tuples`;
|
||||
|
||||
async function fail(action: string, res: Response): Promise<never> {
|
||||
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
|
||||
}
|
||||
|
||||
return {
|
||||
async check(tuple, opts = {}) {
|
||||
const params = tupleParams(tuple);
|
||||
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
|
||||
const res = await http(`${read}/relation-tuples/check?${params}`);
|
||||
// Keto answers 200 {allowed:true} or 403 {allowed:false}; both carry the verdict.
|
||||
if (res.status !== 200 && res.status !== 403) return fail("check", res);
|
||||
return ((await res.json()) as { allowed?: boolean }).allowed === true;
|
||||
},
|
||||
|
||||
async deleteTuple(tuple) {
|
||||
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
|
||||
if (res.status !== 204) await fail("delete tuple", res);
|
||||
},
|
||||
|
||||
async expand(set, opts = {}) {
|
||||
const params = tupleParams(set);
|
||||
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
|
||||
const res = await http(`${read}/relation-tuples/expand?${params}`);
|
||||
if (res.status !== 200) return fail("expand", res);
|
||||
return (await res.json()) as ExpandTree;
|
||||
},
|
||||
|
||||
async listRelations(query = {}) {
|
||||
const params = tupleParams(query);
|
||||
if (query.pageSize !== undefined) params.set("page_size", String(query.pageSize));
|
||||
if (query.pageToken) params.set("page_token", query.pageToken);
|
||||
const res = await http(`${read}/relation-tuples?${params}`);
|
||||
if (res.status !== 200) return fail("list relations", res);
|
||||
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
|
||||
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
|
||||
},
|
||||
|
||||
// PUT is idempotent — re-asserting an existing tuple is a no-op grant.
|
||||
async writeTuple(tuple) {
|
||||
const res = await http(tuples, { body: JSON.stringify(tuple), headers: { "content-type": "application/json" }, method: "PUT" });
|
||||
if (!res.ok) await fail("write tuple", res);
|
||||
},
|
||||
};
|
||||
}
|
||||
2
todo.md
2
todo.md
@@ -77,7 +77,7 @@ everything via Docker.
|
||||
## 4. Auth — identity, session JWT, guards
|
||||
- [x] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. → `src/kratos-public.ts` (`createKratosPublic({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' public API, no SDK dep (built-in `fetch`), `fetchImpl`-injectable like `bootstrap.ts`. `initBrowserFlow(type, {cookie?, returnTo?})` GETs `/self-service/<type>/browser` with `Accept: json` (so Kratos returns the flow + CSRF `Set-Cookie` to relay, not a redirect); `getFlow(type, id, {cookie?})` reads `/self-service/<type>/flows?id=` forwarding the browser cookie; `submitFlow(action, {body, contentType?, cookie?})` POSTs urlencoded to the flow's `ui.action` (manual redirect) → `{ok, status, body, location, setCookie}` (200 success / 400 re-rendered flow-with-errors, no throw / 303 Location or 422 `redirect_browser_to`); `whoami({cookie?, tokenizeAs?})` reads `/sessions/whoami` → `Session|null` (401⇒null), with `?tokenize_as=plainpages` returning the session's `tokenized` JWT. Fail-loud `KratosError` carries `.status` (so §4 line 81 can re-init on an expired 404/410). Flow `ui.nodes` typed loosely — rendering/field-error mapping is §4's renderer. Tests-first (`kratos-public.test.ts`, mock fetch: URLs/JSON-accept/cookie relay/Set-Cookie/tokenize query + 410/500 errors + 400 validation + redirect targets). Building block — no route/E2E yet (the themed flow pages + login completion are the next §4 items). README **Layout** lists it. typecheck + 159 units green.
|
||||
- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})` → `{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green.
|
||||
- [ ] Keto client (fetch): `check`, list/expand relations, write/delete tuples.
|
||||
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
|
||||
- [ ] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages.
|
||||
- [ ] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only.
|
||||
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie.
|
||||
|
||||
Reference in New Issue
Block a user