607 lines
18 KiB
TypeScript
607 lines
18 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("2030-09-05T00:00:00.000Z");
|
|
const finiteExpiry = new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
const delegatedLaterExpiry = new Date(
|
|
now + 31 * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
|
|
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: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
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 noExpiry = await create({
|
|
capabilities: ["kubernetes:read"],
|
|
workspace: "shop",
|
|
});
|
|
expect(noExpiry.status).toBe(403);
|
|
expect((await noExpiry.json()) as { code: string }).toHaveProperty(
|
|
"code",
|
|
"API_KEY_DELEGATION_FORBIDDEN",
|
|
);
|
|
|
|
const laterExpiry = await create({
|
|
capabilities: ["kubernetes:read"],
|
|
workspace: "shop",
|
|
expiresAt: delegatedLaterExpiry,
|
|
});
|
|
expect(laterExpiry.status).toBe(403);
|
|
|
|
const subset = await create({
|
|
capabilities: ["kubernetes:read"],
|
|
workspace: "shop",
|
|
expiresAt: finiteExpiry,
|
|
});
|
|
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(6);
|
|
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: finiteExpiry,
|
|
});
|
|
const unscopedSubset = await app(
|
|
request(
|
|
"/api/v2/users/ci/keys",
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
capabilities: ["kubernetes:read"],
|
|
expiresAt: new Date(now + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
},
|
|
"unscoped-delegation",
|
|
),
|
|
);
|
|
expect(unscopedSubset.status).toBe(201);
|
|
expect(await unscopedSubset.json()).not.toHaveProperty("workspace");
|
|
});
|
|
|
|
test("allows a non-expiring parent to delegate a non-expiring child", async () => {
|
|
const { app, store } = await setup();
|
|
await store.createApiKey({
|
|
id: "key_nonexpiring_parent",
|
|
tokenHash: hashToken("nonexpiring-parent"),
|
|
username: "ci",
|
|
capabilities: ["users:write", "kubernetes:read"],
|
|
});
|
|
const created = await app(
|
|
request(
|
|
"/api/v2/users/ci/keys",
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
|
},
|
|
"nonexpiring-parent",
|
|
),
|
|
);
|
|
expect(created.status).toBe(201);
|
|
const child = (await created.json()) as { token: string };
|
|
expect(child).not.toHaveProperty("expiresAt");
|
|
expect((await app(request("/api/v2/me", {}, child.token))).status).toBe(
|
|
200,
|
|
);
|
|
});
|
|
|
|
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: new Date(now - 24 * 60 * 60 * 1000).toISOString(),
|
|
});
|
|
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: new Date(now + 366 * 24 * 60 * 60 * 1000).toISOString(),
|
|
}),
|
|
}),
|
|
)
|
|
).status,
|
|
).toBe(400);
|
|
});
|
|
|
|
test("preserves non-expiring keys while cleaning up finite keys and sessions", 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"] }),
|
|
}),
|
|
);
|
|
expect(created.status).toBe(201);
|
|
const key = (await created.json()) as { token: string };
|
|
expect(key).not.toHaveProperty("expiresAt");
|
|
const listed = await app(request("/api/v2/users/ci/keys"));
|
|
const { items } = (await listed.json()) as { items: object[] };
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).not.toHaveProperty("expiresAt");
|
|
const finite = await app(
|
|
request("/api/v2/users/ci/keys", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
capabilities: ["kubernetes:read"],
|
|
expiresAt: finiteExpiry,
|
|
}),
|
|
}),
|
|
);
|
|
expect(finite.status).toBe(201);
|
|
const finiteKey = (await finite.json()) as {
|
|
token: string;
|
|
expiresAt: string;
|
|
};
|
|
expect(finiteKey.expiresAt).toBe(finiteExpiry);
|
|
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 app(request("/api/v2/me", {}, finiteKey.token))).status).toBe(
|
|
200,
|
|
);
|
|
expect(
|
|
await cleanupExpiredSessions(store, Date.parse(finiteKey.expiresAt)),
|
|
).toBe(2);
|
|
expect((await app(request("/api/v2/me", {}, finiteKey.token))).status).toBe(
|
|
401,
|
|
);
|
|
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
|
});
|
|
|
|
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: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
|
|
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: finiteExpiry,
|
|
});
|
|
await store.createApiKey({
|
|
id: "key_platform_scope",
|
|
tokenHash: hashToken("wrong-scope-key"),
|
|
username: "admin",
|
|
capabilities: ["platform:adopt"],
|
|
workspace: "shop",
|
|
expiresAt: finiteExpiry,
|
|
});
|
|
await store.createApiKey({
|
|
id: "key_platform_allowed",
|
|
tokenHash: hashToken("platform-key"),
|
|
username: "admin",
|
|
capabilities: ["platform:adopt"],
|
|
workspace: "kuber-system",
|
|
expiresAt: finiteExpiry,
|
|
});
|
|
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: finiteExpiry,
|
|
});
|
|
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);
|
|
});
|
|
});
|