feat: improve API keys and build workflows

This commit is contained in:
2026-10-04 20:05:13 +00:00 Unverified
parent 62f2362a2e
commit e4623efe86
27 changed files with 2080 additions and 235 deletions
+43 -22
View File
@@ -1,6 +1,7 @@
import type { KubernetesObject } from "@kubernetes/client-node";
import { randomUUID } from "node:crypto";
import type { ComposeSpecification } from "../schema/docker.d";
import { DatabaseReconciliationError } from "../lib/database";
import type { Operation as PublicOperation } from "../shared/api";
import type { BuildRequest, Sha256Digest } from "../shared/build-protocol";
import {
@@ -87,7 +88,6 @@ const MAX_LOGIN_FAILURES = 5;
const DEFAULT_JSON_LIMIT = 1024 * 1024;
const WORKSPACE_LEASE_TTL_MS = 30_000;
const WORKSPACE_LEASE_RENEW_INTERVAL_MS = WORKSPACE_LEASE_TTL_MS / 3;
const DEFAULT_API_KEY_MS = 90 * 24 * 60 * 60 * 1000;
const MAX_API_KEY_MS = 365 * 24 * 60 * 60 * 1000;
export interface ApiWorkspaceStore extends WorkspaceStore {
@@ -248,7 +248,7 @@ export async function authenticateRequest(
if (
!apiKey ||
!tokenHashesEqual(tokenHash, apiKey.tokenHash) ||
Date.parse(apiKey.expiresAt) <= now()
(apiKey.expiresAt !== undefined && Date.parse(apiKey.expiresAt) <= now())
)
return;
const user = await options.store.getUser(apiKey.username);
@@ -725,11 +725,17 @@ export function createApp(
username: string,
capabilities: readonly Capability[],
workspace: string | undefined,
expiresAt: string | undefined,
): Promise<void> {
const parent = identity.apiKey;
if (!parent) return;
let reason: "target_user" | "capabilities" | "workspace" | undefined;
let reason:
| "target_user"
| "capabilities"
| "workspace"
| "expiry"
| undefined;
if (username !== parent.username) reason = "target_user";
else if (
!capabilities.every((capability) =>
@@ -739,6 +745,12 @@ export function createApp(
reason = "capabilities";
else if (parent.workspace !== undefined && workspace !== parent.workspace)
reason = "workspace";
else if (
parent.expiresAt !== undefined &&
(expiresAt === undefined ||
Date.parse(expiresAt) > Date.parse(parent.expiresAt))
)
reason = "expiry";
if (!reason) return;
await audit(identity, request, "api_key.create", "denied", {
@@ -746,12 +758,13 @@ export function createApp(
username,
capabilities,
...(workspace && { workspace }),
...(expiresAt && { expiresAt }),
});
throw new HttpError(
403,
"Forbidden",
"API_KEY_DELEGATION_FORBIDDEN",
"API key children must use the caller's user, capabilities, and workspace scope",
"API key children must use the caller's user, capabilities, workspace, and expiry scope",
);
}
@@ -1144,10 +1157,18 @@ export function createApp(
const failed = await transitionOperationToFailure(
operation.metadata.name,
{
code: leaseLost ? "WORKSPACE_LEASE_LOST" : "OPERATION_FAILED",
code: leaseLost
? "WORKSPACE_LEASE_LOST"
: action === "databases.reconcile" &&
error instanceof DatabaseReconciliationError
? "DATABASE_RECONCILE_FAILED"
: "OPERATION_FAILED",
message: leaseLost
? "Workspace operation lease ownership was lost"
: message,
: action === "databases.reconcile" &&
!(error instanceof DatabaseReconciliationError)
? "Database reconciliation failed"
: message,
},
);
const failure = failed.status.error;
@@ -1572,7 +1593,7 @@ export function createApp(
username: key.username,
capabilities: key.capabilities,
...(key.workspace && { workspace: key.workspace }),
expiresAt: key.expiresAt,
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
disabled: Boolean(key.disabled),
})),
});
@@ -1580,13 +1601,9 @@ export function createApp(
if (!keyId && request.method === "POST") {
await requireCapability(identity, request, "users:write");
const body = await readJson(request);
const expiresAt =
body.expiresAt === undefined
? new Date(now() + DEFAULT_API_KEY_MS).toISOString()
: typeof body.expiresAt === "string"
? body.expiresAt
: "";
const expires = new Date(expiresAt);
const expiresAt = body.expiresAt;
const expires =
typeof expiresAt === "string" ? new Date(expiresAt) : undefined;
if (
!Array.isArray(body.capabilities) ||
body.capabilities.length === 0 ||
@@ -1596,16 +1613,19 @@ export function createApp(
(typeof body.workspace !== "string" ||
!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(body.workspace) ||
body.workspace.length > 63)) ||
!Number.isFinite(expires.getTime()) ||
expires.toISOString() !== expiresAt ||
expires.getTime() <= now() ||
expires.getTime() > now() + MAX_API_KEY_MS
(expiresAt !== undefined &&
(typeof expiresAt !== "string" ||
!expires ||
!Number.isFinite(expires.getTime()) ||
expires.toISOString() !== expiresAt ||
expires.getTime() <= now() ||
expires.getTime() > now() + MAX_API_KEY_MS))
)
throw new HttpError(
400,
"Invalid API key",
"API_KEY_INVALID",
"Capabilities and an expiry no more than 365 days away are required",
"Valid capabilities and an optional expiry no more than 365 days away are required",
);
await requireApiKeyDelegation(
identity,
@@ -1615,6 +1635,7 @@ export function createApp(
typeof body.workspace === "string" && body.workspace
? body.workspace
: undefined,
expiresAt as string | undefined,
);
const token = createToken();
const key: ApiKeyRecord = {
@@ -1624,7 +1645,7 @@ export function createApp(
capabilities: body.capabilities,
...(typeof body.workspace === "string" &&
body.workspace && { workspace: body.workspace }),
expiresAt,
...(typeof expiresAt === "string" && { expiresAt }),
};
if (!(await options.store.getUser(username)))
throw new HttpError(
@@ -1639,7 +1660,7 @@ export function createApp(
keyId: key.id,
capabilities: key.capabilities,
...(key.workspace && { workspace: key.workspace }),
expiresAt: key.expiresAt,
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
});
return response(
{
@@ -1647,7 +1668,7 @@ export function createApp(
username,
capabilities: key.capabilities,
...(key.workspace && { workspace: key.workspace }),
expiresAt: key.expiresAt,
...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }),
disabled: false,
token,
},