763b5dc582
## Summary Environment API keys backed by the additional-key table can authenticate API requests using their stored effective scopes. Revoked and expired keys are rejected, branch environments retain their existing routing behavior, and last-used timestamps are updated on a throttled best-effort basis. ## Design API route builders receive the resolved ability and reject restricted keys on routes without an authorization declaration. Existing deployment, environment variable, queue, run, task, batch, session, and waitpoint routes declare the resources they access. Trigger and batch responses return server-signed public access tokens, so additional keys never need access to the environment signing secret. Root-key rotation also keeps public tokens valid for the existing grace window. ## Feature notes - Root environment keys remain unrestricted for backward compatibility. Additional keys enforce their persisted scopes and fail closed on routes without an authorization declaration. - Machine-key requests never exchange one credential for another. Additional keys cannot retrieve the root key, and rotated root keys are not upgraded during their grace window. - Public JWT validation remains host-owned, while installed RBAC plugins continue to supply root-key abilities. - Unfiltered session and run listings preserve existing broad task-read behavior. Filtered requests enforce the supplied task identifiers. - Related-run summaries remain embedded in run retrieval for API compatibility. Retrieving or mutating a related run independently still requires permission for that run. - Queue management authorizes at collection scope, matching the queue permissions currently issued. - Batch responses deliberately include server-signed public access tokens for all clients. Selected-task credentials continue using their original credential for per-item authorization. - Two-phase batches authorize declared task identifiers before creation and authorize every streamed item. Streaming paths that cannot declare the complete task set remain fail closed. - Authentication telemetry records successful credential resolution separately from subsequent resource-authorization failures. - API keys are high-entropy random tokens. SHA-256 is intentionally used for deterministic indexed lookup, not password hashing. ## Deployment notes The schema migration must be present before this code is deployed. Because bearer resolution runs on every authenticated request, deploy the resolver with additional-key lookup disabled, verify root-key and public-token parity, then enable lookup before any additional keys can be issued. The multi-task authorization tightening changes the result for narrowly scoped tokens that request tasks outside their grants. Observe would-deny results before enforcing that check. Request-idempotency keys are also newly isolated by environment and task, so a retry crossing the deployment boundary may execute once more before old cache entries expire. ## Follow-ups - [x] Add a system-wide kill switch for additional-key lookup, defaulted off for the initial deployment. - [x] Add authentication observability by credential kind, result, latency, and lookup path without recording credential values. - [ ] ~Add would-deny observability and an independent enforcement switch for multi-task authorization.~ - [ ] ~Add an independent switch for server-issued batch tokens while root-key parity is verified.~ - [ ] Confirm every API route reachable by a restricted key has an explicit authorization declaration or intentionally fails closed. - [x] Verify root-key rotation, revoked-key grace, and public-token validation through each bearer resolver path.
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import { getMeter } from "@internal/tracing";
|
|
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
|
|
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
|
|
import type {
|
|
BearerCredentialKind,
|
|
BearerLookupPath,
|
|
HostBearerAuthResult,
|
|
RbacResource,
|
|
} from "@trigger.dev/rbac";
|
|
import { authFeatureControls } from "~/services/authFeatureControls.server";
|
|
import { rbac } from "~/services/rbac.server";
|
|
import { singleton } from "~/utils/singleton";
|
|
|
|
export type ApiAuthResult = "success" | "invalid" | "forbidden" | "disabled" | "error";
|
|
|
|
const telemetry = singleton("apiAuthTelemetry", () => {
|
|
const meter = getMeter("api-auth");
|
|
const attempts = meter.createCounter("api_auth.attempts", {
|
|
description: "Completed environment bearer authentication attempts",
|
|
});
|
|
const duration = meter.createHistogram("api_auth.duration_ms", {
|
|
description: "Environment bearer authentication duration",
|
|
unit: "ms",
|
|
});
|
|
|
|
meter
|
|
.createObservableGauge("api_auth.rollout_mode", {
|
|
description: "Active API authentication rollout modes",
|
|
})
|
|
.addCallback((result) => {
|
|
result.observe(1, {
|
|
control: "additional_key_lookup",
|
|
mode: authFeatureControls.additionalApiKeyLookupEnabled() ? "enabled" : "disabled",
|
|
});
|
|
});
|
|
|
|
return { attempts, duration };
|
|
});
|
|
|
|
export async function authenticateBearerWithTelemetry(
|
|
request: Request,
|
|
options: { allowJWT: boolean }
|
|
): Promise<HostBearerAuthResult> {
|
|
const startedAt = performance.now();
|
|
const classified = classifyCredential(request, options.allowJWT);
|
|
let final = { ...classified, result: "error" as ApiAuthResult };
|
|
|
|
try {
|
|
const result = await rbac.authenticateBearer(request, options);
|
|
// The host LazyController always attaches `resolution`; fall back to the
|
|
// format-based classification if a caller (e.g. a test double) omits it.
|
|
const resolution = result.resolution ?? classified;
|
|
final = {
|
|
credentialKind: resolution.credentialKind,
|
|
lookupPath: resolution.lookupPath,
|
|
result: result.ok
|
|
? "success"
|
|
: resolution.lookupPath === "additional_skipped"
|
|
? "disabled"
|
|
: result.status === 403
|
|
? "forbidden"
|
|
: "invalid",
|
|
};
|
|
recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result);
|
|
return result;
|
|
} catch (error) {
|
|
recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result);
|
|
throw error;
|
|
} finally {
|
|
telemetry.duration.record(performance.now() - startedAt, {
|
|
resolver: "rbac",
|
|
credential_kind: final.credentialKind,
|
|
result: final.result,
|
|
lookup_path: final.lookupPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function authenticateAuthorizeBearerWithTelemetry(
|
|
request: Request,
|
|
check: { action: string; resource: RbacResource },
|
|
options: { allowJWT: boolean }
|
|
) {
|
|
// Keep authentication telemetry consistent with apiBuilder: a valid
|
|
// credential records a successful authentication even when the subsequent
|
|
// resource authorization fails. Authorization correctness is covered by the
|
|
// route tests rather than folded into the authentication-health metric.
|
|
const result = await authenticateBearerWithTelemetry(request, options);
|
|
if (!result.ok) return result;
|
|
|
|
if (!result.ability.can(check.action, check.resource)) {
|
|
return { ok: false as const, status: 403 as const, error: "Unauthorized" };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function observeLegacyBearerAuthentication<T extends { ok: boolean } | undefined>(
|
|
request: Request,
|
|
operation: () => Promise<T>
|
|
): Promise<T> {
|
|
const startedAt = performance.now();
|
|
const classified = classifyCredential(request, true);
|
|
const lookupPath: BearerLookupPath =
|
|
classified.credentialKind === "additional_api_key" &&
|
|
!authFeatureControls.additionalApiKeyLookupEnabled()
|
|
? "additional_skipped"
|
|
: classified.lookupPath;
|
|
let result: ApiAuthResult = "error";
|
|
|
|
try {
|
|
const value = await operation();
|
|
result = value?.ok ? "success" : lookupPath === "additional_skipped" ? "disabled" : "invalid";
|
|
recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result);
|
|
return value;
|
|
} catch (error) {
|
|
recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result);
|
|
throw error;
|
|
} finally {
|
|
telemetry.duration.record(performance.now() - startedAt, {
|
|
resolver: "legacy",
|
|
credential_kind: classified.credentialKind,
|
|
result,
|
|
lookup_path: lookupPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
function recordAuthAttempt(
|
|
resolver: "rbac" | "legacy",
|
|
credentialKind: BearerCredentialKind,
|
|
lookupPath: BearerLookupPath,
|
|
result: ApiAuthResult
|
|
) {
|
|
telemetry.attempts.add(1, {
|
|
resolver,
|
|
credential_kind: credentialKind,
|
|
result,
|
|
lookup_path: lookupPath,
|
|
});
|
|
}
|
|
|
|
// Best-effort pre-classification from the raw token format. This is only used
|
|
// for the metric attributes when the resolver throws before returning a
|
|
// resolution; the resolver's own resolution is authoritative on success/failure.
|
|
// Never records the credential itself — only its bounded format class.
|
|
function classifyCredential(
|
|
request: Request,
|
|
allowJWT: boolean
|
|
): { credentialKind: BearerCredentialKind; lookupPath: BearerLookupPath } {
|
|
const token = request.headers
|
|
.get("Authorization")
|
|
?.replace(/^Bearer /, "")
|
|
.trim();
|
|
if (!token) return { credentialKind: "unknown", lookupPath: "not_found" };
|
|
if (token.startsWith("pk_")) {
|
|
return { credentialKind: "legacy_public_key", lookupPath: "legacy_public" };
|
|
}
|
|
if (allowJWT && isPublicJWT(token)) {
|
|
return { credentialKind: "public_jwt", lookupPath: "jwt_current" };
|
|
}
|
|
if (isAdditionalApiKey(token)) {
|
|
return { credentialKind: "additional_api_key", lookupPath: "additional" };
|
|
}
|
|
return token.startsWith("tr_")
|
|
? { credentialKind: "root_api_key", lookupPath: "root_current" }
|
|
: { credentialKind: "unknown", lookupPath: "not_found" };
|
|
}
|