Files
kuber/server/kubernetes-store.ts
T

531 lines
16 KiB
TypeScript

import {
KubeConfig,
KubernetesObjectApi,
PatchStrategy,
type KubernetesObject,
} from "@kubernetes/client-node";
import { createHash } from "node:crypto";
import { createKubernetesHttpLibrary } from "../lib/k8s-http";
import {
normalizeSession,
normalizeUser,
normalizeApiKey,
type ApiKeyRecord,
type AuthStore,
type KuberUser,
type NewKuberUser,
type SessionInput,
type SessionRecord,
type UserUpdate,
} from "./auth";
import { isCapability, isRole, type Capability } from "./authorization";
const FIELD_MANAGER = "kuber-server";
export const KUBER_SYSTEM_NAMESPACE = "kuber-system";
type SecretObject = KubernetesObject & {
data?: 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 decode(value: unknown): string | undefined {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
value,
)
) {
return;
}
const decoded = Buffer.from(value, "base64");
if (decoded.toString("base64") !== value) return;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(decoded);
} catch {
return;
}
}
function hasOnlyKeys(
data: Record<string, string>,
keys: readonly string[],
): boolean {
const actual = Object.keys(data).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function parseRoles(value: unknown): KuberUser["roles"] | undefined {
const decoded = decode(value);
if (!decoded) return;
try {
const roles: unknown = JSON.parse(decoded);
if (
!Array.isArray(roles) ||
roles.length === 0 ||
new Set(roles).size !== roles.length ||
!roles.every(isRole)
)
return;
return roles;
} catch {
return;
}
}
function isSecret(
secret: SecretObject,
type: "user" | "session" | "api-key",
): boolean {
return (
secret.apiVersion === "v1" &&
secret.kind === "Secret" &&
secret.type === "Opaque" &&
secret.metadata?.namespace === KUBER_SYSTEM_NAMESPACE &&
secret.metadata.labels?.["kuber.astrxl.dev/type"] === type &&
Boolean(secret.data) &&
typeof secret.data === "object" &&
!Array.isArray(secret.data)
);
}
function parseCapabilities(value: unknown): Capability[] | undefined {
const decoded = decode(value);
if (!decoded) return;
try {
const capabilities: unknown = JSON.parse(decoded);
if (
!Array.isArray(capabilities) ||
capabilities.length === 0 ||
new Set(capabilities).size !== capabilities.length ||
!capabilities.every(isCapability)
)
return;
return capabilities;
} catch {
return;
}
}
function parseUser(
secret: SecretObject,
expectedUsername?: string,
): KuberUser | undefined {
if (!isSecret(secret, "user")) return;
const userKeys =
secret.data?.authVersion === undefined
? ["username", "passwordHash", "roles", "disabled"]
: ["username", "passwordHash", "roles", "authVersion", "disabled"];
if (!secret.data || !hasOnlyKeys(secret.data, userKeys)) return;
const username = decode(secret.data?.username);
const passwordHash = decode(secret.data?.passwordHash);
const roles = parseRoles(secret.data?.roles);
const disabled = decode(secret.data?.disabled);
const encodedAuthVersion = secret.data?.authVersion;
const authVersion =
encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion));
if (
!username ||
username !== username.trim() ||
(expectedUsername !== undefined && username !== expectedUsername) ||
secret.metadata?.name !== objectName("user", username) ||
!passwordHash ||
!roles ||
(disabled !== "true" && disabled !== "false") ||
!Number.isSafeInteger(authVersion) ||
authVersion < 1
)
return;
return {
username,
passwordHash,
roles,
disabled: disabled === "true",
authVersion,
};
}
function parseSession(secret: SecretObject): SessionRecord | undefined {
if (!isSecret(secret, "session")) return;
const sessionKeys =
secret.data?.authVersion === undefined
? ["tokenHash", "username", "roles", "expiresAt"]
: ["tokenHash", "username", "authVersion", "expiresAt"];
if (!secret.data || !hasOnlyKeys(secret.data, sessionKeys)) return;
const tokenHash = decode(secret.data?.tokenHash);
const username = decode(secret.data?.username);
const expiresAt = decode(secret.data?.expiresAt);
const encodedAuthVersion = secret.data?.authVersion;
const authVersion =
encodedAuthVersion === undefined ? 1 : Number(decode(encodedAuthVersion));
if (
!tokenHash ||
!username ||
!expiresAt ||
secret.metadata?.name !== objectName("session", tokenHash) ||
(encodedAuthVersion === undefined && !parseRoles(secret.data?.roles))
)
return;
try {
return normalizeSession({ tokenHash, username, authVersion, expiresAt });
} catch {
return;
}
}
function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
if (!isSecret(secret, "api-key")) return;
const keys = [
"id",
"tokenHash",
"username",
"capabilities",
"workspace",
"disabled",
];
if (
!secret.data ||
!hasOnlyKeys(
secret.data,
secret.data.expiresAt === undefined ? keys : [...keys, "expiresAt"],
)
)
return;
const id = decode(secret.data.id);
const tokenHash = decode(secret.data.tokenHash);
const username = decode(secret.data.username);
const capabilities = parseCapabilities(secret.data.capabilities);
const workspace =
secret.data.workspace === "" ? "" : decode(secret.data.workspace);
const expiresAt = decode(secret.data.expiresAt);
const disabled = decode(secret.data.disabled);
if (
!id ||
!tokenHash ||
!username ||
!capabilities ||
(secret.data.expiresAt !== undefined && !expiresAt) ||
(workspace !== "" && workspace === undefined) ||
(disabled !== "true" && disabled !== "false") ||
secret.metadata?.name !== objectName("api-key", tokenHash)
)
return;
try {
return normalizeApiKey({
id,
tokenHash,
username,
capabilities,
...(workspace && { workspace }),
...(expiresAt !== undefined && { expiresAt }),
disabled: disabled === "true",
});
} catch {
return;
}
}
function isNotFound(error: unknown): boolean {
return Boolean(
error && typeof error === "object" && "code" in error && error.code === 404,
);
}
function createObjectApi(): KubernetesObjectApi {
const config = new KubeConfig();
if (process.env.KUBERNETES_SERVICE_HOST) config.loadFromCluster();
else config.loadFromDefault();
const makeApiClient = config.makeApiClient.bind(config);
const httpLibrary = createKubernetesHttpLibrary({
maxConcurrent: 4,
minIntervalMs: 0,
});
config.makeApiClient = ((apiClientType) => {
const client = makeApiClient(apiClientType) as unknown as {
api?: { configuration?: { httpApi?: typeof httpLibrary } };
configuration?: { httpApi?: typeof httpLibrary };
};
if (client.api?.configuration)
client.api.configuration.httpApi = httpLibrary;
if (client.configuration) client.configuration.httpApi = httpLibrary;
return client;
}) as typeof config.makeApiClient;
return KubernetesObjectApi.makeApiClient(config);
}
export class KubernetesAuthStore implements AuthStore {
constructor(private readonly objects = createObjectApi()) {}
private async readSecret(name: string): Promise<SecretObject | undefined> {
try {
return (await this.objects.read({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE },
})) as SecretObject;
} catch (error) {
if (isNotFound(error)) return;
throw error;
}
}
private async applySecret(
name: string,
type: "user" | "session" | "api-key",
stringData: Record<string, string>,
): Promise<void> {
await this.objects.patch(
{
apiVersion: "v1",
kind: "Secret",
metadata: {
name,
namespace: KUBER_SYSTEM_NAMESPACE,
labels: { "kuber.astrxl.dev/type": type },
},
type: "Opaque",
stringData,
} as KubernetesObject,
undefined,
undefined,
FIELD_MANAGER,
true,
PatchStrategy.ServerSideApply,
);
}
private async listSecrets(
type: "user" | "session" | "api-key",
): Promise<SecretObject[]> {
const result = await this.objects.list(
"v1",
"Secret",
KUBER_SYSTEM_NAMESPACE,
undefined,
undefined,
undefined,
undefined,
`kuber.astrxl.dev/type=${type}`,
);
return result.items.map((item) => ({
...item,
apiVersion: item.apiVersion ?? "v1",
kind: item.kind ?? "Secret",
})) as SecretObject[];
}
private async deleteSecret(name: string): Promise<boolean> {
try {
await this.objects.delete({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace: KUBER_SYSTEM_NAMESPACE },
});
return true;
} catch (error) {
if (isNotFound(error)) return false;
throw error;
}
}
async getUser(username: string): Promise<KuberUser | undefined> {
const secret = await this.readSecret(objectName("user", username));
return secret ? parseUser(secret, username) : undefined;
}
async listUsers(): Promise<KuberUser[]> {
return (await this.listSecrets("user"))
.map((secret) => parseUser(secret))
.filter((user): user is KuberUser => Boolean(user))
.sort((a, b) => a.username.localeCompare(b.username));
}
async putUser(user: NewKuberUser | KuberUser): Promise<void> {
const normalized = normalizeUser(user);
await this.applySecret(objectName("user", normalized.username), "user", {
username: normalized.username,
passwordHash: normalized.passwordHash,
roles: JSON.stringify(normalized.roles),
authVersion: String(normalized.authVersion),
disabled: String(Boolean(normalized.disabled)),
});
}
async createUser(user: NewKuberUser): Promise<KuberUser> {
if (await this.getUser(user.username))
throw new Error("User already exists");
const normalized = normalizeUser(user);
await this.putUser(normalized);
return normalized;
}
async updateUser(
username: string,
update: UserUpdate,
): Promise<KuberUser | undefined> {
const existing = await this.getUser(username);
if (!existing) return;
const updated = normalizeUser({
...existing,
...update,
username,
authVersion: existing.authVersion + 1,
});
await this.putUser(updated);
return updated;
}
async deleteUser(username: string): Promise<boolean> {
await this.revokeUserSessions(username);
for (const key of await this.listApiKeys(username))
await this.deleteSecret(objectName("api-key", key.tokenHash));
return this.deleteSecret(objectName("user", username));
}
async getSession(tokenHash: string): Promise<SessionRecord | undefined> {
const secret = await this.readSecret(objectName("session", tokenHash));
if (!secret) return;
const session = parseSession(secret);
if (!session || session.tokenHash !== tokenHash) return;
const user = await this.getUser(session.username);
if (!user || user.disabled || user.authVersion !== session.authVersion)
return;
return session;
}
async putSession(session: SessionInput): Promise<void> {
const user = await this.getUser(session.username);
if (!user || user.disabled) throw new Error("Session user is not active");
const authVersion =
"authVersion" in session ? session.authVersion : user.authVersion;
if (authVersion !== user.authVersion)
throw new Error("Session auth version is stale");
const normalized = normalizeSession({
tokenHash: session.tokenHash,
username: session.username,
authVersion,
expiresAt: session.expiresAt,
});
await this.applySecret(
objectName("session", normalized.tokenHash),
"session",
{
tokenHash: normalized.tokenHash,
username: normalized.username,
authVersion: String(normalized.authVersion),
expiresAt: normalized.expiresAt,
},
);
}
async deleteSession(tokenHash: string): Promise<void> {
await this.deleteSecret(objectName("session", tokenHash));
}
async revokeUserSessions(username: string): Promise<number> {
const sessions = (await this.listSecrets("session"))
.map((secret) => parseSession(secret))
.filter(
(session): session is SessionRecord => session?.username === username,
);
for (const session of sessions) await this.deleteSession(session.tokenHash);
return sessions.length;
}
async listExpiredSessions(now = Date.now()): Promise<SessionRecord[]> {
return (await this.listSecrets("session"))
.map((secret) => parseSession(secret))
.filter(
(session): session is SessionRecord =>
session !== undefined && Date.parse(session.expiresAt) <= now,
);
}
async deleteExpiredSessions(now = Date.now()): Promise<number> {
const expired = await this.listExpiredSessions(now);
for (const session of expired) {
await this.deleteSession(session.tokenHash);
}
return expired.length;
}
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
if (!/^[a-f0-9]{64}$/.test(tokenHash)) return;
const key = (await this.listSecrets("api-key"))
.map(parseApiKey)
.find((item): item is ApiKeyRecord => item?.tokenHash === tokenHash);
if (
!key ||
key.disabled ||
(key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now())
)
return;
const user = await this.getUser(key.username);
if (!user || user.disabled) return;
return key;
}
async createApiKey(key: ApiKeyRecord): Promise<void> {
const normalized = normalizeApiKey(key);
const user = await this.getUser(normalized.username);
if (!user || user.disabled) throw new Error("API key user is not active");
await this.applySecret(
objectName("api-key", normalized.tokenHash),
"api-key",
{
id: normalized.id,
tokenHash: normalized.tokenHash,
username: normalized.username,
capabilities: JSON.stringify(normalized.capabilities),
workspace: normalized.workspace ?? "",
...(normalized.expiresAt !== undefined && {
expiresAt: normalized.expiresAt,
}),
disabled: String(Boolean(normalized.disabled)),
},
);
}
async listApiKeys(username: string): Promise<ApiKeyRecord[]> {
return (await this.listSecrets("api-key"))
.map(parseApiKey)
.filter((key): key is ApiKeyRecord => key?.username === username)
.sort((left, right) => left.id.localeCompare(right.id));
}
async revokeApiKey(username: string, id: string): Promise<boolean> {
const key = (await this.listApiKeys(username)).find(
(item) => item.id === id,
);
return key
? this.deleteSecret(objectName("api-key", key.tokenHash))
: false;
}
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
const expired = (await this.listSecrets("api-key"))
.map(parseApiKey)
.filter(
(key): key is ApiKeyRecord =>
key !== undefined &&
key.expiresAt !== undefined &&
Date.parse(key.expiresAt) <= now,
);
for (const key of expired)
await this.deleteSecret(objectName("api-key", key.tokenHash));
return expired.length;
}
}