List grants no plugin declares with a Remove, and save a permission change as one Keto transaction
CI / full-gate (push) Successful in 3m26s
CI / full-gate (push) Successful in 3m26s
This commit is contained in:
@@ -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
@@ -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(/\/+$/, "")) };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user