531 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|