9409ddf9bc
## Summary Projects can create, inspect, expire, and revoke multiple API keys for each environment. Plaintext values are shown only at creation; stored credentials are hashed and the API keys page displays only an obfuscated suffix afterward. Self-hosted installations support full-access additional keys by default. Authorization extensions can provide additional access presets and optional task selection. Additional keys can also mint scoped public access tokens through the Trigger.dev API without receiving the environment signing key. ## Feature notes - Only admin+ can create API keys (Developer can make in Development branch). - JWT self-signing will be a server call when used with new `_ak_` keys. - JWTs with long expiry can keep working even with api key deleted (gets priveleges from api key, signed with root key) - Unfiltered session listings intentionally preserve the existing broad task-read behavior. Filtered listings enforce task-level scopes for every requested task. - Buffered runs without a task identifier are not safely authorizable, so cancel/replay requests fail closed rather than resolving an unscoped run. - Batch and waitpoint endpoints intentionally return server-minted, narrowly scoped public tokens to all callers. These tokens have bounded lifetimes and may remain valid until expiry after API-key revocation. ## Deployment notes Deploy the management UI and public-token endpoint with new key creation disabled. Enable creation for selected organizations after the authentication path and released SDK have been verified, then expand availability gradually. Revoking an API key prevents new bearer requests and new token minting. Public tokens already minted by that key remain valid until their own expiration because they are signed by the environment signing key. ## TODO - [x] Add "Created by" to the key table - [x] Document that streamed batch ingestion is non-atomic and may partially accept items before a validation or authorization error. ## Follow-ups - [x] Add an organization-level feature flag for the API key management UI and creation action. - [x] Document rollout ordering: enable additional-key lookup before enabling issuance. - [x] Add a system-wide gate that can stop new key issuance without disabling authentication for existing keys. - [x] Replace the generic SDK compatibility warning with the first published compatible version. Old SDK will mint an unusable token if given an `_ak_` key. - [x] Add public documentation covering creation, storage, expiration, revocation, SDK compatibility, and public-token lifetime behavior. - [x] Add observability for key creation, revocation, policy preparation failures, and public-token mint failures. - [ ] Exercise create, copy-once display, authenticate, mint, expire, and revoke flows end to end before broad enablement.
201 lines
9.0 KiB
TypeScript
201 lines
9.0 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const FEATURE_FLAG = {
|
|
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
|
|
taskEventRepository: "taskEventRepository",
|
|
hasQueryAccess: "hasQueryAccess",
|
|
hasLogsPageAccess: "hasLogsPageAccess",
|
|
hasAiAccess: "hasAiAccess",
|
|
hasDashboardAgentAccess: "hasDashboardAgentAccess",
|
|
hasComputeAccess: "hasComputeAccess",
|
|
hasPrivateConnections: "hasPrivateConnections",
|
|
hasSso: "hasSso",
|
|
hasThemeSwitcher: "hasThemeSwitcher",
|
|
mollifierEnabled: "mollifierEnabled",
|
|
workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled",
|
|
internalApiOriginEnabled: "internalApiOriginEnabled",
|
|
realtimeBackend: "realtimeBackend",
|
|
computeMigrationEnabled: "computeMigrationEnabled",
|
|
computeMigrationFreePercentage: "computeMigrationFreePercentage",
|
|
computeMigrationPaidPercentage: "computeMigrationPaidPercentage",
|
|
computeMigrationRequireTemplate: "computeMigrationRequireTemplate",
|
|
devBranchesEnabled: "devBranchesEnabled",
|
|
runOpsMintKind: "runOpsMintKind",
|
|
// Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts.
|
|
runOpsMintKindPrev: "runOpsMintKindPrev",
|
|
runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt",
|
|
queueMetricsUiEnabled: "queueMetricsUiEnabled",
|
|
// Per-organization rollout for creating additional environment API keys.
|
|
additionalApiKeysEnabled: "additionalApiKeysEnabled",
|
|
// System-wide kill switch for issuing additional environment API keys.
|
|
additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled",
|
|
// System-wide kill switch for additional (scoped) environment API-key lookup.
|
|
// Defaults off; enable during rollout once the new lookup path is trusted.
|
|
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
|
|
} as const;
|
|
|
|
export const FeatureFlagCatalog = {
|
|
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
|
|
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
|
|
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
|
|
// Gates the in-dashboard AI agent panel. Controllable globally and per-org
|
|
// (org wins). Defaults off via DASHBOARD_AGENT_ENABLED.
|
|
[FEATURE_FLAG.hasDashboardAgentAccess]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.hasSso]: z.coerce.boolean(),
|
|
// Gates the Interface theme setting in /account. Off by default.
|
|
[FEATURE_FLAG.hasThemeSwitcher]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(),
|
|
[FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(),
|
|
// Routes deployed runs' TRIGGER_API_URL to INTERNAL_API_ORIGIN. Per-org, with
|
|
// INTERNAL_API_ORIGIN_ENABLED as the global default (org wins). No-op unless
|
|
// INTERNAL_API_ORIGIN is set.
|
|
// Strict z.boolean(): coercion turns the string "false" into true, which
|
|
// would silently enable the wrong orgs if written as a string.
|
|
[FEATURE_FLAG.internalApiOriginEnabled]: z.boolean(),
|
|
// Which backend serves the realtime run feed. Controllable
|
|
// globally and per-org (org wins). Defaults to "electric" when unset.
|
|
// "shadow" serves Electric but diffs the native path in the background.
|
|
[FEATURE_FLAG.realtimeBackend]: z.enum(["electric", "native", "shadow"]),
|
|
// Strict z.boolean() (not z.coerce.boolean()): coercion turns the string "false"
|
|
// into true, which would silently flip this kill switch / per-org exclude the wrong
|
|
// way if written as a string via the admin PAT route. The admin toggle sends a real
|
|
// boolean, so this only rejects the dangerous stringified case.
|
|
[FEATURE_FLAG.computeMigrationEnabled]: z.boolean(),
|
|
[FEATURE_FLAG.computeMigrationFreePercentage]: z.coerce.number().int().min(0).max(100),
|
|
[FEATURE_FLAG.computeMigrationPaidPercentage]: z.coerce.number().int().min(0).max(100),
|
|
// When on, migrated orgs build their compute template in required mode at deploy
|
|
// (fails the deploy on error) instead of shadow. Strict boolean (see above).
|
|
[FEATURE_FLAG.computeMigrationRequireTemplate]: z.boolean(),
|
|
// Per-org access to development branches. Off unless enabled for the org.
|
|
[FEATURE_FLAG.devBranchesEnabled]: z.coerce.boolean(),
|
|
// Per-org run-ops-id mint cutover. Defaults to "cuid"; only honored when
|
|
// RUN_OPS_MINT_ENABLED is on AND isSplitEnabled() is true.
|
|
[FEATURE_FLAG.runOpsMintKind]: z.enum(["cuid", "runOpsId"]),
|
|
// Grace-linger stamp: the previously-effective kind and the flip timestamp, written
|
|
// by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS).
|
|
[FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]),
|
|
[FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(),
|
|
// Per-org access to the Queue Metrics dashboard UI (view only; emission is global and
|
|
// separate). Off unless enabled for the org.
|
|
[FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(),
|
|
// Strict booleans prevent a stringified "false" from silently enabling API-key
|
|
// creation or lookup. Cold/absent values resolve to the safe `false`.
|
|
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
|
|
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
|
|
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
|
|
};
|
|
|
|
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
|
|
|
|
// Infrastructure flags that are read-only on the global flags page.
|
|
// Shown with current/resolved value but no controls.
|
|
export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [
|
|
FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
|
FEATURE_FLAG.taskEventRepository,
|
|
];
|
|
|
|
// Flags that are read-only on the org-level dialog.
|
|
// Shown with global value but no controls (org can't override these).
|
|
export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
|
|
FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
|
FEATURE_FLAG.taskEventRepository,
|
|
FEATURE_FLAG.runOpsMintKindPrev,
|
|
FEATURE_FLAG.runOpsMintKindFlippedAt,
|
|
// System-wide only — orgs must not be able to override these kill switches.
|
|
FEATURE_FLAG.additionalApiKeyIssuanceEnabled,
|
|
FEATURE_FLAG.additionalApiKeyLookupEnabled,
|
|
];
|
|
|
|
// Create a Zod schema from the existing catalog
|
|
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
|
|
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
|
|
|
|
// Utility function to validate a feature flag value
|
|
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
|
|
key: T,
|
|
value: unknown
|
|
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
|
|
return FeatureFlagCatalog[key].safeParse(value);
|
|
}
|
|
|
|
// Utility function to validate all feature flags at once
|
|
export function validateAllFeatureFlags(values: Record<string, unknown>) {
|
|
return FeatureFlagCatalogSchema.safeParse(values);
|
|
}
|
|
|
|
// Utility function to validate partial feature flags (all keys optional)
|
|
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
|
|
return FeatureFlagCatalogSchema.partial().safeParse(values);
|
|
}
|
|
|
|
// Utility types for catalog-driven UI rendering
|
|
/**
|
|
* Resolve whether deployed runs should use the internal API origin, from the
|
|
* org's feature-flags JSON. Precedence: a per-org override wins in BOTH
|
|
* directions; the global default applies only when the org has not set the
|
|
* flag (or set it to something invalid).
|
|
*/
|
|
export function resolveInternalApiOriginEnabled({
|
|
orgFeatureFlags,
|
|
globalDefault,
|
|
}: {
|
|
orgFeatureFlags: unknown;
|
|
globalDefault: boolean;
|
|
}): boolean {
|
|
const override =
|
|
orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags)
|
|
? (orgFeatureFlags as Record<string, unknown>)[FEATURE_FLAG.internalApiOriginEnabled]
|
|
: undefined;
|
|
|
|
if (override !== undefined) {
|
|
const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override);
|
|
|
|
if (parsed.success) {
|
|
return parsed.data;
|
|
}
|
|
}
|
|
|
|
return globalDefault;
|
|
}
|
|
|
|
export type FlagControlType =
|
|
| { type: "boolean" }
|
|
| { type: "enum"; options: string[] }
|
|
| { type: "number"; min?: number; max?: number }
|
|
| { type: "string" };
|
|
|
|
export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
|
|
const typeName = schema._def.typeName;
|
|
|
|
if (typeName === "ZodBoolean") {
|
|
return { type: "boolean" };
|
|
}
|
|
|
|
if (typeName === "ZodEnum") {
|
|
return { type: "enum", options: schema._def.values as string[] };
|
|
}
|
|
|
|
// z.coerce.number() reports as ZodNumber; pull min/max out of its checks
|
|
// so the UI can render a constrained number input instead of free text.
|
|
if (typeName === "ZodNumber") {
|
|
const checks = (schema._def.checks ?? []) as Array<{ kind: string; value?: number }>;
|
|
const min = checks.find((c) => c.kind === "min")?.value;
|
|
const max = checks.find((c) => c.kind === "max")?.value;
|
|
return { type: "number", min, max };
|
|
}
|
|
|
|
return { type: "string" };
|
|
}
|
|
|
|
export function getAllFlagControlTypes(): Record<string, FlagControlType> {
|
|
const result: Record<string, FlagControlType> = {};
|
|
for (const [key, schema] of Object.entries(FeatureFlagCatalog)) {
|
|
result[key] = getFlagControlType(schema);
|
|
}
|
|
return result;
|
|
}
|