Skip to content

feat(byok): support multiple keys per provider with round-robin rotation#4963

Open
waleedlatif1 wants to merge 91 commits into
stagingfrom
feat/byok-multiple-keys
Open

feat(byok): support multiple keys per provider with round-robin rotation#4963
waleedlatif1 wants to merge 91 commits into
stagingfrom
feat/byok-multiple-keys

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Workspaces can now store multiple BYOK keys per provider (capped at 10); requests round-robin across the pool in creation order
  • Dropped the (workspace_id, provider_id) unique index, added a plain composite lookup index and a nullable name label column — existing rows need no backfill
  • Rotation lives entirely inside getBYOKKey (in-process cursor, decrypt-failure skips to the next key), so all call sites are untouched
  • POST/DELETE on /api/workspaces/[id]/byok-keys take an optional keyId: with it they update/delete that key, without it they keep the old add / delete-all-for-provider semantics
  • BYOK settings UI gains a per-provider Manage modal (key list, optional labels, per-key update/delete, add up to the cap); the mothership tab keeps the single-key mode unchanged

Type of Change

  • New feature

Testing

Added unit tests for rotation (cycle order, per-provider cursors, decrypt fallback) and route semantics (add vs update-by-keyId, cap, delete one/all, authz) — 24 tests passing. Typecheck and check:api-validation pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

waleedlatif1 and others added 30 commits April 3, 2026 23:30
…ership workflow edits via sockets, ui improvements
…ration, signup method feature flags, SSO improvements
* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha
…nts, secrets performance, polling refactors, drag resources in mothership
…endar triggers, docs updates, integrations/models pages improvements
…mat, logs performance improvements

fix(csp): add missing analytics domains, remove unsafe-eval, fix workspace CSP gap (#4179)
fix(landing): return 404 for invalid dynamic route slugs (#4182)
improvement(seo): optimize sitemaps, robots.txt, and core web vitals across sim and docs (#4170)
fix(gemini): support structured output with tools on Gemini 3 models (#4184)
feat(brightdata): add Bright Data integration with 8 tools (#4183)
fix(mothership): fix superagent credentials (#4185)
fix(logs): close sidebar when selected log disappears from filtered list; cleanup (#4186)
v0.6.46: mothership streaming fixes, brightdata integration
waleedlatif1 and others added 23 commits May 19, 2026 18:30
…oogle slides endpoints, DB access pattern improvements
…er, search & replace UX, kb connectors multi-select, mcp negative cache
…ter, hosted-key queueing

v0.6.92: enrichment table column type, table run fixes, scheduled jitter, hosted-key queueing
… messages table, user stats hot path improvements, RB2B
v0.6.95: data enrichment block, nullable workflow description fix
…ables UI improvements, new slack scopes, model-level denylists, object storage tracespans
…ion, together AI, Baseten, Ollama Cloud, Linq integrations, kb doc ownership bindings
…dgebase connector, SSO provider ID allowlist, singleton memory leak fix
…ration, smooth streaming, security hardening, db fixes
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jun 11, 2026 3:10am

Request Review

@gitguardian

gitguardian Bot commented Jun 11, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
29606901 Triggered Generic High Entropy Secret a54dcbe apps/sim/providers/utils.test.ts View secret
32763747 Triggered Generic Password 3e9849b helm/sim/tests/validators_test.yaml View secret
33881454 Triggered Generic Password 7ffc495 apps/sim/app/api/chat/utils.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@cursor

cursor Bot commented Jun 11, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches encrypted credential storage, key-selection for all provider requests, and a schema migration; rotation is per-process so load spread is best-effort across instances.

Overview
Workspaces can store up to 10 BYOK keys per provider (optional name labels) instead of one. A migration drops the (workspace_id, provider_id) unique constraint, adds name, and adds a lookup index.

Runtime: getBYOKKey loads all keys in creation order and round-robins via an in-process cursor; corrupt keys are skipped. Existing call sites stay unchanged.

API: GET returns every key with name; POST without keyId inserts (enforces the cap), with keyId updates that row; DELETE with keyId removes one key, without it removes all keys for the provider.

UI: Workspace BYOK settings use multi-key mode (key count, Manage modal for per-key add/update/delete). BYOKKeyManager still supports single-key mode for the enterprise mothership tab.

Contracts, React Query hooks, and route/rotation unit tests are updated accordingly.

Reviewed by Cursor Bugbot for commit 30bb92a. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 30bb92a. Configure here.

workspaceId,
providerId,
encryptedApiKey: encrypted,
name: name || null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent adds exceed key cap

Medium Severity

New-key creation reads the per-provider count and inserts in separate steps with no transaction or lock. Concurrent POSTs without keyId can each see a count below MAX_BYOK_KEYS_PER_PROVIDER and all succeed, storing more than the advertised ten keys per provider.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30bb92a. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR upgrades BYOK from one key per provider to a pool of up to 10 keys per workspace+provider pair, with round-robin rotation handled entirely in-process via a module-level cursor map. The unique (workspace_id, provider_id) index is dropped and replaced by a plain composite lookup index; a nullable name label column is added. The API route's POST/DELETE handlers gain an optional keyId parameter to target individual keys, and the workspace settings UI gains a per-provider "Manage" modal listing all stored keys.

  • Rotation logic (byok.ts): fetches all keys ordered by creation time, picks the next index via nextRotationIndex, and falls back to the next key if decryption fails — clean and well-tested with 8 unit tests.
  • Schema/migration (0232_byok_multiple_keys.sql, schema.ts): non-breaking migration; no backfill required since existing single-key rows remain valid.
  • UI (byok-key-manager.tsx, byok-provider-keys-modal.tsx, byok.tsx): BYOKKeyManager is refactored into single-key and multi-key modes via a discriminated union prop type, with a new BYOKProviderKeysModal component for per-key management.

Confidence Score: 3/5

The feature is well-structured and thoroughly tested, but the key-cap check in the route handler is not atomic, allowing concurrent requests to store more than 10 keys per provider.

The count-then-insert sequence for adding new keys has no transaction boundary and no database-level enforcement. Two simultaneous POST requests for the same workspace+provider can both read a count below 10 and both successfully insert, silently bypassing the cap. The old unique index provided DB-level safety; nothing equivalent was added in its place.

apps/sim/app/api/workspaces/[id]/byok-keys/route.ts — the count-check → insert path needs a transaction or a DB-level constraint to be safe under concurrent load.

Important Files Changed

Filename Overview
apps/sim/app/api/workspaces/[id]/byok-keys/route.ts Core API route refactored to support per-key add/update/delete; a TOCTOU race allows concurrent inserts to exceed the 10-key cap, and encryptSecret is called before validation guards.
apps/sim/lib/api-key/byok.ts Adds in-process round-robin rotation with decrypt-failure fallback; logic is correct but the module-level counter resets per server instance (documented limitation in serverless deployments).
packages/db/migrations/0232_byok_multiple_keys.sql Drops the unique index (enabling multiple keys per provider), adds a nullable name column, and creates a plain composite lookup index — no backfill needed.
packages/db/schema.ts Schema updated to replace uniqueIndex with a plain index and add the nullable name column, matching the migration.
apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx Refactored to support both single-key and multi-key modes via discriminated union props; contains a silent no-op edge case in handleDelete when multi-key mode lacks a keyId.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RouteHandler as POST /byok-keys
    participant DB as Database
    participant Encryption

    Client->>RouteHandler: "POST {providerId, apiKey, keyId?, name?}"
    RouteHandler->>Encryption: encryptSecret(apiKey)
    Encryption-->>RouteHandler: encrypted

    alt keyId provided (update existing key)
        RouteHandler->>DB: "SELECT id, name WHERE id=keyId AND workspaceId AND providerId"
        DB-->>RouteHandler: existingKey or empty
        alt key not found
            RouteHandler-->>Client: 404 Not Found
        else key found
            RouteHandler->>DB: UPDATE SET encryptedApiKey, name, updatedAt
            RouteHandler-->>Client: 200 success
        end
    else no keyId - add new key
        RouteHandler->>DB: SELECT count() WHERE workspaceId AND providerId
        DB-->>RouteHandler: keyCount
        alt "keyCount >= 10 race window"
            RouteHandler-->>Client: 400 cap exceeded
        else "keyCount < 10"
            RouteHandler->>DB: INSERT new key
            DB-->>RouteHandler: newKey
            RouteHandler-->>Client: 200 success
        end
    end

    Note over RouteHandler,DB: count-check and insert are not atomic
Loading

Reviews (1): Last reviewed commit: "feat(byok): support multiple keys per pr..." | Re-trigger Greptile

Comment on lines 194 to +220
success: true,
key: {
id: existingKey[0].id,
id: existingKey.id,
providerId,
name: updatedName,
maskedKey: maskApiKey(apiKey),
updatedAt: new Date(),
updatedAt,
},
})
}

const [{ keyCount }] = await db
.select({ keyCount: count() })
.from(workspaceBYOKKeys)
.where(
and(
eq(workspaceBYOKKeys.workspaceId, workspaceId),
eq(workspaceBYOKKeys.providerId, providerId)
)
)

if (keyCount >= MAX_BYOK_KEYS_PER_PROVIDER) {
return NextResponse.json(
{
error: `A workspace can store at most ${MAX_BYOK_KEYS_PER_PROVIDER} keys per provider`,
},
{ status: 400 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TOCTOU race: cap can be bypassed by concurrent inserts

The SELECT count()INSERT sequence is not wrapped in a transaction and there is no database-level constraint that enforces the 10-key cap. Two simultaneous POST requests for the same workspace + provider can both read keyCount = 9, both pass the >= 10 guard, and both successfully insert, leaving the workspace with 11 or more keys for that provider. The previous unique index prevented duplicate rows; the new plain lookup index provides no such protection for the cap.

Wrapping the count-check and insert in a serializable transaction (or using a row-level lock with SELECT ... FOR UPDATE) would close the window.

Comment on lines 146 to +148
const { encrypted } = await encryptSecret(apiKey)

const existingKey = await db
.select()
.from(workspaceBYOKKeys)
.where(
and(
eq(workspaceBYOKKeys.workspaceId, workspaceId),
eq(workspaceBYOKKeys.providerId, providerId)
if (keyId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 encryptSecret is called before any request validation

If the keyId doesn't exist in the workspace (→ 404) or the cap is already reached (→ 400), the encryption work has already been done and is simply discarded. Moving the encryptSecret call inside each branch avoids wasting compute on requests that will fail validation.

Suggested change
const { encrypted } = await encryptSecret(apiKey)
const existingKey = await db
.select()
.from(workspaceBYOKKeys)
.where(
and(
eq(workspaceBYOKKeys.workspaceId, workspaceId),
eq(workspaceBYOKKeys.providerId, providerId)
if (keyId) {
if (keyId) {
const { encrypted } = await encryptSecret(apiKey)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 224 to 242
}

const handleDelete = async () => {
if (!deleteConfirmProvider) return
if (!deleteConfirm) return

try {
await onDelete(deleteConfirmProvider)
setDeleteConfirmProvider(null)
if (props.multiKey) {
if (deleteConfirm.keyId) {
await props.onDeleteKey(deleteConfirm.providerId, deleteConfirm.keyId)
}
} else {
await props.onDelete(deleteConfirm.providerId)
}
setDeleteConfirm(null)
} catch (err) {
logger.error('Failed to delete BYOK key', { error: err })
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent no-op delete when keyId is absent in multi-key mode

In the multi-key branch of handleDelete, if deleteConfirm.keyId is undefined the inner if body is skipped, but execution still falls through to setDeleteConfirm(null) — the confirmation dialog closes without calling props.onDeleteKey and without surfacing any error or feedback to the user. While today's UI always populates keyId before opening the confirm modal in multi-key mode, the missing else branch (or an early return) means a future caller could silently no-op a delete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants