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
99 CONVEX_SITE_URL=https://your-deployment.convex.site
1010
1111 # ── 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
1217 # When enabled, local Bun execution is allowed.
1318 # Leave unset in production to default to Cloudflare runtime.
1419 # DANGEROUSLY_ALLOW_LOCAL_VM=1
@@ -39,6 +44,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines
3944 # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800
4045 # MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset)
4146 # MCP_API_KEY_SECRET=change-me
47+# Force managed credentials regardless of deployment-mode defaults
48+# EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1
4249
4350 # ── Stripe Billing (optional — required for checkout/portal) ──
4451 # STRIPE_SECRET_KEY=sk_test_...
executor/packages/database/convex/database/credentials.ts+45−0
@@ -14,6 +14,29 @@ import { · 13 unmodified lines
1414
1515 const recordSchema = z.record(z.unknown());
1616
17+function isTruthyEnvValue(value: string | undefined): boolean {
18+ const normalized = value?.trim().toLowerCase();
19+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
20+}
21+
22+function 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+
36+function shouldEnforceManagedCredentials(): boolean {
37+ return managedCredentialMode() === "cloud";
38+}
39+
1740 function toRecordValue(value: unknown): Record<string, unknown> {
1841 const parsed = recordSchema.safeParse(value);
1942 return parsed.success ? parsed.data : {};
@@ -156,6 +179,12 @@ export const upsertCredential = internalMutation({ · 136 unmodified lines
156179
157180 const exemplar = linkedRows[0] ?? existing ?? null;
158181 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+
159188 const fallbackSecret = toRecordValue(exemplar?.secretJson);
160189 const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret;
161190 if (Object.keys(finalSecret).length === 0) {
@@ -310,6 +339,22 @@ export const listCredentialProviders = internalQuery({ · 148 unmodified lines
310339 args: {},
311340 handler: async () => {
312341 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+
313358 return [
314359 {
315360 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
298298 overridesJson: v.optional(jsonObjectValidator),
299299 },
300300 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−
308301 if (args.scopeType === "account") {
309302 if (!args.accountId) {
310303 throw new Error("accountId is required for account-scoped credentials");
@@ -371,13 +364,6 @@ export const upsertToolSource = workspaceMutation({ · 60 unmodified lines
371364 enabled: v.optional(v.boolean()),
372365 },
373366 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−
381367 return await ctx.runMutation(internal.database.upsertToolSource, {
382368 ...args,
383369 workspaceId: ctx.workspaceId,
@@ -507,23 +493,6 @@ export const deleteToolSource = workspaceMutation({ · 123 unmodified lines
507493 sourceId: v.string(),
508494 },
509495 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−
527496 return await ctx.runMutation(internal.database.deleteToolSource, {
528497 ...args,
529498 workspaceId: ctx.workspaceId,