fix(create): recover the atomic-byok-create commit dropped from #2617 (main lifecycle e2e is red) #2640

Merged
core-devops merged 1 commits from fix/2617-recover-atomic-byok-create into main 2026-06-12 11:26:07 +00:00
4 changed files with 39 additions and 7 deletions
@@ -353,8 +353,14 @@ echo "--- Step 2: provision workspace (POST /workspaces) ---"
# then trigger ONE clean provision via /restart. Seeding the volume is also what
# makes the restart-survival assertion meaningful: the restart path reuses the
# volume rather than any template.
# core#2608: create is now ATOMIC for byok — the create-boundary gate
# hard-rejects a byok model with no credential in scope, and the create-scope
# vendor-key guard accepts the credential in the SAME payload (deriving from
# the payload model instead of the not-yet-stored MODEL secret). So the dummy
# key rides in the create body; the later flip+write steps remain as
# idempotent belt-and-suspenders for the restart path.
CREATE_BODY=$(cat <<JSON
{"name":"Lifecycle E2E Stub","tier":2,"runtime":"$RUNTIME","model":"$LIFECYCLE_MODEL"}
{"name":"Lifecycle E2E Stub","tier":2,"runtime":"$RUNTIME","model":"$LIFECYCLE_MODEL","secrets":{"$LIFECYCLE_LLM_KEY":"$LIFECYCLE_LLM_VALUE"}}
JSON
)
RESP=$(admin_curl -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d "$CREATE_BODY")
@@ -218,6 +218,15 @@ func validateBYOKCredentialSatisfiable(ctx context.Context, runtime, model strin
if prov.IsPlatform() {
return true, ""
}
// Atomic byok create: a payload secret the derived arm accepts satisfies
// the gate outright — no DB work (create(model, secrets) stays one call).
for _, want := range prov.AuthEnv {
for _, have := range payloadSecretKeys {
if have == want {
return true, ""
}
}
}
// BYOK-derived: widen the auth context with the tenant's global secret
// KEYS (names only, never values) and re-derive — a global key can both
// flip the arm disambiguation and satisfy the requirement. A failed key
@@ -685,8 +685,27 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
// so we never have a workspace row without its intended secrets.
//
// Vendor-key guard, CREATE scope (core#2608 companion): the stored-state
// resolver cannot see payload.Model yet (the MODEL secret persists after
// commit), so a byok create would always misresolve platform_managed here
// and reject the very credential the create-boundary gate REQUIRES in the
// payload. Derive from the CREATE inputs instead: when (runtime, model,
// payload keys) resolves a non-platform arm, vendor keys in this payload
// are the atomic-byok-create shape and are allowed; platform-resolving
// creates keep the full guard.
allowVendorKeysForByokCreate := false
if m, regErr := providerRegistry(); regErr == nil && m != nil {
keys := make([]string, 0, len(payload.Secrets))
for k := range payload.Secrets {
keys = append(keys, k)
}
if prov, dErr := m.DeriveProvider(payload.Runtime, strings.TrimSpace(payload.Model), keys); dErr == nil && !prov.IsPlatform() {
allowVendorKeysForByokCreate = true
}
}
for k, v := range payload.Secrets {
if rejectPlatformManagedDirectLLMBypassForWorkspace(c, id, k) {
if !allowVendorKeysForByokCreate && rejectPlatformManagedDirectLLMBypassForWorkspace(c, id, k) {
tx.Rollback() //nolint:errcheck
return
}
@@ -522,11 +522,9 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
// Create() resolves billing mode per-workspace before the secret-strip gate.
// An explicit byok override short-circuits the resolver (precedence 1) so the
// OPENAI_API_KEY write is allowed and reaches the INSERT-and-fail path.
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
// core#2608 create-scope guard: the byok-form payload model derives a
// non-platform arm from CREATE inputs (no DB read), so the vendor-key
// write is allowed and reaches the INSERT-and-fail path directly.
mock.ExpectExec("INSERT INTO workspace_secrets").
WillReturnError(sql.ErrConnDone) // DB failure while writing secret
mock.ExpectRollback() // workspace insert must be rolled back