feat: improve API keys and build workflows
This commit is contained in:
+43
-22
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user