From ca99d36a01dbc3b0361f28193a50c6ec3d9fa701 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 20 Mar 2026 11:11:26 +0900 Subject: [PATCH 1/3] fix(BA-5309): Add migration to convert global-scoped permissions to domain-scoped Replace deprecated scope_type='global' permission rows with domain-scoped equivalents (one row per active domain), preserving role_id, entity_type, and operation values. Uses ON CONFLICT DO NOTHING to handle pre-existing domain-scoped permissions gracefully. Co-Authored-By: Claude Opus 4.6 --- ...42_convert_global_permissions_to_domain.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py diff --git a/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py b/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py new file mode 100644 index 00000000000..a42859a8710 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py @@ -0,0 +1,72 @@ +"""convert global-scoped permissions to domain-scoped + +Revision ID: 5a4e677aea42 +Revises: ffcf0ed13a26 +Create Date: 2026-03-20 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5a4e677aea42" +down_revision = "ffcf0ed13a26" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # (a) Query all active domain names + domain_rows = conn.execute(sa.text("SELECT name FROM domains ORDER BY name")).fetchall() + domain_names = [row[0] for row in domain_rows] + + if not domain_names: + # No domains exist; just delete global rows since there's nowhere to convert them + conn.execute(sa.text("DELETE FROM permissions WHERE scope_type = 'global'")) + return + + # (b) Query all permission rows where scope_type='global' + global_perms = conn.execute( + sa.text( + "SELECT id, role_id, entity_type, operation" + " FROM permissions" + " WHERE scope_type = 'global'" + ) + ).fetchall() + + if not global_perms: + return + + # (c) For each global permission × each domain, insert a domain-scoped row + # ON CONFLICT DO NOTHING handles the unique constraint + # (role_id, scope_type, scope_id, entity_type, operation) + insert_stmt = sa.text( + "INSERT INTO permissions (id, role_id, scope_type, scope_id, entity_type, operation)" + " VALUES (uuid_generate_v4(), :role_id, 'domain', :scope_id, :entity_type, :operation)" + " ON CONFLICT ON CONSTRAINT uq_permissions_role_scope_entity_op DO NOTHING" + ) + + for perm in global_perms: + for domain_name in domain_names: + conn.execute( + insert_stmt, + { + "role_id": str(perm[1]), + "scope_id": domain_name, + "entity_type": perm[2], + "operation": perm[3], + }, + ) + + # (d) Delete all global-scoped permission rows + conn.execute(sa.text("DELETE FROM permissions WHERE scope_type = 'global'")) + + +def downgrade() -> None: + # Global scope is deprecated and has no corresponding RBACElementType. + # The conversion to domain-scoped permissions is not reversible because + # we cannot determine which domain-scoped rows were originally global. + pass From 7e40541f9a395bb01c9bf436bebb523265647ec4 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 20 Mar 2026 11:18:43 +0900 Subject: [PATCH 2/3] changelog: add news fragment for PR #10342 --- changes/10342.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/10342.fix.md diff --git a/changes/10342.fix.md b/changes/10342.fix.md new file mode 100644 index 00000000000..a35d84de3df --- /dev/null +++ b/changes/10342.fix.md @@ -0,0 +1 @@ +Add migration to convert deprecated global-scoped permissions to domain-scoped equivalents, fixing `RBACTypeConversionError` in `adminPermissions` GraphQL query. From 764ad8babd6276908d7d0f1e85e2dae0a1f5c228 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 20 Mar 2026 12:11:16 +0900 Subject: [PATCH 3/3] fix: rebase migration onto RBAC head and apply review feedback - Change down_revision from ffcf0ed13a26 to 0e0723286a7a to resolve multiple Alembic heads and ensure unique constraint exists - Filter domains by is_active to skip inactive domains - Replace nested Python loop with single INSERT...SELECT CROSS JOIN Co-Authored-By: Claude Opus 4.6 --- ...42_convert_global_permissions_to_domain.py | 55 +++++-------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py b/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py index a42859a8710..593b83bb3b5 100644 --- a/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py +++ b/src/ai/backend/manager/models/alembic/versions/5a4e677aea42_convert_global_permissions_to_domain.py @@ -1,7 +1,7 @@ """convert global-scoped permissions to domain-scoped Revision ID: 5a4e677aea42 -Revises: ffcf0ed13a26 +Revises: 0e0723286a7a Create Date: 2026-03-20 00:00:00.000000 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "5a4e677aea42" -down_revision = "ffcf0ed13a26" +down_revision = "0e0723286a7a" branch_labels = None depends_on = None @@ -19,49 +19,22 @@ def upgrade() -> None: conn = op.get_bind() - # (a) Query all active domain names - domain_rows = conn.execute(sa.text("SELECT name FROM domains ORDER BY name")).fetchall() - domain_names = [row[0] for row in domain_rows] - - if not domain_names: - # No domains exist; just delete global rows since there's nowhere to convert them - conn.execute(sa.text("DELETE FROM permissions WHERE scope_type = 'global'")) - return - - # (b) Query all permission rows where scope_type='global' - global_perms = conn.execute( + # Convert global-scoped permissions to domain-scoped by creating one row + # per active domain for each global permission row. + # ON CONFLICT DO NOTHING handles the unique constraint + # (role_id, scope_type, scope_id, entity_type, operation). + conn.execute( sa.text( - "SELECT id, role_id, entity_type, operation" - " FROM permissions" - " WHERE scope_type = 'global'" + "INSERT INTO permissions (id, role_id, scope_type, scope_id, entity_type, operation)" + " SELECT uuid_generate_v4(), p.role_id, 'domain', d.name, p.entity_type, p.operation" + " FROM permissions AS p" + " CROSS JOIN domains AS d" + " WHERE p.scope_type = 'global' AND d.is_active IS TRUE" + " ON CONFLICT ON CONSTRAINT uq_permissions_role_scope_entity_op DO NOTHING" ) - ).fetchall() - - if not global_perms: - return - - # (c) For each global permission × each domain, insert a domain-scoped row - # ON CONFLICT DO NOTHING handles the unique constraint - # (role_id, scope_type, scope_id, entity_type, operation) - insert_stmt = sa.text( - "INSERT INTO permissions (id, role_id, scope_type, scope_id, entity_type, operation)" - " VALUES (uuid_generate_v4(), :role_id, 'domain', :scope_id, :entity_type, :operation)" - " ON CONFLICT ON CONSTRAINT uq_permissions_role_scope_entity_op DO NOTHING" ) - for perm in global_perms: - for domain_name in domain_names: - conn.execute( - insert_stmt, - { - "role_id": str(perm[1]), - "scope_id": domain_name, - "entity_type": perm[2], - "operation": perm[3], - }, - ) - - # (d) Delete all global-scoped permission rows + # Delete all global-scoped permission rows now that they are converted conn.execute(sa.text("DELETE FROM permissions WHERE scope_type = 'global'"))