From e044ade85cd40a4d967f6f14163b6c76c8829ff5 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Wed, 15 Jul 2026 18:42:02 +0900 Subject: [PATCH 1/2] fix(BA-6897): make pre-constraint RBAC backfill upgrades skip existing rows Six RBAC backfill migrations insert with a bare ON CONFLICT DO NOTHING. At their point in the chain the permissions table carries only a primary key on id, and id defaults to uuid_generate_v4(), so the only conflict target is a freshly generated UUID and the clause never fires. The same holds for permission_groups and object_permissions in the vfolder migration. Re-running any of these upgrades duplicated every row. This was masked while the downgrades deleted the rows first. With the downgrades now forward-only, a downgrade/upgrade cycle would accumulate duplicates until the chain reached the dedupe migrations that run before the unique constraints are created. Skip existing rows explicitly instead, and let DISTINCT collapse repeats within a batch. association_scopes_entities is left alone: it already carries uq_scope_id_entity_id at these revisions, so its bare ON CONFLICT works. The nine migrations whose constraints already exist are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6866c_migrate_notification_data_to_rbac.py | 14 +++++- ...85ae0dd371_migrate_vfolder_data_to_rbac.py | 43 ++++++++++++++++--- ...5338ff571_migrate_artifact_data_to_rbac.py | 14 +++++- ..._migrate_artifact_registry_data_to_rbac.py | 14 +++++- ...ed3b6d4_migrate_app_config_data_to_rbac.py | 14 +++++- ...0_migrate_model_deployment_data_to_rbac.py | 14 +++++- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/013a6676866c_migrate_notification_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/013a6676866c_migrate_notification_data_to_rbac.py index 3d935c4478f..a2c2eaceb7d 100644 --- a/src/ai/backend/manager/models/alembic/versions/013a6676866c_migrate_notification_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/013a6676866c_migrate_notification_data_to_rbac.py @@ -112,10 +112,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{input.permission_group_id}', '{input.entity_type}', '{input.operation}')" for input in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) diff --git a/src/ai/backend/manager/models/alembic/versions/2185ae0dd371_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/2185ae0dd371_migrate_vfolder_data_to_rbac.py index 73732bc313d..ab2a838c752 100644 --- a/src/ai/backend/manager/models/alembic/versions/2185ae0dd371_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/2185ae0dd371_migrate_vfolder_data_to_rbac.py @@ -105,10 +105,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{perm_id}', '{entity_type}', '{operation}')" for perm_id, entity_type, operation in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) @@ -224,10 +234,20 @@ def _add_permission_groups_for_vfolder_invitations(db_conn: Connection) -> None: if values_list: values = ", ".join(values_list) + # `permission_groups` has no unique constraint on these columns at + # this revision, so ON CONFLICT has nothing to match on -- skip + # existing rows explicitly to keep re-runs idempotent. insert_query = sa.text(f""" INSERT INTO permission_groups (role_id, scope_type, scope_id) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.role_id::uuid, v.scope_type, v.scope_id + FROM (VALUES {values}) AS v(role_id, scope_type, scope_id) + WHERE NOT EXISTS ( + SELECT 1 + FROM permission_groups pg + WHERE pg.role_id = v.role_id::uuid + AND pg.scope_type = v.scope_type + AND pg.scope_id = v.scope_id + ) """) db_conn.execute(insert_query) @@ -283,10 +303,21 @@ def _add_object_permissions_for_vfolder_invitations(db_conn: Connection) -> None if values_list: values = ", ".join(values_list) + # `object_permissions` has no unique constraint on these columns at + # this revision, so ON CONFLICT has nothing to match on -- skip + # existing rows explicitly to keep re-runs idempotent. insert_query = sa.text(f""" INSERT INTO object_permissions (role_id, entity_type, entity_id, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.role_id::uuid, v.entity_type, v.entity_id, v.operation + FROM (VALUES {values}) AS v(role_id, entity_type, entity_id, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM object_permissions o + WHERE o.role_id = v.role_id::uuid + AND o.entity_type = v.entity_type + AND o.entity_id = v.entity_id + AND o.operation = v.operation + ) """) db_conn.execute(insert_query) diff --git a/src/ai/backend/manager/models/alembic/versions/67f5338ff571_migrate_artifact_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/67f5338ff571_migrate_artifact_data_to_rbac.py index 0cd275f50d4..1faf7731a84 100644 --- a/src/ai/backend/manager/models/alembic/versions/67f5338ff571_migrate_artifact_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/67f5338ff571_migrate_artifact_data_to_rbac.py @@ -94,10 +94,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{perm_id}', '{entity_type}', '{operation}')" for perm_id, entity_type, operation in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) diff --git a/src/ai/backend/manager/models/alembic/versions/6d850788c7c8_migrate_artifact_registry_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6d850788c7c8_migrate_artifact_registry_data_to_rbac.py index fcf31f2fa44..70fc8b3cd7b 100644 --- a/src/ai/backend/manager/models/alembic/versions/6d850788c7c8_migrate_artifact_registry_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6d850788c7c8_migrate_artifact_registry_data_to_rbac.py @@ -94,10 +94,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{perm_id}', '{entity_type}', '{operation}')" for perm_id, entity_type, operation in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) diff --git a/src/ai/backend/manager/models/alembic/versions/a5e87ed3b6d4_migrate_app_config_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/a5e87ed3b6d4_migrate_app_config_data_to_rbac.py index b95a627a0ca..00c7bd98234 100644 --- a/src/ai/backend/manager/models/alembic/versions/a5e87ed3b6d4_migrate_app_config_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/a5e87ed3b6d4_migrate_app_config_data_to_rbac.py @@ -103,10 +103,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{input.permission_group_id}', '{input.entity_type}', '{input.operation}')" for input in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) diff --git a/src/ai/backend/manager/models/alembic/versions/d0a3c0716970_migrate_model_deployment_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/d0a3c0716970_migrate_model_deployment_data_to_rbac.py index 275cbb8cac4..a157735a32e 100644 --- a/src/ai/backend/manager/models/alembic/versions/d0a3c0716970_migrate_model_deployment_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/d0a3c0716970_migrate_model_deployment_data_to_rbac.py @@ -106,10 +106,20 @@ def _migrate_new_entity_type(db_conn: Connection) -> None: f"('{input.permission_group_id}', '{input.entity_type}', '{input.operation}')" for input in inputs ) + # `permissions` has no unique constraint on these columns at this + # revision, so ON CONFLICT has nothing to match on -- skip existing + # rows explicitly to keep re-runs idempotent. query = sa.text(f""" INSERT INTO permissions (permission_group_id, entity_type, operation) - VALUES {values} - ON CONFLICT DO NOTHING + SELECT DISTINCT v.permission_group_id::uuid, v.entity_type, v.operation + FROM (VALUES {values}) AS v(permission_group_id, entity_type, operation) + WHERE NOT EXISTS ( + SELECT 1 + FROM permissions p + WHERE p.permission_group_id = v.permission_group_id::uuid + AND p.entity_type = v.entity_type + AND p.operation = v.operation + ) """) db_conn.execute(query) From 92c17e7afa0e7bf611c07f6c155b7af01e9a6dfc Mon Sep 17 00:00:00 2001 From: Gyubong Date: Wed, 15 Jul 2026 18:44:07 +0900 Subject: [PATCH 2/2] docs: add news fragment Co-Authored-By: Claude Opus 4.8 (1M context) --- changes/12883.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/12883.fix.md diff --git a/changes/12883.fix.md b/changes/12883.fix.md new file mode 100644 index 00000000000..86f986b0f84 --- /dev/null +++ b/changes/12883.fix.md @@ -0,0 +1 @@ +Make RBAC backfill migrations skip already-present rows so re-running them inserts no duplicates