Skip to content

Commit 78d6b29

Browse files
fregataaclaude
andauthored
fix(BA-5763): backfill missing role-to-scope mappings for migration-created roles (#11159)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 175a544 commit 78d6b29

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

changes/11159.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Backfill missing role-to-scope mappings in `association_scopes_entities` for migration-created SYSTEM roles so that GraphQL scope resolution no longer returns null
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
"""backfill role-to-scope mappings in association_scopes_entities
2+
3+
Migrations 2c9000848b6e (user roles), e43125b98bba (domain roles), and
4+
430b1631804d (project roles) created SYSTEM-sourced roles but never inserted
5+
the corresponding (entity_type='role') rows into association_scopes_entities.
6+
This causes GraphQL scope resolution to return null for these roles.
7+
8+
This migration backfills the missing entries by:
9+
1. Matching role_domain_*_admin roles to their domain via name parsing.
10+
2. Matching role_project_*_admin roles to their project via the 8-char UUID
11+
prefix in the role name.
12+
3. Matching role_user_* and user-* roles to their user via the user_roles
13+
join table. The former were created by earlier RBAC migrations; the
14+
latter by 2c9000848b6e which uses the runtime naming convention.
15+
4. Skipping role_superadmin and role_monitor (global roles with no entity
16+
scope).
17+
18+
The migration is idempotent (INSERT ... ON CONFLICT DO NOTHING).
19+
20+
Revision ID: d3683e2703ff
21+
Revises: f9a8cfaca907
22+
Create Date: 2026-04-16
23+
24+
"""
25+
26+
from typing import Any
27+
28+
import sqlalchemy as sa
29+
from alembic import op
30+
from sqlalchemy.dialects.postgresql import insert as pg_insert
31+
from sqlalchemy.engine import Connection
32+
33+
from ai.backend.manager.models.base import GUID, IDColumn, metadata
34+
35+
# revision identifiers, used by Alembic.
36+
revision = "d3683e2703ff"
37+
down_revision = "f9a8cfaca907"
38+
# Part of: 26.5.0
39+
branch_labels = None
40+
depends_on = None
41+
42+
_registry_metadata = metadata
43+
44+
45+
def _get_roles_table() -> sa.Table:
46+
return sa.Table(
47+
"roles",
48+
_registry_metadata,
49+
IDColumn(),
50+
sa.Column("name", sa.String(64), nullable=False),
51+
sa.Column("source", sa.VARCHAR(16), nullable=False),
52+
extend_existing=True,
53+
)
54+
55+
56+
def _get_assoc_table() -> sa.Table:
57+
return sa.Table(
58+
"association_scopes_entities",
59+
_registry_metadata,
60+
IDColumn(),
61+
sa.Column("scope_type", sa.String(32), nullable=False),
62+
sa.Column("scope_id", sa.String(64), nullable=False),
63+
sa.Column("entity_type", sa.String(32), nullable=False),
64+
sa.Column("entity_id", sa.String(64), nullable=False),
65+
extend_existing=True,
66+
)
67+
68+
69+
def _get_domains_table() -> sa.Table:
70+
return sa.Table(
71+
"domains",
72+
_registry_metadata,
73+
sa.Column("name", sa.String(64), primary_key=True),
74+
extend_existing=True,
75+
)
76+
77+
78+
def _get_groups_table() -> sa.Table:
79+
return sa.Table(
80+
"groups",
81+
_registry_metadata,
82+
IDColumn(),
83+
extend_existing=True,
84+
)
85+
86+
87+
def _get_user_roles_table() -> sa.Table:
88+
return sa.Table(
89+
"user_roles",
90+
_registry_metadata,
91+
IDColumn(),
92+
sa.Column("user_id", GUID, nullable=False),
93+
sa.Column("role_id", GUID, nullable=False),
94+
extend_existing=True,
95+
)
96+
97+
98+
def _insert_skip_on_conflict(
99+
db_conn: Connection, table: sa.Table, rows: list[dict[str, Any]]
100+
) -> None:
101+
if rows:
102+
stmt = pg_insert(table).values(rows).on_conflict_do_nothing()
103+
db_conn.execute(stmt)
104+
105+
106+
def _backfill_domain_admin_roles(db_conn: Connection) -> None:
107+
"""Backfill role→scope for role_domain_*_admin roles.
108+
109+
Role name pattern: ``role_domain_{domain_name}_admin``
110+
Scope: (domain, domain_name)
111+
"""
112+
roles = _get_roles_table()
113+
assoc = _get_assoc_table()
114+
domains = _get_domains_table()
115+
116+
# Find domain admin roles that have no scope mapping yet.
117+
already_mapped = (
118+
sa.select(sa.literal(1))
119+
.select_from(assoc)
120+
.where(
121+
assoc.c.entity_type == "role",
122+
assoc.c.entity_id == sa.cast(roles.c.id, sa.String),
123+
)
124+
.exists()
125+
)
126+
127+
# Extract domain name from role name:
128+
# "role_domain_{name}_admin" → strip prefix "role_domain_" (12 chars)
129+
# and suffix "_admin" (6 chars).
130+
domain_name_expr = sa.func.substring(
131+
roles.c.name,
132+
sa.literal(13), # len("role_domain_") + 1
133+
sa.func.length(roles.c.name) - sa.literal(18), # 12 + 6
134+
)
135+
136+
stmt = sa.select(roles.c.id, domain_name_expr.label("domain_name")).where(
137+
roles.c.name.like("role_domain_%_admin"),
138+
roles.c.source == "system",
139+
sa.not_(already_mapped),
140+
)
141+
rows = db_conn.execute(stmt).all()
142+
143+
# Verify that the domains actually exist.
144+
domain_names = {r.domain_name for r in rows}
145+
if domain_names:
146+
existing = set(
147+
db_conn.scalars(sa.select(domains.c.name).where(domains.c.name.in_(domain_names))).all()
148+
)
149+
else:
150+
existing = set()
151+
152+
assoc_rows = [
153+
{
154+
"scope_type": "domain",
155+
"scope_id": r.domain_name,
156+
"entity_type": "role",
157+
"entity_id": str(r.id),
158+
}
159+
for r in rows
160+
if r.domain_name in existing
161+
]
162+
_insert_skip_on_conflict(db_conn, assoc, assoc_rows)
163+
164+
165+
def _backfill_project_admin_roles(db_conn: Connection) -> None:
166+
"""Backfill role→scope for role_project_*_admin roles.
167+
168+
Role name pattern: ``role_project_{id[:8]}_admin``
169+
Scope: (project, project_id)
170+
"""
171+
roles = _get_roles_table()
172+
assoc = _get_assoc_table()
173+
groups = _get_groups_table()
174+
175+
already_mapped = (
176+
sa.select(sa.literal(1))
177+
.select_from(assoc)
178+
.where(
179+
assoc.c.entity_type == "role",
180+
assoc.c.entity_id == sa.cast(roles.c.id, sa.String),
181+
)
182+
.exists()
183+
)
184+
185+
# Extract 8-char UUID prefix from role name:
186+
# "role_project_{id8}_admin" → chars 14..21
187+
id_prefix_expr = sa.func.substring(roles.c.name, sa.literal(14), sa.literal(8))
188+
189+
# Match against groups whose UUID starts with the same 8 chars.
190+
stmt = (
191+
sa.select(roles.c.id.label("role_id"), groups.c.id.label("project_id"))
192+
.select_from(
193+
roles.join(
194+
groups,
195+
sa.func.substring(sa.cast(groups.c.id, sa.String), 1, 8) == id_prefix_expr,
196+
)
197+
)
198+
.where(
199+
roles.c.name.like("role_project_%_admin"),
200+
roles.c.source == "system",
201+
sa.not_(already_mapped),
202+
)
203+
)
204+
rows = db_conn.execute(stmt).all()
205+
206+
assoc_rows = [
207+
{
208+
"scope_type": "project",
209+
"scope_id": str(r.project_id),
210+
"entity_type": "role",
211+
"entity_id": str(r.role_id),
212+
}
213+
for r in rows
214+
]
215+
_insert_skip_on_conflict(db_conn, assoc, assoc_rows)
216+
217+
218+
def _backfill_user_roles(db_conn: Connection) -> None:
219+
"""Backfill role→scope for user system roles.
220+
221+
Two naming conventions exist:
222+
- ``role_user_{username}`` — created by earlier RBAC migrations
223+
- ``user-{uuid[:8]}`` — created by migration 2c9000848b6e
224+
225+
Scope: (user, user_id)
226+
227+
We resolve user_id via the user_roles join table — the user_roles mapping
228+
was created by the original migrations, only the association_scopes_entities
229+
entry was missed.
230+
"""
231+
roles = _get_roles_table()
232+
assoc = _get_assoc_table()
233+
user_roles = _get_user_roles_table()
234+
235+
already_mapped = (
236+
sa.select(sa.literal(1))
237+
.select_from(assoc)
238+
.where(
239+
assoc.c.entity_type == "role",
240+
assoc.c.entity_id == sa.cast(roles.c.id, sa.String),
241+
)
242+
.exists()
243+
)
244+
245+
stmt = (
246+
sa.select(
247+
roles.c.id.label("role_id"),
248+
user_roles.c.user_id,
249+
)
250+
.select_from(roles.join(user_roles, user_roles.c.role_id == roles.c.id))
251+
.where(
252+
sa.or_(
253+
roles.c.name.like("role_user_%"),
254+
roles.c.name.like("user-%"),
255+
),
256+
roles.c.source == "system",
257+
# Exclude global roles that happen to start with "role_user_"
258+
roles.c.name.notin_(["role_superadmin", "role_monitor"]),
259+
sa.not_(already_mapped),
260+
)
261+
)
262+
rows = db_conn.execute(stmt).all()
263+
264+
assoc_rows = [
265+
{
266+
"scope_type": "user",
267+
"scope_id": str(r.user_id),
268+
"entity_type": "role",
269+
"entity_id": str(r.role_id),
270+
}
271+
for r in rows
272+
]
273+
_insert_skip_on_conflict(db_conn, assoc, assoc_rows)
274+
275+
276+
def upgrade() -> None:
277+
conn = op.get_bind()
278+
_backfill_domain_admin_roles(conn)
279+
_backfill_project_admin_roles(conn)
280+
_backfill_user_roles(conn)
281+
282+
283+
def downgrade() -> None:
284+
# The backfill only adds missing data. Removing it would re-break scope
285+
# resolution, so downgrade is intentionally a no-op.
286+
pass

0 commit comments

Comments
 (0)