feat: add CI deployment and live progress
This commit is contained in:
+138
-5
@@ -9,6 +9,8 @@ import { createKubernetesHttpLibrary } from "../lib/k8s-http";
|
||||
import {
|
||||
normalizeSession,
|
||||
normalizeUser,
|
||||
normalizeApiKey,
|
||||
type ApiKeyRecord,
|
||||
type AuthStore,
|
||||
type KuberUser,
|
||||
type NewKuberUser,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
type SessionRecord,
|
||||
type UserUpdate,
|
||||
} from "./auth";
|
||||
import { isRole } from "./authorization";
|
||||
import { isCapability, isRole, type Capability } from "./authorization";
|
||||
|
||||
const FIELD_MANAGER = "kuber-server";
|
||||
export const KUBER_SYSTEM_NAMESPACE = "kuber-system";
|
||||
@@ -81,7 +83,10 @@ function parseRoles(value: unknown): KuberUser["roles"] | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function isSecret(secret: SecretObject, type: "user" | "session"): boolean {
|
||||
function isSecret(
|
||||
secret: SecretObject,
|
||||
type: "user" | "session" | "api-key",
|
||||
): boolean {
|
||||
return (
|
||||
secret.apiVersion === "v1" &&
|
||||
secret.kind === "Secret" &&
|
||||
@@ -94,6 +99,24 @@ function isSecret(secret: SecretObject, type: "user" | "session"): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -160,6 +183,51 @@ function parseSession(secret: SecretObject): SessionRecord | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
|
||||
if (!isSecret(secret, "api-key")) return;
|
||||
const keys = [
|
||||
"id",
|
||||
"tokenHash",
|
||||
"username",
|
||||
"capabilities",
|
||||
"workspace",
|
||||
"expiresAt",
|
||||
"disabled",
|
||||
];
|
||||
if (!secret.data || !hasOnlyKeys(secret.data, keys)) 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 = decode(secret.data.workspace);
|
||||
const expiresAt = decode(secret.data.expiresAt);
|
||||
const disabled = decode(secret.data.disabled);
|
||||
if (
|
||||
!id ||
|
||||
!tokenHash ||
|
||||
!username ||
|
||||
!capabilities ||
|
||||
!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,
|
||||
disabled: disabled === "true",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error && typeof error === "object" && "code" in error && error.code === 404,
|
||||
@@ -172,7 +240,10 @@ function createObjectApi(): KubernetesObjectApi {
|
||||
else config.loadFromDefault();
|
||||
|
||||
const makeApiClient = config.makeApiClient.bind(config);
|
||||
const httpLibrary = createKubernetesHttpLibrary();
|
||||
const httpLibrary = createKubernetesHttpLibrary({
|
||||
maxConcurrent: 4,
|
||||
minIntervalMs: 0,
|
||||
});
|
||||
config.makeApiClient = ((apiClientType) => {
|
||||
const client = makeApiClient(apiClientType) as unknown as {
|
||||
api?: { configuration?: { httpApi?: typeof httpLibrary } };
|
||||
@@ -205,7 +276,7 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
|
||||
private async applySecret(
|
||||
name: string,
|
||||
type: "user" | "session",
|
||||
type: "user" | "session" | "api-key",
|
||||
stringData: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await this.objects.patch(
|
||||
@@ -228,7 +299,9 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
);
|
||||
}
|
||||
|
||||
private async listSecrets(type: "user" | "session"): Promise<SecretObject[]> {
|
||||
private async listSecrets(
|
||||
type: "user" | "session" | "api-key",
|
||||
): Promise<SecretObject[]> {
|
||||
const result = await this.objects.list(
|
||||
"v1",
|
||||
"Secret",
|
||||
@@ -309,6 +382,8 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -378,4 +453,62 @@ export class KubernetesAuthStore implements AuthStore {
|
||||
}
|
||||
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 || 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 ?? "",
|
||||
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 && Date.parse(key.expiresAt) <= now,
|
||||
);
|
||||
for (const key of expired)
|
||||
await this.deleteSecret(objectName("api-key", key.tokenHash));
|
||||
return expired.length;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user