feat: v2
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
KubernetesObject,
|
||||
KubernetesObjectApi,
|
||||
} from "@kubernetes/client-node";
|
||||
import { hashToken } from "../../server/auth";
|
||||
import {
|
||||
KubernetesAuthStore,
|
||||
KUBER_SYSTEM_NAMESPACE,
|
||||
} from "../../server/kubernetes-store";
|
||||
|
||||
type StoredSecret = KubernetesObject & {
|
||||
data?: Record<string, string>;
|
||||
stringData?: Record<string, string>;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
function objectName(prefix: string, value: string): string {
|
||||
const digest = createHash("sha256").update(value).digest("hex").slice(0, 48);
|
||||
return `${prefix}-${digest}`;
|
||||
}
|
||||
|
||||
function encode(value: string): string {
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
function secret(
|
||||
recordType: "user" | "session",
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
): StoredSecret {
|
||||
return {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name,
|
||||
namespace: KUBER_SYSTEM_NAMESPACE,
|
||||
labels: { "kuber.astrxl.dev/type": recordType },
|
||||
},
|
||||
type: "Opaque",
|
||||
data: Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [key, encode(value)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
class FakeObjects {
|
||||
readonly secrets = new Map<string, StoredSecret>();
|
||||
readonly patches: StoredSecret[] = [];
|
||||
readonly deleted: string[] = [];
|
||||
|
||||
async read(value: KubernetesObject): Promise<StoredSecret> {
|
||||
const found = this.secrets.get(value.metadata?.name ?? "");
|
||||
if (!found) throw { code: 404 };
|
||||
return found;
|
||||
}
|
||||
|
||||
async patch(value: StoredSecret): Promise<StoredSecret> {
|
||||
const stored: StoredSecret = {
|
||||
...value,
|
||||
data: Object.fromEntries(
|
||||
Object.entries(value.stringData ?? {}).map(([key, item]) => [
|
||||
key,
|
||||
encode(item),
|
||||
]),
|
||||
),
|
||||
};
|
||||
delete stored.stringData;
|
||||
this.patches.push(value);
|
||||
this.secrets.set(value.metadata?.name ?? "", stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
async list(
|
||||
_apiVersion: string,
|
||||
_kind: string,
|
||||
_namespace: string,
|
||||
_pretty?: string,
|
||||
_exact?: boolean,
|
||||
_export?: boolean,
|
||||
_fieldSelector?: string,
|
||||
labelSelector?: string,
|
||||
): Promise<{ items: StoredSecret[] }> {
|
||||
const type = labelSelector?.split("=")[1];
|
||||
return {
|
||||
items: [...this.secrets.values()].filter(
|
||||
(item) => item.metadata?.labels?.["kuber.astrxl.dev/type"] === type,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async delete(value: KubernetesObject): Promise<void> {
|
||||
const name = value.metadata?.name ?? "";
|
||||
if (!this.secrets.delete(name)) throw { code: 404 };
|
||||
this.deleted.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
function setup(): { fake: FakeObjects; store: KubernetesAuthStore } {
|
||||
const fake = new FakeObjects();
|
||||
return {
|
||||
fake,
|
||||
store: new KubernetesAuthStore(fake as unknown as KubernetesObjectApi),
|
||||
};
|
||||
}
|
||||
|
||||
describe("KubernetesAuthStore", () => {
|
||||
test("persists authVersion and not copied roles in new sessions", async () => {
|
||||
const { fake, store } = setup();
|
||||
await store.putUser({
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
const tokenHash = hashToken("token");
|
||||
await store.putSession({
|
||||
tokenHash,
|
||||
username: "alice",
|
||||
roles: ["admin"],
|
||||
expiresAt: "2026-09-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(fake.patches[0]?.stringData).toMatchObject({ authVersion: "1" });
|
||||
expect(fake.patches[1]?.stringData).toEqual({
|
||||
tokenHash,
|
||||
username: "alice",
|
||||
authVersion: "1",
|
||||
expiresAt: "2026-09-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await store.getSession(tokenHash)).toMatchObject({ authVersion: 1 });
|
||||
});
|
||||
|
||||
test("reads legacy records at version one and invalidates them on update", async () => {
|
||||
const { fake, store } = setup();
|
||||
const tokenHash = hashToken("legacy");
|
||||
fake.secrets.set(
|
||||
objectName("user", "alice"),
|
||||
secret("user", objectName("user", "alice"), {
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: JSON.stringify(["viewer"]),
|
||||
disabled: "false",
|
||||
}),
|
||||
);
|
||||
fake.secrets.set(
|
||||
objectName("session", tokenHash),
|
||||
secret("session", objectName("session", tokenHash), {
|
||||
tokenHash,
|
||||
username: "alice",
|
||||
roles: JSON.stringify(["admin"]),
|
||||
expiresAt: "2026-09-03T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await store.getUser("alice"))?.authVersion).toBe(1);
|
||||
expect((await store.getSession(tokenHash))?.authVersion).toBe(1);
|
||||
expect(
|
||||
(await store.updateUser("alice", { roles: ["operator"] }))?.authVersion,
|
||||
).toBe(2);
|
||||
expect(await store.getSession(tokenHash)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fails closed for malformed, mislabeled, and swapped Secret data", async () => {
|
||||
const { fake, store } = setup();
|
||||
const name = objectName("user", "alice");
|
||||
const valid = secret("user", name, {
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: JSON.stringify(["viewer"]),
|
||||
authVersion: "1",
|
||||
disabled: "false",
|
||||
});
|
||||
|
||||
fake.secrets.set(name, { ...valid, data: { ...valid.data, roles: "%%%" } });
|
||||
expect(await store.getUser("alice")).toBeUndefined();
|
||||
fake.secrets.set(name, { ...valid, type: "kubernetes.io/tls" });
|
||||
expect(await store.getUser("alice")).toBeUndefined();
|
||||
fake.secrets.set(name, {
|
||||
...valid,
|
||||
data: { ...valid.data, unexpected: encode("value") },
|
||||
});
|
||||
expect(await store.getUser("alice")).toBeUndefined();
|
||||
fake.secrets.set(
|
||||
name,
|
||||
secret("user", name, {
|
||||
username: "bob",
|
||||
passwordHash: "hash",
|
||||
roles: JSON.stringify(["viewer"]),
|
||||
authVersion: "1",
|
||||
disabled: "false",
|
||||
}),
|
||||
);
|
||||
expect(await store.getUser("alice")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("lists, creates, updates, revokes, and deletes users and sessions", async () => {
|
||||
const { fake, store } = setup();
|
||||
await store.createUser({
|
||||
username: "bob",
|
||||
passwordHash: "b",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.createUser({
|
||||
username: "alice",
|
||||
passwordHash: "a",
|
||||
roles: ["operator"],
|
||||
});
|
||||
await expect(
|
||||
store.createUser({
|
||||
username: "alice",
|
||||
passwordHash: "a",
|
||||
roles: ["viewer"],
|
||||
}),
|
||||
).rejects.toThrow("already exists");
|
||||
expect((await store.listUsers()).map((user) => user.username)).toEqual([
|
||||
"alice",
|
||||
"bob",
|
||||
]);
|
||||
expect(
|
||||
(await store.updateUser("alice", { disabled: true }))?.authVersion,
|
||||
).toBe(2);
|
||||
|
||||
const bobToken = hashToken("bob");
|
||||
await store.putSession({
|
||||
tokenHash: bobToken,
|
||||
username: "bob",
|
||||
authVersion: 1,
|
||||
expiresAt: "2026-09-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await store.deleteUser("bob")).toBe(true);
|
||||
expect(fake.secrets.has(objectName("session", bobToken))).toBe(false);
|
||||
expect(await store.deleteUser("missing")).toBe(false);
|
||||
});
|
||||
|
||||
test("lists and deletes expired sessions without a cluster", async () => {
|
||||
const { fake, store } = setup();
|
||||
await store.putUser({
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
for (const [token, expiration] of [
|
||||
["expired", "2026-09-01T00:00:00.000Z"],
|
||||
["active", "2026-09-03T00:00:00.000Z"],
|
||||
] as const) {
|
||||
await store.putSession({
|
||||
tokenHash: hashToken(token),
|
||||
username: "alice",
|
||||
authVersion: 1,
|
||||
expiresAt: expiration,
|
||||
});
|
||||
}
|
||||
const now = Date.parse("2026-09-02T00:00:00.000Z");
|
||||
expect(await store.listExpiredSessions(now)).toHaveLength(1);
|
||||
expect(await store.deleteExpiredSessions(now)).toBe(1);
|
||||
expect(fake.secrets.has(objectName("session", hashToken("active")))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user