List grants no plugin declares with a Remove, and save a permission change as one Keto transaction
CI / full-gate (push) Successful in 3m26s

This commit is contained in:
2026-11-08 22:04:52 +01:00
parent 1cc98017d2
commit 4d2e7b59fd
20 changed files with 318 additions and 160 deletions
+17
View File
@@ -104,3 +104,20 @@ test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 t
(e: unknown) => e instanceof KetoError && e.status === 404,
);
});
test("transactTuples PATCHes every insert and delete in one request (204 resolves; non-204 throws; empty sends nothing)", async () => {
const add = { namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER };
const drop = { namespace: "Permission", object: "b:read", relation: "granted", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).transactTuples({ delete: [drop], insert: [add] });
assert.equal(calls.length, 1);
assert.equal(calls[0]!.method, "PATCH");
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ action: "insert", relation_tuple: add }, { action: "delete", relation_tuple: drop }]);
await keto(fetchImpl).transactTuples({ delete: [], insert: [] });
assert.equal(calls.length, 1, "an empty change is not a request");
await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).transactTuples({ delete: [], insert: [add] }),
(e: unknown) => e instanceof KetoError && e.status === 400,
);
});
+36 -15
View File
@@ -1,6 +1,6 @@
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `deleteTuple`/`transactTuples` 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).
@@ -56,6 +56,8 @@ export interface KetoClient {
deleteTuple(tuple: RelationTuple): Promise<void>;
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
listRelations(query?: RelationQuery): Promise<RelationList>;
// Inserts and deletes in one Keto transaction: all of them apply or none does.
transactTuples(changes: { delete: RelationTuple[]; insert: RelationTuple[] }): Promise<void>;
writeTuple(tuple: RelationTuple): Promise<void>;
}
@@ -75,16 +77,14 @@ function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
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());
}
async function fail(action: string, res: Response): Promise<never> {
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
}
type ReadApi = Pick<KetoClient, "check" | "expand" | "listRelations">;
type WriteApi = Pick<KetoClient, "deleteTuple" | "transactTuples" | "writeTuple">;
function readApi(http: typeof fetch, read: string): ReadApi {
return {
async check(tuple, opts = {}) {
const params = tupleParams(tuple);
@@ -95,11 +95,6 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
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));
@@ -117,11 +112,37 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
},
};
}
function writeApi(http: typeof fetch, write: string): WriteApi {
const tuples = `${write}/admin/relation-tuples`;
const json = { "content-type": "application/json" };
return {
async deleteTuple(tuple) {
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
if (res.status !== 204) await fail("delete tuple", res);
},
async transactTuples(changes) {
const patches = [
...changes.insert.map((relation_tuple) => ({ action: "insert", relation_tuple })),
...changes.delete.map((relation_tuple) => ({ action: "delete", relation_tuple })),
];
if (patches.length === 0) return;
const res = await http(tuples, { body: JSON.stringify(patches), headers: json, method: "PATCH" });
if (res.status !== 204) await fail("transact tuples", res);
},
// 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" });
const res = await http(tuples, { body: JSON.stringify(tuple), headers: json, method: "PUT" });
if (!res.ok) await fail("write tuple", res);
},
};
}
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
const http = config.fetchImpl ?? fetch;
return { ...readApi(http, config.readUrl.replace(/\/+$/, "")), ...writeApi(http, config.writeUrl.replace(/\/+$/, "")) };
}
+1
View File
@@ -16,6 +16,7 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
deleteTuple: async () => {},
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
transactTuples: async () => {},
writeTuple: async () => {},
...over,
});
+54 -61
View File
@@ -16,7 +16,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
import { ecJwk, fakeKeto, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
@@ -314,31 +314,28 @@ test("admin permission grants: a save from a stale page is refused, and the page
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
});
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
// The grant changes go to Keto as one transaction, so a Keto failure applies none of them and
// there is nothing to revoke: the host's error page answers, and the stored grants are unchanged.
test("admin permission grants: a Keto failure applies none of the changes and revokes no live token", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
const keto = fakeKeto(tuples, {
writeTuple: async (tp) => {
if (++writes === 2) throw new Error("keto unreachable");
tuples.push(tp);
},
});
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 502);
const page = await res.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 500);
assert.deepEqual(tuples.map((tp) => tp.object), ["users:read"]);
assert.equal(denylist.isRevoked(ada, 0), false);
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
assert.equal(groupRes.status, 500);
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
@@ -360,54 +357,50 @@ test("admin permission grants: a save that changes nothing is not refused for a
assert.equal(denylist.isRevoked(ada, 0), false);
});
// Keto answered the writes but not the re-read: the report must still reach the admin.
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
// grantDiff never revokes a name nobody declares, so the page lists it apart and Remove is the only way out.
test("admin permission grants: a grant no plugin declares is listed with a Remove, which deletes it and revokes live tokens", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [];
let writes = 0;
let down = false;
let failureTakesKetoDown = true;
const keto = fakeKeto(tuples, {
listRelations: async (q = {}) => {
if (down) throw new Error("keto unreachable");
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
},
writeTuple: async (tp) => {
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
tuples.push(tp);
},
});
const held = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
const eng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
const tuples: RelationTuple[] = [
held("users:read", { subject_id: `user:${ada}` }), held("old:write", { subject_id: `user:${ada}` }),
held("old:write", eng), held("old:write", { subject_id: "user:someone-else" }),
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(res.status, 502);
assert.match(await res.text(), /stopped part-way/);
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
const page = await (await get(`/auth-admin/users/${ada}`)).text();
assert.match(page, /Permissions no installed plugin declares/);
assert.match(page, new RegExp(`action="/auth-admin/users/${ada}/permissions/remove#permissions-h"`));
assert.match(page, /name="remove" value="old:write"/);
assert.doesNotMatch(page, /name="remove" value="users:read"/, "a declared name has no Remove");
const readerPage = await (await get(`/auth-admin/users/${ada}`, ["users:read"])).text();
assert.match(readerPage, /old:write/);
assert.doesNotMatch(readerPage, /permissions\/remove/);
// The same on a group, whose page needs Keto for its members as well.
down = false;
writes = 0;
tuples.length = 0;
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(groupRes.status, 502);
assert.match(await groupRes.text(), /stopped part-way/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
// A declared name is the picker's to change, so a crafted Remove for one does nothing.
const declared = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=users%3Aread`);
assert.equal(declared.status, 303);
assert.equal(tuples.length, 5);
assert.equal(denylist.isRevoked(ada, 0), false);
// With Keto still answering, the group page itself reports it and shows what is stored.
failureTakesKetoDown = false;
down = false;
writes = 0;
tuples.length = 1;
const again = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/auth-admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(again.status, 502);
const page = await again.text();
assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/);
const removed = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`);
assert.equal(removed.status, 303);
assert.equal(tuples.length, 4);
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_id === `user:${ada}`));
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"), "the group's grant is untouched");
assert.equal(denylist.isRevoked(ada, 0), true);
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`)).status, 303, "a second click is harmless");
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, "remove=old%3Awrite")).status, 403, "CSRF-guarded");
const groupPage = await (await get("/auth-admin/groups/eng")).text();
assert.match(groupPage, /action="\/auth-admin\/groups\/eng\/permissions\/remove#permissions-h"/);
assert.equal((await post("/auth-admin/groups/eng/permissions/remove", `_csrf=${token}&remove=old%3Awrite`)).status, 303);
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"));
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_id === "user:someone-else"), "another subject's grant is untouched");
});
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
+13 -8
View File
@@ -57,14 +57,19 @@ export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boole
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
expand: async () => ({ type: "leaf" }),
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
...over,
});
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => {
const remove = (f: Partial<RelationTuple>): void => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); };
const add = (tp: RelationTuple): void => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); };
return {
check: async () => false,
deleteTuple: async (f) => remove(f),
expand: async () => ({ type: "leaf" }),
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
transactTuples: async (c) => { c.insert.forEach(add); c.delete.forEach(remove); },
writeTuple: async (tp) => add(tp),
...over,
};
};
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.