Visitar URL original
fix(store)!: refuse a create-database upsert that rebinds an existing database by vsai12 · Pull Request #21557 · bytebase/bytebase · GitHub
Skip to content

fix(store)!: refuse a create-database upsert that rebinds an existing database - #21557

Open
vsai12 wants to merge 1 commit into
mainfrom
fix/create-db-no-overwrite
Open

vsai12 wants to merge 1 commit into
mainfrom
fix/create-db-no-overwrite

Conversation

@vsai12

@vsai12 vsai12 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

A create-database change can no longer take over a database that is already live in another project. Before this fix, a create-database plan that named such a database rebound it to the plan's project, changed its environment, and dropped its labels before the engine create ran, so the caller's own project roles (SQL select, DDL and DML) then applied to its data. The rebind committed in its own transaction, so it stayed whatever happened next: on PostgreSQL, CockroachDB, Redshift and MongoDB the engine create was skipped or wrote into the moved database and the task completed, so the move was silent; on the other engines the task failed at the create, but the database had already moved. The caller needed only the rights to drive a plan and a rollout in their own project (running the task through task-run permission, or an automatic rollout policy), and no access to the project the database belonged to. The path is the same for the console, the public API and MCP.

The fix

Store.UpsertDatabase overwrites project, environment and metadata on an (instance, name) conflict, and the create-database task is its only caller. The refusal now lives in that write:

ON CONFLICT (instance, name) DO UPDATE SET … WHERE db.project = EXCLUDED.project

and the call refuses when the statement returns no row. Because the guard is part of the write, not a prior read, two concurrent first-creates of one name from different projects cannot both win: one inserts, the other meets the conflict and its WHERE fails. A project-scoped instance forces its own project in projectForUpsert, so the clause always holds there and its behavior is unchanged. A genuine move between projects still goes through DatabaseService/UpdateDatabase, which checks both projects.

The guard does not exempt a soft-deleted row. A deleted row is either a genuinely dropped database or a live one the instance's sync allowlist excluded, and the store cannot tell them apart (backend/runner/schemasync/syncer.go), so exempting it would re-open the takeover for the excluded-but-live case. The cost is narrow and stated below.

UpsertDatabase has one caller, so the guard sits safely in the statement with no new parameter and no split of a shared-looking primitive; a short comment on the clause says so.

Who was affected

  • Present in every supported release, including 3.4.0 and through 3.23.0: UpsertDatabase's overwriting ON CONFLICT clause is there in each (git show <tag>:backend/store/database.go). The exact first release is older still and not pinned here.
  • Workspace-level instances (instances not bound to a project). Project-bound instances were already guarded.
  • Callers who can drive a plan and a rollout on any project — Project Owner, Workspace DBA, Workspace Admin, the GitOps Service Agent, or a Project Developer with a Project Releaser — and can run the task (task-run permission, or an automatic rollout policy).

The narrow cost

Reusing a genuinely-dropped database's name from another project is now refused, which origin/main allowed. The owning project can still reuse its own dropped name (the WHERE matches), and a create-database task that re-runs against its own database is unaffected. There is no API to move a soft-deleted row to a new project, so a cross-project reuse of a dropped name has no path until the follow-up's engine-existence check restores it. This is a rare, cross-project operation; refusing it is the price of a store-only guard that cannot tell a dropped name from an allowlist-excluded live database. The refusal also keeps the old row's changelog, revisions and schema (keyed on instance and name, with no project column) from moving to the new project, which the pre-fix reuse did move, so it is as much a fix as a cost.

Not covered here

  • Case-variant names. The guard does not cover case-variant names on case-insensitive engines; tracked internally.
  • Same-project create-over-existing. A create-database plan that names an existing database in the caller's own project still upserts over it, resetting its environment and labels within the project. Within the caller's authority, lower severity; the follow-up ticket covers it too.
  • Cross-project reuse of a dropped name. Refused (see The narrow cost); the follow-up's engine-existence check would restore it.
  • The two upstream gaps this relies on — spec validation checks existence only for change targets, and the scheduler's drift check returns early for create-database tasks — are left as they are. The store is the right enforcement point because only inside the ON CONFLICT statement is the refusal atomic with the write; a plan-time API check would leave a window, since nothing re-checks a create before the task runs.

Breaking Changes

  • A create-database task that names a database already live in a different project now fails with an invalid-argument error before any engine call, and the database is not moved. Before, it rebound the record to the plan's project (then the task completed on PostgreSQL, CockroachDB, Redshift and MongoDB, or failed at the engine on the others, with the move already committed either way). A genuine move uses UpdateDatabase. Reusing a genuinely-dropped name from another project is also refused now (see The narrow cost); the owning project can still reuse its own dropped name.

Release note

MCP sessions and API callers can no longer take over a database that is live in another project through a create-database plan: a plan that names such a database now fails before the database is moved, instead of rebinding it to the plan's project and dropping its labels. A database's project and environment decide which approval and SQL review rules apply to it, and the project's roles decide who can read and change its data, so this closes a cross-project escalation. Reusing a genuinely-dropped database name from another project is also refused; the owning project can still reuse its own.

Docs to update (bytebase.com)

  • None required; this is a server-side guard with no API-surface change. If the create-database docs describe naming an existing database, note that it now fails unless the name is free.

Tests

  • backend/store/database_ownership_test.go:
    • TestDatabaseWritersRespectProjectInstanceOwnership gains a workspace-instance case — a cross-project upsert is refused with common.Invalid and the row is unchanged; the same-project upsert still succeeds.
    • TestWorkspaceInstanceUpsertDroppedNameStaysWithItsProject — a dropped name cannot be taken by another project; the owning project can reuse it.
    • TestWorkspaceInstanceUpsertRaceKeepsOneOwner — two concurrent first-creates of one name from different projects: exactly one wins, the other is refused.
  • backend/tests/create_database_takeover_test.go: TestCreateDatabasePlanCannotTakeOverAnotherProjectsDatabase drives the full path as a Project Owner of the plan's project only; the task fails naming the refusal, and the existing database keeps its project, environment and labels.
  • All four fail on origin/main's store and pass with the fix.

Receipts, read at 80524fc and branch HEAD:

Claim Receipt
The upsert overwrites project/environment/metadata on conflict; the guard is the WHERE in that clause backend/store/database.go UpsertDatabase
The create-database task is the only production caller grep -rn 'UpsertDatabase(' backend --include='*.go' (non-test): only backend/runner/taskrun/database_create_executor.go
A project instance forces its own project projectForUpsert, backend/store/database.go
Rebind commits before the engine create backend/runner/taskrun/database_create_executor.go upserts, then runs the create; PostgreSQL/CockroachDB/Redshift skip it for an existing database, MongoDB's createCollection writes into the moved database, so the task completes silently there; the other engines run a plain CREATE DATABASE and fail after the move
Overwriting upsert present at v3.4.0 git show v3.4.0:backend/store/database.go has ON CONFLICT (instance, name) DO UPDATE SET project = EXCLUDED.project, ... in UpsertDatabase (tag is v3.4.0, no 3.4.0 tag)
Caller's project roles grant data access on the moved database backend/store/predefined_roles.go (Project Owner holds permission.SQLSelect, SQLDdl, SQLDml)
Present in 3.10.0 and every later tag `git show 3.10.0:backend/store/database.go
Spec validation checks existence only for change targets backend/api/v1/plan_service.go validateSpecs create branch
The drift check returns early for create-database backend/runner/taskrun/running_scheduler.go validateTaskFreshness (Task_DATABASE_CREATE)
Roles that can drive plan + rollout backend/store/predefined_roles.go

🤖 Generated with Claude Code

… database

UpsertDatabase overwrites project, environment and metadata on an
(instance, name) conflict, and the create-database task is its only
caller. On a workspace-level instance nothing stopped a create-database
plan that named a database already registered in another project from
rebinding it to the plan's project and dropping its labels. The rebind
committed before the engine create ran, so the caller's own project roles
then covered the database's data; on PostgreSQL, CockroachDB, Redshift and
MongoDB the task completed and the move was silent, on the other engines
it failed at the create with the move already done. A caller needed only
plan and rollout rights in their own project, and no access to the
database's project.

The refusal lives in the upsert: ON CONFLICT ... DO UPDATE ... WHERE
db.project = EXCLUDED.project, refusing when no row is written. Because it
is part of the write, two concurrent first creates of one name from
different projects cannot both win. A soft-deleted row is held to its
project too, not exempted: the deleted flag also marks a live database the
sync allowlist excluded, which the store cannot tell from a freed name.
The narrow cost is that reusing a genuinely-dropped name from another
project is refused; an engine-existence check that restores it is a
follow-up. A project instance already forces its own project. A real move
goes through DatabaseService/UpdateDatabase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vsai12
vsai12 requested a review from a team as a code owner October 8, 2026 01:59
@vsai12 vsai12 added the breaking label Oct 8, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T02:02:35.978440Z c84521b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@vsai12
vsai12 requested a review from ecmadao October 8, 2026 05:20

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants