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,
},
+16 -9
View File
@@ -36,7 +36,7 @@ export type ApiKeyRecord = {
username: string;
capabilities: Capability[];
workspace?: string;
expiresAt: string;
expiresAt?: string;
disabled?: boolean;
};
@@ -129,12 +129,14 @@ export function normalizeApiKey(key: NewApiKey): ApiKeyRecord {
) {
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");
if (key.expiresAt !== undefined) {
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,
@@ -279,7 +281,12 @@ export class MemoryAuthStore implements AuthStore {
async getApiKey(tokenHash: string): Promise<ApiKeyRecord | undefined> {
const key = this.apiKeysByTokenHash.get(tokenHash);
if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return;
if (
!key ||
key.disabled ||
(key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now())
)
return;
const user = this.users.get(key.username);
if (!user || user.disabled) return;
return key;
@@ -309,7 +316,7 @@ export class MemoryAuthStore implements AuthStore {
async deleteExpiredApiKeys(now = Date.now()): Promise<number> {
const expired = [...this.apiKeys.values()].filter(
(key) => Date.parse(key.expiresAt) <= now,
(key) => key.expiresAt !== undefined && Date.parse(key.expiresAt) <= now,
);
for (const key of expired) this.deleteApiKey(key.id);
return expired.length;
+125 -10
View File
@@ -38,7 +38,13 @@ export interface BuildCas extends MaterializeCas {
put(data: Uint8Array, expected?: Sha256Digest): Promise<Sha256Digest>;
}
export type JobPhase = "queued" | "running" | "succeeded" | "failed";
export type JobPhase =
| "queued"
| "creating"
| "starting"
| "running"
| "succeeded"
| "failed";
export interface BuildJobObservation {
phase: JobPhase;
@@ -187,7 +193,41 @@ function validateRequest(request: BuildRequest): void {
throw new BuildValidationError("Build arguments must be strings");
}
assertSha256Digest(request.spec.workspace);
if (
[
request.spec.image,
...(Array.isArray(request.destinations)
? request.destinations.map((destination) => destination?.image)
: []),
].some((image) => typeof image === "string" && /[,"\r\n]/.test(image))
)
throw new BuildValidationError(
"Build image cannot contain BuildKit output separators",
);
parseImageReference(request.spec.image);
if (request.destinations !== undefined) {
if (!Array.isArray(request.destinations))
throw new BuildValidationError("Build destinations must be an array");
const services = new Set([request.service]);
const images = new Set([request.spec.image]);
for (const destination of request.destinations) {
if (
!destination ||
typeof destination.service !== "string" ||
!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(destination.service) ||
destination.service.length > 63 ||
services.has(destination.service) ||
typeof destination.image !== "string" ||
images.has(destination.image)
)
throw new BuildValidationError(
"Build destinations must have unique services and images",
);
parseImageReference(destination.image);
services.add(destination.service);
images.add(destination.image);
}
}
}
function recordStatus(record: BuildRecord): BuildStatus {
@@ -195,6 +235,7 @@ function recordStatus(record: BuildRecord): BuildStatus {
version,
id,
state,
phase,
createdAt,
startedAt,
finishedAt,
@@ -205,6 +246,7 @@ function recordStatus(record: BuildRecord): BuildStatus {
version,
id,
state,
...(phase && { phase }),
createdAt,
...(startedAt && { startedAt }),
...(finishedAt && { finishedAt }),
@@ -381,8 +423,32 @@ export class BuildController {
async submitBuild(request: BuildRequest): Promise<BuildStatus> {
request = clone(request);
if (
request.destinations !== undefined &&
!Array.isArray(request.destinations)
)
throw new BuildValidationError("Build destinations must be an array");
if (
request.destinations?.some(
(destination) =>
!destination ||
typeof destination.service !== "string" ||
typeof destination.image !== "string",
)
)
throw new BuildValidationError("Invalid build destination");
if (this.options.imageName)
request.spec.image = this.options.imageName(request);
if (this.options.imageName && request.destinations)
request.destinations = request.destinations.map((destination) => ({
service: destination.service,
image: this.options.imageName!({
...request,
service: destination.service,
spec: { ...request.spec, image: destination.image },
destinations: undefined,
}),
}));
validateRequest(request);
const imageKey = `${request.project}\0${request.service}\0${request.spec.image}`;
const previous =
@@ -417,6 +483,7 @@ export class BuildController {
version: BUILD_PROTOCOL_VERSION,
id: request.id,
state: "queued",
phase: "queued",
createdAt,
};
const record: BuildRecord = {
@@ -492,6 +559,16 @@ export class BuildController {
const pushImage = this.options.pushImage
? this.options.pushImage(request)
: request.spec.image;
const pushImages = request.destinations?.map((destination) =>
this.options.pushImage
? this.options.pushImage({
...request,
service: destination.service,
spec: { ...request.spec, image: destination.image },
destinations: undefined,
})
: destination.image,
);
const job = createBuildJob({
name: jobName,
namespace: this.options.namespace,
@@ -503,6 +580,7 @@ export class BuildController {
),
cacheImage,
pushImage,
pushImages,
pushRegistryInsecure: this.options.pushRegistryInsecure,
cacheRegistryInsecure:
this.options.cacheRegistryInsecure ??
@@ -683,14 +761,23 @@ export class BuildController {
record.spec.jobName,
);
if (!observation) return recordStatus(record);
if (observation.phase === "running" && record.status.state === "queued") {
if (
(observation.phase === "queued" ||
observation.phase === "creating" ||
observation.phase === "starting" ||
observation.phase === "running") &&
(record.status.state === "queued" || observation.phase === "running")
) {
await requireLeaseOwnership();
record = await this.setState(
record,
"running",
{
startedAt: observation.startedAt ?? this.now().toISOString(),
},
observation.phase === "running" ? "running" : "queued",
observation.phase === "running"
? {
phase: "running",
startedAt: observation.startedAt ?? this.now().toISOString(),
}
: { phase: observation.phase },
reconcileLease,
);
} else if (observation.phase === "failed") {
@@ -702,6 +789,7 @@ export class BuildController {
startedAt: record.status.startedAt ?? observation.startedAt,
finishedAt: observation.finishedAt ?? this.now().toISOString(),
error: observation.error ?? "BuildKit Job failed",
phase: "done",
},
reconcileLease,
);
@@ -710,6 +798,14 @@ export class BuildController {
try {
digest = await this.digestResolver(record.spec.request.spec.image);
assertSha256Digest(digest);
for (const destination of record.spec.request.destinations ?? []) {
const aliasDigest = await this.digestResolver(destination.image);
assertSha256Digest(aliasDigest);
if (aliasDigest !== digest)
throw new Error(
`Pushed image ${destination.image} has a different digest`,
);
}
} catch (error) {
await requireLeaseOwnership();
record = await this.setState(
@@ -718,6 +814,7 @@ export class BuildController {
{
finishedAt: this.now().toISOString(),
error: `Unable to resolve pushed image digest: ${error instanceof Error ? error.message : String(error)}`,
phase: "done",
},
reconcileLease,
);
@@ -731,6 +828,7 @@ export class BuildController {
startedAt: record.status.startedAt ?? observation.startedAt,
finishedAt: observation.finishedAt ?? this.now().toISOString(),
digest,
phase: "done",
},
reconcileLease,
);
@@ -783,6 +881,7 @@ export class BuildController {
const next = await this.setState(record, "failed", {
finishedAt: this.now().toISOString(),
error: "Build cancelled",
phase: "done",
cancelled: true,
});
return recordStatus(next);
@@ -799,9 +898,12 @@ export class BuildController {
});
}
async getBuildResult(
id: string,
): Promise<{ image: string; digest: Sha256Digest; reference: string }> {
async getBuildResult(id: string): Promise<{
image: string;
digest: Sha256Digest;
reference: string;
references?: Record<string, string>;
}> {
const record = await this.requireBuild(id);
if (record.status.state !== "succeeded" || !record.status.digest)
throw new BuildConflictError("Build has no immutable image result");
@@ -811,6 +913,17 @@ export class BuildController {
image,
digest: record.status.digest,
reference: `${image}@${record.status.digest}`,
...(record.spec.request.destinations?.length && {
references: Object.fromEntries(
record.spec.request.destinations.map((destination) => {
const alias = parseImageReference(destination.image);
return [
destination.service,
`${alias.registry}/${alias.repository}@${record.status.digest}`,
];
}),
),
}),
};
}
@@ -857,7 +970,8 @@ export class BuildController {
values: Partial<BuildRecord["status"]>,
reconcileLease?: BuildReconciliationLease,
): Promise<BuildRecord> {
if (record.status.state === state && state === "running") return record;
if (record.status.state === state && record.status.phase === values.phase)
return record;
return this.updateBuild(
record,
(next) => {
@@ -878,6 +992,7 @@ export class BuildController {
await this.setState(current, "failed", {
finishedAt: this.now().toISOString(),
error: message,
phase: "done",
});
}
+5 -1
View File
@@ -8,6 +8,7 @@ export type BuildJobOptions = {
workspaceSubPath?: string;
cacheImage: string;
pushImage?: string;
pushImages?: string[];
pushRegistryInsecure?: boolean;
cacheRegistryInsecure?: boolean;
buildkitImage?: string;
@@ -72,6 +73,9 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob {
? `${workspace}/${dockerfilePath}`
: `${context}/Dockerfile`;
const outputImage = options.pushImage ?? options.spec.image;
const outputImages = [outputImage, ...(options.pushImages ?? [])];
if (outputImages.some((image) => !image || /[,"\r\n]/.test(image)))
throw new Error("Invalid BuildKit output image name");
const importCacheInsecure = options.cacheRegistryInsecure
? ",registry.insecure=true"
: "";
@@ -92,7 +96,7 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob {
...options.spec.buildArgs.map((arg) => `--opt=build-arg:${arg}`),
`--import-cache=type=registry,ref=${options.cacheImage}${importCacheInsecure}`,
`--export-cache=type=registry,ref=${options.cacheImage},mode=max${exportCacheInsecure}`,
`--output=type=image,name=${outputImage},push=true${outputInsecure}`,
`--output=type=image,${outputImages.length === 1 ? `name=${outputImage}` : `"name=${outputImages.join(",")}"`},push=true${outputInsecure}`,
];
const labels = {
"app.kubernetes.io/name": "kuber-buildkit",
+32 -8
View File
@@ -887,18 +887,42 @@ export class KubernetesBuildOperations implements BuildKubernetesOperations {
(condition) =>
condition.type === "Complete" && condition.status === "True",
);
const phase = failed
? "failed"
: complete
? "succeeded"
: (job.status?.active ?? 0) > 0
let phase: BuildJobObservation["phase"] = "queued";
let podError: string | undefined;
let containerStartedAt: string | undefined;
if (!failed && !complete) {
const pods = await this.core.listNamespacedPod({
namespace,
labelSelector: `job-name=${name}`,
});
const pod = pods.items
.sort(
(a, b) =>
(a.metadata?.creationTimestamp?.getTime() ?? 0) -
(b.metadata?.creationTimestamp?.getTime() ?? 0),
)
.at(-1);
const container = pod?.status?.containerStatuses?.find(
(entry) => entry.name === "buildkit",
);
phase =
pod?.status?.phase === "Running" && container?.state?.running
? "running"
: "queued";
: !pod || !pod.spec?.nodeName
? "creating"
: "starting";
podError =
container?.state?.terminated?.message ??
container?.state?.waiting?.message;
containerStartedAt = container?.state?.running?.startedAt?.toISOString();
} else phase = failed ? "failed" : "succeeded";
return {
phase,
startedAt: job.status?.startTime?.toISOString(),
startedAt: phase === "running" ? containerStartedAt : undefined,
finishedAt: job.status?.completionTime?.toISOString(),
...(failed?.message && { error: failed.message }),
...((failed?.message || podError) && {
error: failed?.message ?? podError,
}),
};
}
+24 -8
View File
@@ -191,15 +191,22 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
"username",
"capabilities",
"workspace",
"expiresAt",
"disabled",
];
if (!secret.data || !hasOnlyKeys(secret.data, keys)) return;
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 = decode(secret.data.workspace);
const workspace =
secret.data.workspace === "" ? "" : decode(secret.data.workspace);
const expiresAt = decode(secret.data.expiresAt);
const disabled = decode(secret.data.disabled);
if (
@@ -207,7 +214,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
!tokenHash ||
!username ||
!capabilities ||
!expiresAt ||
(secret.data.expiresAt !== undefined && !expiresAt) ||
(workspace !== "" && workspace === undefined) ||
(disabled !== "true" && disabled !== "false") ||
secret.metadata?.name !== objectName("api-key", tokenHash)
@@ -220,7 +227,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined {
username,
capabilities,
...(workspace && { workspace }),
expiresAt,
...(expiresAt !== undefined && { expiresAt }),
disabled: disabled === "true",
});
} catch {
@@ -459,7 +466,12 @@ export class KubernetesAuthStore implements AuthStore {
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;
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;
@@ -478,7 +490,9 @@ export class KubernetesAuthStore implements AuthStore {
username: normalized.username,
capabilities: JSON.stringify(normalized.capabilities),
workspace: normalized.workspace ?? "",
expiresAt: normalized.expiresAt,
...(normalized.expiresAt !== undefined && {
expiresAt: normalized.expiresAt,
}),
disabled: String(Boolean(normalized.disabled)),
},
);
@@ -505,7 +519,9 @@ export class KubernetesAuthStore implements AuthStore {
.map(parseApiKey)
.filter(
(key): key is ApiKeyRecord =>
key !== undefined && Date.parse(key.expiresAt) <= now,
key !== undefined &&
key.expiresAt !== undefined &&
Date.parse(key.expiresAt) <= now,
);
for (const key of expired)
await this.deleteSecret(objectName("api-key", key.tokenHash));
+3 -1
View File
@@ -237,7 +237,9 @@ export function sanitizeOperationError(
): OperationError {
const message =
action === "databases.reconcile"
? "Database reconciliation failed"
? error.code === "DATABASE_RECONCILE_FAILED"
? redactString(error.message)
: "Database reconciliation failed"
: action === "storage.reconcile"
? "Storage reconciliation failed"
: redactString(error.message);
+12 -13
View File
@@ -43,7 +43,9 @@ export type ParsedImageReference = {
function validateRepository(repository: string, image: string): void {
if (
!repository ||
repository.split("/").some((part) => !part || part === "." || part === "..")
repository
.split("/")
.some((part) => !/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/.test(part))
)
throw new Error(`Invalid image reference: ${image}`);
}
@@ -63,29 +65,26 @@ export function parseImageReference(image: string): ParsedImageReference {
throw new Error(`Invalid image reference: ${image}`);
const at = repositoryAndReference.lastIndexOf("@");
let digest: Sha256Digest | undefined;
if (at !== -1) {
const value = repositoryAndReference.slice(at + 1);
assertSha256Digest(value);
digest = value;
repositoryAndReference = repositoryAndReference.slice(0, at);
validateRepository(repositoryAndReference, image);
return {
registry,
repository: repositoryAndReference,
reference: value,
digest: value,
};
}
const lastSlash = repositoryAndReference.lastIndexOf("/");
const colon = repositoryAndReference.lastIndexOf(":");
const reference =
colon > lastSlash ? repositoryAndReference.slice(colon + 1) : "latest";
const tag =
colon > lastSlash ? repositoryAndReference.slice(colon + 1) : undefined;
const repository =
colon > lastSlash
tag !== undefined
? repositoryAndReference.slice(0, colon)
: repositoryAndReference;
validateRepository(repository, image);
if (!reference) throw new Error(`Invalid image reference: ${image}`);
return { registry, repository, reference };
if (tag !== undefined && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
throw new Error(`Invalid image reference: ${image}`);
if (digest) return { registry, repository, reference: digest, digest };
return { registry, repository, reference: tag ?? "latest" };
}
function bearerParameters(