Files
kuber/tests/server/api-keys.test.ts
T

528 lines
16 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { cleanupExpiredSessions, createApp } from "../../server/app";
import { hashToken, MemoryAuthStore } from "../../server/auth";
import { MemoryAuditStore } from "../../server/audit-store";
import { MemoryOperationStore } from "../../server/operation-store";
import type { ManagementService } from "../../server/management";
import { MemoryTrustStore } from "../../server/trust-store";
import { MemoryWorkspaceStore } from "../../server/workspace-store";
const now = Date.parse("2026-09-05T00:00:00.000Z");
function request(path: string, init: RequestInit = {}, token = "admin-token") {
const headers = new Headers(init.headers);
headers.set("authorization", `Bearer ${token}`);
return new Request(`https://kuber.astrxl.dev${path}`, { ...init, headers });
}
async function setup() {
const store = new MemoryAuthStore();
const auditStore = new MemoryAuditStore(() => new Date(now));
const operationStore = new MemoryOperationStore(() => new Date(now));
await store.putUser({
username: "admin",
passwordHash: "hash",
roles: ["admin"],
});
await store.putUser({
username: "ci",
passwordHash: "hash",
roles: ["operator"],
});
await store.putSession({
tokenHash: hashToken("admin-token"),
username: "admin",
authVersion: 1,
expiresAt: "2026-10-05T00:00:00.000Z",
});
return {
store,
auditStore,
operationStore,
app: createApp({ store, auditStore, operationStore, now: () => now }),
};
}
describe("API keys", () => {
test("creates once, lists without a token or hash, and revokes", async () => {
const { app, auditStore } = await setup();
const created = await app(
request("/api/v2/users/ci/keys", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
capabilities: ["kubernetes:read"],
workspace: "shop",
}),
}),
);
expect(created.status).toBe(201);
const key = (await created.json()) as { id: string; token: string };
expect(key.token).toHaveLength(43);
const listed = await app(request("/api/v2/users/ci/keys"));
const body = JSON.stringify(await listed.json());
expect(body).not.toContain(key.token);
expect(body).not.toContain(hashToken(key.token));
expect(JSON.stringify(await auditStore.list())).not.toContain(key.token);
expect(JSON.stringify(await auditStore.list())).not.toContain(
hashToken(key.token),
);
expect(
(
await app(
request(`/api/v2/users/ci/keys/${key.id}`, { method: "DELETE" }),
)
).status,
).toBe(204);
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
});
test("uses key capabilities rather than owner roles and invalidates disabled owners", async () => {
const { app, store } = await setup();
await store.createApiKey({
id: "key_capability_test",
tokenHash: hashToken("ci-key"),
username: "ci",
capabilities: ["kubernetes:read"],
expiresAt: "2026-10-05T00:00:00.000Z",
});
expect((await app(request("/api/v2/users", {}, "ci-key"))).status).toBe(
403,
);
expect((await app(request("/api/v2/me", {}, "ci-key"))).status).toBe(200);
await store.updateUser("ci", { disabled: true });
expect((await app(request("/api/v2/me", {}, "ci-key"))).status).toBe(401);
});
test("limits API key children to the parent's user, capabilities, and workspace", async () => {
const { app, store, auditStore } = await setup();
await store.createApiKey({
id: "key_delegation_parent",
tokenHash: hashToken("delegation-parent"),
username: "ci",
capabilities: ["users:write", "kubernetes:read"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
const create = (body: unknown) =>
app(
request(
"/api/v2/users/ci/keys",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
"delegation-parent",
),
);
const capabilities = await create({
capabilities: ["kubernetes:write"],
workspace: "shop",
});
expect(capabilities.status).toBe(403);
const capabilityError = (await capabilities.json()) as { code: string };
expect(capabilityError.code).toBe("API_KEY_DELEGATION_FORBIDDEN");
const workspace = await create({ capabilities: ["kubernetes:read"] });
expect(workspace.status).toBe(403);
const differentWorkspace = await create({
capabilities: ["kubernetes:read"],
workspace: "other",
});
expect(differentWorkspace.status).toBe(403);
const subset = await create({
capabilities: ["kubernetes:read"],
workspace: "shop",
});
expect(subset.status).toBe(201);
expect(
((await subset.json()) as { capabilities: string[] }).capabilities,
).toEqual(["kubernetes:read"]);
const otherUser = await app(
request(
"/api/v2/users/admin/keys",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
capabilities: ["kubernetes:read"],
workspace: "shop",
}),
},
"delegation-parent",
),
);
expect(otherUser.status).toBe(403);
const denied = await auditStore.list();
expect(
denied.filter(
(event) =>
event.spec.action === "api_key.create" &&
event.spec.outcome === "denied",
),
).toHaveLength(4);
expect(JSON.stringify(denied)).not.toContain("delegation-parent");
await store.createApiKey({
id: "key_unscoped_delegation",
tokenHash: hashToken("unscoped-delegation"),
username: "ci",
capabilities: ["users:write", "kubernetes:read"],
expiresAt: "2026-10-05T00:00:00.000Z",
});
const unscopedSubset = await app(
request(
"/api/v2/users/ci/keys",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
},
"unscoped-delegation",
),
);
expect(unscopedSubset.status).toBe(201);
expect(await unscopedSubset.json()).not.toHaveProperty("workspace");
});
test("rejects expired keys and expiry longer than 365 days", async () => {
const { app, store } = await setup();
await store.createApiKey({
id: "key_expiry_test_1",
tokenHash: hashToken("expired-key"),
username: "ci",
capabilities: ["kubernetes:read"],
expiresAt: "2026-09-04T00:00:00.000Z",
});
expect((await app(request("/api/v2/me", {}, "expired-key"))).status).toBe(
401,
);
expect(
(
await app(
request("/api/v2/users/ci/keys", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
capabilities: ["kubernetes:read"],
expiresAt: "2027-09-06T00:00:00.000Z",
}),
}),
)
).status,
).toBe(400);
});
test("uses the default expiry, cleans up expired keys, and does not log keys out", async () => {
const { app, store } = await setup();
const created = await app(
request("/api/v2/users/ci/keys", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
}),
);
const key = (await created.json()) as { token: string; expiresAt: string };
expect(key.expiresAt).toBe("2026-12-04T00:00:00.000Z");
expect(
(await app(request("/api/v2/logout", { method: "POST" }, key.token)))
.status,
).toBe(204);
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
expect(await cleanupExpiredSessions(store, Date.parse(key.expiresAt))).toBe(
2,
);
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
});
test("denies a workspace-scoped key outside its workspace", async () => {
const { app, store } = await setup();
await store.createApiKey({
id: "key_scope_test_1",
tokenHash: hashToken("scoped-key"),
username: "ci",
capabilities: ["kubernetes:read"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
expect(
(await app(request("/api/v2/workspaces/other", {}, "scoped-key"))).status,
).toBe(403);
});
test("allows scoped keys to apply only in their workspace and rejects deleted owners", async () => {
const { store } = await setup();
const workspaceStore = new MemoryWorkspaceStore({
uid: () => "workspace-uid",
});
for (const id of ["shop", "other"])
await workspaceStore.create({
id,
source: { uri: `oci://example/${id}`, digest: "sha256:abc" },
});
const trustStore = new MemoryTrustStore();
const fingerprint = "a".repeat(64);
await trustStore.grant("shop", fingerprint);
let applies = 0;
const app = createApp({
store,
workspaceStore,
trustStore,
operationStore: new MemoryOperationStore(() => new Date(now)),
management: {
applyResources: async () => {
applies += 1;
return [];
},
} as unknown as ManagementService,
now: () => now,
});
await store.createApiKey({
id: "key_apply_scope_1",
tokenHash: hashToken("scoped-apply-key"),
username: "ci",
capabilities: ["kubernetes:write"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
const apply = (workspace: string) =>
app(
request(
`/api/v2/workspaces/${workspace}/resources/apply`,
{
method: "POST",
headers: {
"idempotency-key": `apply-${workspace}`,
"x-kuber-trust-project": "shop",
"x-kuber-trust-fingerprint": fingerprint,
},
body: JSON.stringify({ resources: [] }),
},
"scoped-apply-key",
),
);
expect((await apply("shop")).status).toBe(200);
expect((await apply("other")).status).toBe(403);
expect(applies).toBe(1);
await store.deleteUser("ci");
expect(
(await app(request("/api/v2/me", {}, "scoped-apply-key"))).status,
).toBe(401);
});
test("limits workspace-scoped keys to their own audit records", async () => {
const { app, store, auditStore } = await setup();
await store.createApiKey({
id: "key_audit_scope_1",
tokenHash: hashToken("scoped-audit-key"),
username: "ci",
capabilities: ["users:read"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
await auditStore.append({
actor: { username: "admin" },
action: "workspace.shop",
workspaceId: "shop",
outcome: "success",
});
await auditStore.append({
actor: { username: "admin" },
action: "workspace.other",
workspaceId: "other",
outcome: "success",
});
await auditStore.append({
actor: { username: "admin" },
action: "platform.global",
outcome: "success",
});
const unfiltered = await app(
request("/api/v2/audit", {}, "scoped-audit-key"),
);
expect(unfiltered.status).toBe(200);
const audit = (await unfiltered.json()) as {
items: { spec: { action: string } }[];
};
expect(audit.items.map((event) => event.spec.action)).toEqual([
"workspace.shop",
]);
expect(
(
await app(
request("/api/v2/audit?workspaceId=other", {}, "scoped-audit-key"),
)
).status,
).toBe(403);
});
test("automatically scopes unfiltered operation lists for workspace keys", async () => {
const { app, store, operationStore } = await setup();
await operationStore.create({
workspaceId: "shop",
action: "resources.apply",
idempotencyKey: "shop-operation",
});
await operationStore.create({
workspaceId: "other",
action: "resources.apply",
idempotencyKey: "other-operation",
});
await store.createApiKey({
id: "key_operation_scope_1",
tokenHash: hashToken("scoped-operation-key"),
username: "ci",
capabilities: ["kubernetes:read"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
const unfiltered = await app(
request("/api/v2/operations", {}, "scoped-operation-key"),
);
expect(unfiltered.status).toBe(200);
expect(
(
(await unfiltered.json()) as {
items: { spec: { workspaceId: string } }[];
}
).items.map((operation) => operation.spec.workspaceId),
).toEqual(["shop"]);
expect(
(
await app(
request(
"/api/v2/operations?workspaceId=shop",
{},
"scoped-operation-key",
),
)
).status,
).toBe(200);
expect(
(
await app(
request(
"/api/v2/operations?workspaceId=other",
{},
"scoped-operation-key",
),
)
).status,
).toBe(403);
});
test("does not inherit an admin owner's platform adoption privilege", async () => {
const { store } = await setup();
const adopted: string[] = [];
const app = createApp({
store,
adoption: {
adopt: async () => ({
workspaceId: "",
workspaceUid: "",
resourcesAdopted: 0,
}),
adoptPlatform: async (workspaceUid) => {
adopted.push(workspaceUid);
return {
workspaceId: "kuber-system",
workspaceUid,
resourcesAdopted: 1,
};
},
},
now: () => now,
});
await store.createApiKey({
id: "key_platform_owner",
tokenHash: hashToken("admin-owner-key"),
username: "admin",
capabilities: ["kubernetes:write"],
workspace: "kuber-system",
expiresAt: "2026-10-05T00:00:00.000Z",
});
await store.createApiKey({
id: "key_platform_scope",
tokenHash: hashToken("wrong-scope-key"),
username: "admin",
capabilities: ["platform:adopt"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
await store.createApiKey({
id: "key_platform_allowed",
tokenHash: hashToken("platform-key"),
username: "admin",
capabilities: ["platform:adopt"],
workspace: "kuber-system",
expiresAt: "2026-10-05T00:00:00.000Z",
});
const adopt = (token: string) =>
app(
request(
"/api/v2/platform/kuber-system/adopt",
{
method: "POST",
body: JSON.stringify({ workspaceUid: "platform" }),
},
token,
),
);
expect((await adopt("admin-owner-key")).status).toBe(403);
expect((await adopt("wrong-scope-key")).status).toBe(403);
expect((await adopt("platform-key")).status).toBe(200);
expect(adopted).toEqual(["platform"]);
});
test("allows a workspace-scoped key to use matching project build routes", async () => {
const { app, store } = await setup();
await store.createApiKey({
id: "key_scope_build_1",
tokenHash: hashToken("scoped-build-key"),
username: "ci",
capabilities: ["kubernetes:write"],
workspace: "shop",
expiresAt: "2026-10-05T00:00:00.000Z",
});
const matching = await app(
request(
"/api/v2/images/resolve",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "shop", service: "web" }),
},
"scoped-build-key",
),
);
expect(matching.status).toBe(503);
expect(
(
await app(
request(
"/api/v2/images/resolve",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "other", service: "web" }),
},
"scoped-build-key",
),
)
).status,
).toBe(403);
});
});