feat: add CI deployment and live progress
This commit is contained in:
+102
-1
@@ -1,5 +1,10 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { isRole, type Role } from "./authorization";
|
||||
import {
|
||||
isCapability,
|
||||
isRole,
|
||||
type Capability,
|
||||
type Role,
|
||||
} from "./authorization";
|
||||
|
||||
export type KuberUser = {
|
||||
username: string;
|
||||
@@ -25,6 +30,18 @@ export type SessionInput =
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type ApiKeyRecord = {
|
||||
id: string;
|
||||
tokenHash: string;
|
||||
username: string;
|
||||
capabilities: Capability[];
|
||||
workspace?: string;
|
||||
expiresAt: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type NewApiKey = ApiKeyRecord;
|
||||
|
||||
export type NewKuberUser = Omit<KuberUser, "authVersion"> & {
|
||||
authVersion?: number;
|
||||
};
|
||||
@@ -49,6 +66,11 @@ export interface AuthStore {
|
||||
revokeUserSessions(username: string): Promise<number>;
|
||||
listExpiredSessions(now?: number): Promise<SessionRecord[]>;
|
||||
deleteExpiredSessions(now?: number): Promise<number>;
|
||||
getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined>;
|
||||
createApiKey(key: NewApiKey): Promise<void>;
|
||||
listApiKeys(username: string): Promise<ApiKeyRecord[]>;
|
||||
revokeApiKey(username: string, id: string): Promise<boolean>;
|
||||
deleteExpiredApiKeys(now?: number): Promise<number>;
|
||||
}
|
||||
|
||||
export function normalizeUser(user: NewKuberUser | KuberUser): KuberUser {
|
||||
@@ -85,6 +107,42 @@ export function normalizeSession(session: SessionRecord): SessionRecord {
|
||||
return { ...session };
|
||||
}
|
||||
|
||||
export function normalizeApiKey(key: NewApiKey): ApiKeyRecord {
|
||||
if (!/^[a-zA-Z0-9_-]{16,128}$/.test(key.id))
|
||||
throw new Error("API key ID is invalid");
|
||||
if (!/^[a-f0-9]{64}$/.test(key.tokenHash))
|
||||
throw new Error("API key token hash must be a SHA-256 hex digest");
|
||||
if (!key.username || key.username !== key.username.trim())
|
||||
throw new Error("API key username is required");
|
||||
if (
|
||||
!Array.isArray(key.capabilities) ||
|
||||
key.capabilities.length === 0 ||
|
||||
new Set(key.capabilities).size !== key.capabilities.length ||
|
||||
!key.capabilities.every(isCapability)
|
||||
) {
|
||||
throw new Error("API key requires unique valid capabilities");
|
||||
}
|
||||
if (
|
||||
key.workspace !== undefined &&
|
||||
(!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(key.workspace) ||
|
||||
key.workspace.length > 63)
|
||||
) {
|
||||
throw new Error("API key workspace scope is invalid");
|
||||
}
|
||||
const expiresAt = new Date(key.expiresAt);
|
||||
if (
|
||||
!Number.isFinite(expiresAt.getTime()) ||
|
||||
expiresAt.toISOString() !== key.expiresAt
|
||||
) {
|
||||
throw new Error("API key expiration must be an ISO timestamp");
|
||||
}
|
||||
return {
|
||||
...key,
|
||||
capabilities: [...key.capabilities],
|
||||
disabled: Boolean(key.disabled),
|
||||
};
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
@@ -107,6 +165,7 @@ export function tokenHashesEqual(left: string, right: string): boolean {
|
||||
export class MemoryAuthStore implements AuthStore {
|
||||
readonly users = new Map<string, KuberUser>();
|
||||
readonly sessions = new Map<string, SessionRecord>();
|
||||
readonly apiKeys = new Map<string, ApiKeyRecord>();
|
||||
|
||||
async getUser(username: string): Promise<KuberUser | undefined> {
|
||||
return this.users.get(username);
|
||||
@@ -148,6 +207,8 @@ export class MemoryAuthStore implements AuthStore {
|
||||
|
||||
async deleteUser(username: string): Promise<boolean> {
|
||||
await this.revokeUserSessions(username);
|
||||
for (const [id, key] of this.apiKeys)
|
||||
if (key.username === username) this.apiKeys.delete(id);
|
||||
return this.users.delete(username);
|
||||
}
|
||||
|
||||
@@ -201,4 +262,44 @@ export class MemoryAuthStore implements AuthStore {
|
||||
for (const session of expired) this.sessions.delete(session.tokenHash);
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
|
||||
const key = [...this.apiKeys.values()].find(
|
||||
(item) => item.tokenHash === tokenHash,
|
||||
);
|
||||
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
|
||||
const user = this.users.get(key.username);
|
||||
if (!user || user.disabled) return;
|
||||
return key;
|
||||
}
|
||||
|
||||
async createApiKey(key: NewApiKey): Promise<void> {
|
||||
const normalized = normalizeApiKey(key);
|
||||
const user = this.users.get(normalized.username);
|
||||
if (!user || user.disabled) throw new Error("API key user is not active");
|
||||
if (this.apiKeys.has(normalized.id))
|
||||
throw new Error("API key already exists");
|
||||
this.apiKeys.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
async listApiKeys(username: string): Promise<ApiKeyRecord[]> {
|
||||
return [...this.apiKeys.values()]
|
||||
.filter((key) => key.username === username)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
async revokeApiKey(username: string, id: string): Promise<boolean> {
|
||||
const key = this.apiKeys.get(id);
|
||||
if (!key || key.username !== username) return false;
|
||||
this.apiKeys.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
|
||||
const expired = [...this.apiKeys.values()].filter(
|
||||
(key) => Date.parse(key.expiresAt) <= now,
|
||||
);
|
||||
for (const key of expired) this.apiKeys.delete(key.id);
|
||||
return expired.length;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user