5651 lines
209 KiB
TypeScript
5651 lines
209 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, desc, eq, inArray, like, ne, notInArray, notLike, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
companies,
|
|
companySecretBindings,
|
|
companySecretProviderConfigs,
|
|
companySecrets,
|
|
companySecretVersions,
|
|
companyMemberships,
|
|
environments,
|
|
heartbeatRuns,
|
|
issues,
|
|
projects,
|
|
routines,
|
|
secretAccessEvents,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AgentApiKeyScope,
|
|
AgentEnvConfig,
|
|
CompanySecretBindingTarget,
|
|
EnvBinding,
|
|
RemoteSecretImportCandidate,
|
|
RemoteSecretImportConflict,
|
|
RemoteSecretImportRowResult,
|
|
SecretProviderConfigDiscoveryPreviewResult,
|
|
SecretBindingTargetType,
|
|
SecretProjectionClass,
|
|
SecretProvider,
|
|
SecretProviderConfigHealthResponse,
|
|
SecretProviderConfigHealthStatus,
|
|
SecretProviderConfigStatus,
|
|
SecretVersionSelector,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
CLASS3_STATIC_LEASE_ALLOWLIST,
|
|
createSecretProviderConfigSchema,
|
|
deriveProjectUrlKey,
|
|
envBindingSchema,
|
|
isUuidLike,
|
|
normalizeAgentUrlKey,
|
|
secretProviderConfigPayloadSchema,
|
|
secretProviderConfigDiscoveryPreviewSchema,
|
|
updateSecretProviderConfigSchema,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import {
|
|
checkSecretProviders,
|
|
getSecretProvider,
|
|
listSecretProviders,
|
|
} from "../secrets/provider-registry.js";
|
|
import type {
|
|
PreparedSecretVersion,
|
|
RemoteSecretListResult,
|
|
SecretProviderHealthCheck,
|
|
SecretProviderModule,
|
|
SecretProviderVaultRuntimeConfig,
|
|
SecretProviderWriteContext,
|
|
} from "../secrets/types.js";
|
|
import { isSecretProviderClientError } from "../secrets/types.js";
|
|
import { authorizationDeniedDetails, authorizationService } from "./authorization.js";
|
|
import { findActiveServerAdapter } from "../adapters/index.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
// Only a `local_encrypted` secret can hold a literal directory path, so only a
|
|
// `local_encrypted` secret can ever name a Codex account-home directory. A
|
|
// create or a rotate that writes a new `local_encrypted` value runs inside
|
|
// this lock for its whole call, so it can never commit a value in the exact
|
|
// window an account-home cleanup's claimant scan already decided "no
|
|
// claimant" but has not yet deleted the directory. See
|
|
// `withAccountHomeSecretMutationLock`'s own comment in the codex-local
|
|
// adapter for the full race this closes.
|
|
//
|
|
// The lock alone still lets a queued write commit a directory the cleanup
|
|
// already removed, once the cleanup's own lock-holding section runs first and
|
|
// frees the lock. `assertAccountHomeCacheDirStillValid` closes that window: a
|
|
// create or a rotate calls it inside the same lock, right before it commits,
|
|
// so a value that named a directory the cleanup just deleted fails instead of
|
|
// writing a secret that points at nothing.
|
|
import {
|
|
assertAccountHomeCacheDirStillValid,
|
|
withAccountHomeSecretMutationLock,
|
|
} from "@paperclipai/adapter-codex-local/server";
|
|
|
|
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
|
|
// System consumer id for a durable orphan-sandbox teardown. The cleanup sweep
|
|
// resolves the recorded connection secret under this id so the audit trail
|
|
// marks the read as an orphan-sandbox teardown, not a normal environment read.
|
|
export const SANDBOX_CLEANUP_CONSUMER_ID = "environment-sandbox-cleanup";
|
|
// System consumer id for a device-login account-home secret check. A device
|
|
// login resolves a pre-existing secret's value under this id, so the audit
|
|
// trail marks the read as a same-account idempotency check, not a normal
|
|
// runtime bind.
|
|
export const DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID = "device-login-secret-check";
|
|
const SENSITIVE_ENV_KEY_RE =
|
|
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
|
const REDACTED_SENTINEL = "***REDACTED***";
|
|
const COMING_SOON_SECRET_PROVIDERS: ReadonlySet<SecretProvider> = new Set([
|
|
"gcp_secret_manager",
|
|
"vault",
|
|
]);
|
|
const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
|
hermes_gateway: ["apiKey"],
|
|
};
|
|
const USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT = "user_secret_definitions_company_key_uq";
|
|
const USER_SECRET_VALUE_UNIQUE_CONSTRAINT = "company_secrets_user_definition_owner_uq";
|
|
// The unique index on (secretId, version). A concurrent rotation that inserts the
|
|
// same next version first makes the loser's insert fail this constraint.
|
|
const COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT = "company_secret_versions_secret_version_uq";
|
|
// The one stale-rotation conflict text. The rotate function returns it for every
|
|
// stale race, so the caller sees one fixed 409 and no owner-value state.
|
|
const SECRET_VERSION_STALE_CONFLICT = "The secret version is stale. Reload and confirm the rotation again.";
|
|
|
|
// The fixed Claude Code OAuth user-secret definition. The Claude login flow owns
|
|
// only this compile-time key and these fixed properties. A caller never selects
|
|
// the key, the name, the provider, the mode, or the status.
|
|
const CLAUDE_CODE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
const CLAUDE_CODE_OAUTH_DEFINITION = {
|
|
key: CLAUDE_CODE_OAUTH_TOKEN_KEY,
|
|
name: "Claude Code OAuth token",
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
status: "active",
|
|
} as const;
|
|
// The fixed, non-secret conflict text. The helper returns it when a stored
|
|
// definition for the fixed key does not match the fixed shape. The text echoes
|
|
// no caller input.
|
|
const CLAUDE_OAUTH_DEFINITION_CONFLICT =
|
|
"A conflicting Claude Code OAuth token definition already exists.";
|
|
// The fixed, non-secret text for a stale confirmed rotation. The text is the
|
|
// same for every stale reason, so it discloses no owner-value state.
|
|
const CLAUDE_OAUTH_STALE_CONFIRMATION =
|
|
"The Claude login confirmation is stale. Reload the page and confirm again.";
|
|
// The fixed, non-secret text for a first write that finds an existing value. The
|
|
// caller must confirm a replacement to rotate it.
|
|
const CLAUDE_OAUTH_VALUE_EXISTS =
|
|
"A Claude login value already exists. Confirm a replacement to rotate it.";
|
|
// The metadata field that records the setup-token session id on the owner value.
|
|
// It is the idempotency key for one completion. It is not a secret.
|
|
const CLAUDE_OAUTH_SESSION_METADATA_FIELD = "claudeSetupTokenSessionId";
|
|
|
|
/** The stored result of one owner-bound Claude OAuth completion. It holds no secret. */
|
|
export interface ClaudeOAuthUserSecretResult {
|
|
secretId: string;
|
|
latestVersion: number;
|
|
definitionId: string;
|
|
}
|
|
|
|
// --- The server-enforced Claude OAuth binding invariant ------------
|
|
|
|
/** The adapter that owns the fixed Claude Code OAuth token binding. */
|
|
export const CLAUDE_LOCAL_ADAPTER_TYPE = "claude_local";
|
|
|
|
// The one fixed, non-secret error for every rejected stored-session claim. The
|
|
// text is byte-identical for a missing, foreign, cross-company, cross-owner,
|
|
// cross-adapter, cross-environment, expired, non-stored, or already-consumed
|
|
// claim, so a caller cannot tell the reasons apart.
|
|
export const CLAUDE_OAUTH_CLAIM_REJECTED =
|
|
"The Claude login binding requires a valid stored-session claim.";
|
|
|
|
// The generic credential-conflict text. It names no token value and no owner
|
|
// configuration. It tells the caller only that a higher-priority credential is
|
|
// configured together with the Claude login token.
|
|
export const CLAUDE_OAUTH_CREDENTIAL_CONFLICT =
|
|
"A higher-priority Claude credential is configured. Remove it to use the Claude login token.";
|
|
|
|
const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
|
|
|
/** Reads the `env` record of an adapter config, or an empty record. */
|
|
function readAdapterEnvRecord(config: unknown): Record<string, unknown> {
|
|
if (typeof config !== "object" || config === null || Array.isArray(config)) return {};
|
|
const env = (config as Record<string, unknown>).env;
|
|
if (typeof env !== "object" || env === null || Array.isArray(env)) return {};
|
|
return env as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Returns true when a binding is the exact fixed Claude Code OAuth user-secret
|
|
* reference. The fixed binding is a `user_secret_ref` whose key is the fixed
|
|
* key. Any other shape (a plain value, a company secret reference, or a
|
|
* different user-secret key) is a replacement or a weaker binding.
|
|
*/
|
|
export function isFixedClaudeOAuthBinding(binding: unknown): boolean {
|
|
if (typeof binding !== "object" || binding === null) return false;
|
|
const record = binding as Record<string, unknown>;
|
|
return record.type === "user_secret_ref" && record.key === CLAUDE_CODE_OAUTH_TOKEN_KEY;
|
|
}
|
|
|
|
/** True when the config carries the exact fixed OAuth binding. */
|
|
function hasFixedClaudeOAuthBinding(config: unknown): boolean {
|
|
return isFixedClaudeOAuthBinding(readAdapterEnvRecord(config)[CLAUDE_CODE_OAUTH_TOKEN_KEY]);
|
|
}
|
|
|
|
/**
|
|
* Returns true when the config delivers a non-empty ANTHROPIC_API_KEY. A plain
|
|
* binding counts only when it has a non-empty value. A company or user secret
|
|
* reference always counts, because it resolves to a value at runtime.
|
|
*/
|
|
function hasAnthropicApiKeyCredential(config: unknown): boolean {
|
|
const binding = readAdapterEnvRecord(config)[ANTHROPIC_API_KEY_ENV];
|
|
if (typeof binding === "string") return binding.trim().length > 0;
|
|
if (typeof binding !== "object" || binding === null) return false;
|
|
const record = binding as Record<string, unknown>;
|
|
if (record.type === "plain") {
|
|
return typeof record.value === "string" && record.value.trim().length > 0;
|
|
}
|
|
return record.type === "secret_ref" || record.type === "user_secret_ref";
|
|
}
|
|
|
|
export interface ClaudeOAuthBindingInvariantInput {
|
|
/** The effective adapter type of the write. */
|
|
adapterType: string | null | undefined;
|
|
/** The normalized adapter config the write persists. */
|
|
nextConfig: unknown;
|
|
/** The stored adapter config before the write. Null on a create. */
|
|
priorConfig?: unknown;
|
|
}
|
|
|
|
export interface ClaudeOAuthBindingInvariantDecision {
|
|
/** True when the write adds the fixed binding that the prior config lacked. */
|
|
introducesBinding: boolean;
|
|
/** True when the write keeps an existing fixed binding unchanged. */
|
|
keepsBinding: boolean;
|
|
}
|
|
|
|
/**
|
|
* The Claude OAuth binding check. It runs after generic normalization and
|
|
* before every database write on a `claude_local` create, hire, update,
|
|
* approval activation, and configuration rollback path.
|
|
*
|
|
* The `CLAUDE_CODE_OAUTH_TOKEN` binding behaves like a normal environment
|
|
* variable. A normal write can remove the fixed binding, or re-point it to a
|
|
* plain value, a company-secret reference, or a different user-secret key. The
|
|
* function no longer locks a prior fixed binding against removal or replacement.
|
|
*
|
|
* A write to a non-claude_local adapter that has no prior fixed binding stays
|
|
* outside the Claude login flow. A prior fixed binding keeps the function active
|
|
* for the write, so the function still reports whether the write keeps that
|
|
* binding, independently of the destination adapter type.
|
|
*
|
|
* The precedence policy runs on every path: the fixed binding together with a
|
|
* non-empty ANTHROPIC_API_KEY is a conflict. The function rejects that conflict
|
|
* with a generic message that names no token value and no owner configuration.
|
|
*
|
|
* The function returns whether the write introduces the fixed binding or keeps
|
|
* an existing one. The create and hire paths consume a stored-session claim when
|
|
* the write introduces the binding. The update, approval, and rollback paths
|
|
* reject a newly introduced binding, because they carry no claim.
|
|
*/
|
|
export function assertClaudeOAuthBindingInvariant(
|
|
input: ClaudeOAuthBindingInvariantInput,
|
|
): ClaudeOAuthBindingInvariantDecision {
|
|
const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE;
|
|
const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig);
|
|
const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig);
|
|
|
|
// A write to a non-claude_local adapter that has no prior fixed binding is a
|
|
// normal non-Claude configuration. It stays outside the Claude login flow. A
|
|
// prior fixed binding always keeps the function active, so a write still
|
|
// reports the binding state after a move to another adapter type.
|
|
if (!isClaudeLocal && !priorIsFixed) {
|
|
return { introducesBinding: false, keepsBinding: false };
|
|
}
|
|
|
|
if (nextIsFixed && hasAnthropicApiKeyCredential(input.nextConfig)) {
|
|
throw new HttpError(409, CLAUDE_OAUTH_CREDENTIAL_CONFLICT, {
|
|
code: "claude_oauth_credential_conflict",
|
|
});
|
|
}
|
|
return {
|
|
introducesBinding: nextIsFixed && !priorIsFixed,
|
|
keepsBinding: nextIsFixed && priorIsFixed,
|
|
};
|
|
}
|
|
|
|
/** The fixed error the create and hire paths raise for a rejected claim. */
|
|
export function claudeOAuthClaimRejectedError(): HttpError {
|
|
return new HttpError(409, CLAUDE_OAUTH_CLAIM_REJECTED, { code: "claude_oauth_claim_rejected" });
|
|
}
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
|
|
|
function isUniqueConstraintViolation(error: unknown, constraintName: string) {
|
|
const seen = new Set<unknown>();
|
|
let current = error;
|
|
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
|
seen.add(current);
|
|
const maybe = current as {
|
|
code?: string;
|
|
constraint?: string;
|
|
constraint_name?: string;
|
|
cause?: unknown;
|
|
};
|
|
const constraint = maybe.constraint ?? maybe.constraint_name;
|
|
if (maybe.code === "23505" && constraint === constraintName) return true;
|
|
current = maybe.cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function remoteProviderHttpError(error: unknown, context: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId: string;
|
|
operation: string;
|
|
providerConfig?: Record<string, unknown> | null;
|
|
}): HttpError {
|
|
if (isSecretProviderClientError(error)) {
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
companyId: context.companyId,
|
|
provider: context.provider,
|
|
providerConfigId: context.providerConfigId,
|
|
operation: context.operation,
|
|
providerErrorCode: error.code,
|
|
},
|
|
"remote secret provider request failed",
|
|
);
|
|
return new HttpError(error.status, error.message, safeRemoteProviderErrorDetails(error, context));
|
|
}
|
|
if (error instanceof HttpError) return error;
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
companyId: context.companyId,
|
|
provider: context.provider,
|
|
providerConfigId: context.providerConfigId,
|
|
operation: context.operation,
|
|
providerErrorCode: "provider_error",
|
|
},
|
|
"remote secret provider request failed",
|
|
);
|
|
return new HttpError(502, "Remote secret provider request failed.", safeRemoteProviderErrorDetails(null, context));
|
|
}
|
|
|
|
function remoteProviderWriteHttpError(error: unknown, context: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
operation: string;
|
|
}): HttpError {
|
|
return remoteProviderHttpError(error, {
|
|
companyId: context.companyId,
|
|
provider: context.provider,
|
|
providerConfigId: context.providerConfig?.id ?? context.providerConfigId ?? "deployment-default",
|
|
operation: context.operation,
|
|
providerConfig: context.providerConfig?.config ?? null,
|
|
});
|
|
}
|
|
|
|
async function throwProviderWriteOrReservedRowRollbackError(input: {
|
|
error: unknown;
|
|
rollbackReservedRow: () => Promise<unknown>;
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
operation: string;
|
|
}): Promise<never> {
|
|
const providerError = remoteProviderWriteHttpError(input.error, input);
|
|
try {
|
|
await input.rollbackReservedRow();
|
|
} catch (rollbackError) {
|
|
const providerConfigId = input.providerConfig?.id ?? input.providerConfigId ?? "deployment-default";
|
|
logger.warn(
|
|
{
|
|
err: rollbackError,
|
|
providerErr: providerError,
|
|
companyId: input.companyId,
|
|
provider: input.provider,
|
|
providerConfigId,
|
|
operation: input.operation,
|
|
},
|
|
"remote secret provider write failed and reserved secret rollback failed",
|
|
);
|
|
throw new HttpError(500, "Secret create failed and Paperclip could not roll back the local secret reservation.", {
|
|
code: "secret_create_rollback_failed",
|
|
provider: input.provider,
|
|
operation: input.operation,
|
|
providerConfigId,
|
|
providerError: {
|
|
status: providerError.status,
|
|
message: providerError.message,
|
|
details: providerError.details ?? null,
|
|
},
|
|
});
|
|
}
|
|
throw providerError;
|
|
}
|
|
|
|
function providerConfigIdentifier(input: {
|
|
providerConfigId?: string | null;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
}) {
|
|
return input.providerConfig?.id ?? input.providerConfigId ?? "deployment-default";
|
|
}
|
|
|
|
async function deleteLocalSecretCreateReservationOrThrow(input: {
|
|
db: Pick<Db, "delete">;
|
|
secretId: string;
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
operation: string;
|
|
}) {
|
|
try {
|
|
await input.db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, input.secretId));
|
|
await input.db.delete(companySecrets).where(eq(companySecrets.id, input.secretId));
|
|
} catch (rollbackError) {
|
|
const providerConfigId = providerConfigIdentifier(input);
|
|
logger.warn(
|
|
{
|
|
err: rollbackError,
|
|
companyId: input.companyId,
|
|
provider: input.provider,
|
|
providerConfigId,
|
|
operation: input.operation,
|
|
},
|
|
"secret create failed and local reserved secret rollback failed",
|
|
);
|
|
throw new HttpError(500, "Secret create failed and Paperclip could not roll back the local secret reservation.", {
|
|
code: "secret_create_rollback_failed",
|
|
provider: input.provider,
|
|
operation: input.operation,
|
|
providerConfigId,
|
|
});
|
|
}
|
|
}
|
|
|
|
function throwProviderCleanupFailedAfterCreateRollback(input: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
operation: string;
|
|
}): never {
|
|
const providerConfigId = providerConfigIdentifier(input);
|
|
throw new HttpError(500, "Secret create failed and Paperclip could not clean up the remote provider secret.", {
|
|
code: "secret_create_provider_cleanup_failed",
|
|
provider: input.provider,
|
|
operation: input.operation,
|
|
providerConfigId,
|
|
localCleanupHandle: true,
|
|
});
|
|
}
|
|
|
|
function safeRemoteProviderErrorDetails(
|
|
error: { code: string } | null,
|
|
context: {
|
|
provider: SecretProvider;
|
|
providerConfigId: string;
|
|
operation: string;
|
|
providerConfig?: Record<string, unknown> | null;
|
|
},
|
|
): Record<string, unknown> {
|
|
if (
|
|
context.provider !== "aws_secrets_manager" ||
|
|
context.operation !== "secret_provider_config.discovery.preview"
|
|
) {
|
|
if (context.provider !== "aws_secrets_manager") {
|
|
return { code: error?.code ?? "provider_error" };
|
|
}
|
|
const details: Record<string, unknown> = {
|
|
code: error?.code ?? "provider_error",
|
|
provider: context.provider,
|
|
operation: context.operation,
|
|
providerConfigId: context.providerConfigId,
|
|
};
|
|
const region = safeString(context.providerConfig?.region);
|
|
if (region) details.region = region;
|
|
details.credentialPath = "Paperclip server runtime/provider credential path";
|
|
if (error?.code === "access_denied") {
|
|
if (context.operation === "secret.create") {
|
|
details.requiredCapability = "secretsmanager:CreateSecret";
|
|
details.actionableMessage =
|
|
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault. If the vault config uses a KMS key, the runtime credentials also need KMS write permissions for that key.";
|
|
details.safeAlternative =
|
|
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.";
|
|
} else if (context.operation === "secret.rotate") {
|
|
details.requiredCapability = "secretsmanager:PutSecretValue";
|
|
details.actionableMessage =
|
|
"AWS managed secret rotation needs secretsmanager:PutSecretValue for the selected provider vault and managed secret path.";
|
|
}
|
|
}
|
|
return details;
|
|
}
|
|
const details: Record<string, unknown> = {
|
|
code: error?.code ?? "provider_error",
|
|
provider: context.provider,
|
|
operation: context.operation,
|
|
providerConfigId: context.providerConfigId,
|
|
};
|
|
const region = safeString(context.providerConfig?.region);
|
|
if (region) details.region = region;
|
|
details.providerVaultContext = context.providerConfigId === "discovery-preview" ? "draft_config" : "provider_config";
|
|
details.credentialPath = "Paperclip server runtime/provider credential path";
|
|
if (error?.code === "access_denied") {
|
|
details.requiredCapability = "secretsmanager:ListSecrets";
|
|
details.actionableMessage =
|
|
"AWS discovery preview needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path.";
|
|
details.safeAlternative =
|
|
"If the operator already knows the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required.";
|
|
}
|
|
return details;
|
|
}
|
|
|
|
function safeString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function remoteImportRowFailureReason(error: unknown, fallback: string, context: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId: string;
|
|
operation: string;
|
|
}): string {
|
|
if (isSecretProviderClientError(error)) {
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
companyId: context.companyId,
|
|
provider: context.provider,
|
|
providerConfigId: context.providerConfigId,
|
|
operation: context.operation,
|
|
providerErrorCode: error.code,
|
|
},
|
|
"remote secret import row provider failure",
|
|
);
|
|
return error.message;
|
|
}
|
|
if (error instanceof HttpError && error.status < 500) return error.message;
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
companyId: context.companyId,
|
|
provider: context.provider,
|
|
providerConfigId: context.providerConfigId,
|
|
operation: context.operation,
|
|
providerErrorCode: "provider_error",
|
|
},
|
|
"remote secret import row failed",
|
|
);
|
|
return fallback;
|
|
}
|
|
|
|
async function cleanupPreparedProviderWrite(input: {
|
|
provider: SecretProviderModule;
|
|
prepared: PreparedSecretVersion;
|
|
providerConfig: SecretProviderVaultRuntimeConfig | null;
|
|
context: SecretProviderWriteContext;
|
|
mode: "archive" | "delete";
|
|
operation: string;
|
|
}): Promise<boolean> {
|
|
try {
|
|
await input.provider.deleteOrArchive({
|
|
material: input.prepared.material,
|
|
externalRef: input.prepared.externalRef,
|
|
providerConfig: input.providerConfig,
|
|
context: input.context,
|
|
mode: input.mode,
|
|
});
|
|
return true;
|
|
} catch (cleanupError) {
|
|
logger.warn(
|
|
{
|
|
err: cleanupError,
|
|
companyId: input.context.companyId,
|
|
provider: input.provider.id,
|
|
providerConfigId: input.providerConfig?.id ?? null,
|
|
operation: input.operation,
|
|
},
|
|
"remote secret provider cleanup failed after db write failure",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type CanonicalEnvBinding =
|
|
| { type: "plain"; value: string }
|
|
| {
|
|
type: "secret_ref";
|
|
secretId: string;
|
|
version: number | "latest";
|
|
projectionClass: SecretProjectionClass;
|
|
projectionAllowlistKey: string | null;
|
|
}
|
|
| {
|
|
type: "user_secret_ref";
|
|
key: string;
|
|
version: number | "latest";
|
|
required: boolean;
|
|
allowMissingOverride: boolean;
|
|
};
|
|
|
|
type SecretAccessConsumerType = SecretBindingTargetType | "agent_api" | "plugin_worker";
|
|
|
|
type SecretConsumerContext = {
|
|
consumerType: SecretAccessConsumerType;
|
|
consumerId: string;
|
|
configPath?: string | null;
|
|
responsibleUserId?: string | null;
|
|
actorType?: "agent" | "user" | "system" | "plugin";
|
|
actorId?: string | null;
|
|
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
pluginId?: string | null;
|
|
allowedBindingIds?: string[] | null;
|
|
};
|
|
|
|
type SecretBindingContext = Omit<SecretConsumerContext, "consumerType"> & {
|
|
consumerType: SecretBindingTargetType;
|
|
};
|
|
|
|
type SecretResolutionOptions = {
|
|
bindingContext?: SecretBindingContext;
|
|
accessContext?: SecretConsumerContext;
|
|
allowUserSecretScope?: boolean;
|
|
};
|
|
|
|
export type AgentSecretReadContext = {
|
|
agentId: string;
|
|
configPath: string;
|
|
bindingId?: string | null;
|
|
actorSource: "agent_jwt" | "agent_key";
|
|
keyId?: string | null;
|
|
keyScope?: AgentApiKeyScope | null;
|
|
heartbeatRunId: string;
|
|
issueId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
registerForRedaction: (value: string) => void | Promise<void>;
|
|
};
|
|
|
|
export type AgentSecretAccessEntry = {
|
|
secretId: string;
|
|
bindingId: string;
|
|
configPath: string;
|
|
key: string;
|
|
name: string;
|
|
description: string | null;
|
|
delivery: "env" | "api" | "both";
|
|
projectionClass: SecretProjectionClass;
|
|
latestVersion: number;
|
|
versionSelector: SecretVersionSelector;
|
|
resolvedVersion: number;
|
|
};
|
|
|
|
type ResolveAdapterConfigForRuntimeOptions = {
|
|
adapterType?: string | null;
|
|
skipUserSecrets?: boolean;
|
|
/**
|
|
* Selects how user-scoped secrets are mediated for this resolution.
|
|
*
|
|
* - `"declared"` (default): the resolver injects a `configPath`, activating
|
|
* `resolveUserSecretValue`'s declaration guard. A persisted consumer's real
|
|
* declaration rows satisfy it; an undeclared required ref → `binding_missing`.
|
|
* - `"owner_scoped"`: for a prospective, non-persisted config (e.g. adapter
|
|
* test-environment). The user-secret call omits `configPath` so the
|
|
* declaration lookup is skipped and the value resolves by definition + owner
|
|
* boundary; the company `secret_ref` call routes through `bindingContext:
|
|
* undefined` (audit-only `accessContext`) to preserve today's zero-enforcement
|
|
* company-secret behavior while gaining actor attribution. Opt-in per call.
|
|
*/
|
|
userSecretMediation?: "declared" | "owner_scoped";
|
|
};
|
|
|
|
export type RuntimeSecretManifestEntry = {
|
|
configPath: string;
|
|
envKey: string | null;
|
|
secretId: string;
|
|
bindingId?: string | null;
|
|
secretKey: string;
|
|
version: number;
|
|
provider: SecretProvider;
|
|
providerVersionRef?: string | null;
|
|
outcome: "success" | "failure";
|
|
errorCode?: string | null;
|
|
};
|
|
|
|
export type MissingRuntimeBinding = {
|
|
consumerType: SecretBindingTargetType;
|
|
consumerId: string;
|
|
configPath: string;
|
|
envKey: string;
|
|
bindingType?: "secret_ref" | "user_secret_ref";
|
|
secretId: string | null;
|
|
secretName: string | null;
|
|
userSecretDefinitionId?: string | null;
|
|
userSecretDefinitionKey?: string | null;
|
|
userSecretDefinitionName?: string | null;
|
|
responsibleUserId?: string | null;
|
|
errorCode?: SecretResolutionErrorCode;
|
|
};
|
|
|
|
function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType {
|
|
if (consumerType === "plugin_worker") return "plugin";
|
|
if (consumerType === "agent_api") return "agent";
|
|
return consumerType;
|
|
}
|
|
|
|
type RuntimeSecretResolution = {
|
|
value: string;
|
|
manifestEntry: RuntimeSecretManifestEntry;
|
|
};
|
|
|
|
type SecretResolutionErrorCode =
|
|
| "binding_missing"
|
|
| "secret_deleted"
|
|
| "secret_inactive"
|
|
| "secret_scope_invalid"
|
|
| "responsible_user_missing"
|
|
| "user_secret_definition_missing"
|
|
| "user_secret_definition_inactive"
|
|
| "user_secret_missing"
|
|
| "version_missing"
|
|
| "version_inactive"
|
|
| "provider_error";
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function isSensitiveEnvKey(key: string) {
|
|
return SENSITIVE_ENV_KEY_RE.test(key);
|
|
}
|
|
|
|
export function normalizeSecretKey(input: string) {
|
|
return input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 120);
|
|
}
|
|
|
|
function deriveSecretNameFromExternalRef(externalRef: string) {
|
|
const trimmed = externalRef.trim();
|
|
const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed);
|
|
const name = arnMatch?.[1] ?? trimmed;
|
|
return name.split("/").filter(Boolean).at(-1) ?? name;
|
|
}
|
|
|
|
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
|
if (typeof binding === "string") {
|
|
return { type: "plain", value: binding };
|
|
}
|
|
if (binding.type === "plain") {
|
|
return { type: "plain", value: String(binding.value) };
|
|
}
|
|
if (binding.type === "user_secret_ref") {
|
|
return {
|
|
type: "user_secret_ref",
|
|
key: binding.key,
|
|
version: binding.version ?? "latest",
|
|
required: binding.required ?? true,
|
|
allowMissingOverride: binding.allowMissingOverride ?? false,
|
|
};
|
|
}
|
|
return {
|
|
type: "secret_ref",
|
|
secretId: binding.secretId,
|
|
version: binding.version ?? "latest",
|
|
projectionClass: binding.projectionClass ?? "unclassified",
|
|
projectionAllowlistKey: binding.projectionAllowlistKey ?? null,
|
|
};
|
|
}
|
|
|
|
function assertClass3StaticLeaseAllowed(input: {
|
|
targetType: SecretBindingTargetType;
|
|
configPath: string;
|
|
projectionClass?: string | null;
|
|
projectionAllowlistKey?: string | null;
|
|
}) {
|
|
const projectionClass = input.projectionClass ?? "unclassified";
|
|
if (projectionClass !== "class_3_static_lease") return;
|
|
if (!input.projectionAllowlistKey?.trim()) {
|
|
throw unprocessable("Class-3 static lease bindings require an allowlist key", {
|
|
code: "class_3_static_lease_allowlist_required",
|
|
targetType: input.targetType,
|
|
configPath: input.configPath,
|
|
});
|
|
}
|
|
const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some((entry) =>
|
|
entry.key === input.projectionAllowlistKey
|
|
&& entry.targetType === input.targetType
|
|
&& entry.configPath === input.configPath
|
|
);
|
|
if (!allowed) {
|
|
throw unprocessable("Class-3 static lease binding is outside the approved allowlist", {
|
|
code: "class_3_static_lease_not_allowed",
|
|
allowlistKey: input.projectionAllowlistKey,
|
|
targetType: input.targetType,
|
|
configPath: input.configPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus {
|
|
return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready";
|
|
}
|
|
|
|
function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
|
|
if (isSecretProviderClientError(error)) return "provider_error";
|
|
if (error instanceof HttpError) {
|
|
const details = asRecord(error.details);
|
|
switch (details?.code) {
|
|
case "binding_missing":
|
|
case "secret_deleted":
|
|
case "secret_inactive":
|
|
case "version_missing":
|
|
case "version_inactive":
|
|
case "provider_error":
|
|
return details.code;
|
|
}
|
|
if (error.message === "Secret is not active") return "secret_inactive";
|
|
if (error.message === "User secret value is not configured") return "user_secret_missing";
|
|
if (error.message === "Responsible user is required for user secret resolution") {
|
|
return "responsible_user_missing";
|
|
}
|
|
if (error.message.startsWith("User secret definition not found")) {
|
|
return "user_secret_definition_missing";
|
|
}
|
|
if (error.message === "User secret definition is not active") return "user_secret_definition_inactive";
|
|
if (error.message === "User-scoped secrets must be resolved through user secret declarations") {
|
|
return "secret_scope_invalid";
|
|
}
|
|
if (error.message === "Secret version not found") return "version_missing";
|
|
if (error.message === "Secret version is not active") return "version_inactive";
|
|
if (
|
|
error.message === "Secret resolution requires a binding config path" ||
|
|
error.message.startsWith("Secret is not bound to ")
|
|
) {
|
|
return "binding_missing";
|
|
}
|
|
if (error.status >= 500) return "provider_error";
|
|
}
|
|
return "provider_error";
|
|
}
|
|
|
|
function assertSecretBindingConfigPath(input: {
|
|
targetType: SecretBindingTargetType;
|
|
configPath: string;
|
|
}) {
|
|
if (!input.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return;
|
|
if (input.targetType !== "agent") {
|
|
throw unprocessable("API-only secret access bindings must target an agent");
|
|
}
|
|
const alias = input.configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
|
|
if (!ENV_KEY_RE.test(alias)) {
|
|
throw unprocessable(`Invalid agent secret access alias: ${alias || "(empty)"}`);
|
|
}
|
|
}
|
|
|
|
function missingUserSecretDefinitionRuntimeBinding(
|
|
entry: {
|
|
key: string;
|
|
configPath: string;
|
|
binding: Extract<CanonicalEnvBinding, { type: "user_secret_ref" }>;
|
|
},
|
|
context: Omit<SecretConsumerContext, "configPath">,
|
|
definition: typeof userSecretDefinitions.$inferSelect | null,
|
|
errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive",
|
|
): MissingRuntimeBinding {
|
|
return {
|
|
consumerType: missingRuntimeConsumerType(context.consumerType),
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition?.id ?? null,
|
|
userSecretDefinitionKey: definition?.key ?? entry.binding.key,
|
|
userSecretDefinitionName: definition?.name ?? null,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
errorCode,
|
|
};
|
|
}
|
|
|
|
// A direct-resolution path (Test or save) resolves one user-secret binding at a
|
|
// time. When the definition is gone, this builder names the environment
|
|
// variable, the consumer, and the unresolved definition so the actor can find
|
|
// the dangling binding. The message keeps the exact "User secret definition not
|
|
// found" prefix so `secretResolutionErrorCode` still maps it to
|
|
// `user_secret_definition_missing`. It never includes a secret value.
|
|
type UserSecretDefinitionResolutionContext = {
|
|
envKey?: string | null;
|
|
configPath?: string | null;
|
|
consumerType?: string | null;
|
|
consumerId?: string | null;
|
|
};
|
|
|
|
function envKeyFromConfigPath(configPath?: string | null): string | null {
|
|
if (!configPath) return null;
|
|
const separatorIndex = configPath.lastIndexOf(".");
|
|
const key = separatorIndex >= 0 ? configPath.slice(separatorIndex + 1) : configPath;
|
|
return key.length > 0 ? key : null;
|
|
}
|
|
|
|
const BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE = "User secret definition not found";
|
|
|
|
function userSecretDefinitionNotFoundMessage(
|
|
input: { definitionId?: string | null; definitionKey?: string | null },
|
|
context?: UserSecretDefinitionResolutionContext,
|
|
): string {
|
|
const parts: string[] = [];
|
|
const envKey = context?.envKey ?? envKeyFromConfigPath(context?.configPath);
|
|
if (envKey) parts.push(`environment variable "${envKey}"`);
|
|
if (context?.consumerId) {
|
|
parts.push(`${context.consumerType ?? "consumer"} ${context.consumerId}`);
|
|
}
|
|
if (input.definitionKey) {
|
|
parts.push(`definition key "${input.definitionKey}"`);
|
|
} else if (input.definitionId) {
|
|
parts.push(`definition id "${input.definitionId}"`);
|
|
}
|
|
if (parts.length === 0) return BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE;
|
|
return `${BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE} for ${parts.join(", ")}`;
|
|
}
|
|
|
|
function assertSelectableProviderConfig(config: {
|
|
provider: string;
|
|
status: string;
|
|
companyId: string;
|
|
}, companyId: string, provider: SecretProvider) {
|
|
if (config.companyId !== companyId) throw unprocessable("Provider vault must belong to same company");
|
|
if (config.provider !== provider) throw unprocessable("Provider vault must match the secret provider");
|
|
if (config.status === "coming_soon") {
|
|
throw unprocessable("Provider vault is locked while coming soon");
|
|
}
|
|
if (config.status === "disabled") {
|
|
throw unprocessable("Provider vault is disabled");
|
|
}
|
|
}
|
|
|
|
export function secretService(db: Db | DbTransaction) {
|
|
const authorization = authorizationService(db);
|
|
|
|
type NormalizeEnvOptions = {
|
|
strictMode?: boolean;
|
|
fieldPath?: string;
|
|
};
|
|
type NormalizeAdapterConfigOptions = {
|
|
strictMode?: boolean;
|
|
adapterType?: string | null;
|
|
actor?: { userId?: string | null; agentId?: string | null };
|
|
};
|
|
|
|
async function getById(id: string, source: Pick<Db | DbTransaction, "select"> = db) {
|
|
return source
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getByName(companyId: string, name: string) {
|
|
return db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
eq(companySecrets.name, name),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getByKey(companyId: string, key: string) {
|
|
return db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.key, key),
|
|
eq(companySecrets.scope, "company"),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getUserSecretDefinitionById(
|
|
companyId: string,
|
|
definitionId: string,
|
|
source: Pick<Db | DbTransaction, "select"> = db,
|
|
) {
|
|
return source
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(and(
|
|
eq(userSecretDefinitions.companyId, companyId),
|
|
eq(userSecretDefinitions.id, definitionId),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getUserSecretDefinitionByKey(
|
|
companyId: string,
|
|
key: string,
|
|
source: Pick<Db | DbTransaction, "select"> = db,
|
|
) {
|
|
return source
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(and(
|
|
eq(userSecretDefinitions.companyId, companyId),
|
|
eq(userSecretDefinitions.key, key),
|
|
ne(userSecretDefinitions.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function resolveUserSecretDefinition(
|
|
companyId: string,
|
|
input: { definitionId?: string | null; definitionKey?: string | null },
|
|
source: Pick<Db | DbTransaction, "select"> = db,
|
|
context?: UserSecretDefinitionResolutionContext,
|
|
) {
|
|
const definition = input.definitionId
|
|
? await getUserSecretDefinitionById(companyId, input.definitionId, source)
|
|
: input.definitionKey
|
|
? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source)
|
|
: null;
|
|
if (!definition || definition.deletedAt || definition.status === "deleted") {
|
|
throw notFound(userSecretDefinitionNotFoundMessage(input, context));
|
|
}
|
|
if (definition.companyId !== companyId) {
|
|
throw unprocessable("User secret definition must belong to same company");
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
async function getUserSecretValue(input: {
|
|
companyId: string;
|
|
ownerUserId: string;
|
|
definitionId: string;
|
|
}) {
|
|
return db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, input.companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.ownerUserId, input.ownerUserId),
|
|
eq(companySecrets.userSecretDefinitionId, input.definitionId),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getUserSecretValueById(companyId: string, ownerUserId: string, secretId: string) {
|
|
const secret = await getById(secretId);
|
|
if (!secret || secret.status === "deleted" || secret.scope !== "user") {
|
|
throw notFound("User secret value not found");
|
|
}
|
|
if (secret.companyId !== companyId || secret.ownerUserId !== ownerUserId) {
|
|
throw notFound("User secret value not found");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
async function getSecretVersion(secretId: string, version: number) {
|
|
return db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
and(
|
|
eq(companySecretVersions.secretId, secretId),
|
|
eq(companySecretVersions.version, version),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getBinding(input: {
|
|
companyId: string;
|
|
secretId: string;
|
|
consumerType: SecretBindingTargetType;
|
|
consumerId: string;
|
|
configPath: string;
|
|
}) {
|
|
return db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, input.companyId),
|
|
eq(companySecretBindings.secretId, input.secretId),
|
|
eq(companySecretBindings.targetType, input.consumerType),
|
|
eq(companySecretBindings.targetId, input.consumerId),
|
|
eq(companySecretBindings.configPath, input.configPath),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function assertBindingContext(
|
|
companyId: string,
|
|
secretId: string,
|
|
context: SecretBindingContext | undefined,
|
|
) {
|
|
if (!context) return null;
|
|
if (!context.configPath) {
|
|
throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" });
|
|
}
|
|
const binding = await getBinding({
|
|
companyId,
|
|
secretId,
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: context.configPath,
|
|
});
|
|
if (!binding) {
|
|
throw unprocessable(
|
|
`Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
|
|
{ code: "binding_missing" },
|
|
);
|
|
}
|
|
if (
|
|
Array.isArray(context.allowedBindingIds) &&
|
|
!context.allowedBindingIds.includes(binding.id)
|
|
) {
|
|
throw unprocessable(
|
|
"Secret binding is outside the active low-trust boundary",
|
|
{ code: "binding_not_allowed" },
|
|
);
|
|
}
|
|
assertClass3StaticLeaseAllowed({
|
|
targetType: binding.targetType as SecretBindingTargetType,
|
|
configPath: binding.configPath,
|
|
projectionClass: binding.projectionClass,
|
|
projectionAllowlistKey: binding.projectionAllowlistKey,
|
|
});
|
|
return binding;
|
|
}
|
|
|
|
async function recordAccessEvent(input: {
|
|
companyId: string;
|
|
secretId: string;
|
|
userSecretDefinitionId?: string | null;
|
|
secretScope?: string | null;
|
|
version: number | null;
|
|
provider: SecretProvider;
|
|
context: SecretConsumerContext | undefined;
|
|
credentialOwnerUserId?: string | null;
|
|
credentialSubjectType?: string | null;
|
|
credentialSubjectId?: string | null;
|
|
outcome: "success" | "failure";
|
|
errorCode?: string | null;
|
|
}) {
|
|
if (!input.context) return;
|
|
await db.insert(secretAccessEvents).values({
|
|
companyId: input.companyId,
|
|
secretId: input.secretId,
|
|
userSecretDefinitionId: input.userSecretDefinitionId ?? null,
|
|
secretScope: input.secretScope ?? "company",
|
|
version: input.version,
|
|
provider: input.provider,
|
|
responsibleUserId: input.context.responsibleUserId ?? null,
|
|
credentialOwnerUserId: input.credentialOwnerUserId ?? null,
|
|
credentialSubjectType: input.credentialSubjectType ?? null,
|
|
credentialSubjectId: input.credentialSubjectId ?? null,
|
|
actorType: input.context.actorType ?? "system",
|
|
actorId: input.context.actorId ?? null,
|
|
consumerType: input.context.consumerType,
|
|
consumerId: input.context.consumerId,
|
|
configPath: input.context.configPath ?? null,
|
|
issueId: input.context.issueId ?? null,
|
|
heartbeatRunId: input.context.heartbeatRunId ?? null,
|
|
pluginId: input.context.pluginId ?? null,
|
|
outcome: input.outcome,
|
|
errorCode: input.errorCode ?? null,
|
|
});
|
|
}
|
|
|
|
async function assertSecretInCompany(
|
|
companyId: string,
|
|
secretId: string,
|
|
source: Pick<Db | DbTransaction, "select"> = db,
|
|
) {
|
|
const secret = await getById(secretId, source);
|
|
if (!secret) throw notFound("Secret not found");
|
|
if (secret.status === "deleted") throw notFound("Secret not found");
|
|
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
|
if (secret.scope !== "company") throw unprocessable("Secret references require company-scoped secrets");
|
|
return secret;
|
|
}
|
|
|
|
async function getProviderConfigById(id: string) {
|
|
return db
|
|
.select()
|
|
.from(companySecretProviderConfigs)
|
|
.where(eq(companySecretProviderConfigs.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function assertProviderConfigForSecret(
|
|
companyId: string,
|
|
provider: SecretProvider,
|
|
providerConfigId: string | null | undefined,
|
|
) {
|
|
if (!providerConfigId) return null;
|
|
const providerConfig = await getProviderConfigById(providerConfigId);
|
|
if (!providerConfig) throw notFound("Provider vault not found");
|
|
assertSelectableProviderConfig(providerConfig, companyId, provider);
|
|
return providerConfig;
|
|
}
|
|
|
|
function toProviderVaultRuntimeConfig(
|
|
providerConfig: Awaited<ReturnType<typeof getProviderConfigById>> | null,
|
|
): SecretProviderVaultRuntimeConfig | null {
|
|
if (!providerConfig) return null;
|
|
return {
|
|
id: providerConfig.id,
|
|
provider: providerConfig.provider as SecretProvider,
|
|
status: providerConfig.status,
|
|
config: providerConfig.config ?? {},
|
|
};
|
|
}
|
|
|
|
async function getSelectableRuntimeProviderConfig(input: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
providerConfigId: string | null | undefined;
|
|
}) {
|
|
const providerConfig = await assertProviderConfigForSecret(
|
|
input.companyId,
|
|
input.provider,
|
|
input.providerConfigId,
|
|
);
|
|
return toProviderVaultRuntimeConfig(providerConfig);
|
|
}
|
|
|
|
function validateProviderConfigPayload(
|
|
provider: SecretProvider,
|
|
config: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const parsed = secretProviderConfigPayloadSchema.safeParse({ provider, config });
|
|
if (!parsed.success) {
|
|
throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
|
}
|
|
return parsed.data.config;
|
|
}
|
|
|
|
function toDraftProviderVaultRuntimeConfig(input: {
|
|
companyId: string;
|
|
provider: SecretProvider;
|
|
config: Record<string, unknown>;
|
|
}): SecretProviderVaultRuntimeConfig {
|
|
return {
|
|
id: `discovery-preview-${input.companyId}`,
|
|
provider: input.provider,
|
|
status: "ready",
|
|
config: validateProviderConfigPayload(input.provider, input.config),
|
|
};
|
|
}
|
|
|
|
function providerConfigHealth(input: {
|
|
id: string;
|
|
provider: SecretProvider;
|
|
status: SecretProviderConfigStatus;
|
|
config: Record<string, unknown>;
|
|
}): Omit<SecretProviderConfigHealthResponse, "checkedAt"> | null {
|
|
if (input.status === "disabled") {
|
|
return {
|
|
configId: input.id,
|
|
provider: input.provider,
|
|
status: "disabled",
|
|
message: "Provider vault is disabled.",
|
|
details: { code: "disabled", message: "Provider vault is disabled." },
|
|
};
|
|
}
|
|
if (input.status === "coming_soon" || COMING_SOON_SECRET_PROVIDERS.has(input.provider)) {
|
|
return {
|
|
configId: input.id,
|
|
provider: input.provider,
|
|
status: "coming_soon",
|
|
message: "Provider vault runtime is locked while coming soon.",
|
|
details: {
|
|
code: "runtime_locked",
|
|
message: "Provider vault runtime is locked while coming soon.",
|
|
guidance: ["Draft metadata may be saved, but create, rotate, and resolve stay unavailable."],
|
|
},
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mapProviderModuleHealth(input: {
|
|
configId: string;
|
|
provider: SecretProvider;
|
|
providerStatus: SecretProviderConfigStatus;
|
|
health: SecretProviderHealthCheck;
|
|
}): Omit<SecretProviderConfigHealthResponse, "checkedAt"> {
|
|
const status: SecretProviderConfigHealthStatus =
|
|
input.health.status === "ok"
|
|
? input.providerStatus === "warning" ? "warning" : "ready"
|
|
: input.health.status === "error"
|
|
? "error"
|
|
: "warning";
|
|
const guidance = [
|
|
...(input.health.warnings ?? []),
|
|
...(input.health.backupGuidance ?? []),
|
|
];
|
|
return {
|
|
configId: input.configId,
|
|
provider: input.provider,
|
|
status,
|
|
message: input.health.message,
|
|
details: {
|
|
code: input.health.status === "ok" ? "provider_ready" : "provider_needs_attention",
|
|
message: input.health.message,
|
|
guidance: guidance.length > 0 ? guidance : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function resolveSecretValueInternal(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
options?: SecretResolutionOptions,
|
|
): Promise<RuntimeSecretResolution> {
|
|
const bindingContext = options?.bindingContext;
|
|
const accessContext = options?.accessContext ?? bindingContext;
|
|
const secret = await getById(secretId);
|
|
if (!secret) throw notFound("Secret not found");
|
|
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
|
if (secret.scope !== "company" && !options?.allowUserSecretScope) {
|
|
throw unprocessable("User-scoped secrets must be resolved through user secret declarations", {
|
|
code: "secret_scope_invalid",
|
|
});
|
|
}
|
|
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
|
const providerId = secret.provider as SecretProvider;
|
|
const configPath = accessContext?.configPath ?? null;
|
|
try {
|
|
if (secret.status === "deleted") {
|
|
throw new HttpError(404, "Secret not found", { code: "secret_deleted" });
|
|
}
|
|
if (secret.status !== "active") {
|
|
throw unprocessable("Secret is not active", { code: "secret_inactive" });
|
|
}
|
|
const binding = await assertBindingContext(companyId, secret.id, bindingContext);
|
|
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
|
if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" });
|
|
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
|
|
throw unprocessable("Secret version is not active", { code: "version_inactive" });
|
|
}
|
|
const provider = getSecretProvider(providerId);
|
|
const providerConfig = await getSelectableRuntimeProviderConfig({
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: secret.providerConfigId,
|
|
});
|
|
const value = await provider.resolveVersion({
|
|
material: versionRow.material as Record<string, unknown>,
|
|
externalRef: secret.externalRef,
|
|
providerVersionRef: versionRow.providerVersionRef,
|
|
providerConfig,
|
|
context: {
|
|
companyId,
|
|
secretId: secret.id,
|
|
secretKey: secret.key,
|
|
version: resolvedVersion,
|
|
},
|
|
});
|
|
await Promise.all([
|
|
db
|
|
.update(companySecrets)
|
|
.set({ lastResolvedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.catch(() => undefined),
|
|
recordAccessEvent({
|
|
companyId,
|
|
secretId: secret.id,
|
|
userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
|
|
secretScope: secret.scope,
|
|
version: resolvedVersion,
|
|
provider: providerId,
|
|
context: accessContext,
|
|
credentialOwnerUserId: secret.ownerUserId ?? null,
|
|
credentialSubjectType: secret.scope === "user" ? "user" : null,
|
|
credentialSubjectId: secret.ownerUserId ?? null,
|
|
outcome: "success",
|
|
}).catch(() => undefined),
|
|
]);
|
|
return {
|
|
value,
|
|
manifestEntry: {
|
|
configPath: configPath ?? "",
|
|
envKey: configPath?.startsWith("env.") ? configPath.slice("env.".length) : null,
|
|
secretId: secret.id,
|
|
bindingId: binding?.id ?? null,
|
|
secretKey: secret.key,
|
|
version: resolvedVersion,
|
|
provider: providerId,
|
|
providerVersionRef: versionRow.providerVersionRef,
|
|
outcome: "success",
|
|
},
|
|
};
|
|
} catch (err) {
|
|
const errorCode = secretResolutionErrorCode(err);
|
|
await recordAccessEvent({
|
|
companyId,
|
|
secretId: secret.id,
|
|
userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
|
|
secretScope: secret.scope,
|
|
version: resolvedVersion,
|
|
provider: providerId,
|
|
context: accessContext,
|
|
credentialOwnerUserId: secret.ownerUserId ?? null,
|
|
credentialSubjectType: secret.scope === "user" ? "user" : null,
|
|
credentialSubjectId: secret.ownerUserId ?? null,
|
|
outcome: "failure",
|
|
errorCode,
|
|
}).catch(() => undefined);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function isSecretResolutionOptions(
|
|
value: SecretBindingContext | SecretResolutionOptions | undefined,
|
|
): value is SecretResolutionOptions {
|
|
return Boolean(value && ("bindingContext" in value || "accessContext" in value));
|
|
}
|
|
|
|
async function resolveSecretValue(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
contextOrOptions?: SecretBindingContext | SecretResolutionOptions,
|
|
): Promise<string> {
|
|
const options = isSecretResolutionOptions(contextOrOptions)
|
|
? contextOrOptions
|
|
: { bindingContext: contextOrOptions, accessContext: contextOrOptions };
|
|
return (await resolveSecretValueInternal(companyId, secretId, version, options)).value;
|
|
}
|
|
|
|
async function resolveSecretValueForEphemeralAccess(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
context: SecretConsumerContext,
|
|
): Promise<string> {
|
|
if (context.consumerType !== "system" || context.consumerId !== "environment-probe-config") {
|
|
throw forbidden("Ephemeral secret resolution is limited to draft environment probes");
|
|
}
|
|
if (
|
|
(context.actorType !== "agent" && context.actorType !== "user") ||
|
|
!context.actorId?.trim()
|
|
) {
|
|
throw forbidden("Ephemeral secret resolution requires an authenticated actor");
|
|
}
|
|
const actor =
|
|
context.actorType === "agent"
|
|
? {
|
|
type: "agent" as const,
|
|
agentId: context.actorId,
|
|
companyId,
|
|
source: context.actorSource === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const,
|
|
}
|
|
: {
|
|
type: "board" as const,
|
|
userId: context.actorId,
|
|
source: context.actorSource === "local_implicit"
|
|
? "local_implicit" as const
|
|
: context.actorSource === "board_key"
|
|
? "board_key" as const
|
|
: context.actorSource === "cloud_tenant"
|
|
? "cloud_tenant" as const
|
|
: "session" as const,
|
|
};
|
|
const decision = await authorization.decide({
|
|
actor,
|
|
action: "secrets:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (!decision.allowed) {
|
|
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
|
}
|
|
return (await resolveSecretValueInternal(companyId, secretId, version, {
|
|
accessContext: context,
|
|
})).value;
|
|
}
|
|
|
|
// Resolve a connection secret for a durable orphan-sandbox teardown.
|
|
//
|
|
// The retry destroys a remote sandbox that a failed acquire left allocated.
|
|
// The teardown needs the same connection secret the acquire used. But the
|
|
// environment binding may be gone: a delete removed the environment, or a
|
|
// provider change replaced the binding. So this path authorizes the read from
|
|
// the durable `pending_cleanup` lease row, not from the environment binding,
|
|
// and it never checks the binding. The caller passes only a secret id that the
|
|
// durable row recorded, so the scope stays narrow. The resolution records an
|
|
// access event for audit, and the value never enters lease metadata.
|
|
async function resolveSecretValueForSandboxCleanup(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
context: {
|
|
configPath: string;
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
},
|
|
): Promise<string> {
|
|
return (
|
|
await resolveSecretValueInternal(companyId, secretId, version, {
|
|
// Audit-only access context. No `bindingContext`, so the resolver never
|
|
// asserts the environment binding that a delete or a provider change may
|
|
// have removed.
|
|
accessContext: {
|
|
consumerType: "system",
|
|
consumerId: SANDBOX_CLEANUP_CONSUMER_ID,
|
|
actorType: "system",
|
|
actorId: null,
|
|
configPath: context.configPath,
|
|
issueId: context.issueId ?? null,
|
|
heartbeatRunId: context.heartbeatRunId ?? null,
|
|
},
|
|
})
|
|
).value;
|
|
}
|
|
|
|
// Resolve a pre-existing `CODEX_HOME_<handle>` (or equivalent) secret's
|
|
// current value for a device-login idempotency check. A device login that
|
|
// finds a secret already at its expected name must compare the stored
|
|
// value against the account home it just resolved, not trust the name
|
|
// alone: a stale or foreign secret at that name would otherwise let the
|
|
// login report success while a bound agent reads the wrong credential
|
|
// home. The read is audit-only (no `bindingContext`), because the secret
|
|
// may carry no environment binding yet.
|
|
async function resolveSecretValueForDeviceLoginCheck(
|
|
companyId: string,
|
|
secretId: string,
|
|
context: { configPath: string },
|
|
): Promise<string> {
|
|
return (
|
|
await resolveSecretValueInternal(companyId, secretId, "latest", {
|
|
accessContext: {
|
|
consumerType: "system",
|
|
consumerId: DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID,
|
|
actorType: "system",
|
|
actorId: null,
|
|
configPath: context.configPath,
|
|
},
|
|
})
|
|
).value;
|
|
}
|
|
|
|
async function resolveSecretValueForAgentAccess(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
context: AgentSecretReadContext,
|
|
): Promise<{ value: string; version: number }> {
|
|
if (context.actorSource !== "agent_jwt") {
|
|
throw forbidden("Agent secret access requires a run-bound agent token");
|
|
}
|
|
if (!isUuidLike(context.heartbeatRunId)) {
|
|
throw forbidden("Agent secret access requires a verified heartbeat run");
|
|
}
|
|
if (!context.configPath.startsWith("env.") && !context.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) {
|
|
throw forbidden("Secret access is not granted for this binding path");
|
|
}
|
|
assertSecretBindingConfigPath({ targetType: "agent", configPath: context.configPath });
|
|
|
|
const run = await db
|
|
.select({ id: heartbeatRuns.id, contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.id, context.heartbeatRunId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, context.agentId),
|
|
eq(heartbeatRuns.status, "running"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!run) {
|
|
throw forbidden("Agent secret access requires a verified heartbeat run");
|
|
}
|
|
|
|
const decision = await authorization.decide({
|
|
actor: {
|
|
type: "agent",
|
|
agentId: context.agentId,
|
|
companyId,
|
|
source: "agent_jwt",
|
|
keyId: context.keyId ?? null,
|
|
keyScope: context.keyScope ?? null,
|
|
runId: context.heartbeatRunId,
|
|
},
|
|
action: "secrets:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (!decision.allowed) {
|
|
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
|
}
|
|
|
|
let bindingContext: SecretBindingContext = {
|
|
consumerType: "agent",
|
|
consumerId: context.agentId,
|
|
configPath: context.configPath,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
actorType: "agent",
|
|
actorId: context.agentId,
|
|
actorSource: context.actorSource,
|
|
issueId: context.issueId ?? null,
|
|
heartbeatRunId: context.heartbeatRunId,
|
|
};
|
|
if (context.bindingId) {
|
|
const binding = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(and(
|
|
eq(companySecretBindings.id, context.bindingId),
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.secretId, secretId),
|
|
eq(companySecretBindings.configPath, context.configPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!binding) throw forbidden("Secret access is not granted for this agent");
|
|
|
|
const runContext = asRecord(run.contextSnapshot) ?? {};
|
|
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
|
|
const manifestBindingIds = new Set(
|
|
Array.isArray(manifest)
|
|
? manifest.flatMap((entry) => {
|
|
const record = asRecord(entry) ?? {};
|
|
return typeof record.bindingId === "string" ? [record.bindingId] : [];
|
|
})
|
|
: [],
|
|
);
|
|
const isDirectAgentBinding = binding.targetType === "agent" && binding.targetId === context.agentId;
|
|
if (!isDirectAgentBinding && !manifestBindingIds.has(binding.id)) {
|
|
throw forbidden("Secret access is not granted for this agent run");
|
|
}
|
|
bindingContext = {
|
|
...bindingContext,
|
|
consumerType: binding.targetType as SecretBindingTargetType,
|
|
consumerId: binding.targetId,
|
|
};
|
|
}
|
|
|
|
const runContext = asRecord(run.contextSnapshot) ?? {};
|
|
const effectiveIssueId = context.issueId ?? (
|
|
typeof runContext.issueId === "string"
|
|
? runContext.issueId
|
|
: typeof (asRecord(runContext.paperclipIssue) ?? {}).id === "string"
|
|
? String((asRecord(runContext.paperclipIssue) ?? {}).id)
|
|
: null
|
|
);
|
|
bindingContext.issueId = effectiveIssueId;
|
|
|
|
const accessContext: SecretConsumerContext = {
|
|
consumerType: "agent_api",
|
|
consumerId: context.agentId,
|
|
configPath: context.configPath,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
actorType: "agent",
|
|
actorId: context.agentId,
|
|
actorSource: context.actorSource,
|
|
issueId: effectiveIssueId,
|
|
heartbeatRunId: context.heartbeatRunId,
|
|
};
|
|
|
|
try {
|
|
const resolution = await resolveSecretValueInternal(companyId, secretId, version, {
|
|
bindingContext,
|
|
accessContext,
|
|
});
|
|
await context.registerForRedaction(resolution.value);
|
|
await logActivity(db as Db, {
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: context.agentId,
|
|
action: "secret.value.read",
|
|
entityType: "secret",
|
|
entityId: secretId,
|
|
agentId: context.agentId,
|
|
runId: context.heartbeatRunId,
|
|
issueId: effectiveIssueId,
|
|
details: {
|
|
configPath: context.configPath,
|
|
outcome: "success",
|
|
version: resolution.manifestEntry.version,
|
|
},
|
|
});
|
|
return {
|
|
value: resolution.value,
|
|
version: resolution.manifestEntry.version,
|
|
};
|
|
} catch (error) {
|
|
const errorCode = secretResolutionErrorCode(error);
|
|
await logActivity(db as Db, {
|
|
companyId,
|
|
actorType: "agent",
|
|
actorId: context.agentId,
|
|
action: "secret.value.read",
|
|
entityType: "secret",
|
|
entityId: secretId,
|
|
agentId: context.agentId,
|
|
runId: context.heartbeatRunId,
|
|
issueId: effectiveIssueId,
|
|
details: {
|
|
configPath: context.configPath,
|
|
outcome: "failure",
|
|
errorCode,
|
|
},
|
|
}).catch(() => undefined);
|
|
if (errorCode === "binding_missing" || errorCode === "secret_scope_invalid") {
|
|
throw forbidden("Secret access is not granted for this agent");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function listAgentSecretAccess(
|
|
companyId: string,
|
|
context: Omit<AgentSecretReadContext, "configPath" | "bindingId" | "registerForRedaction">,
|
|
): Promise<AgentSecretAccessEntry[]> {
|
|
if (context.actorSource !== "agent_jwt" || !isUuidLike(context.heartbeatRunId)) {
|
|
throw forbidden("Agent secret access requires a run-bound agent token");
|
|
}
|
|
const run = await db
|
|
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.id, context.heartbeatRunId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, context.agentId),
|
|
eq(heartbeatRuns.status, "running"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!run) throw forbidden("Agent secret access requires a verified heartbeat run");
|
|
|
|
const decision = await authorization.decide({
|
|
actor: {
|
|
type: "agent",
|
|
agentId: context.agentId,
|
|
companyId,
|
|
source: "agent_jwt",
|
|
keyId: context.keyId ?? null,
|
|
keyScope: context.keyScope ?? null,
|
|
runId: context.heartbeatRunId,
|
|
},
|
|
action: "secrets:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
|
|
|
const runContext = asRecord(run.contextSnapshot) ?? {};
|
|
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
|
|
const manifestBindingIds = Array.isArray(manifest)
|
|
? manifest.flatMap((entry) => {
|
|
const bindingId = (asRecord(entry) ?? {}).bindingId;
|
|
return typeof bindingId === "string" ? [bindingId] : [];
|
|
})
|
|
: [];
|
|
const [directBindings, runtimeBindings] = await Promise.all([
|
|
db.select().from(companySecretBindings).where(and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, "agent"),
|
|
eq(companySecretBindings.targetId, context.agentId),
|
|
or(
|
|
like(companySecretBindings.configPath, "env.%"),
|
|
like(companySecretBindings.configPath, `${AGENT_ACCESS_CONFIG_PATH_PREFIX}%`),
|
|
),
|
|
)),
|
|
manifestBindingIds.length > 0
|
|
? db.select().from(companySecretBindings).where(and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
inArray(companySecretBindings.id, manifestBindingIds),
|
|
))
|
|
: Promise.resolve([]),
|
|
]);
|
|
const bindings = [...new Map([...directBindings, ...runtimeBindings].map((binding) => [binding.id, binding])).values()];
|
|
if (bindings.length === 0) return [];
|
|
|
|
const secrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
eq(companySecrets.status, "active"),
|
|
inArray(companySecrets.id, [...new Set(bindings.map((binding) => binding.secretId))]),
|
|
));
|
|
const secretsById = new Map(secrets.map((secret) => [secret.id, secret]));
|
|
const bindingsBySecret = new Map<string, typeof bindings>();
|
|
for (const binding of bindings) {
|
|
const current = bindingsBySecret.get(binding.secretId) ?? [];
|
|
current.push(binding);
|
|
bindingsBySecret.set(binding.secretId, current);
|
|
}
|
|
|
|
return [...bindingsBySecret.entries()].flatMap(([secretId, secretBindings]) => {
|
|
const secret = secretsById.get(secretId);
|
|
if (!secret) return [];
|
|
const accessBinding = secretBindings.find((binding) => binding.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX));
|
|
const selectedBinding = accessBinding ?? secretBindings[0];
|
|
const hasEnv = secretBindings.some((binding) => binding.configPath.startsWith("env."));
|
|
const hasApi = Boolean(accessBinding);
|
|
const versionSelector: SecretVersionSelector = selectedBinding.versionSelector === "latest"
|
|
? "latest"
|
|
: Number(selectedBinding.versionSelector);
|
|
const delivery: AgentSecretAccessEntry["delivery"] = hasEnv && hasApi ? "both" : hasEnv ? "env" : "api";
|
|
return [{
|
|
secretId,
|
|
bindingId: selectedBinding.id,
|
|
configPath: selectedBinding.configPath,
|
|
key: secret.key,
|
|
name: secret.name,
|
|
description: secret.description ?? null,
|
|
delivery,
|
|
projectionClass: (selectedBinding.projectionClass ?? "unclassified") as SecretProjectionClass,
|
|
latestVersion: secret.latestVersion,
|
|
versionSelector,
|
|
resolvedVersion: versionSelector === "latest" ? secret.latestVersion : versionSelector,
|
|
}];
|
|
}).sort((left, right) => left.key.localeCompare(right.key));
|
|
}
|
|
|
|
async function resolveSecretVersion(
|
|
companyId: string,
|
|
secretId: string,
|
|
version: number | "latest",
|
|
context?: SecretBindingContext,
|
|
): Promise<number> {
|
|
const secret = await getById(secretId);
|
|
if (!secret) throw notFound("Secret not found");
|
|
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
|
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
|
if (secret.status === "deleted") {
|
|
throw new HttpError(404, "Secret not found", { code: "secret_deleted" });
|
|
}
|
|
if (secret.status !== "active") {
|
|
throw unprocessable("Secret is not active", { code: "secret_inactive" });
|
|
}
|
|
await assertBindingContext(companyId, secret.id, context);
|
|
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
|
if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" });
|
|
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
|
|
throw unprocessable("Secret version is not active", { code: "version_inactive" });
|
|
}
|
|
return resolvedVersion;
|
|
}
|
|
|
|
async function normalizeEnvConfig(
|
|
companyId: string,
|
|
envValue: unknown,
|
|
opts?: NormalizeEnvOptions,
|
|
): Promise<AgentEnvConfig> {
|
|
const record = asRecord(envValue);
|
|
if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`);
|
|
|
|
const normalized: AgentEnvConfig = {};
|
|
for (const [key, rawBinding] of Object.entries(record)) {
|
|
if (!ENV_KEY_RE.test(key)) {
|
|
throw unprocessable(`Invalid environment variable name: ${key}`);
|
|
}
|
|
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) {
|
|
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
|
}
|
|
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "plain") {
|
|
if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) {
|
|
throw unprocessable(
|
|
`Strict secret mode requires secret references for sensitive key: ${key}`,
|
|
);
|
|
}
|
|
if (binding.value === REDACTED_SENTINEL) {
|
|
throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`);
|
|
}
|
|
normalized[key] = binding;
|
|
continue;
|
|
}
|
|
if (binding.type === "user_secret_ref") {
|
|
normalized[key] = binding;
|
|
continue;
|
|
}
|
|
|
|
await assertSecretInCompany(companyId, binding.secretId);
|
|
normalized[key] = {
|
|
type: "secret_ref",
|
|
secretId: binding.secretId,
|
|
version: binding.version,
|
|
projectionClass: binding.projectionClass,
|
|
projectionAllowlistKey: binding.projectionAllowlistKey,
|
|
};
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function normalizeAdapterConfigForPersistenceInternal(
|
|
companyId: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
opts?: NormalizeAdapterConfigOptions,
|
|
) {
|
|
const normalized = { ...adapterConfig };
|
|
if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
|
normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
|
|
}
|
|
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType);
|
|
for (const key of secretFieldKeys) {
|
|
if (!Object.prototype.hasOwnProperty.call(adapterConfig, key)) continue;
|
|
const value = await normalizeSchemaSecretFieldForPersistence(companyId, {
|
|
adapterType: opts?.adapterType ?? null,
|
|
key,
|
|
rawValue: adapterConfig[key],
|
|
actor: opts?.actor,
|
|
});
|
|
if (value === undefined) {
|
|
delete normalized[key];
|
|
} else {
|
|
normalized[key] = value;
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function listAdapterSchemaSecretFieldKeys(adapterType: string | null | undefined): Promise<string[]> {
|
|
if (!adapterType) return [];
|
|
const adapter = findActiveServerAdapter(adapterType);
|
|
const fallback = [...(FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS[adapterType] ?? [])];
|
|
if (!adapter?.getConfigSchema) return fallback;
|
|
try {
|
|
const schema = await adapter.getConfigSchema();
|
|
return [...new Set([
|
|
...fallback,
|
|
...schema.fields
|
|
.filter((field) => field.meta?.secret === true)
|
|
.map((field) => field.key),
|
|
])];
|
|
} catch (err) {
|
|
logger.warn({ err, adapterType }, "adapter config schema unavailable while normalizing secret fields");
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function normalizeSchemaSecretFieldForPersistence(
|
|
companyId: string,
|
|
input: {
|
|
adapterType: string | null;
|
|
key: string;
|
|
rawValue: unknown;
|
|
actor?: { userId?: string | null; agentId?: string | null };
|
|
},
|
|
): Promise<EnvBinding | undefined> {
|
|
if (input.rawValue === null || input.rawValue === undefined) return undefined;
|
|
const parsed = envBindingSchema.safeParse(input.rawValue);
|
|
if (!parsed.success) {
|
|
throw unprocessable(`${input.key} must be a string, plain binding, or secret reference`);
|
|
}
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "secret_ref") {
|
|
await assertSecretInCompany(companyId, binding.secretId);
|
|
return {
|
|
type: "secret_ref",
|
|
secretId: binding.secretId,
|
|
version: binding.version,
|
|
projectionClass: binding.projectionClass,
|
|
projectionAllowlistKey: binding.projectionAllowlistKey,
|
|
};
|
|
}
|
|
if (binding.type === "user_secret_ref") {
|
|
throw unprocessable(`${input.key} must be a string, plain binding, or company secret reference`);
|
|
}
|
|
const value = binding.value.trim();
|
|
if (!value) return undefined;
|
|
if (value === REDACTED_SENTINEL) {
|
|
throw unprocessable(`Refusing to persist redacted placeholder for key: ${input.key}`);
|
|
}
|
|
const id = randomUUID();
|
|
const adapterPart = normalizeSecretKey(input.adapterType ?? "adapter");
|
|
const fieldPart = normalizeSecretKey(input.key);
|
|
const secret = await createManagedLocalSecret(companyId, {
|
|
name: `${adapterPart}.${fieldPart}.${id}`,
|
|
key: `${adapterPart}.${fieldPart}.${id}`,
|
|
value,
|
|
description: `Adapter config secret for ${input.adapterType ?? "adapter"}.${input.key}`,
|
|
}, input.actor);
|
|
return {
|
|
type: "secret_ref",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
};
|
|
}
|
|
|
|
// Every `local_encrypted` secret this function creates runs under
|
|
// `withAccountHomeSecretMutationLock`, so its write can never commit inside
|
|
// the window a Codex account-home cleanup already used to decide no secret
|
|
// claims the directory it is about to delete. A create queued behind the
|
|
// lock can still win it after the cleanup already removed that directory,
|
|
// so check the directory's existence inside the same lock, right before
|
|
// the write, the same way `create:` and `rotate:` below do.
|
|
async function createManagedLocalSecret(
|
|
companyId: string,
|
|
input: {
|
|
name: string;
|
|
key: string;
|
|
value: string;
|
|
description?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
return withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
|
await assertAccountHomeCacheDirStillValid(undefined, companyId, input.value);
|
|
return createManagedLocalSecretUnlocked(companyId, input, actor);
|
|
});
|
|
}
|
|
|
|
async function createManagedLocalSecretUnlocked(
|
|
companyId: string,
|
|
input: {
|
|
name: string;
|
|
key: string;
|
|
value: string;
|
|
description?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
const existing = await getByName(companyId, input.name);
|
|
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
|
const key = normalizeSecretKey(input.key);
|
|
if (!key) throw unprocessable("Secret key is required");
|
|
const duplicateKey = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
eq(companySecrets.key, key),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (duplicateKey) throw conflict(`Secret key already exists: ${key}`);
|
|
|
|
const provider = getSecretProvider("local_encrypted");
|
|
const providerConfig = await getSelectableRuntimeProviderConfig({
|
|
companyId,
|
|
provider: "local_encrypted",
|
|
providerConfigId: null,
|
|
});
|
|
const providerWriteContext = {
|
|
companyId,
|
|
secretKey: key,
|
|
secretName: input.name,
|
|
version: 1,
|
|
};
|
|
const reservedSecret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key,
|
|
name: input.name,
|
|
provider: "local_encrypted",
|
|
providerConfigId: null,
|
|
status: "archived",
|
|
managedMode: "paperclip_managed",
|
|
externalRef: null,
|
|
providerMetadata: null,
|
|
latestVersion: 0,
|
|
description: input.description ?? null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
let prepared: PreparedSecretVersion | null = null;
|
|
try {
|
|
prepared = await provider.createSecret({
|
|
value: input.value,
|
|
externalRef: null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
});
|
|
const preparedSecret = prepared;
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: reservedSecret.id,
|
|
version: 1,
|
|
material: preparedSecret.material,
|
|
valueSha256: preparedSecret.valueSha256,
|
|
fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
|
|
providerVersionRef: preparedSecret.providerVersionRef ?? null,
|
|
status: "disabled",
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
});
|
|
return await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(companySecretVersions)
|
|
.set({ status: "current" })
|
|
.where(and(
|
|
eq(companySecretVersions.secretId, reservedSecret.id),
|
|
eq(companySecretVersions.version, 1),
|
|
));
|
|
const secret = await tx
|
|
.update(companySecrets)
|
|
.set({
|
|
status: "active",
|
|
externalRef: preparedSecret.externalRef,
|
|
latestVersion: 1,
|
|
lastRotatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, reservedSecret.id))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
if (!secret) throw notFound("Secret not found");
|
|
return secret;
|
|
});
|
|
} catch (error) {
|
|
if (prepared) {
|
|
await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "delete",
|
|
operation: "adapter_config_secret.create_rollback",
|
|
}).catch(() => false);
|
|
}
|
|
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
|
|
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// The body `create:` below runs, unchanged. Extracted to a named function so
|
|
// `create:` can wrap it in `withAccountHomeSecretMutationLock` only for the
|
|
// one provider (`local_encrypted`) that can hold a literal directory path,
|
|
// without duplicating this whole write sequence.
|
|
async function createSecretUnlocked(
|
|
companyId: string,
|
|
input: {
|
|
name: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
value?: string | null;
|
|
key?: string | null;
|
|
managedMode?: "paperclip_managed" | "external_reference";
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
const existing = await getByName(companyId, input.name);
|
|
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
|
const key = normalizeSecretKey(input.key ?? input.name);
|
|
if (!key) throw unprocessable("Secret key is required");
|
|
const duplicateKey = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
eq(companySecrets.key, key),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (duplicateKey) throw conflict(`Secret key already exists: ${key}`);
|
|
|
|
const managedMode = input.managedMode ?? "paperclip_managed";
|
|
const provider = getSecretProvider(input.provider);
|
|
const providerConfig = await getSelectableRuntimeProviderConfig({
|
|
companyId,
|
|
provider: input.provider,
|
|
providerConfigId: input.providerConfigId,
|
|
});
|
|
if (managedMode === "external_reference" && !input.externalRef?.trim()) {
|
|
throw unprocessable("External reference secrets require externalRef");
|
|
}
|
|
if (managedMode === "paperclip_managed" && input.externalRef?.trim()) {
|
|
throw unprocessable("Managed secrets cannot override externalRef");
|
|
}
|
|
if (managedMode === "paperclip_managed" && !input.value?.trim()) {
|
|
throw unprocessable("Managed secrets require value");
|
|
}
|
|
const providerWriteContext = {
|
|
companyId,
|
|
secretKey: key,
|
|
secretName: input.name,
|
|
version: 1,
|
|
};
|
|
const reservedSecret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key,
|
|
name: input.name,
|
|
provider: input.provider,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
status: "archived",
|
|
managedMode,
|
|
externalRef: null,
|
|
providerMetadata: input.providerMetadata ?? null,
|
|
latestVersion: 0,
|
|
description: input.description ?? null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
let prepared: PreparedSecretVersion;
|
|
try {
|
|
prepared =
|
|
managedMode === "external_reference"
|
|
? await provider.linkExternalSecret({
|
|
externalRef: input.externalRef ?? "",
|
|
providerVersionRef: input.providerVersionRef ?? null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
})
|
|
: await provider.createSecret({
|
|
value: input.value ?? "",
|
|
externalRef: null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
});
|
|
} catch (error) {
|
|
throw await throwProviderWriteOrReservedRowRollbackError({
|
|
error,
|
|
rollbackReservedRow: () => db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)),
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
providerConfig,
|
|
operation: "secret.create",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await db
|
|
.update(companySecrets)
|
|
.set({
|
|
externalRef: prepared.externalRef,
|
|
latestVersion: 1,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, reservedSecret.id));
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: reservedSecret.id,
|
|
version: 1,
|
|
material: prepared.material,
|
|
valueSha256: prepared.valueSha256,
|
|
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
|
providerVersionRef: prepared.providerVersionRef ?? null,
|
|
status: "disabled",
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
});
|
|
} catch (error) {
|
|
if (managedMode === "paperclip_managed") {
|
|
const cleaned = await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "delete",
|
|
operation: "create.prepare_rollback",
|
|
});
|
|
if (!cleaned) {
|
|
throwProviderCleanupFailedAfterCreateRollback({
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
providerConfig,
|
|
operation: "create.prepare_rollback",
|
|
});
|
|
}
|
|
}
|
|
await deleteLocalSecretCreateReservationOrThrow({
|
|
db,
|
|
secretId: reservedSecret.id,
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
providerConfig,
|
|
operation: "create.prepare_rollback",
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
return await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(companySecretVersions)
|
|
.set({ status: "current" })
|
|
.where(and(
|
|
eq(companySecretVersions.secretId, reservedSecret.id),
|
|
eq(companySecretVersions.version, 1),
|
|
));
|
|
|
|
const secret = await tx
|
|
.update(companySecrets)
|
|
.set({
|
|
status: "active",
|
|
externalRef: prepared.externalRef,
|
|
latestVersion: 1,
|
|
lastRotatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, reservedSecret.id))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
if (!secret) throw notFound("Secret not found");
|
|
return secret;
|
|
});
|
|
} catch (error) {
|
|
if (managedMode === "paperclip_managed") {
|
|
const cleaned = await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "delete",
|
|
operation: "create.rollback",
|
|
});
|
|
if (!cleaned) {
|
|
throwProviderCleanupFailedAfterCreateRollback({
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
providerConfig,
|
|
operation: "create.rollback",
|
|
});
|
|
}
|
|
}
|
|
await deleteLocalSecretCreateReservationOrThrow({
|
|
db,
|
|
secretId: reservedSecret.id,
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
providerConfig,
|
|
operation: "create.rollback",
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// The body of `rotate:` below, unchanged. Extracted to a named function so
|
|
// `rotate:` can wrap it in `withAccountHomeSecretMutationLock` only when the
|
|
// secret being rotated is `local_encrypted`, without duplicating this whole
|
|
// write sequence.
|
|
async function rotateUnlocked(
|
|
secretId: string,
|
|
input: {
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
// The optional owner-bound compare-and-set guard. When set, the final
|
|
// update matches the latest version, so a concurrent rotation between the
|
|
// read and the write cannot pass. A mismatch throws a 409 conflict.
|
|
expectedLatestVersion?: number;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
const secret = await getById(secretId);
|
|
if (!secret) throw notFound("Secret not found");
|
|
if (secret.status !== "active") throw unprocessable("Cannot rotate a non-active secret");
|
|
if (input.expectedLatestVersion !== undefined && secret.latestVersion !== input.expectedLatestVersion) {
|
|
throw conflict(SECRET_VERSION_STALE_CONFLICT);
|
|
}
|
|
const providerId = secret.provider as SecretProvider;
|
|
const provider = getSecretProvider(providerId);
|
|
const providerConfigId =
|
|
input.providerConfigId === undefined ? secret.providerConfigId : input.providerConfigId;
|
|
const providerConfig = await getSelectableRuntimeProviderConfig({
|
|
companyId: secret.companyId,
|
|
provider: providerId,
|
|
providerConfigId,
|
|
});
|
|
const nextVersion = secret.latestVersion + 1;
|
|
const externalValueWrite =
|
|
secret.managedMode === "external_reference" && Boolean(input.value?.trim());
|
|
if (externalValueWrite) {
|
|
const currentRef = secret.externalRef?.trim();
|
|
if (!currentRef) {
|
|
throw unprocessable("External reference secrets require externalRef");
|
|
}
|
|
if (input.externalRef?.trim() && input.externalRef.trim() !== currentRef) {
|
|
throw unprocessable(
|
|
"Provide either a new value or a new external reference, not both",
|
|
);
|
|
}
|
|
if (input.providerVersionRef?.trim()) {
|
|
throw unprocessable("Value updates cannot pin providerVersionRef");
|
|
}
|
|
if (!provider.updateExternalSecretValue) {
|
|
throw unprocessable(
|
|
`${provider.descriptor().label} does not support writing values to external reference secrets`,
|
|
);
|
|
}
|
|
}
|
|
if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) {
|
|
throw unprocessable("External reference secrets require externalRef");
|
|
}
|
|
if (secret.managedMode !== "external_reference" && input.externalRef?.trim()) {
|
|
throw unprocessable("Managed secrets cannot override externalRef");
|
|
}
|
|
if (secret.managedMode !== "external_reference" && !input.value?.trim()) {
|
|
throw unprocessable("Managed secrets require value");
|
|
}
|
|
const providerWriteContext = {
|
|
companyId: secret.companyId,
|
|
secretKey: secret.key,
|
|
secretName: secret.name,
|
|
version: nextVersion,
|
|
};
|
|
let prepared: PreparedSecretVersion;
|
|
try {
|
|
prepared = externalValueWrite
|
|
? await provider.updateExternalSecretValue!({
|
|
externalRef: secret.externalRef ?? "",
|
|
value: input.value ?? "",
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
})
|
|
: secret.managedMode === "external_reference"
|
|
? await provider.linkExternalSecret({
|
|
externalRef: input.externalRef ?? secret.externalRef ?? "",
|
|
providerVersionRef: input.providerVersionRef ?? null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
})
|
|
: await provider.createVersion({
|
|
value: input.value ?? "",
|
|
externalRef: secret.externalRef ?? null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
});
|
|
} catch (error) {
|
|
throw remoteProviderWriteHttpError(error, {
|
|
companyId: secret.companyId,
|
|
provider: provider.id,
|
|
providerConfigId,
|
|
providerConfig,
|
|
operation: "secret.rotate",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: secret.id,
|
|
version: nextVersion,
|
|
material: prepared.material,
|
|
valueSha256: prepared.valueSha256,
|
|
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
|
providerVersionRef: prepared.providerVersionRef ?? null,
|
|
status: "disabled",
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
});
|
|
} catch (error) {
|
|
if (secret.managedMode !== "external_reference" || externalValueWrite) {
|
|
await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "archive",
|
|
operation: "rotate.prepare_rollback",
|
|
});
|
|
}
|
|
// A guarded concurrent rotation inserted the same next version first, so
|
|
// this insert fails the (secretId, version) unique index. The loser never
|
|
// reaches the compare-and-set guard below. Normalize only that one
|
|
// collision to the same stale conflict, so a guarded rotation always
|
|
// returns one fixed 409. Re-throw every other error unchanged.
|
|
if (
|
|
input.expectedLatestVersion !== undefined &&
|
|
isUniqueConstraintViolation(error, COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT)
|
|
) {
|
|
throw conflict(SECRET_VERSION_STALE_CONFLICT);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
return await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(companySecretVersions)
|
|
.set({ status: "previous" })
|
|
.where(and(
|
|
eq(companySecretVersions.secretId, secret.id),
|
|
ne(companySecretVersions.version, nextVersion),
|
|
));
|
|
await tx
|
|
.update(companySecretVersions)
|
|
.set({ status: "current" })
|
|
.where(and(
|
|
eq(companySecretVersions.secretId, secret.id),
|
|
eq(companySecretVersions.version, nextVersion),
|
|
));
|
|
|
|
const updated = await tx
|
|
.update(companySecrets)
|
|
.set({
|
|
latestVersion: nextVersion,
|
|
externalRef: prepared.externalRef,
|
|
providerConfigId,
|
|
lastRotatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(companySecrets.id, secret.id),
|
|
// The compare-and-set guard. It matches the latest version, so a
|
|
// concurrent rotation that already bumped it fails this predicate.
|
|
input.expectedLatestVersion === undefined
|
|
? undefined
|
|
: eq(companySecrets.latestVersion, input.expectedLatestVersion),
|
|
))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!updated) {
|
|
// The predicate matched no row. A supplied expected version means a
|
|
// concurrent rotation won the race; return the stale conflict. An
|
|
// unguarded rotation means the secret is gone.
|
|
throw input.expectedLatestVersion === undefined
|
|
? notFound("Secret not found")
|
|
: conflict(SECRET_VERSION_STALE_CONFLICT);
|
|
}
|
|
return updated;
|
|
});
|
|
} catch (error) {
|
|
if (secret.managedMode !== "external_reference" || externalValueWrite) {
|
|
const cleaned = await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "archive",
|
|
operation: "rotate.rollback",
|
|
});
|
|
if (cleaned) {
|
|
await db
|
|
.delete(companySecretVersions)
|
|
.where(and(
|
|
eq(companySecretVersions.secretId, secret.id),
|
|
eq(companySecretVersions.version, nextVersion),
|
|
))
|
|
.catch(() => undefined);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// The body of `update:` below, unchanged. Extracted to a named function so
|
|
// `update:` can wrap it in `withAccountHomeSecretMutationLock`, keyed by the
|
|
// company the pre-check read, without duplicating this whole patch
|
|
// sequence.
|
|
async function updateUnlocked(
|
|
secretId: string,
|
|
patch: {
|
|
name?: string;
|
|
key?: string;
|
|
status?: "active" | "disabled" | "archived" | "deleted";
|
|
providerConfigId?: string | null;
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
},
|
|
) {
|
|
const secret = await getById(secretId);
|
|
if (!secret) throw notFound("Secret not found");
|
|
if (secret.status === "deleted") throw notFound("Secret not found");
|
|
|
|
if (patch.name && patch.name !== secret.name) {
|
|
const duplicate = await getByName(secret.companyId, patch.name);
|
|
if (duplicate && duplicate.id !== secret.id) {
|
|
throw conflict(`Secret already exists: ${patch.name}`);
|
|
}
|
|
}
|
|
const nextKey = patch.key ? normalizeSecretKey(patch.key) : secret.key;
|
|
if (!nextKey) throw unprocessable("Secret key is required");
|
|
if (nextKey !== secret.key) {
|
|
const duplicateKey = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, secret.companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
eq(companySecrets.key, nextKey),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (duplicateKey && duplicateKey.id !== secret.id) {
|
|
throw conflict(`Secret key already exists: ${nextKey}`);
|
|
}
|
|
}
|
|
const deleting = patch.status === "deleted";
|
|
if (deleting && secret.managedMode === "paperclip_managed") {
|
|
throw unprocessable("Managed secrets must be deleted through DELETE /secrets/:id");
|
|
}
|
|
if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) {
|
|
throw unprocessable("Managed secrets cannot override externalRef");
|
|
}
|
|
if (
|
|
secret.managedMode === "external_reference" &&
|
|
patch.externalRef !== undefined &&
|
|
patch.externalRef !== secret.externalRef
|
|
) {
|
|
throw unprocessable(
|
|
"External reference secrets cannot be retargeted through generic update",
|
|
);
|
|
}
|
|
if (
|
|
secret.managedMode === "external_reference" &&
|
|
patch.providerConfigId !== undefined &&
|
|
patch.providerConfigId !== secret.providerConfigId
|
|
) {
|
|
throw unprocessable(
|
|
"External reference secrets cannot change provider vault through generic update",
|
|
);
|
|
}
|
|
if (
|
|
secret.managedMode === "paperclip_managed" &&
|
|
patch.providerConfigId !== undefined &&
|
|
patch.providerConfigId !== secret.providerConfigId
|
|
) {
|
|
throw unprocessable(
|
|
"Managed secrets cannot change provider vault through PATCH; use rotate() to migrate to a new vault",
|
|
);
|
|
}
|
|
if (patch.providerConfigId !== undefined) {
|
|
await assertProviderConfigForSecret(
|
|
secret.companyId,
|
|
secret.provider as SecretProvider,
|
|
patch.providerConfigId,
|
|
);
|
|
}
|
|
|
|
return db
|
|
.update(companySecrets)
|
|
.set({
|
|
key: deleting ? `${secret.key}__deleted__${secret.id}` : nextKey,
|
|
name: deleting ? `${secret.name}__deleted__${secret.id}` : patch.name ?? secret.name,
|
|
status: patch.status ?? secret.status,
|
|
providerConfigId:
|
|
patch.providerConfigId === undefined ? secret.providerConfigId : patch.providerConfigId,
|
|
description:
|
|
patch.description === undefined ? secret.description : patch.description,
|
|
externalRef:
|
|
patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
|
|
providerMetadata:
|
|
patch.providerMetadata === undefined ? secret.providerMetadata : patch.providerMetadata,
|
|
deletedAt: deleting ? new Date() : secret.deletedAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
function collectTargetIds(
|
|
bindings: Array<typeof companySecretBindings.$inferSelect>,
|
|
targetType: SecretBindingTargetType,
|
|
opts?: { uuidOnly?: boolean },
|
|
) {
|
|
return [
|
|
...new Set(
|
|
bindings
|
|
.filter((binding) => binding.targetType === targetType)
|
|
.map((binding) => binding.targetId)
|
|
.filter((id) => !opts?.uuidOnly || isUuidLike(id)),
|
|
),
|
|
];
|
|
}
|
|
|
|
function fallbackBindingTarget(binding: typeof companySecretBindings.$inferSelect): CompanySecretBindingTarget {
|
|
return {
|
|
type: binding.targetType as SecretBindingTargetType,
|
|
id: binding.targetId,
|
|
label: binding.targetId,
|
|
href: null,
|
|
status: null,
|
|
};
|
|
}
|
|
|
|
async function buildBindingTargetMap(
|
|
companyId: string,
|
|
bindings: Array<typeof companySecretBindings.$inferSelect>,
|
|
) {
|
|
const targetMap = new Map<string, CompanySecretBindingTarget>();
|
|
const setTarget = (target: CompanySecretBindingTarget) => {
|
|
targetMap.set(`${target.type}:${target.id}`, target);
|
|
};
|
|
|
|
const agentIds = collectTargetIds(bindings, "agent", { uuidOnly: true });
|
|
if (agentIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: agents.id,
|
|
name: agents.name,
|
|
title: agents.title,
|
|
status: agents.status,
|
|
})
|
|
.from(agents)
|
|
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "agent",
|
|
id: row.id,
|
|
label: row.title ? `${row.name} (${row.title})` : row.name,
|
|
href: `/agents/${normalizeAgentUrlKey(row.name) ?? row.id}`,
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
const projectIds = collectTargetIds(bindings, "project", { uuidOnly: true });
|
|
if (projectIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: projects.id,
|
|
name: projects.name,
|
|
status: projects.status,
|
|
})
|
|
.from(projects)
|
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "project",
|
|
id: row.id,
|
|
label: row.name,
|
|
href: `/projects/${deriveProjectUrlKey(row.name, row.id)}`,
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
const environmentIds = collectTargetIds(bindings, "environment", { uuidOnly: true });
|
|
if (environmentIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: environments.id,
|
|
name: environments.name,
|
|
status: environments.status,
|
|
})
|
|
.from(environments)
|
|
.where(inArray(environments.id, environmentIds));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "environment",
|
|
id: row.id,
|
|
label: row.name,
|
|
href: "/company/settings/instance/environments",
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
const routineIds = collectTargetIds(bindings, "routine", { uuidOnly: true });
|
|
if (routineIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: routines.id,
|
|
title: routines.title,
|
|
status: routines.status,
|
|
})
|
|
.from(routines)
|
|
.where(and(eq(routines.companyId, companyId), inArray(routines.id, routineIds)));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "routine",
|
|
id: row.id,
|
|
label: row.title,
|
|
href: `/routines/${row.id}`,
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
const issueIds = collectTargetIds(bindings, "issue", { uuidOnly: true });
|
|
if (issueIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "issue",
|
|
id: row.id,
|
|
label: row.identifier ? `${row.identifier} ${row.title}` : row.title,
|
|
href: `/issues/${row.identifier ?? row.id}`,
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
const runIds = collectTargetIds(bindings, "run", { uuidOnly: true });
|
|
if (runIds.length > 0) {
|
|
const rows = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds)));
|
|
for (const row of rows) {
|
|
setTarget({
|
|
type: "run",
|
|
id: row.id,
|
|
label: `Run ${row.id.slice(0, 8)}`,
|
|
href: `/agents/${row.agentId}/runs/${row.id}`,
|
|
status: row.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
return targetMap;
|
|
}
|
|
|
|
async function buildRemoteImportConflictMaps(companyId: string, provider: SecretProvider) {
|
|
const activeSecrets = await db
|
|
.select({
|
|
id: companySecrets.id,
|
|
name: companySecrets.name,
|
|
key: companySecrets.key,
|
|
provider: companySecrets.provider,
|
|
providerConfigId: companySecrets.providerConfigId,
|
|
externalRef: companySecrets.externalRef,
|
|
status: companySecrets.status,
|
|
})
|
|
.from(companySecrets)
|
|
.where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted")));
|
|
return {
|
|
byProviderConfigExternalRef: new Map(
|
|
activeSecrets
|
|
.filter((secret) =>
|
|
secret.provider === provider &&
|
|
typeof secret.externalRef === "string" &&
|
|
secret.externalRef.trim()
|
|
)
|
|
.map((secret) => [
|
|
remoteImportExternalRefKey(secret.providerConfigId, secret.externalRef!),
|
|
secret,
|
|
]),
|
|
),
|
|
byName: new Map(activeSecrets.map((secret) => [secret.name, secret])),
|
|
byKey: new Map(activeSecrets.map((secret) => [secret.key, secret])),
|
|
};
|
|
}
|
|
|
|
function remoteImportExternalRefKey(providerConfigId: string | null | undefined, externalRef: string) {
|
|
return `${providerConfigId ?? "default"}\0${externalRef.trim()}`;
|
|
}
|
|
|
|
function sanitizeRemoteProviderMetadata(
|
|
provider: SecretProvider,
|
|
metadata: Record<string, unknown> | null | undefined,
|
|
): Record<string, unknown> | null {
|
|
if (!metadata || provider !== "aws_secrets_manager") return null;
|
|
const safe: Record<string, unknown> = {};
|
|
for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) {
|
|
const value = metadata[key];
|
|
if (typeof value === "string" || value === null) safe[key] = value;
|
|
}
|
|
for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) {
|
|
const value = metadata[key];
|
|
if (typeof value === "boolean" || typeof value === "number") safe[key] = value;
|
|
}
|
|
return Object.keys(safe).length > 0 ? safe : null;
|
|
}
|
|
|
|
function remoteImportConflictsFor(input: {
|
|
providerConfigId: string | null;
|
|
externalRef: string;
|
|
name: string;
|
|
key: string;
|
|
maps: Awaited<ReturnType<typeof buildRemoteImportConflictMaps>>;
|
|
}): RemoteSecretImportConflict[] {
|
|
const conflicts: RemoteSecretImportConflict[] = [];
|
|
const duplicate = input.maps.byProviderConfigExternalRef.get(
|
|
remoteImportExternalRefKey(input.providerConfigId, input.externalRef),
|
|
);
|
|
if (duplicate) {
|
|
conflicts.push({
|
|
type: "exact_reference",
|
|
existingSecretId: duplicate.id,
|
|
message: "An existing secret already links this exact provider reference.",
|
|
});
|
|
return conflicts;
|
|
}
|
|
const nameConflict = input.maps.byName.get(input.name);
|
|
if (nameConflict) {
|
|
conflicts.push({
|
|
type: "name",
|
|
existingSecretId: nameConflict.id,
|
|
message: `Secret name already exists: ${input.name}`,
|
|
});
|
|
}
|
|
const keyConflict = input.maps.byKey.get(input.key);
|
|
if (keyConflict) {
|
|
conflicts.push({
|
|
type: "key",
|
|
existingSecretId: keyConflict.id,
|
|
message: `Secret key already exists: ${input.key}`,
|
|
});
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
async function getRemoteImportProviderConfig(companyId: string, providerConfigId: string) {
|
|
const providerConfig = await getProviderConfigById(providerConfigId);
|
|
if (!providerConfig) throw notFound("Provider vault not found");
|
|
const provider = providerConfig.provider as SecretProvider;
|
|
assertSelectableProviderConfig(providerConfig, companyId, provider);
|
|
return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
|
|
}
|
|
|
|
async function createUserSecretValueInternal(
|
|
companyId: string,
|
|
ownerUserId: string,
|
|
input: {
|
|
definitionId?: string | null;
|
|
definitionKey?: string | null;
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
const definition = await resolveUserSecretDefinition(companyId, input);
|
|
if (definition.status !== "active") {
|
|
throw unprocessable("User secret definition is not active");
|
|
}
|
|
const existing = await getUserSecretValue({
|
|
companyId,
|
|
ownerUserId,
|
|
definitionId: definition.id,
|
|
});
|
|
if (existing) throw conflict("User secret value already exists");
|
|
|
|
const providerId = definition.provider as SecretProvider;
|
|
const managedMode = definition.managedMode as "paperclip_managed" | "external_reference";
|
|
if (managedMode === "external_reference" && !input.externalRef?.trim()) {
|
|
throw unprocessable("External reference user secrets require externalRef");
|
|
}
|
|
if (managedMode === "paperclip_managed" && input.externalRef?.trim()) {
|
|
throw unprocessable("Managed user secrets cannot override externalRef");
|
|
}
|
|
if (managedMode === "paperclip_managed" && !input.value?.trim()) {
|
|
throw unprocessable("Managed user secrets require value");
|
|
}
|
|
|
|
const providerConfigId =
|
|
input.providerConfigId === undefined ? definition.providerConfigId : input.providerConfigId;
|
|
const provider = getSecretProvider(providerId);
|
|
const providerConfig = await getSelectableRuntimeProviderConfig({
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId,
|
|
});
|
|
const idSuffix = randomUUID();
|
|
const key = normalizeSecretKey(`user.${definition.key}.${idSuffix}`);
|
|
const name = `${definition.name} (${ownerUserId})`;
|
|
const providerWriteContext = {
|
|
companyId,
|
|
secretKey: key,
|
|
secretName: definition.name,
|
|
version: 1,
|
|
};
|
|
let reservedSecret: typeof companySecrets.$inferSelect;
|
|
try {
|
|
reservedSecret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
scope: "user",
|
|
ownerUserId,
|
|
userSecretDefinitionId: definition.id,
|
|
key,
|
|
name,
|
|
provider: providerId,
|
|
providerConfigId: providerConfigId ?? null,
|
|
status: "archived",
|
|
managedMode,
|
|
externalRef: null,
|
|
providerMetadata: definition.providerMetadata ?? null,
|
|
latestVersion: 0,
|
|
description: definition.description ?? null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, USER_SECRET_VALUE_UNIQUE_CONSTRAINT)) {
|
|
throw conflict("User secret value already exists");
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
let prepared: PreparedSecretVersion;
|
|
try {
|
|
prepared =
|
|
managedMode === "external_reference"
|
|
? await provider.linkExternalSecret({
|
|
externalRef: input.externalRef ?? "",
|
|
providerVersionRef: input.providerVersionRef ?? null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
})
|
|
: await provider.createSecret({
|
|
value: input.value ?? "",
|
|
externalRef: null,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
});
|
|
} catch (error) {
|
|
throw await throwProviderWriteOrReservedRowRollbackError({
|
|
error,
|
|
rollbackReservedRow: () => db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)),
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId,
|
|
providerConfig,
|
|
operation: "secret.create",
|
|
});
|
|
}
|
|
|
|
try {
|
|
return await db.transaction(async (tx) => {
|
|
await tx.insert(companySecretVersions).values({
|
|
secretId: reservedSecret.id,
|
|
version: 1,
|
|
material: prepared.material,
|
|
valueSha256: prepared.valueSha256,
|
|
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
|
providerVersionRef: prepared.providerVersionRef ?? null,
|
|
status: "current",
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
});
|
|
const secret = await tx
|
|
.update(companySecrets)
|
|
.set({
|
|
status: "active",
|
|
externalRef: prepared.externalRef,
|
|
latestVersion: 1,
|
|
lastRotatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, reservedSecret.id))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
if (!secret) throw notFound("User secret value not found");
|
|
return secret;
|
|
});
|
|
} catch (error) {
|
|
if (managedMode === "paperclip_managed") {
|
|
const cleaned = await cleanupPreparedProviderWrite({
|
|
provider,
|
|
prepared,
|
|
providerConfig,
|
|
context: providerWriteContext,
|
|
mode: "delete",
|
|
operation: "user_secret_value.create_rollback",
|
|
});
|
|
if (!cleaned) {
|
|
throwProviderCleanupFailedAfterCreateRollback({
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId,
|
|
providerConfig,
|
|
operation: "user_secret_value.create_rollback",
|
|
});
|
|
}
|
|
}
|
|
await deleteLocalSecretCreateReservationOrThrow({
|
|
db,
|
|
secretId: reservedSecret.id,
|
|
companyId,
|
|
provider: provider.id,
|
|
providerConfigId,
|
|
providerConfig,
|
|
operation: "user_secret_value.create_rollback",
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- Claude Code OAuth login: narrow definition and owner-bound write -------
|
|
|
|
type UserSecretDefinitionRow = typeof userSecretDefinitions.$inferSelect;
|
|
type CompanySecretRow = typeof companySecrets.$inferSelect;
|
|
|
|
/** True only when a stored definition matches the fixed Claude OAuth shape. */
|
|
function isCompatibleClaudeOAuthDefinition(definition: UserSecretDefinitionRow) {
|
|
return (
|
|
definition.key === CLAUDE_CODE_OAUTH_DEFINITION.key &&
|
|
definition.name === CLAUDE_CODE_OAUTH_DEFINITION.name &&
|
|
definition.provider === CLAUDE_CODE_OAUTH_DEFINITION.provider &&
|
|
definition.managedMode === CLAUDE_CODE_OAUTH_DEFINITION.managedMode &&
|
|
definition.status === CLAUDE_CODE_OAUTH_DEFINITION.status
|
|
);
|
|
}
|
|
|
|
/** Reads the recorded setup-token session id from an owner value, or null. */
|
|
function readClaudeOAuthSessionId(secret: CompanySecretRow): string | null {
|
|
const metadata = secret.providerMetadata;
|
|
if (!metadata || typeof metadata !== "object") return null;
|
|
const value = (metadata as Record<string, unknown>)[CLAUDE_OAUTH_SESSION_METADATA_FIELD];
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
/** Records the setup-token session id on the owner value. Not a secret. */
|
|
async function stampClaudeOAuthSessionId(
|
|
secret: CompanySecretRow,
|
|
sessionId: string,
|
|
): Promise<CompanySecretRow> {
|
|
const nextMetadata: Record<string, unknown> = {
|
|
...(secret.providerMetadata ?? {}),
|
|
[CLAUDE_OAUTH_SESSION_METADATA_FIELD]: sessionId,
|
|
};
|
|
return db
|
|
.update(companySecrets)
|
|
.set({ providerMetadata: nextMetadata, updatedAt: new Date() })
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? secret);
|
|
}
|
|
|
|
function toClaudeOAuthResult(secret: CompanySecretRow): ClaudeOAuthUserSecretResult {
|
|
return {
|
|
secretId: secret.id,
|
|
latestVersion: secret.latestVersion,
|
|
definitionId: secret.userSecretDefinitionId ?? "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensures the fixed Claude Code OAuth user-secret definition for a company. The
|
|
* helper accepts no key, name, provider, mode, or status from a caller. It
|
|
* reads the existing definition by the company and the fixed key. It returns an
|
|
* exact compatible definition. It rejects a conflicting definition with 409 and
|
|
* does not mutate it. After a uniqueness conflict it re-reads the row and
|
|
* compares the fixed fields before it returns.
|
|
*/
|
|
async function ensureClaudeOAuthUserSecretDefinitionInternal(
|
|
companyId: string,
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
): Promise<UserSecretDefinitionRow> {
|
|
const existing = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
|
if (existing) {
|
|
if (!isCompatibleClaudeOAuthDefinition(existing)) {
|
|
throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT);
|
|
}
|
|
return existing;
|
|
}
|
|
try {
|
|
return await db
|
|
.insert(userSecretDefinitions)
|
|
.values({
|
|
companyId,
|
|
key: CLAUDE_CODE_OAUTH_DEFINITION.key,
|
|
name: CLAUDE_CODE_OAUTH_DEFINITION.name,
|
|
description: null,
|
|
status: CLAUDE_CODE_OAUTH_DEFINITION.status,
|
|
provider: CLAUDE_CODE_OAUTH_DEFINITION.provider,
|
|
providerConfigId: null,
|
|
managedMode: CLAUDE_CODE_OAUTH_DEFINITION.managedMode,
|
|
providerMetadata: null,
|
|
usageGuidance: null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
updatedByAgentId: actor?.agentId ?? null,
|
|
updatedByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) {
|
|
// A concurrent create won the race. Re-read and compare the fixed fields.
|
|
const raced = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
|
if (raced && isCompatibleClaudeOAuthDefinition(raced)) return raced;
|
|
throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The owner-bound compare-and-set for the Claude Code OAuth value. It has two
|
|
* modes. `first_write` creates a value only when no owner value exists.
|
|
* `confirmed_rotation` rotates only after confirmation with the expected secret
|
|
* id and the expected latest version. The session id is the idempotency key: a
|
|
* repeated successful completion returns the stored result and creates no new
|
|
* version.
|
|
*/
|
|
async function completeClaudeOAuthUserSecretInternal(
|
|
companyId: string,
|
|
ownerUserId: string,
|
|
input: {
|
|
sessionId: string;
|
|
mode: "first_write" | "confirmed_rotation";
|
|
value: string;
|
|
expectedSecretId?: string | null;
|
|
expectedLatestVersion?: number | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
): Promise<ClaudeOAuthUserSecretResult> {
|
|
const sessionId = input.sessionId?.trim();
|
|
if (!sessionId) throw unprocessable("A Claude login session id is required");
|
|
const value = input.value?.trim();
|
|
if (!value) throw unprocessable("A Claude login token value is required");
|
|
|
|
const definition = await ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor);
|
|
|
|
// Idempotency: a prior successful completion for this session returns the
|
|
// stored result and creates no new version.
|
|
const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
|
if (existing && readClaudeOAuthSessionId(existing) === sessionId) {
|
|
return toClaudeOAuthResult(existing);
|
|
}
|
|
|
|
if (input.mode === "first_write") {
|
|
if (existing) throw conflict(CLAUDE_OAUTH_VALUE_EXISTS);
|
|
let created: CompanySecretRow;
|
|
try {
|
|
created = await createUserSecretValueInternal(
|
|
companyId,
|
|
ownerUserId,
|
|
{ definitionId: definition.id, value },
|
|
actor,
|
|
);
|
|
} catch (error) {
|
|
// Two concurrent first writes race the partial unique index. Re-read and
|
|
// return the stored result only when this session already won.
|
|
if (error instanceof HttpError && error.status === 409) {
|
|
const raced = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
|
if (raced && readClaudeOAuthSessionId(raced) === sessionId) {
|
|
return toClaudeOAuthResult(raced);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
const stamped = await stampClaudeOAuthSessionId(created, sessionId);
|
|
return toClaudeOAuthResult(stamped);
|
|
}
|
|
|
|
// confirmed_rotation.
|
|
if (!input.expectedSecretId || input.expectedLatestVersion == null) {
|
|
throw unprocessable("A confirmed rotation requires expectedSecretId and expectedLatestVersion");
|
|
}
|
|
// The owner-scoped lookup fails closed for a cross-owner or cross-company id.
|
|
const current = await getUserSecretValueById(companyId, ownerUserId, input.expectedSecretId);
|
|
if (current.userSecretDefinitionId !== definition.id) {
|
|
throw notFound("User secret value not found");
|
|
}
|
|
if (current.latestVersion !== input.expectedLatestVersion) {
|
|
throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION);
|
|
}
|
|
let rotated: CompanySecretRow;
|
|
try {
|
|
rotated = await secretService(db).rotate(
|
|
current.id,
|
|
{ value, expectedLatestVersion: input.expectedLatestVersion },
|
|
actor,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof HttpError && error.status === 409) {
|
|
// A concurrent rotation bumped the version between the read and the
|
|
// predicate. Return the same fixed stale-confirmation conflict.
|
|
throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION);
|
|
}
|
|
throw error;
|
|
}
|
|
const stamped = await stampClaudeOAuthSessionId(rotated, sessionId);
|
|
return toClaudeOAuthResult(stamped);
|
|
}
|
|
|
|
/**
|
|
* Reads the stored Claude Code OAuth value metadata for one owner. It returns
|
|
* only the secret id and the latest version, never the token. The client uses
|
|
* the version as the expected version of a later confirmed rotation.
|
|
*
|
|
* The reader derives no definition, no owner, and no secret id from a caller.
|
|
* It reads the fixed definition by the company and the fixed key. It reads the
|
|
* value by the company, the owner, and that definition. It returns null when no
|
|
* definition or no owner value exists, so a foreign value and a missing value
|
|
* look the same to the caller. It never creates the definition.
|
|
*/
|
|
async function readClaudeOAuthUserSecretStatusInternal(
|
|
companyId: string,
|
|
ownerUserId: string,
|
|
): Promise<{ secretId: string; latestVersion: number } | null> {
|
|
const definition = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
|
if (!definition || !isCompatibleClaudeOAuthDefinition(definition)) return null;
|
|
const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
|
if (!existing) return null;
|
|
return { secretId: existing.id, latestVersion: existing.latestVersion };
|
|
}
|
|
|
|
// A delete can stop the generated account-home secret from resolving to
|
|
// the value a device-login promotion already validated, the same way a
|
|
// rename, an archive, or a disable can (see `update:`'s comment). Run every
|
|
// delete inside `withAccountHomeSecretMutationLock`, keyed by the company a
|
|
// pre-check read, so a delete can never land in the gap between the
|
|
// promotion's terminal-commit re-check and the commit it guards.
|
|
// `removeSecretUnlocked` re-reads the secret under the lock, so a delete
|
|
// that lost a race between the pre-check and the lock still sees current
|
|
// state, not the pre-check's stale read.
|
|
async function removeSecretInternal(secretId: string) {
|
|
const preCheckSecret = await getById(secretId);
|
|
if (!preCheckSecret) return null;
|
|
return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, () =>
|
|
removeSecretUnlocked(secretId),
|
|
);
|
|
}
|
|
|
|
// The body of `removeSecretInternal` above, unchanged. Extracted to a named
|
|
// function so that wrapper can hold the lock across this whole delete
|
|
// sequence without duplicating it.
|
|
async function removeSecretUnlocked(secretId: string) {
|
|
const secret = await getById(secretId);
|
|
if (!secret) return null;
|
|
const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
|
|
const providerId = secret.provider as SecretProvider;
|
|
const provider = getSecretProvider(providerId);
|
|
if (secret.status !== "deleted") {
|
|
await db
|
|
.update(companySecrets)
|
|
.set({
|
|
key: `${secret.key}__deleted__${secret.id}`,
|
|
name: `${secret.name}__deleted__${secret.id}`,
|
|
status: "deleted",
|
|
deletedAt: secret.deletedAt ?? new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, secretId));
|
|
}
|
|
const providerConfig = secret.providerConfigId
|
|
? await getProviderConfigById(secret.providerConfigId)
|
|
: null;
|
|
const providerRuntimeConfig =
|
|
providerConfig && providerConfig.status !== "disabled" && providerConfig.status !== "coming_soon"
|
|
? toProviderVaultRuntimeConfig(providerConfig)
|
|
: null;
|
|
if (!secret.providerConfigId || providerRuntimeConfig) {
|
|
try {
|
|
await provider.deleteOrArchive({
|
|
material: versionRow?.material as Record<string, unknown> | undefined,
|
|
externalRef: secret.externalRef,
|
|
providerConfig: providerRuntimeConfig,
|
|
context: {
|
|
companyId: secret.companyId,
|
|
secretKey: secret.key,
|
|
secretName: secret.name,
|
|
version: secret.latestVersion,
|
|
},
|
|
mode: "delete",
|
|
});
|
|
} catch (error) {
|
|
if (!isSecretProviderClientError(error) || error.code !== "not_found") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
|
|
return secret;
|
|
}
|
|
|
|
async function removeUserSecretDefinitionInternal(
|
|
companyId: string,
|
|
definitionId: string,
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) {
|
|
const existing = await resolveUserSecretDefinition(companyId, { definitionId });
|
|
const values = await db
|
|
.select({ id: companySecrets.id })
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.userSecretDefinitionId, definitionId),
|
|
));
|
|
for (const value of values) {
|
|
await removeSecretInternal(value.id);
|
|
}
|
|
return db
|
|
.update(userSecretDefinitions)
|
|
.set({
|
|
key: `${existing.key}__deleted__${existing.id}`,
|
|
status: "deleted",
|
|
deletedAt: existing.deletedAt ?? new Date(),
|
|
updatedByAgentId: actor?.agentId ?? null,
|
|
updatedByUserId: actor?.userId ?? null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(userSecretDefinitions.companyId, companyId),
|
|
eq(userSecretDefinitions.id, definitionId),
|
|
))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
return {
|
|
listProviders: () => listSecretProviders(),
|
|
|
|
checkProviders: () => checkSecretProviders(),
|
|
|
|
previewProviderConfigDiscovery: async (
|
|
companyId: string,
|
|
input: {
|
|
provider: SecretProvider;
|
|
config?: Record<string, unknown>;
|
|
query?: string | null;
|
|
nextToken?: string | null;
|
|
pageSize?: number;
|
|
},
|
|
): Promise<SecretProviderConfigDiscoveryPreviewResult> => {
|
|
const parsed = secretProviderConfigDiscoveryPreviewSchema.safeParse({
|
|
provider: input.provider,
|
|
config: input.config ?? {},
|
|
query: input.query,
|
|
nextToken: input.nextToken,
|
|
pageSize: input.pageSize,
|
|
});
|
|
if (!parsed.success) {
|
|
throw unprocessable("Invalid provider vault discovery config", parsed.error.flatten());
|
|
}
|
|
const providerId = parsed.data.provider as SecretProvider;
|
|
const provider = getSecretProvider(providerId);
|
|
if (!provider.discoverProviderConfigs) {
|
|
throw unprocessable(`${providerId} provider does not support provider vault discovery`);
|
|
}
|
|
const runtimeConfig = toDraftProviderVaultRuntimeConfig({
|
|
companyId,
|
|
provider: providerId,
|
|
config: parsed.data.config,
|
|
});
|
|
try {
|
|
return await provider.discoverProviderConfigs({
|
|
companyId,
|
|
providerConfig: runtimeConfig,
|
|
query: parsed.data.query,
|
|
nextToken: parsed.data.nextToken,
|
|
pageSize: parsed.data.pageSize,
|
|
});
|
|
} catch (error) {
|
|
throw remoteProviderHttpError(error, {
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: "discovery-preview",
|
|
operation: "secret_provider_config.discovery.preview",
|
|
providerConfig: parsed.data.config,
|
|
});
|
|
}
|
|
},
|
|
|
|
listProviderConfigs: (companyId: string) =>
|
|
db
|
|
.select()
|
|
.from(companySecretProviderConfigs)
|
|
.where(eq(companySecretProviderConfigs.companyId, companyId))
|
|
.orderBy(desc(companySecretProviderConfigs.createdAt)),
|
|
|
|
getProviderConfigById,
|
|
|
|
createProviderConfig: async (
|
|
companyId: string,
|
|
input: {
|
|
provider: SecretProvider;
|
|
displayName: string;
|
|
status?: SecretProviderConfigStatus;
|
|
isDefault?: boolean;
|
|
config?: Record<string, unknown>;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const parsed = createSecretProviderConfigSchema.safeParse(input);
|
|
if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
|
const status = input.status ?? defaultProviderConfigStatus(input.provider);
|
|
if ((status === "coming_soon" || status === "disabled") && input.isDefault) {
|
|
throw unprocessable("Only ready or warning provider vaults can be default");
|
|
}
|
|
const normalizedConfig = validateProviderConfigPayload(input.provider, input.config ?? {});
|
|
return db.transaction(async (tx) => {
|
|
if (input.isDefault) {
|
|
await tx
|
|
.update(companySecretProviderConfigs)
|
|
.set({ isDefault: false, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(companySecretProviderConfigs.companyId, companyId),
|
|
eq(companySecretProviderConfigs.provider, input.provider),
|
|
));
|
|
}
|
|
return tx
|
|
.insert(companySecretProviderConfigs)
|
|
.values({
|
|
companyId,
|
|
provider: input.provider,
|
|
displayName: input.displayName.trim(),
|
|
status,
|
|
isDefault: input.isDefault ?? false,
|
|
config: normalizedConfig,
|
|
disabledAt: status === "disabled" ? new Date() : null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
});
|
|
},
|
|
|
|
updateProviderConfig: async (
|
|
id: string,
|
|
patch: {
|
|
displayName?: string;
|
|
status?: SecretProviderConfigStatus;
|
|
isDefault?: boolean;
|
|
config?: Record<string, unknown>;
|
|
},
|
|
) => {
|
|
const existing = await getProviderConfigById(id);
|
|
if (!existing) return null;
|
|
const parsed = updateSecretProviderConfigSchema.safeParse(patch);
|
|
if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten());
|
|
const provider = existing.provider as SecretProvider;
|
|
const status = patch.status ?? (existing.status as SecretProviderConfigStatus);
|
|
if (COMING_SOON_SECRET_PROVIDERS.has(provider) && status !== "coming_soon" && status !== "disabled") {
|
|
throw unprocessable(`${provider} provider vaults are locked while coming soon`);
|
|
}
|
|
if ((status === "coming_soon" || status === "disabled") && patch.isDefault) {
|
|
throw unprocessable("Only ready or warning provider vaults can be default");
|
|
}
|
|
const normalizedConfig =
|
|
patch.config === undefined
|
|
? existing.config
|
|
: validateProviderConfigPayload(provider, patch.config);
|
|
return db.transaction(async (tx) => {
|
|
if (patch.isDefault) {
|
|
await tx
|
|
.update(companySecretProviderConfigs)
|
|
.set({ isDefault: false, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(companySecretProviderConfigs.companyId, existing.companyId),
|
|
eq(companySecretProviderConfigs.provider, existing.provider),
|
|
));
|
|
}
|
|
return tx
|
|
.update(companySecretProviderConfigs)
|
|
.set({
|
|
displayName: patch.displayName?.trim() ?? existing.displayName,
|
|
status,
|
|
isDefault: status === "disabled" || status === "coming_soon" ? false : patch.isDefault ?? existing.isDefault,
|
|
config: normalizedConfig,
|
|
disabledAt: status === "disabled" ? existing.disabledAt ?? new Date() : null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecretProviderConfigs.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
});
|
|
},
|
|
|
|
disableProviderConfig: async (id: string) => {
|
|
const existing = await getProviderConfigById(id);
|
|
if (!existing) return null;
|
|
return db
|
|
.update(companySecretProviderConfigs)
|
|
.set({
|
|
status: "disabled",
|
|
isDefault: false,
|
|
disabledAt: existing.disabledAt ?? new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecretProviderConfigs.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
},
|
|
|
|
removeProviderConfig: async (id: string) =>
|
|
db
|
|
.delete(companySecretProviderConfigs)
|
|
.where(eq(companySecretProviderConfigs.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null),
|
|
|
|
setDefaultProviderConfig: async (id: string) => {
|
|
const existing = await getProviderConfigById(id);
|
|
if (!existing) return null;
|
|
if (existing.status === "coming_soon" || existing.status === "disabled") {
|
|
throw unprocessable("Only ready or warning provider vaults can be default");
|
|
}
|
|
return db.transaction(async (tx) => {
|
|
const current = await tx
|
|
.select()
|
|
.from(companySecretProviderConfigs)
|
|
.where(eq(companySecretProviderConfigs.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current) return null;
|
|
if (current.status === "coming_soon" || current.status === "disabled") {
|
|
throw unprocessable("Only ready or warning provider vaults can be default");
|
|
}
|
|
await tx
|
|
.update(companySecretProviderConfigs)
|
|
.set({ isDefault: false, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(companySecretProviderConfigs.companyId, current.companyId),
|
|
eq(companySecretProviderConfigs.provider, current.provider),
|
|
));
|
|
const updated = await tx
|
|
.update(companySecretProviderConfigs)
|
|
.set({ isDefault: true, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(companySecretProviderConfigs.id, id),
|
|
notInArray(companySecretProviderConfigs.status, ["coming_soon", "disabled"]),
|
|
))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) throw unprocessable("Only ready or warning provider vaults can be default");
|
|
return updated;
|
|
});
|
|
},
|
|
|
|
checkProviderConfigHealth: async (id: string) => {
|
|
const existing = await getProviderConfigById(id);
|
|
if (!existing) return null;
|
|
const checkedAt = new Date();
|
|
const staticHealth = providerConfigHealth({
|
|
id: existing.id,
|
|
provider: existing.provider as SecretProvider,
|
|
status: existing.status as SecretProviderConfigStatus,
|
|
config: existing.config ?? {},
|
|
});
|
|
const provider = getSecretProvider(existing.provider as SecretProvider);
|
|
const health = staticHealth ?? mapProviderModuleHealth({
|
|
configId: existing.id,
|
|
provider: existing.provider as SecretProvider,
|
|
providerStatus: existing.status as SecretProviderConfigStatus,
|
|
health: await provider.healthCheck({
|
|
providerConfig: toProviderVaultRuntimeConfig(existing),
|
|
}),
|
|
});
|
|
await db
|
|
.update(companySecretProviderConfigs)
|
|
.set({
|
|
healthStatus: health.status,
|
|
healthCheckedAt: checkedAt,
|
|
healthMessage: health.message,
|
|
healthDetails: health.details as unknown as Record<string, unknown>,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecretProviderConfigs.id, id));
|
|
return { ...health, checkedAt };
|
|
},
|
|
|
|
list: async (companyId: string) => {
|
|
const [secrets, referenceCounts] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "company"),
|
|
ne(companySecrets.status, "deleted"),
|
|
))
|
|
.orderBy(desc(companySecrets.createdAt)),
|
|
db
|
|
.select({
|
|
secretId: companySecretBindings.secretId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(companySecretBindings)
|
|
.where(eq(companySecretBindings.companyId, companyId))
|
|
.groupBy(companySecretBindings.secretId),
|
|
]);
|
|
const countsBySecretId = new Map(referenceCounts.map((row) => [row.secretId, row.count]));
|
|
return secrets.map((secret) => ({
|
|
...secret,
|
|
referenceCount: countsBySecretId.get(secret.id) ?? 0,
|
|
}));
|
|
},
|
|
|
|
listBindings: (companyId: string, secretId?: string) =>
|
|
db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(
|
|
secretId
|
|
? and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))
|
|
: eq(companySecretBindings.companyId, companyId),
|
|
)
|
|
.orderBy(desc(companySecretBindings.createdAt)),
|
|
|
|
listBindingReferences: async (companyId: string, secretId: string) => {
|
|
const bindings = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId)))
|
|
.orderBy(desc(companySecretBindings.createdAt));
|
|
const targetMap = await buildBindingTargetMap(companyId, bindings);
|
|
return bindings.map((binding) => ({
|
|
...binding,
|
|
target:
|
|
targetMap.get(`${binding.targetType}:${binding.targetId}`) ??
|
|
fallbackBindingTarget(binding),
|
|
}));
|
|
},
|
|
|
|
listAccessEvents: (companyId: string, secretId: string) =>
|
|
db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
|
|
.orderBy(desc(secretAccessEvents.createdAt)),
|
|
|
|
listUserSecretDefinitions: (companyId: string) =>
|
|
db
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
|
|
.orderBy(desc(userSecretDefinitions.createdAt)),
|
|
|
|
getUserSecretDefinitionById: (companyId: string, definitionId: string) =>
|
|
getUserSecretDefinitionById(companyId, definitionId),
|
|
|
|
createUserSecretDefinition: async (
|
|
companyId: string,
|
|
input: {
|
|
key: string;
|
|
name: string;
|
|
description?: string | null;
|
|
status?: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
managedMode?: "paperclip_managed" | "external_reference";
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
usageGuidance?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const key = input.key.trim();
|
|
const duplicate = await getUserSecretDefinitionByKey(companyId, key);
|
|
if (duplicate) throw conflict(`User secret definition already exists: ${key}`);
|
|
await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId);
|
|
try {
|
|
return await db
|
|
.insert(userSecretDefinitions)
|
|
.values({
|
|
companyId,
|
|
key,
|
|
name: input.name.trim(),
|
|
description: input.description ?? null,
|
|
status: input.status ?? "active",
|
|
provider: input.provider,
|
|
providerConfigId: input.providerConfigId ?? null,
|
|
managedMode: input.managedMode ?? "paperclip_managed",
|
|
providerMetadata: input.providerMetadata ?? null,
|
|
usageGuidance: input.usageGuidance ?? null,
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
updatedByAgentId: actor?.agentId ?? null,
|
|
updatedByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) {
|
|
throw conflict(`User secret definition already exists: ${key}`);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateUserSecretDefinition: async (
|
|
companyId: string,
|
|
definitionId: string,
|
|
patch: {
|
|
key?: string;
|
|
name?: string;
|
|
description?: string | null;
|
|
status?: string;
|
|
providerConfigId?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
usageGuidance?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const existing = await resolveUserSecretDefinition(companyId, { definitionId });
|
|
if (patch.status === "deleted") {
|
|
return removeUserSecretDefinitionInternal(companyId, existing.id, actor);
|
|
}
|
|
const nextKey = patch.key?.trim() ?? existing.key;
|
|
if (nextKey !== existing.key) {
|
|
const duplicate = await getUserSecretDefinitionByKey(companyId, nextKey);
|
|
if (duplicate && duplicate.id !== existing.id) {
|
|
throw conflict(`User secret definition already exists: ${nextKey}`);
|
|
}
|
|
}
|
|
if (patch.providerConfigId !== undefined) {
|
|
await assertProviderConfigForSecret(
|
|
companyId,
|
|
existing.provider as SecretProvider,
|
|
patch.providerConfigId,
|
|
);
|
|
}
|
|
return db
|
|
.update(userSecretDefinitions)
|
|
.set({
|
|
key: nextKey,
|
|
name: patch.name?.trim() ?? existing.name,
|
|
description: patch.description === undefined ? existing.description : patch.description,
|
|
status: patch.status ?? existing.status,
|
|
providerConfigId:
|
|
patch.providerConfigId === undefined ? existing.providerConfigId : patch.providerConfigId,
|
|
providerMetadata:
|
|
patch.providerMetadata === undefined ? existing.providerMetadata : patch.providerMetadata,
|
|
usageGuidance:
|
|
patch.usageGuidance === undefined ? existing.usageGuidance : patch.usageGuidance,
|
|
updatedByAgentId: actor?.agentId ?? null,
|
|
updatedByUserId: actor?.userId ?? null,
|
|
deletedAt: existing.deletedAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(userSecretDefinitions.companyId, companyId),
|
|
eq(userSecretDefinitions.id, definitionId),
|
|
))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
},
|
|
|
|
removeUserSecretDefinition: async (
|
|
companyId: string,
|
|
definitionId: string,
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => removeUserSecretDefinitionInternal(companyId, definitionId, actor),
|
|
|
|
getUserSecretDefinitionCoverage: async (companyId: string, definitionId: string) => {
|
|
await resolveUserSecretDefinition(companyId, { definitionId });
|
|
const [members, values] = await Promise.all([
|
|
db
|
|
.select({ principalId: companyMemberships.principalId })
|
|
.from(companyMemberships)
|
|
.where(and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
)),
|
|
db
|
|
.select({ status: companySecrets.status, ownerUserId: companySecrets.ownerUserId })
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.userSecretDefinitionId, definitionId),
|
|
ne(companySecrets.status, "deleted"),
|
|
)),
|
|
]);
|
|
const memberIds = new Set(members.map((member) => member.principalId));
|
|
const configuredCount = values.filter((value) =>
|
|
value.status === "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
|
|
).length;
|
|
const inactiveCount = values.filter((value) =>
|
|
value.status !== "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
|
|
).length;
|
|
return {
|
|
definitionId,
|
|
configuredCount,
|
|
inactiveCount,
|
|
missingCount: Math.max(0, memberIds.size - configuredCount - inactiveCount),
|
|
};
|
|
},
|
|
|
|
listCurrentUserSecretValues: async (companyId: string, ownerUserId: string) => {
|
|
const definitions = await db
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
|
|
.orderBy(desc(userSecretDefinitions.createdAt));
|
|
const values = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.ownerUserId, ownerUserId),
|
|
ne(companySecrets.status, "deleted"),
|
|
));
|
|
const valuesByDefinitionId = new Map(values.map((value) => [value.userSecretDefinitionId, value]));
|
|
return definitions.map((definition) => ({
|
|
definition,
|
|
secret: valuesByDefinitionId.get(definition.id) ?? null,
|
|
}));
|
|
},
|
|
|
|
createCurrentUserSecretValue: createUserSecretValueInternal,
|
|
|
|
// The narrow Claude Code OAuth definition helper. A caller passes
|
|
// no key, name, provider, mode, or status. The route calls it only after the
|
|
// authenticated board-user, company, and sandbox checks pass.
|
|
ensureClaudeOAuthUserSecretDefinition: (
|
|
companyId: string,
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor),
|
|
|
|
// The owner-bound Claude Code OAuth compare-and-set. It creates a
|
|
// first value or rotates after a confirmed expected version. The session id
|
|
// is the idempotency key for one completion.
|
|
completeClaudeOAuthUserSecret: completeClaudeOAuthUserSecretInternal,
|
|
|
|
// The owner-bound Claude Code OAuth status read. It returns only
|
|
// the secret id and the latest version for the owner value, or null. It never
|
|
// returns the token and never creates the definition. The status route reads
|
|
// the expected version from it before it captures the confirmed rotation.
|
|
readClaudeOAuthUserSecretStatus: readClaudeOAuthUserSecretStatusInternal,
|
|
|
|
rotateCurrentUserSecretValue: async (
|
|
companyId: string,
|
|
ownerUserId: string,
|
|
secretId: string,
|
|
input: {
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
|
|
return await (async () => {
|
|
await resolveUserSecretDefinition(companyId, { definitionId: secret.userSecretDefinitionId });
|
|
return (await secretService(db).rotate(secret.id, input, actor));
|
|
})();
|
|
},
|
|
|
|
updateCurrentUserSecretValue: async (
|
|
companyId: string,
|
|
ownerUserId: string,
|
|
secretId: string,
|
|
patch: {
|
|
status?: "active" | "disabled" | "archived" | "deleted";
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
|
|
if (
|
|
patch.value != null ||
|
|
patch.externalRef != null ||
|
|
patch.providerVersionRef != null ||
|
|
patch.providerConfigId != null
|
|
) {
|
|
return await secretService(db).rotateCurrentUserSecretValue(
|
|
companyId,
|
|
ownerUserId,
|
|
secret.id,
|
|
patch,
|
|
actor,
|
|
);
|
|
}
|
|
if (patch.status === "deleted") {
|
|
return await secretService(db).removeCurrentUserSecretValue(companyId, ownerUserId, secret.id);
|
|
}
|
|
return db
|
|
.update(companySecrets)
|
|
.set({
|
|
status: patch.status ?? secret.status,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
},
|
|
|
|
removeCurrentUserSecretValue: async (companyId: string, ownerUserId: string, secretId: string) => {
|
|
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
|
|
return await secretService(db).remove(secret.id);
|
|
},
|
|
|
|
syncUserSecretDeclarationsForTarget: async (
|
|
companyId: string,
|
|
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
|
refs: Array<{
|
|
definitionKey: string;
|
|
configPath: string;
|
|
envKey: string;
|
|
versionSelector?: SecretVersionSelector;
|
|
required?: boolean;
|
|
allowMissingOverride?: boolean;
|
|
label?: string | null;
|
|
}>,
|
|
options?: { db?: SecretBindingDb; replaceAll?: boolean },
|
|
) => {
|
|
const targetDb = options?.db ?? db;
|
|
const normalizedRefs: Array<{
|
|
definitionId: string;
|
|
configPath: string;
|
|
envKey: string;
|
|
versionSelector: SecretVersionSelector;
|
|
required: boolean;
|
|
allowMissingOverride: boolean;
|
|
label: string | null;
|
|
}> = [];
|
|
for (const ref of refs) {
|
|
const definition = await resolveUserSecretDefinition(
|
|
companyId,
|
|
{ definitionKey: ref.definitionKey },
|
|
targetDb,
|
|
{
|
|
envKey: ref.envKey,
|
|
configPath: ref.configPath,
|
|
consumerType: target.targetType,
|
|
consumerId: target.targetId,
|
|
},
|
|
);
|
|
normalizedRefs.push({
|
|
definitionId: definition.id,
|
|
configPath: ref.configPath,
|
|
envKey: ref.envKey,
|
|
versionSelector: ref.versionSelector ?? "latest",
|
|
required: ref.required ?? true,
|
|
allowMissingOverride: ref.allowMissingOverride ?? false,
|
|
label: ref.label ?? null,
|
|
});
|
|
}
|
|
|
|
const pathPrefix = target.pathPrefix ?? "env";
|
|
const writeDeclarations = async (executor: SecretBindingDb) => {
|
|
if (options?.replaceAll) {
|
|
await executor
|
|
.delete(userSecretDeclarations)
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.targetType, target.targetType),
|
|
eq(userSecretDeclarations.targetId, target.targetId),
|
|
));
|
|
} else {
|
|
await executor
|
|
.delete(userSecretDeclarations)
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.targetType, target.targetType),
|
|
eq(userSecretDeclarations.targetId, target.targetId),
|
|
like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
|
|
));
|
|
}
|
|
if (normalizedRefs.length === 0) return;
|
|
await executor.insert(userSecretDeclarations).values(
|
|
normalizedRefs.map((ref) => ({
|
|
companyId,
|
|
userSecretDefinitionId: ref.definitionId,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId,
|
|
configPath: ref.configPath,
|
|
envKey: ref.envKey,
|
|
versionSelector: String(ref.versionSelector),
|
|
required: ref.required,
|
|
allowMissingOverride: ref.allowMissingOverride,
|
|
label: ref.label,
|
|
})),
|
|
);
|
|
};
|
|
|
|
if (options?.db) {
|
|
await writeDeclarations(targetDb);
|
|
} else {
|
|
await db.transaction(async (tx) => writeDeclarations(tx));
|
|
}
|
|
return normalizedRefs;
|
|
},
|
|
|
|
resolveUserSecretValue: async (
|
|
companyId: string,
|
|
input: {
|
|
definitionKey?: string | null;
|
|
definitionId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
version?: SecretVersionSelector;
|
|
required?: boolean;
|
|
allowMissingOverride?: boolean;
|
|
},
|
|
context?: SecretConsumerContext,
|
|
): Promise<RuntimeSecretResolution | null> => {
|
|
const responsibleUserId = input.responsibleUserId ?? context?.responsibleUserId ?? null;
|
|
const optionalBinding = input.allowMissingOverride || input.required === false;
|
|
let definition: typeof userSecretDefinitions.$inferSelect;
|
|
try {
|
|
definition = await resolveUserSecretDefinition(companyId, input, db, {
|
|
configPath: context?.configPath ?? null,
|
|
consumerType: context?.consumerType ?? null,
|
|
consumerId: context?.consumerId ?? null,
|
|
});
|
|
} catch (error) {
|
|
if (optionalBinding && error instanceof HttpError && error.status === 404) return null;
|
|
throw error;
|
|
}
|
|
if (definition.status !== "active") {
|
|
if (optionalBinding) return null;
|
|
throw unprocessable("User secret definition is not active");
|
|
}
|
|
if (!responsibleUserId?.trim()) {
|
|
if (optionalBinding) return null;
|
|
throw unprocessable("Responsible user is required for user secret resolution", {
|
|
code: "responsible_user_missing",
|
|
});
|
|
}
|
|
let declaration: typeof userSecretDeclarations.$inferSelect | null = null;
|
|
if (context?.configPath) {
|
|
declaration = await db
|
|
.select()
|
|
.from(userSecretDeclarations)
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
|
|
eq(userSecretDeclarations.targetType, context.consumerType),
|
|
eq(userSecretDeclarations.targetId, context.consumerId),
|
|
eq(userSecretDeclarations.configPath, context.configPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!declaration) {
|
|
if (optionalBinding) return null;
|
|
throw unprocessable(
|
|
`User secret is not declared for ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
|
|
{ code: "binding_missing" },
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
Array.isArray(context?.allowedBindingIds) &&
|
|
(!declaration || !context.allowedBindingIds.includes(declaration.id))
|
|
) {
|
|
throw unprocessable(
|
|
"User secret declaration is outside the active low-trust boundary",
|
|
{ code: "binding_not_allowed" },
|
|
);
|
|
}
|
|
const secret = await getUserSecretValue({
|
|
companyId,
|
|
ownerUserId: responsibleUserId,
|
|
definitionId: definition.id,
|
|
});
|
|
if (!secret) {
|
|
if (optionalBinding) return null;
|
|
throw unprocessable("User secret value is not configured", {
|
|
code: "user_secret_missing",
|
|
definitionId: definition.id,
|
|
responsibleUserId,
|
|
});
|
|
}
|
|
const resolution = await resolveSecretValueInternal(
|
|
companyId,
|
|
secret.id,
|
|
input.version ?? "latest",
|
|
{
|
|
accessContext: context ? { ...context, responsibleUserId } : undefined,
|
|
allowUserSecretScope: true,
|
|
},
|
|
);
|
|
return {
|
|
...resolution,
|
|
manifestEntry: {
|
|
...resolution.manifestEntry,
|
|
bindingId: declaration?.id ?? resolution.manifestEntry.bindingId ?? null,
|
|
},
|
|
};
|
|
},
|
|
|
|
previewRemoteImport: async (
|
|
companyId: string,
|
|
input: {
|
|
providerConfigId: string;
|
|
query?: string | null;
|
|
nextToken?: string | null;
|
|
pageSize?: number;
|
|
},
|
|
) => {
|
|
const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(
|
|
companyId,
|
|
input.providerConfigId,
|
|
);
|
|
const provider = getSecretProvider(providerId);
|
|
if (!provider.listRemoteSecrets) {
|
|
throw unprocessable(`${providerId} provider does not support remote import listing`);
|
|
}
|
|
let listed: RemoteSecretListResult;
|
|
try {
|
|
listed = await provider.listRemoteSecrets({
|
|
providerConfig: runtimeConfig,
|
|
query: input.query,
|
|
nextToken: input.nextToken,
|
|
pageSize: input.pageSize,
|
|
});
|
|
} catch (error) {
|
|
throw remoteProviderHttpError(error, {
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: providerConfig.id,
|
|
operation: "remote_import.preview",
|
|
});
|
|
}
|
|
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
|
|
const candidates: RemoteSecretImportCandidate[] = [];
|
|
for (const remote of listed.secrets) {
|
|
const externalRef = remote.externalRef.trim();
|
|
const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef);
|
|
const name = remoteName || deriveSecretNameFromExternalRef(externalRef);
|
|
const key = normalizeSecretKey(name);
|
|
let canonicalExternalRef = externalRef;
|
|
const conflicts: RemoteSecretImportConflict[] = [];
|
|
try {
|
|
const prepared = await provider.linkExternalSecret({
|
|
externalRef,
|
|
providerVersionRef: remote.providerVersionRef ?? null,
|
|
providerConfig: runtimeConfig,
|
|
context: {
|
|
companyId,
|
|
secretKey: key || "remote-import-preview",
|
|
secretName: name,
|
|
version: 1,
|
|
},
|
|
});
|
|
canonicalExternalRef = prepared.externalRef ?? externalRef;
|
|
} catch (error) {
|
|
conflicts.push({
|
|
type: "provider_guardrail",
|
|
message: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: providerConfig.id,
|
|
operation: "remote_import.preview.link_external_reference",
|
|
}),
|
|
});
|
|
}
|
|
conflicts.push(...remoteImportConflictsFor({
|
|
providerConfigId: providerConfig.id,
|
|
externalRef: canonicalExternalRef,
|
|
name,
|
|
key,
|
|
maps,
|
|
}));
|
|
const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference");
|
|
const hasConflict = conflicts.length > 0;
|
|
candidates.push({
|
|
externalRef,
|
|
remoteName,
|
|
name,
|
|
key,
|
|
providerVersionRef: remote.providerVersionRef ?? null,
|
|
providerMetadata: sanitizeRemoteProviderMetadata(providerId, remote.metadata),
|
|
status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready",
|
|
importable: !hasConflict,
|
|
conflicts,
|
|
});
|
|
}
|
|
return {
|
|
providerConfigId: providerConfig.id,
|
|
provider: providerId,
|
|
nextToken: listed.nextToken ?? null,
|
|
candidates,
|
|
};
|
|
},
|
|
|
|
importRemoteSecrets: async (
|
|
companyId: string,
|
|
input: {
|
|
providerConfigId: string;
|
|
secrets: Array<{
|
|
externalRef: string;
|
|
name?: string | null;
|
|
key?: string | null;
|
|
description?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}>;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig(
|
|
companyId,
|
|
input.providerConfigId,
|
|
);
|
|
const provider = getSecretProvider(providerId);
|
|
if (provider.descriptor().supportsExternalReferences === false) {
|
|
throw unprocessable(`${providerId} provider does not support linked external references`);
|
|
}
|
|
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
|
|
const results: RemoteSecretImportRowResult[] = [];
|
|
|
|
for (const selection of input.secrets) {
|
|
const externalRef = selection.externalRef.trim();
|
|
const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef);
|
|
const key = normalizeSecretKey(selection.key?.trim() || name);
|
|
const description = selection.description?.trim() || null;
|
|
let prepared: PreparedSecretVersion | undefined;
|
|
const conflicts = remoteImportConflictsFor({
|
|
providerConfigId: providerConfig.id,
|
|
externalRef,
|
|
name,
|
|
key,
|
|
maps,
|
|
});
|
|
if (!key) {
|
|
results.push({
|
|
externalRef,
|
|
name,
|
|
key,
|
|
status: "error",
|
|
reason: "Secret key is required",
|
|
secretId: null,
|
|
conflicts,
|
|
});
|
|
continue;
|
|
}
|
|
if (conflicts.length === 0) {
|
|
try {
|
|
prepared = await provider.linkExternalSecret({
|
|
externalRef,
|
|
providerVersionRef: selection.providerVersionRef ?? null,
|
|
providerConfig: runtimeConfig,
|
|
context: {
|
|
companyId,
|
|
secretKey: key,
|
|
secretName: name,
|
|
version: 1,
|
|
},
|
|
});
|
|
const canonicalDuplicate = maps.byProviderConfigExternalRef.get(
|
|
remoteImportExternalRefKey(providerConfig.id, prepared.externalRef ?? externalRef),
|
|
);
|
|
if (canonicalDuplicate) {
|
|
conflicts.push({
|
|
type: "exact_reference",
|
|
existingSecretId: canonicalDuplicate.id,
|
|
message: "An existing secret already links this exact provider reference.",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
results.push({
|
|
externalRef,
|
|
name,
|
|
key,
|
|
status: "error",
|
|
reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", {
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: providerConfig.id,
|
|
operation: "remote_import.prepare_external_reference",
|
|
}),
|
|
secretId: null,
|
|
conflicts: [],
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
if (conflicts.length > 0) {
|
|
results.push({
|
|
externalRef,
|
|
name,
|
|
key,
|
|
status: "skipped",
|
|
reason: conflicts.some((conflict) => conflict.type === "exact_reference")
|
|
? "exact_reference_duplicate"
|
|
: "name_or_key_conflict",
|
|
secretId: null,
|
|
conflicts,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (!prepared) {
|
|
prepared = await provider.linkExternalSecret({
|
|
externalRef,
|
|
providerVersionRef: selection.providerVersionRef ?? null,
|
|
providerConfig: runtimeConfig,
|
|
context: {
|
|
companyId,
|
|
secretKey: key,
|
|
secretName: name,
|
|
version: 1,
|
|
},
|
|
});
|
|
}
|
|
if (!prepared) {
|
|
throw unprocessable("Provider rejected this external reference");
|
|
}
|
|
const preparedSecret = prepared;
|
|
const secret = await db.transaction(async (tx) => {
|
|
const inserted = await tx
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key,
|
|
name,
|
|
provider: providerId,
|
|
providerConfigId: providerConfig.id,
|
|
status: "active",
|
|
managedMode: "external_reference",
|
|
externalRef: preparedSecret.externalRef,
|
|
providerMetadata: null,
|
|
latestVersion: 1,
|
|
description,
|
|
lastRotatedAt: new Date(),
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
await tx.insert(companySecretVersions).values({
|
|
secretId: inserted.id,
|
|
version: 1,
|
|
material: preparedSecret.material,
|
|
valueSha256: preparedSecret.valueSha256,
|
|
fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
|
|
providerVersionRef: preparedSecret.providerVersionRef ?? null,
|
|
status: "current",
|
|
createdByAgentId: actor?.agentId ?? null,
|
|
createdByUserId: actor?.userId ?? null,
|
|
});
|
|
return inserted;
|
|
});
|
|
maps.byProviderConfigExternalRef.set(
|
|
remoteImportExternalRefKey(providerConfig.id, preparedSecret.externalRef ?? externalRef),
|
|
secret,
|
|
);
|
|
maps.byName.set(name, secret);
|
|
maps.byKey.set(key, secret);
|
|
results.push({
|
|
externalRef,
|
|
name,
|
|
key,
|
|
status: "imported",
|
|
reason: null,
|
|
secretId: secret.id,
|
|
conflicts: [],
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
externalRef,
|
|
name,
|
|
key,
|
|
status: "error",
|
|
reason: remoteImportRowFailureReason(error, "Import failed", {
|
|
companyId,
|
|
provider: providerId,
|
|
providerConfigId: providerConfig.id,
|
|
operation: "remote_import.commit",
|
|
}),
|
|
secretId: null,
|
|
conflicts: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
providerConfigId: providerConfig.id,
|
|
provider: providerId,
|
|
importedCount: results.filter((result) => result.status === "imported").length,
|
|
skippedCount: results.filter((result) => result.status === "skipped").length,
|
|
errorCount: results.filter((result) => result.status === "error").length,
|
|
results,
|
|
};
|
|
},
|
|
|
|
getById,
|
|
getByName,
|
|
getByKey,
|
|
resolveSecretValue,
|
|
resolveSecretVersion,
|
|
resolveSecretValueForAgentAccess,
|
|
listAgentSecretAccess,
|
|
resolveSecretValueForEphemeralAccess,
|
|
resolveSecretValueForSandboxCleanup,
|
|
resolveSecretValueForDeviceLoginCheck,
|
|
|
|
// A plain string value can equal a Codex account-home path regardless of
|
|
// which provider stores it: an AWS Secrets Manager-backed secret (or any
|
|
// other provider) resolves to the same literal string a `local_encrypted`
|
|
// secret would. So every provider's create runs inside
|
|
// `withAccountHomeSecretMutationLock`, not only `local_encrypted`'s. This
|
|
// closes the account-home cleanup race for every provider: a create can no
|
|
// longer commit a value between the cleanup's final claimant scan and its
|
|
// delete just because it named a provider the old check treated as safe.
|
|
create: async (
|
|
companyId: string,
|
|
input: {
|
|
name: string;
|
|
provider: SecretProvider;
|
|
providerConfigId?: string | null;
|
|
value?: string | null;
|
|
key?: string | null;
|
|
managedMode?: "paperclip_managed" | "external_reference";
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
return withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
|
// A queued create can win the lock after an account-home cleanup
|
|
// already removed the directory this value names. Check the
|
|
// directory's existence inside the same lock, right before the
|
|
// write, so that order fails instead of committing a dangling path.
|
|
// `assertAccountHomeCacheDirStillValid` is a no-op for a value that is
|
|
// not under the account-home cache root, so this never rejects an
|
|
// unrelated secret write.
|
|
if (input.value) {
|
|
await assertAccountHomeCacheDirStillValid(undefined, companyId, input.value);
|
|
}
|
|
return createSecretUnlocked(companyId, input, actor);
|
|
});
|
|
},
|
|
|
|
// Same reasoning as `create:` above: a rotate's new value can equal a
|
|
// Codex account-home path under any provider, so every provider's rotate
|
|
// runs inside `withAccountHomeSecretMutationLock`, not only
|
|
// `local_encrypted`'s. The pre-check reads the secret only to find its
|
|
// company for the lock; `rotateUnlocked` re-reads the secret under the
|
|
// lock, so a rotation that lost a race between the pre-check and the lock
|
|
// still sees current state, not the pre-check's stale read.
|
|
rotate: async (
|
|
secretId: string,
|
|
input: {
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
expectedLatestVersion?: number;
|
|
},
|
|
actor?: { userId?: string | null; agentId?: string | null },
|
|
) => {
|
|
const preCheckSecret = await getById(secretId);
|
|
if (!preCheckSecret) throw notFound("Secret not found");
|
|
return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, async () => {
|
|
// Same reasoning as `create:` above: check the new value's directory
|
|
// inside the same lock the write commits under, so a rotate queued
|
|
// behind an account-home cleanup cannot commit a directory the
|
|
// cleanup already removed.
|
|
if (input.value) {
|
|
await assertAccountHomeCacheDirStillValid(undefined, preCheckSecret.companyId, input.value);
|
|
}
|
|
return rotateUnlocked(secretId, input, actor);
|
|
});
|
|
},
|
|
|
|
// A patch can rename a secret (`patch.name`) or move it out of the
|
|
// `active` status (archive, disable, or a soft delete for an
|
|
// `external_reference` secret) — any of which can stop the generated
|
|
// account-home secret from resolving to the value a device-login
|
|
// promotion already validated. Run every update inside
|
|
// `withAccountHomeSecretMutationLock`, the same lock the promotion's
|
|
// terminal-commit re-check holds across its own resolve-and-commit
|
|
// section, so a rename, an archive, a disable, or a soft delete can never
|
|
// land in the gap between that re-check and the commit it guards: it
|
|
// either finishes (and the re-check observes it and rejects) before that
|
|
// section acquires the lock, or it waits for that section to finish
|
|
// first.
|
|
update: async (
|
|
secretId: string,
|
|
patch: {
|
|
name?: string;
|
|
key?: string;
|
|
status?: "active" | "disabled" | "archived" | "deleted";
|
|
providerConfigId?: string | null;
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
},
|
|
) => {
|
|
const preCheckSecret = await getById(secretId);
|
|
if (!preCheckSecret) throw notFound("Secret not found");
|
|
return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, () =>
|
|
updateUnlocked(secretId, patch),
|
|
);
|
|
},
|
|
|
|
createBinding: async (input: {
|
|
companyId: string;
|
|
secretId: string;
|
|
targetType: SecretBindingTargetType;
|
|
targetId: string;
|
|
configPath: string;
|
|
versionSelector?: SecretVersionSelector;
|
|
required?: boolean;
|
|
label?: string | null;
|
|
projectionClass?: SecretProjectionClass;
|
|
projectionAllowlistKey?: string | null;
|
|
}) => {
|
|
await assertSecretInCompany(input.companyId, input.secretId);
|
|
assertSecretBindingConfigPath(input);
|
|
assertClass3StaticLeaseAllowed({
|
|
targetType: input.targetType,
|
|
configPath: input.configPath,
|
|
projectionClass: input.projectionClass,
|
|
projectionAllowlistKey: input.projectionAllowlistKey,
|
|
});
|
|
const existing = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, input.companyId),
|
|
eq(companySecretBindings.targetType, input.targetType),
|
|
eq(companySecretBindings.targetId, input.targetId),
|
|
eq(companySecretBindings.configPath, input.configPath),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existing) throw conflict(`Secret binding already exists at ${input.configPath}`);
|
|
return db
|
|
.insert(companySecretBindings)
|
|
.values({
|
|
companyId: input.companyId,
|
|
secretId: input.secretId,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
configPath: input.configPath,
|
|
versionSelector: String(input.versionSelector ?? "latest"),
|
|
required: input.required ?? true,
|
|
label: input.label ?? null,
|
|
projectionClass: input.projectionClass ?? "unclassified",
|
|
projectionAllowlistKey: input.projectionAllowlistKey ?? null,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
},
|
|
|
|
syncSecretRefsForTarget: async (
|
|
companyId: string,
|
|
target: { targetType: SecretBindingTargetType; targetId: string },
|
|
refs: Array<{
|
|
secretId: string;
|
|
configPath: string;
|
|
versionSelector?: SecretVersionSelector;
|
|
required?: boolean;
|
|
label?: string | null;
|
|
projectionClass?: SecretProjectionClass;
|
|
projectionAllowlistKey?: string | null;
|
|
}>,
|
|
options?: { replaceAll?: boolean },
|
|
) => {
|
|
const normalizedRefs: Array<{
|
|
secretId: string;
|
|
configPath: string;
|
|
versionSelector: SecretVersionSelector;
|
|
required: boolean;
|
|
label: string | null;
|
|
projectionClass: SecretProjectionClass;
|
|
projectionAllowlistKey: string | null;
|
|
}> = [];
|
|
for (const ref of refs) {
|
|
await assertSecretInCompany(companyId, ref.secretId);
|
|
assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath });
|
|
const projectionClass = ref.projectionClass ?? "unclassified";
|
|
const projectionAllowlistKey = ref.projectionAllowlistKey ?? null;
|
|
assertClass3StaticLeaseAllowed({
|
|
targetType: target.targetType,
|
|
configPath: ref.configPath,
|
|
projectionClass,
|
|
projectionAllowlistKey,
|
|
});
|
|
normalizedRefs.push({
|
|
secretId: ref.secretId,
|
|
configPath: ref.configPath,
|
|
versionSelector: ref.versionSelector ?? "latest",
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
projectionClass,
|
|
projectionAllowlistKey,
|
|
});
|
|
}
|
|
|
|
const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
|
|
|
|
await db.transaction(async (tx) => {
|
|
if (options?.replaceAll) {
|
|
await tx
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
),
|
|
);
|
|
} else if (pathPrefixes.length > 0) {
|
|
for (const pathPrefix of pathPrefixes) {
|
|
await tx
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
or(
|
|
eq(companySecretBindings.configPath, pathPrefix),
|
|
like(companySecretBindings.configPath, `${pathPrefix}.%`),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
await tx
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
),
|
|
);
|
|
}
|
|
if (normalizedRefs.length === 0) return;
|
|
await tx.insert(companySecretBindings).values(
|
|
normalizedRefs.map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector),
|
|
required: ref.required,
|
|
label: ref.label,
|
|
projectionClass: ref.projectionClass,
|
|
projectionAllowlistKey: ref.projectionAllowlistKey,
|
|
})),
|
|
);
|
|
});
|
|
return normalizedRefs;
|
|
},
|
|
|
|
/**
|
|
* Replace the config-derived secret bindings of an instance-scoped target
|
|
* (an environment). Instance-scoped targets are shared across companies,
|
|
* so each binding is written under the company that owns the referenced
|
|
* secret rather than a single caller-supplied company context — a
|
|
* re-point to a secret owned by another company moves the binding with
|
|
* it. All non-`env.*` bindings of the target are replaced across every
|
|
* company (env-var bindings stay company-scoped and are managed by
|
|
* `syncEnvBindingsForTarget`).
|
|
*
|
|
* Every referenced secret is loaded and validated before any row is
|
|
* written, and the delete + insert run on one executor, so an invalid
|
|
* ref (deleted or unknown secret) fails the whole call without leaving
|
|
* the target half-bound.
|
|
*/
|
|
replaceSecretRefsForInstanceTarget: async (
|
|
target: { targetType: SecretBindingTargetType; targetId: string },
|
|
refs: Array<{
|
|
secretId: string;
|
|
configPath: string;
|
|
versionSelector?: SecretVersionSelector;
|
|
required?: boolean;
|
|
label?: string | null;
|
|
projectionClass?: SecretProjectionClass;
|
|
projectionAllowlistKey?: string | null;
|
|
}>,
|
|
options?: { db?: SecretBindingDb },
|
|
) => {
|
|
const normalizedRefs: Array<{
|
|
companyId: string;
|
|
secretId: string;
|
|
configPath: string;
|
|
versionSelector: SecretVersionSelector;
|
|
required: boolean;
|
|
label: string | null;
|
|
projectionClass: SecretProjectionClass;
|
|
projectionAllowlistKey: string | null;
|
|
}> = [];
|
|
const readDb = options?.db ?? db;
|
|
for (const ref of refs) {
|
|
const secret = await getById(ref.secretId, readDb);
|
|
if (!secret || secret.status === "deleted") {
|
|
throw unprocessable(
|
|
`Secret referenced at ${ref.configPath} was not found`,
|
|
{ code: "secret_missing", configPath: ref.configPath },
|
|
);
|
|
}
|
|
assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath });
|
|
const projectionClass = ref.projectionClass ?? "unclassified";
|
|
const projectionAllowlistKey = ref.projectionAllowlistKey ?? null;
|
|
assertClass3StaticLeaseAllowed({
|
|
targetType: target.targetType,
|
|
configPath: ref.configPath,
|
|
projectionClass,
|
|
projectionAllowlistKey,
|
|
});
|
|
normalizedRefs.push({
|
|
companyId: secret.companyId,
|
|
secretId: ref.secretId,
|
|
configPath: ref.configPath,
|
|
versionSelector: ref.versionSelector ?? "latest",
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
projectionClass,
|
|
projectionAllowlistKey,
|
|
});
|
|
}
|
|
|
|
const writeBindings = async (executor: SecretBindingDb) => {
|
|
await executor
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
notLike(companySecretBindings.configPath, "env.%"),
|
|
),
|
|
);
|
|
if (normalizedRefs.length === 0) return;
|
|
await executor.insert(companySecretBindings).values(
|
|
normalizedRefs.map((ref) => ({
|
|
companyId: ref.companyId,
|
|
secretId: ref.secretId,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector),
|
|
required: ref.required,
|
|
label: ref.label,
|
|
projectionClass: ref.projectionClass,
|
|
projectionAllowlistKey: ref.projectionAllowlistKey,
|
|
})),
|
|
);
|
|
};
|
|
|
|
if (options?.db) {
|
|
await writeBindings(options.db);
|
|
} else {
|
|
await db.transaction(async (tx) => {
|
|
await writeBindings(tx);
|
|
});
|
|
}
|
|
return normalizedRefs;
|
|
},
|
|
|
|
/**
|
|
* Describe secret refs (id + config path) with the referenced secret's
|
|
* name, status, and owning company. Environments are instance-scoped
|
|
* while secrets are company-scoped, so an environment can legitimately
|
|
* reference a secret a given company's picker cannot list; this gives
|
|
* instance-level readers enough metadata to present such refs honestly.
|
|
* Returns names across companies — callers must sit behind an
|
|
* instance-level authorization gate. Never returns secret values.
|
|
*/
|
|
describeSecretRefs: async (
|
|
refs: Array<{ secretId: string; configPath: string }>,
|
|
): Promise<Array<{
|
|
configPath: string;
|
|
secretId: string;
|
|
name: string;
|
|
status: string;
|
|
companyId: string;
|
|
companyName: string | null;
|
|
}>> => {
|
|
if (refs.length === 0) return [];
|
|
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
|
|
const secretRows = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(inArray(companySecrets.id, secretIds));
|
|
const secretsById = new Map(secretRows.map((row) => [row.id, row]));
|
|
const companyIds = [...new Set(secretRows.map((row) => row.companyId))];
|
|
const companyRows = companyIds.length > 0
|
|
? await db.select().from(companies).where(inArray(companies.id, companyIds))
|
|
: [];
|
|
const companyNamesById = new Map(companyRows.map((row) => [row.id, row.name]));
|
|
return refs.flatMap((ref) => {
|
|
const secret = secretsById.get(ref.secretId);
|
|
if (!secret) return [];
|
|
return [{
|
|
configPath: ref.configPath,
|
|
secretId: secret.id,
|
|
name: secret.name,
|
|
status: secret.status,
|
|
companyId: secret.companyId,
|
|
companyName: companyNamesById.get(secret.companyId) ?? null,
|
|
}];
|
|
});
|
|
},
|
|
|
|
listBindingCompanyIdsForTarget: async (
|
|
target: { targetType: SecretBindingTargetType; targetId: string },
|
|
): Promise<string[]> =>
|
|
db
|
|
.select({ companyId: companySecretBindings.companyId })
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
),
|
|
)
|
|
.then((rows) => [...new Set(rows.map((row) => row.companyId))]),
|
|
|
|
syncEnvBindingsForTarget: async (
|
|
companyId: string,
|
|
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
|
|
envValue: unknown,
|
|
options?: { db?: SecretBindingDb },
|
|
) => {
|
|
const record = asRecord(envValue) ?? {};
|
|
const refs: Array<{
|
|
secretId: string;
|
|
configPath: string;
|
|
versionSelector: SecretVersionSelector;
|
|
projectionClass: SecretProjectionClass;
|
|
projectionAllowlistKey: string | null;
|
|
}> = [];
|
|
const userRefs: Array<{
|
|
definitionKey: string;
|
|
configPath: string;
|
|
envKey: string;
|
|
versionSelector: SecretVersionSelector;
|
|
required: boolean;
|
|
allowMissingOverride: boolean;
|
|
}> = [];
|
|
const pathPrefix = target.pathPrefix ?? "env";
|
|
const bindingDb = options?.db ?? db;
|
|
for (const [key, rawBinding] of Object.entries(record)) {
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) continue;
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "user_secret_ref") {
|
|
await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb, {
|
|
envKey: key,
|
|
configPath: `${pathPrefix}.${key}`,
|
|
consumerType: target.targetType,
|
|
consumerId: target.targetId,
|
|
});
|
|
userRefs.push({
|
|
definitionKey: binding.key,
|
|
configPath: `${pathPrefix}.${key}`,
|
|
envKey: key,
|
|
versionSelector: binding.version,
|
|
required: binding.required,
|
|
allowMissingOverride: binding.allowMissingOverride,
|
|
});
|
|
continue;
|
|
}
|
|
if (binding.type !== "secret_ref") continue;
|
|
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
|
|
const configPath = `${pathPrefix}.${key}`;
|
|
assertClass3StaticLeaseAllowed({
|
|
targetType: target.targetType,
|
|
configPath,
|
|
projectionClass: binding.projectionClass,
|
|
projectionAllowlistKey: binding.projectionAllowlistKey,
|
|
});
|
|
refs.push({
|
|
secretId: binding.secretId,
|
|
configPath,
|
|
versionSelector: binding.version,
|
|
projectionClass: binding.projectionClass,
|
|
projectionAllowlistKey: binding.projectionAllowlistKey,
|
|
});
|
|
}
|
|
|
|
const writeBindings = async (targetDb: SecretBindingDb) => {
|
|
await targetDb
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, target.targetType),
|
|
eq(companySecretBindings.targetId, target.targetId),
|
|
like(companySecretBindings.configPath, `${pathPrefix}.%`),
|
|
),
|
|
);
|
|
if (refs.length === 0) return;
|
|
await targetDb.insert(companySecretBindings).values(
|
|
refs.map((ref) => ({
|
|
companyId,
|
|
secretId: ref.secretId,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector),
|
|
required: true,
|
|
projectionClass: ref.projectionClass,
|
|
projectionAllowlistKey: ref.projectionAllowlistKey,
|
|
})),
|
|
);
|
|
};
|
|
|
|
const writeUserDeclarations = async (targetDb: SecretBindingDb) => {
|
|
await targetDb
|
|
.delete(userSecretDeclarations)
|
|
.where(
|
|
and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.targetType, target.targetType),
|
|
eq(userSecretDeclarations.targetId, target.targetId),
|
|
like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
|
|
),
|
|
);
|
|
if (userRefs.length === 0) return;
|
|
const definitions = new Map<string, string>();
|
|
for (const ref of userRefs) {
|
|
const definition = await resolveUserSecretDefinition(
|
|
companyId,
|
|
{ definitionKey: ref.definitionKey },
|
|
targetDb,
|
|
{
|
|
envKey: ref.envKey,
|
|
configPath: ref.configPath,
|
|
consumerType: target.targetType,
|
|
consumerId: target.targetId,
|
|
},
|
|
);
|
|
definitions.set(ref.definitionKey, definition.id);
|
|
}
|
|
await targetDb.insert(userSecretDeclarations).values(
|
|
userRefs.map((ref) => ({
|
|
companyId,
|
|
userSecretDefinitionId: definitions.get(ref.definitionKey)!,
|
|
targetType: target.targetType,
|
|
targetId: target.targetId,
|
|
configPath: ref.configPath,
|
|
envKey: ref.envKey,
|
|
versionSelector: String(ref.versionSelector),
|
|
required: ref.required,
|
|
allowMissingOverride: ref.allowMissingOverride,
|
|
})),
|
|
);
|
|
};
|
|
|
|
if (options?.db) {
|
|
await writeBindings(options.db);
|
|
await writeUserDeclarations(options.db);
|
|
} else {
|
|
await db.transaction(async (tx) => {
|
|
await writeBindings(tx);
|
|
await writeUserDeclarations(tx);
|
|
});
|
|
}
|
|
return refs;
|
|
},
|
|
|
|
remove: removeSecretInternal,
|
|
|
|
normalizeAdapterConfigForPersistence: async (
|
|
companyId: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
opts?: NormalizeAdapterConfigOptions,
|
|
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
|
|
|
normalizeEnvBindingsForPersistence: async (
|
|
companyId: string,
|
|
envValue: unknown,
|
|
opts?: NormalizeEnvOptions,
|
|
) => normalizeEnvConfig(companyId, envValue, opts),
|
|
|
|
normalizeHireApprovalPayloadForPersistence: async (
|
|
companyId: string,
|
|
payload: Record<string, unknown>,
|
|
opts?: NormalizeAdapterConfigOptions,
|
|
) => {
|
|
const normalized = { ...payload };
|
|
const adapterConfig = asRecord(payload.adapterConfig);
|
|
if (adapterConfig) {
|
|
normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal(
|
|
companyId,
|
|
adapterConfig,
|
|
opts,
|
|
);
|
|
}
|
|
return normalized;
|
|
},
|
|
|
|
resolveEnvBindings: async (
|
|
companyId: string,
|
|
envValue: unknown,
|
|
context?: Omit<SecretBindingContext, "configPath">,
|
|
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
|
const record = asRecord(envValue);
|
|
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] };
|
|
const resolved: Record<string, string> = {};
|
|
const secretKeys = new Set<string>();
|
|
const manifest: RuntimeSecretManifestEntry[] = [];
|
|
|
|
for (const [key, rawBinding] of Object.entries(record)) {
|
|
if (!ENV_KEY_RE.test(key)) {
|
|
throw unprocessable(`Invalid environment variable name: ${key}`);
|
|
}
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) {
|
|
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
|
}
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "plain") {
|
|
resolved[key] = binding.value;
|
|
} else if (binding.type === "secret_ref") {
|
|
const secretResolution = await resolveSecretValueInternal(
|
|
companyId,
|
|
binding.secretId,
|
|
binding.version,
|
|
context
|
|
? {
|
|
bindingContext: { ...context, configPath: `env.${key}` },
|
|
accessContext: { ...context, configPath: `env.${key}` },
|
|
}
|
|
: undefined,
|
|
);
|
|
resolved[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
} else {
|
|
const secretResolution = await secretService(db).resolveUserSecretValue(
|
|
companyId,
|
|
{
|
|
definitionKey: binding.key,
|
|
version: binding.version,
|
|
required: binding.required,
|
|
allowMissingOverride: binding.allowMissingOverride,
|
|
},
|
|
context
|
|
? {
|
|
...context,
|
|
configPath: `env.${key}`,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
}
|
|
: undefined,
|
|
);
|
|
if (secretResolution) {
|
|
resolved[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
}
|
|
}
|
|
}
|
|
return { env: resolved, secretKeys, manifest };
|
|
},
|
|
|
|
// Pre-dispatch validation: list declared secret refs in an env-like config
|
|
// that have no binding for the given consumer, WITHOUT resolving any secret
|
|
// values. Callers use this to surface a configuration-incomplete blocker
|
|
// before a run is dispatched instead of letting resolution throw mid-setup.
|
|
collectMissingRuntimeBindings: async (
|
|
companyId: string,
|
|
envValue: unknown,
|
|
context: Omit<SecretBindingContext, "configPath">,
|
|
): Promise<MissingRuntimeBinding[]> => {
|
|
const record = asRecord(envValue);
|
|
if (!record) return [];
|
|
const secretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
|
|
if (!ENV_KEY_RE.test(key)) return [];
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) return [];
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type !== "secret_ref") return [];
|
|
return [{ key, configPath: `env.${key}`, secretId: binding.secretId }];
|
|
});
|
|
const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
|
|
if (!ENV_KEY_RE.test(key)) return [];
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) return [];
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type !== "user_secret_ref") return [];
|
|
if (!binding.required || binding.allowMissingOverride) return [];
|
|
return [{ key, configPath: `env.${key}`, binding }];
|
|
});
|
|
if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
|
|
|
|
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
|
|
entry,
|
|
found: await getBinding({
|
|
companyId,
|
|
secretId: entry.secretId,
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
}),
|
|
})));
|
|
const missingEntries = bindingChecks
|
|
.filter((check) => !check.found)
|
|
.map((check) => check.entry);
|
|
|
|
const secretRows = await Promise.all(
|
|
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
|
|
secretId,
|
|
await getById(secretId).catch(() => null),
|
|
] as const),
|
|
);
|
|
const secretsById = new Map(secretRows);
|
|
|
|
const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "secret_ref",
|
|
secretId: entry.secretId,
|
|
secretName: secretsById.get(entry.secretId)?.name ?? null,
|
|
}));
|
|
|
|
const missingUserSecretBindings: MissingRuntimeBinding[] = [];
|
|
for (const entry of userSecretRefs) {
|
|
let definition: typeof userSecretDefinitions.$inferSelect | null = null;
|
|
try {
|
|
definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
|
|
} catch {
|
|
missingUserSecretBindings.push(
|
|
missingUserSecretDefinitionRuntimeBinding(
|
|
entry,
|
|
context,
|
|
null,
|
|
"user_secret_definition_missing",
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
if (definition.status !== "active") {
|
|
missingUserSecretBindings.push(
|
|
missingUserSecretDefinitionRuntimeBinding(
|
|
entry,
|
|
context,
|
|
definition,
|
|
"user_secret_definition_inactive",
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const declaration = await db
|
|
.select()
|
|
.from(userSecretDeclarations)
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
|
|
eq(userSecretDeclarations.targetType, context.consumerType),
|
|
eq(userSecretDeclarations.targetId, context.consumerId),
|
|
eq(userSecretDeclarations.configPath, entry.configPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!declaration) {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
errorCode: "binding_missing",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!context.responsibleUserId?.trim()) {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: null,
|
|
errorCode: "responsible_user_missing",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const secret = await getUserSecretValue({
|
|
companyId,
|
|
ownerUserId: context.responsibleUserId,
|
|
definitionId: definition.id,
|
|
});
|
|
if (!secret || secret.status !== "active") {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: secret?.id ?? null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: context.responsibleUserId,
|
|
errorCode: secret ? "secret_inactive" : "user_secret_missing",
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...missingSecretBindings, ...missingUserSecretBindings];
|
|
},
|
|
|
|
collectMissingAdapterConfigRuntimeBindings: async (
|
|
companyId: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
adapterType: string | null | undefined,
|
|
context: Omit<SecretBindingContext, "configPath">,
|
|
): Promise<MissingRuntimeBinding[]> => {
|
|
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType);
|
|
const secretRefs = secretFieldKeys.flatMap((key) => {
|
|
const parsed = envBindingSchema.safeParse(adapterConfig[key]);
|
|
if (!parsed.success) return [];
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type !== "secret_ref") return [];
|
|
return [{ key, configPath: key, secretId: binding.secretId }];
|
|
});
|
|
const userSecretRefs = secretFieldKeys.flatMap((key) => {
|
|
const parsed = envBindingSchema.safeParse(adapterConfig[key]);
|
|
if (!parsed.success) return [];
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type !== "user_secret_ref") return [];
|
|
if (!binding.required || binding.allowMissingOverride) return [];
|
|
return [{ key, configPath: key, binding }];
|
|
});
|
|
if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
|
|
|
|
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
|
|
entry,
|
|
found: await getBinding({
|
|
companyId,
|
|
secretId: entry.secretId,
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
}),
|
|
})));
|
|
const missingEntries = bindingChecks
|
|
.filter((check) => !check.found)
|
|
.map((check) => check.entry);
|
|
|
|
const secretRows = await Promise.all(
|
|
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
|
|
secretId,
|
|
await getById(secretId).catch(() => null),
|
|
] as const),
|
|
);
|
|
const secretsById = new Map(secretRows);
|
|
|
|
const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "secret_ref",
|
|
secretId: entry.secretId,
|
|
secretName: secretsById.get(entry.secretId)?.name ?? null,
|
|
}));
|
|
|
|
const missingUserSecretBindings: MissingRuntimeBinding[] = [];
|
|
for (const entry of userSecretRefs) {
|
|
let definition: typeof userSecretDefinitions.$inferSelect | null = null;
|
|
try {
|
|
definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
|
|
} catch {
|
|
missingUserSecretBindings.push(
|
|
missingUserSecretDefinitionRuntimeBinding(
|
|
entry,
|
|
context,
|
|
null,
|
|
"user_secret_definition_missing",
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
if (definition.status !== "active") {
|
|
missingUserSecretBindings.push(
|
|
missingUserSecretDefinitionRuntimeBinding(
|
|
entry,
|
|
context,
|
|
definition,
|
|
"user_secret_definition_inactive",
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const declaration = await db
|
|
.select()
|
|
.from(userSecretDeclarations)
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
|
|
eq(userSecretDeclarations.targetType, context.consumerType),
|
|
eq(userSecretDeclarations.targetId, context.consumerId),
|
|
eq(userSecretDeclarations.configPath, entry.configPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!declaration) {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
errorCode: "binding_missing",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!context.responsibleUserId?.trim()) {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: null,
|
|
errorCode: "responsible_user_missing",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const secret = await getUserSecretValue({
|
|
companyId,
|
|
ownerUserId: context.responsibleUserId,
|
|
definitionId: definition.id,
|
|
});
|
|
if (!secret || secret.status !== "active") {
|
|
missingUserSecretBindings.push({
|
|
consumerType: context.consumerType,
|
|
consumerId: context.consumerId,
|
|
configPath: entry.configPath,
|
|
envKey: entry.key,
|
|
bindingType: "user_secret_ref",
|
|
secretId: secret?.id ?? null,
|
|
secretName: null,
|
|
userSecretDefinitionId: definition.id,
|
|
userSecretDefinitionKey: definition.key,
|
|
userSecretDefinitionName: definition.name,
|
|
responsibleUserId: context.responsibleUserId,
|
|
errorCode: secret ? "secret_inactive" : "user_secret_missing",
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...missingSecretBindings, ...missingUserSecretBindings];
|
|
},
|
|
|
|
resolveAdapterConfigForRuntime: async (
|
|
companyId: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
context?: Omit<SecretBindingContext, "configPath">,
|
|
opts?: ResolveAdapterConfigForRuntimeOptions,
|
|
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
|
const ownerScoped = opts?.userSecretMediation === "owner_scoped";
|
|
// Fail closed: owner_scoped skips declaration mediation, so an
|
|
// allowedBindingIds allowlist has no declaration to enforce against.
|
|
// Rejecting (rather than silently stripping) prevents a future low-trust
|
|
// owner_scoped caller from bypassing an allowlist by choosing this mode.
|
|
// Any supplied array — including an empty one, which requests "allow
|
|
// nothing" — is rejected: owner_scoped cannot honor either intent, and
|
|
// letting `[]` slip through would resolve every owner secret, the exact
|
|
// opposite of what an empty allowlist asks for.
|
|
if (ownerScoped && Array.isArray(context?.allowedBindingIds)) {
|
|
throw unprocessable(
|
|
"allowedBindingIds is not supported with owner_scoped user-secret mediation",
|
|
{ code: "owner_scoped_allowed_bindings_unsupported" },
|
|
);
|
|
}
|
|
const resolved = { ...adapterConfig };
|
|
const secretKeys = new Set<string>();
|
|
const manifest: RuntimeSecretManifestEntry[] = [];
|
|
if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
|
const record = asRecord(adapterConfig.env);
|
|
if (!record) {
|
|
resolved.env = {};
|
|
} else {
|
|
const env: Record<string, string> = {};
|
|
for (const [key, rawBinding] of Object.entries(record)) {
|
|
if (!ENV_KEY_RE.test(key)) {
|
|
throw unprocessable(`Invalid environment variable name: ${key}`);
|
|
}
|
|
const parsed = envBindingSchema.safeParse(rawBinding);
|
|
if (!parsed.success) {
|
|
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
|
}
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "plain") {
|
|
env[key] = binding.value;
|
|
} else if (binding.type === "secret_ref") {
|
|
const secretResolution = await resolveSecretValueInternal(
|
|
companyId,
|
|
binding.secretId,
|
|
binding.version,
|
|
context
|
|
? ownerScoped
|
|
? {
|
|
// owner_scoped: omit bindingContext so assertBindingContext
|
|
// returns null (no binding enforcement) — preserves today's
|
|
// undefined-context behavior for a prospective config —
|
|
// while still carrying the actor via accessContext for audit.
|
|
accessContext: { ...context, configPath: `env.${key}` },
|
|
}
|
|
: {
|
|
bindingContext: { ...context, configPath: `env.${key}` },
|
|
accessContext: { ...context, configPath: `env.${key}` },
|
|
}
|
|
: undefined,
|
|
);
|
|
env[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
} else {
|
|
if (opts?.skipUserSecrets) continue;
|
|
const secretResolution = await secretService(db).resolveUserSecretValue(
|
|
companyId,
|
|
{
|
|
definitionKey: binding.key,
|
|
version: binding.version,
|
|
required: binding.required,
|
|
allowMissingOverride: binding.allowMissingOverride,
|
|
},
|
|
context
|
|
? ownerScoped
|
|
? {
|
|
// owner_scoped: omit configPath so resolveUserSecretValue's
|
|
// `if (context?.configPath)` declaration guard stays false —
|
|
// resolution proceeds by definition + owner boundary, with no
|
|
// declaration row required for a prospective config.
|
|
...context,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
}
|
|
: {
|
|
...context,
|
|
configPath: `env.${key}`,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
}
|
|
: undefined,
|
|
);
|
|
if (secretResolution) {
|
|
env[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
}
|
|
}
|
|
}
|
|
resolved.env = env;
|
|
}
|
|
}
|
|
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType);
|
|
for (const key of secretFieldKeys) {
|
|
const parsed = envBindingSchema.safeParse(adapterConfig[key]);
|
|
if (!parsed.success) continue;
|
|
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
|
if (binding.type === "plain") continue;
|
|
if (binding.type === "user_secret_ref") {
|
|
if (opts?.skipUserSecrets) {
|
|
delete resolved[key];
|
|
continue;
|
|
}
|
|
const secretResolution = await secretService(db).resolveUserSecretValue(
|
|
companyId,
|
|
{
|
|
definitionKey: binding.key,
|
|
version: binding.version,
|
|
required: binding.required,
|
|
allowMissingOverride: binding.allowMissingOverride,
|
|
},
|
|
context
|
|
? ownerScoped
|
|
? {
|
|
// owner_scoped: omit configPath so the declaration guard stays
|
|
// false — resolve by definition + owner boundary.
|
|
...context,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
}
|
|
: {
|
|
...context,
|
|
configPath: key,
|
|
responsibleUserId: context.responsibleUserId ?? null,
|
|
}
|
|
: undefined,
|
|
);
|
|
if (secretResolution) {
|
|
resolved[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
}
|
|
continue;
|
|
}
|
|
const secretResolution = await resolveSecretValueInternal(
|
|
companyId,
|
|
binding.secretId,
|
|
binding.version,
|
|
context
|
|
? ownerScoped
|
|
? {
|
|
// owner_scoped: omit bindingContext (no binding enforcement),
|
|
// carry the actor via accessContext for audit only.
|
|
accessContext: { ...context, configPath: key },
|
|
}
|
|
: {
|
|
bindingContext: { ...context, configPath: key },
|
|
accessContext: { ...context, configPath: key },
|
|
}
|
|
: undefined,
|
|
);
|
|
resolved[key] = secretResolution.value;
|
|
manifest.push(secretResolution.manifestEntry);
|
|
secretKeys.add(key);
|
|
}
|
|
return { config: resolved, secretKeys, manifest };
|
|
},
|
|
};
|
|
}
|