|
| 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