History

Enforce managed credential providers in cloud mode

Rhys Sullivan committed 8 months ago

Commit 9ba2b7e1 parent 334bb6d3 files · +52 −31Browse files

Unified · Split

3 files
  1. .env.example +7 −0
  2. executor/packages/database/convex/database/credentials.ts +45 −0
  3. executor/packages/database/convex/workspace.ts +0 −31
.env.example+7−0
@@ -9,6 +9,11 @@ CONVEX_URL=https://your-deployment.convex.cloud · 8 unmodified lines
9CONVEX_SITE_URL=https://your-deployment.convex.site9CONVEX_SITE_URL=https://your-deployment.convex.site
10 10
11# ── Runtime execution mode (set in Convex prod) ──11# ── Runtime execution mode (set in Convex prod) ──
12# Deployment mode controls security defaults in backend logic.
13# - cloud: enforce managed credential storage and other cloud-safe defaults
14# - self-hosted: allow local storage primitives for single-host deployments
15# - unset: default to self-hosted (set cloud/prod explicitly in hosted deploys)
16# EXECUTOR_DEPLOYMENT_MODE=cloud
12# When enabled, local Bun execution is allowed.17# When enabled, local Bun execution is allowed.
13# Leave unset in production to default to Cloudflare runtime.18# Leave unset in production to default to Cloudflare runtime.
14# DANGEROUSLY_ALLOW_LOCAL_VM=119# DANGEROUSLY_ALLOW_LOCAL_VM=1
@@ -39,6 +44,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines
39# ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=60480044# ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800
40# MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset)45# MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset)
41# MCP_API_KEY_SECRET=change-me46# MCP_API_KEY_SECRET=change-me
47# Force managed credentials regardless of deployment-mode defaults
48# EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1
42 49
43# ── Stripe Billing (optional — required for checkout/portal) ──50# ── Stripe Billing (optional — required for checkout/portal) ──
44# STRIPE_SECRET_KEY=sk_test_...51# STRIPE_SECRET_KEY=sk_test_...
executor/packages/database/convex/database/credentials.ts+45−0
@@ -14,6 +14,29 @@ import { · 13 unmodified lines
14 14
15const recordSchema = z.record(z.unknown());15const recordSchema = z.record(z.unknown());
16 16
17function isTruthyEnvValue(value: string | undefined): boolean {
18 const normalized = value?.trim().toLowerCase();
19 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
20}
21
22function managedCredentialMode(): "cloud" | "self-hosted" {
23 const explicit = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase();
24 if (explicit === "cloud" || explicit === "hosted" || explicit === "production" || explicit === "prod") {
25 return "cloud";
26 }
27 if (explicit === "self-hosted" || explicit === "self_hosted" || explicit === "selfhosted") {
28 return "self-hosted";
29 }
30 if (isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MANAGED_CREDENTIALS)) {
31 return "cloud";
32 }
33 return "self-hosted";
34}
35
36function shouldEnforceManagedCredentials(): boolean {
37 return managedCredentialMode() === "cloud";
38}
39
17function toRecordValue(value: unknown): Record<string, unknown> {40function toRecordValue(value: unknown): Record<string, unknown> {
18 const parsed = recordSchema.safeParse(value);41 const parsed = recordSchema.safeParse(value);
19 return parsed.success ? parsed.data : {};42 return parsed.success ? parsed.data : {};
@@ -156,6 +179,12 @@ export const upsertCredential = internalMutation({ · 136 unmodified lines
156 179
157 const exemplar = linkedRows[0] ?? existing ?? null;180 const exemplar = linkedRows[0] ?? existing ?? null;
158 const provider = args.provider ?? exemplar?.provider ?? "local-convex";181 const provider = args.provider ?? exemplar?.provider ?? "local-convex";
182 if (shouldEnforceManagedCredentials() && provider !== "workos-vault") {
183 throw new Error(
184 "Managed credential storage is required in cloud deployments. Configure WORKOS_API_KEY and use provider 'workos-vault'.",
185 );
186 }
187
159 const fallbackSecret = toRecordValue(exemplar?.secretJson);188 const fallbackSecret = toRecordValue(exemplar?.secretJson);
160 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;189 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
161 if (Object.keys(finalSecret).length === 0) {190 if (Object.keys(finalSecret).length === 0) {
@@ -310,6 +339,22 @@ export const listCredentialProviders = internalQuery({ · 148 unmodified lines
310 args: {},339 args: {},
311 handler: async () => {340 handler: async () => {
312 const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim());341 const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim());
342 const enforceManaged = shouldEnforceManagedCredentials();
343
344 if (enforceManaged && !workosEnabled) {
345 return [] as const;
346 }
347
348 if (enforceManaged) {
349 return [
350 {
351 id: "workos-vault",
352 label: "Encrypted",
353 description: "Secrets are stored in WorkOS Vault.",
354 },
355 ] as const;
356 }
357
313 return [358 return [
314 {359 {
315 id: workosEnabled ? "workos-vault" : "local-convex",360 id: workosEnabled ? "workos-vault" : "local-convex",
executor/packages/database/convex/workspace.ts+0−31
@@ -298,13 +298,6 @@ export const upsertCredential = workspaceMutation({ · 297 unmodified lines
298 overridesJson: v.optional(jsonObjectValidator),298 overridesJson: v.optional(jsonObjectValidator),
299 },299 },
300 handler: async (ctx, args) => {300 handler: async (ctx, args) => {
301 if (args.scopeType === "organization") {
302 const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
303 if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
304 throw new Error("Only organization admins can manage organization-level credentials");
305 }
306 }
307
308 if (args.scopeType === "account") {301 if (args.scopeType === "account") {
309 if (!args.accountId) {302 if (!args.accountId) {
310 throw new Error("accountId is required for account-scoped credentials");303 throw new Error("accountId is required for account-scoped credentials");
@@ -371,13 +364,6 @@ export const upsertToolSource = workspaceMutation({ · 60 unmodified lines
371 enabled: v.optional(v.boolean()),364 enabled: v.optional(v.boolean()),
372 },365 },
373 handler: async (ctx, args) => {366 handler: async (ctx, args) => {
374 if (args.scopeType === "organization") {
375 const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
376 if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
377 throw new Error("Only organization admins can manage organization-level tool sources");
378 }
379 }
380
381 return await ctx.runMutation(internal.database.upsertToolSource, {367 return await ctx.runMutation(internal.database.upsertToolSource, {
382 ...args,368 ...args,
383 workspaceId: ctx.workspaceId,369 workspaceId: ctx.workspaceId,
@@ -507,23 +493,6 @@ export const deleteToolSource = workspaceMutation({ · 123 unmodified lines
507 sourceId: v.string(),493 sourceId: v.string(),
508 },494 },
509 handler: async (ctx, args) => {495 handler: async (ctx, args) => {
510 const sources = await ctx.runQuery(internal.database.listToolSources, {
511 workspaceId: ctx.workspaceId,
512 });
513 let source: { id: string; scopeType?: "organization" | "workspace" } | undefined;
514 for (const entry of sources as Array<{ id: string; scopeType?: "organization" | "workspace" }>) {
515 if (entry.id === args.sourceId) {
516 source = entry;
517 break;
518 }
519 }
520 if (source?.scopeType === "organization") {
521 const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id);
522 if (!organizationMembership || !isAdminRole(organizationMembership.role)) {
523 throw new Error("Only organization admins can delete organization-level tool sources");
524 }
525 }
526
527 return await ctx.runMutation(internal.database.deleteToolSource, {496 return await ctx.runMutation(internal.database.deleteToolSource, {
528 ...args,497 ...args,
529 workspaceId: ctx.workspaceId,498 workspaceId: ctx.workspaceId,