From fb136b6641fe72fe2798461475ff7eda4d0dd72c Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 1 May 2026 20:32:52 +0900 Subject: [PATCH 1/8] feat(BA-5380): seed RBAC permissions for vfolder:data and session:app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-only Alembic migrations that backfill owner/admin permissions for the new sub-entity types in domain/project/user scopes, and migrate vfolder_permissions invitations to per-entity `vfolder:data` grants. Sub-entity scope-entity edges are intentionally omitted — the resolver walks parent edges via `permission_entity_type`. Downgrade is a no-op. Resolves BA-5380 Resolves BA-5383 --- ...632aad9d5d9_migrate_session_app_to_rbac.py | 98 +++++++++ ...5a7a62a687_migrate_vfolder_data_to_rbac.py | 190 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py create mode 100644 src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py diff --git a/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py new file mode 100644 index 00000000000..5c4d47d4f0c --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py @@ -0,0 +1,98 @@ +"""migrate_session_app_to_rbac + +Revision ID: 3632aad9d5d9 +Revises: 6e5a7a62a687 +Create Date: 2026-05-01 00:00:01.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "3632aad9d5d9" +down_revision = "6e5a7a62a687" +branch_labels = None +depends_on = None + +# Part of: 26.5.0 + +# Constants +MEMBER_ROLE_SUFFIX = "member" +ENTITY_TYPE = "session:app" + +# Org-hierarchy scope types. Other scope_type values in `permissions` +# (e.g. 'vfolder', 'model_deployment') are entity-as-scope grants for +# specific entities; they must be excluded from the entity-type seed. +ORG_SCOPE_TYPES = ["domain", "project", "user"] + +# session:app is owner-only and read-only: app endpoints expose live +# state, so write/delete operations on the sub-entity itself are not +# meaningful. +OWNER_OPERATIONS = ["read"] + + +def _seed_entity_type_permissions(db_conn: Connection) -> None: + """Seed `session:app` entity-type permissions for all non-member roles. + + For every distinct (role, scope) tuple already present in `permissions`, + insert one `read` row, except for roles whose name ends with `member` + (which intentionally have no access to internal session apps). + + Mirrors `30c8308738ee_migrate_session_data_to_rbac` and the + accompanying `vfolder:data` migration. + """ + insert_query = sa.text(""" + WITH role_scopes AS ( + SELECT DISTINCT + p.role_id, + r.name AS role_name, + p.scope_type, + p.scope_id + FROM permissions p + JOIN roles r ON p.role_id = r.id + ), + role_operations AS ( + SELECT + rs.role_id, + rs.scope_type, + rs.scope_id, + unnest(CAST(:owner_ops AS text[])) AS operation + FROM role_scopes rs + WHERE rs.role_name NOT LIKE :member_pattern + AND rs.scope_type = ANY(CAST(:org_scopes AS text[])) + ) + INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) + SELECT + role_id, + scope_type, + scope_id, + :entity_type AS entity_type, + operation + FROM role_operations + ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING + """) + db_conn.execute( + insert_query, + { + "owner_ops": OWNER_OPERATIONS, + "member_pattern": f"%{MEMBER_ROLE_SUFFIX}", + "org_scopes": ORG_SCOPE_TYPES, + "entity_type": ENTITY_TYPE, + }, + ) + + +def upgrade() -> None: + conn = op.get_bind() + _seed_entity_type_permissions(conn) + + +def downgrade() -> None: + # Intentionally a no-op. Once the runtime starts using `session:app`, + # operators may grant/revoke additional permissions on this entity type. + # A blanket DELETE WHERE entity_type='session:app' would erase those + # operator-managed rows together with the seed, so this migration is + # forward-only by design. + pass diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py new file mode 100644 index 00000000000..e4f473e31bd --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -0,0 +1,190 @@ +"""migrate_vfolder_data_to_rbac + +Revision ID: 6e5a7a62a687 +Revises: 8c1f7d3a9e2b +Create Date: 2026-05-01 00:00:00.000000 + +""" + +from uuid import UUID + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection + +# revision identifiers, used by Alembic. +revision = "6e5a7a62a687" +down_revision = "8c1f7d3a9e2b" +branch_labels = None +depends_on = None + +# Part of: 26.5.0 + +# Constants +BATCH_SIZE = 1000 +MEMBER_ROLE_SUFFIX = "member" +ENTITY_TYPE = "vfolder:data" +USER_SCOPE_TYPE = "user" +VFOLDER_SCOPE_TYPE = "vfolder" +REF_RELATION_TYPE = "ref" + +# Org-hierarchy scope types. Other scope_type values in `permissions` +# (e.g. 'vfolder', 'model_deployment') are entity-as-scope grants for +# specific entities, not role-binding scopes — including them in the +# entity-type seed would over-grant per-entity invitees with full +# owner ops on `vfolder:data`. +ORG_SCOPE_TYPES = ["domain", "project", "user"] + +# vfolder:data is owner-only: owner gets full CRUD on internal data, +# but soft-delete is intentionally omitted (no two-stage delete for data). +OWNER_OPERATIONS = ["create", "read", "update", "hard-delete"] + +# Mount permission → vfolder:data operations. +# Aligned with vfolder:data owner ops (no soft-delete). +MOUNT_PERMISSION_TO_OPERATIONS: dict[str, list[str]] = { + "ro": ["read"], + "rw": ["read", "update"], + "wd": ["read", "update", "hard-delete"], +} + + +def _seed_entity_type_permissions(db_conn: Connection) -> None: + """Seed `vfolder:data` entity-type permissions for all non-member roles. + + For every distinct (role, scope) tuple already present in `permissions`, + insert one row per owner operation, except: + - roles whose name ends with `member` (project/user member roles get nothing) + - domain-scoped roles whose name ends with `member` (already excluded above) + + This mirrors the pattern in `30c8308738ee_migrate_session_data_to_rbac`. + """ + insert_query = sa.text(""" + WITH role_scopes AS ( + SELECT DISTINCT + p.role_id, + r.name AS role_name, + p.scope_type, + p.scope_id + FROM permissions p + JOIN roles r ON p.role_id = r.id + ), + role_operations AS ( + SELECT + rs.role_id, + rs.scope_type, + rs.scope_id, + unnest(CAST(:owner_ops AS text[])) AS operation + FROM role_scopes rs + WHERE rs.role_name NOT LIKE :member_pattern + AND rs.scope_type = ANY(CAST(:org_scopes AS text[])) + ) + INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) + SELECT + role_id, + scope_type, + scope_id, + :entity_type AS entity_type, + operation + FROM role_operations + ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING + """) + db_conn.execute( + insert_query, + { + "owner_ops": OWNER_OPERATIONS, + "member_pattern": f"%{MEMBER_ROLE_SUFFIX}", + "org_scopes": ORG_SCOPE_TYPES, + "entity_type": ENTITY_TYPE, + }, + ) + + +def _seed_invitation_permissions(db_conn: Connection) -> None: + """Migrate vfolder_permissions invitations to vfolder:data permissions. + + Uses the modern entity-as-scope pattern (matches RBACGranter): + for each (invited user, vfolder, mount permission), insert + permissions(role_id=, scope_type='vfolder', + scope_id=, entity_type='vfolder:data', + operation=). + + No `association_scopes_entities` rows are inserted: vfolder:data + inherits scope from the parent vfolder edge created earlier, and the + unique constraint `uq_scope_id_entity_id` (scope_type, scope_id, + entity_id) would conflict with the existing vfolder edges anyway. + """ + last_id = UUID("00000000-0000-0000-0000-000000000000") + while True: + # Resolve each invited user's user-scope ("system") role. + # A user's system role is identified by holding any permission whose + # scope is the user's own user-scope. + query = sa.text(""" + SELECT + vp.id AS row_id, + vp.vfolder::text AS vfolder_id, + vp.permission AS mount_permission, + ur.role_id AS role_id + FROM vfolder_permissions vp + JOIN user_roles ur ON ur.user_id = vp."user" + JOIN permissions p ON p.role_id = ur.role_id + WHERE p.scope_type = :user_scope + AND p.scope_id = vp."user"::text + AND vp.id > :last_id + GROUP BY vp.id, vp.vfolder, vp.permission, ur.role_id + ORDER BY vp.id + LIMIT :limit + """) + rows = db_conn.execute( + query, + { + "user_scope": USER_SCOPE_TYPE, + "last_id": last_id, + "limit": BATCH_SIZE, + }, + ).all() + if not rows: + break + + last_id = rows[-1].row_id + + values_list: list[dict[str, str]] = [] + for row in rows: + ops = MOUNT_PERMISSION_TO_OPERATIONS.get(row.mount_permission) + if not ops: + # Unknown mount permission value — skip silently rather than + # fail the migration. + continue + for operation in ops: + values_list.append({ + "role_id": str(row.role_id), + "scope_type": VFOLDER_SCOPE_TYPE, + "scope_id": row.vfolder_id, + "entity_type": ENTITY_TYPE, + "operation": operation, + }) + + if values_list: + insert_query = sa.text(""" + INSERT INTO permissions + (role_id, scope_type, scope_id, entity_type, operation) + VALUES + (:role_id, :scope_type, :scope_id, :entity_type, :operation) + ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) + DO NOTHING + """) + db_conn.execute(insert_query, values_list) + + +def upgrade() -> None: + conn = op.get_bind() + _seed_entity_type_permissions(conn) + _seed_invitation_permissions(conn) + + +def downgrade() -> None: + # Intentionally a no-op. Once the runtime starts using `vfolder:data`, + # operators may grant/revoke additional permissions on this entity type. + # A blanket DELETE WHERE entity_type='vfolder:data' would erase those + # operator-managed rows together with the seed, so this migration is + # forward-only by design. + pass From 32c6cf43ba6f25e1aaf646124a3c8d7169682c67 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 1 May 2026 20:33:35 +0900 Subject: [PATCH 2/8] changelog: add news fragment for PR #11457 --- changes/11457.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/11457.feature.md diff --git a/changes/11457.feature.md b/changes/11457.feature.md new file mode 100644 index 00000000000..70596b30185 --- /dev/null +++ b/changes/11457.feature.md @@ -0,0 +1 @@ +Backfill RBAC permissions for the new `vfolder:data` and `session:app` sub-entity types and migrate existing vfolder share invitations to per-entity `vfolder:data` grants. From eb83dcbf3d7999ac5ef44363daa209a7771b9149 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 1 May 2026 20:41:34 +0900 Subject: [PATCH 3/8] chore(BA-5380): drop unused constant and refine changelog - Remove unused `REF_RELATION_TYPE` constant from the vfolder:data migration. - Clarify the 11457 news fragment. --- changes/11457.feature.md | 2 +- .../versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/changes/11457.feature.md b/changes/11457.feature.md index 70596b30185..4402783a0e3 100644 --- a/changes/11457.feature.md +++ b/changes/11457.feature.md @@ -1 +1 @@ -Backfill RBAC permissions for the new `vfolder:data` and `session:app` sub-entity types and migrate existing vfolder share invitations to per-entity `vfolder:data` grants. +Add Alembic data migrations that seed `vfolder:data` and `session:app` RBAC permissions on existing roles in domain/project/user scopes, and migrate existing vfolder share invitations to per-entity `vfolder:data` grants using the entity-as-scope pattern. diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py index e4f473e31bd..fefb40ca810 100644 --- a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -26,7 +26,6 @@ ENTITY_TYPE = "vfolder:data" USER_SCOPE_TYPE = "user" VFOLDER_SCOPE_TYPE = "vfolder" -REF_RELATION_TYPE = "ref" # Org-hierarchy scope types. Other scope_type values in `permissions` # (e.g. 'vfolder', 'model_deployment') are entity-as-scope grants for From 27ecc99dc9c29417f3d4f8700f875ee1f104c018 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Fri, 1 May 2026 22:38:32 +0900 Subject: [PATCH 4/8] refactor(BA-5380): switch to per-entity grants to prevent walker leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier broad scope-wide seed granted vfolder:data and session:app operations on domain/project/user scopes for non-member roles. The RBAC resolver's scope-chain walker traverses user → project → domain via the membership edges in association_scopes_entities, so a project- or domain-scoped grant on these owner-only sub-entities would let project admins (or domain admins) reach user-owned vfolders and sessions inside their scope — violating the owner-only intent. Replace with per-entity (entity-as-scope) grants: - user-owned vfolders → owner's system role only - project-owned vfolders → project's non-member roles only - vfolder_permissions invitations → invitee's system role with mount-permission-mapped ops (already entity-as-scope) - live sessions → creator's system role + project non-member roles - dead sessions (TERMINATING/TERMINATED/CANCELLED/ERROR) excluded These rows match the resolver's self-scope branch only; the walker never visits them, so no upward leak. --- ...632aad9d5d9_migrate_session_app_to_rbac.py | 116 +++++++++------ ...5a7a62a687_migrate_vfolder_data_to_rbac.py | 139 ++++++++++-------- 2 files changed, 156 insertions(+), 99 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py index 5c4d47d4f0c..2c43677ecb0 100644 --- a/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py @@ -19,74 +19,108 @@ # Part of: 26.5.0 # Constants -MEMBER_ROLE_SUFFIX = "member" +MEMBER_ROLE_PATTERN = "%member" ENTITY_TYPE = "session:app" +USER_SCOPE_TYPE = "user" +PROJECT_SCOPE_TYPE = "project" +SESSION_SCOPE_TYPE = "session" +READ_OPERATION = "read" -# Org-hierarchy scope types. Other scope_type values in `permissions` -# (e.g. 'vfolder', 'model_deployment') are entity-as-scope grants for -# specific entities; they must be excluded from the entity-type seed. -ORG_SCOPE_TYPES = ["domain", "project", "user"] +# Sessions in these terminal/error states no longer expose a usable app +# endpoint, so granting `session:app` permissions on them would be wasted +# rows that never resolve at the runtime. +DEAD_SESSION_STATUSES = ["TERMINATING", "TERMINATED", "CANCELLED", "ERROR"] -# session:app is owner-only and read-only: app endpoints expose live -# state, so write/delete operations on the sub-entity itself are not -# meaningful. -OWNER_OPERATIONS = ["read"] +def _seed_user_session_grants(db_conn: Connection) -> None: + """Per-entity grants for the session creator. + + For each live session created by user U, grant U's user-scope + ("system") role read on that specific `session:app` via the + entity-as-scope pattern. Lands in the resolver's self-scope branch + only — no leak via scope-walker. + """ + insert_query = sa.text(""" + WITH user_role_sessions AS ( + SELECT DISTINCT + ur.role_id, + s.id::text AS session_id + FROM sessions s + JOIN user_roles ur ON ur.user_id = s.user_uuid + JOIN permissions p ON p.role_id = ur.role_id + WHERE s.status::text <> ALL(CAST(:dead_statuses AS text[])) + AND p.scope_type = :user_scope + AND p.scope_id = s.user_uuid::text + ) + INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) + SELECT + urs.role_id, + :scope_type AS scope_type, + urs.session_id AS scope_id, + :entity_type AS entity_type, + :operation AS operation + FROM user_role_sessions urs + ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING + """) + db_conn.execute( + insert_query, + { + "dead_statuses": DEAD_SESSION_STATUSES, + "user_scope": USER_SCOPE_TYPE, + "scope_type": SESSION_SCOPE_TYPE, + "entity_type": ENTITY_TYPE, + "operation": READ_OPERATION, + }, + ) -def _seed_entity_type_permissions(db_conn: Connection) -> None: - """Seed `session:app` entity-type permissions for all non-member roles. - For every distinct (role, scope) tuple already present in `permissions`, - insert one `read` row, except for roles whose name ends with `member` - (which intentionally have no access to internal session apps). +def _seed_project_session_grants(db_conn: Connection) -> None: + """Per-entity grants for the project's owner/admin roles. - Mirrors `30c8308738ee_migrate_session_data_to_rbac` and the - accompanying `vfolder:data` migration. + For each live session in project P (sessions always carry group_id), + grant P's non-member roles read on that specific `session:app`. """ insert_query = sa.text(""" - WITH role_scopes AS ( + WITH project_role_sessions AS ( SELECT DISTINCT p.role_id, - r.name AS role_name, - p.scope_type, - p.scope_id - FROM permissions p - JOIN roles r ON p.role_id = r.id - ), - role_operations AS ( - SELECT - rs.role_id, - rs.scope_type, - rs.scope_id, - unnest(CAST(:owner_ops AS text[])) AS operation - FROM role_scopes rs - WHERE rs.role_name NOT LIKE :member_pattern - AND rs.scope_type = ANY(CAST(:org_scopes AS text[])) + s.id::text AS session_id + FROM sessions s + JOIN permissions p + ON p.scope_type = :project_scope + AND p.scope_id = s.group_id::text + JOIN roles r ON r.id = p.role_id + WHERE s.status::text <> ALL(CAST(:dead_statuses AS text[])) + AND s.group_id IS NOT NULL + AND r.name NOT LIKE :member_pattern ) INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) SELECT - role_id, - scope_type, - scope_id, + prs.role_id, + :scope_type AS scope_type, + prs.session_id AS scope_id, :entity_type AS entity_type, - operation - FROM role_operations + :operation AS operation + FROM project_role_sessions prs ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING """) db_conn.execute( insert_query, { - "owner_ops": OWNER_OPERATIONS, - "member_pattern": f"%{MEMBER_ROLE_SUFFIX}", - "org_scopes": ORG_SCOPE_TYPES, + "dead_statuses": DEAD_SESSION_STATUSES, + "project_scope": PROJECT_SCOPE_TYPE, + "scope_type": SESSION_SCOPE_TYPE, "entity_type": ENTITY_TYPE, + "operation": READ_OPERATION, + "member_pattern": MEMBER_ROLE_PATTERN, }, ) def upgrade() -> None: conn = op.get_bind() - _seed_entity_type_permissions(conn) + _seed_user_session_grants(conn) + _seed_project_session_grants(conn) def downgrade() -> None: diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py index fefb40ca810..f33986f2ffc 100644 --- a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -22,20 +22,15 @@ # Constants BATCH_SIZE = 1000 -MEMBER_ROLE_SUFFIX = "member" +MEMBER_ROLE_PATTERN = "%member" ENTITY_TYPE = "vfolder:data" USER_SCOPE_TYPE = "user" +PROJECT_SCOPE_TYPE = "project" VFOLDER_SCOPE_TYPE = "vfolder" -# Org-hierarchy scope types. Other scope_type values in `permissions` -# (e.g. 'vfolder', 'model_deployment') are entity-as-scope grants for -# specific entities, not role-binding scopes — including them in the -# entity-type seed would over-grant per-entity invitees with full -# owner ops on `vfolder:data`. -ORG_SCOPE_TYPES = ["domain", "project", "user"] - -# vfolder:data is owner-only: owner gets full CRUD on internal data, -# but soft-delete is intentionally omitted (no two-stage delete for data). +# vfolder:data is owner-only: only the literal owner gets full CRUD on +# internal data. Soft-delete is intentionally omitted because there is no +# two-stage delete for vfolder data. OWNER_OPERATIONS = ["create", "read", "update", "hard-delete"] # Mount permission → vfolder:data operations. @@ -47,76 +42,103 @@ } -def _seed_entity_type_permissions(db_conn: Connection) -> None: - """Seed `vfolder:data` entity-type permissions for all non-member roles. +def _seed_user_owned_vfolder_grants(db_conn: Connection) -> None: + """Per-entity grants for user-owned vfolders. + + For each vfolder owned by user U, grant U's user-scope ("system") role + full vfolder:data owner operations on that specific vfolder via the + entity-as-scope pattern. Grants land in the resolver's self-scope + branch (matched on `scope_type='vfolder' AND scope_id=vfolder_id`) so + they never leak upward via the scope-chain walker. + """ + insert_query = sa.text(""" + WITH user_role_vfolders AS ( + SELECT DISTINCT + ur.role_id, + v.id::text AS vfolder_id + FROM vfolders v + JOIN user_roles ur ON ur.user_id = v."user" + JOIN permissions p ON p.role_id = ur.role_id + WHERE v.ownership_type = 'user' + AND v."user" IS NOT NULL + AND p.scope_type = :user_scope + AND p.scope_id = v."user"::text + ) + INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) + SELECT + urv.role_id, + :scope_type AS scope_type, + urv.vfolder_id AS scope_id, + :entity_type AS entity_type, + unnest(CAST(:owner_ops AS text[])) AS operation + FROM user_role_vfolders urv + ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING + """) + db_conn.execute( + insert_query, + { + "user_scope": USER_SCOPE_TYPE, + "scope_type": VFOLDER_SCOPE_TYPE, + "entity_type": ENTITY_TYPE, + "owner_ops": OWNER_OPERATIONS, + }, + ) + - For every distinct (role, scope) tuple already present in `permissions`, - insert one row per owner operation, except: - - roles whose name ends with `member` (project/user member roles get nothing) - - domain-scoped roles whose name ends with `member` (already excluded above) +def _seed_project_owned_vfolder_grants(db_conn: Connection) -> None: + """Per-entity grants for project-owned vfolders. - This mirrors the pattern in `30c8308738ee_migrate_session_data_to_rbac`. + For each vfolder owned by project P, grant P's non-member roles + (project owner / project admin) full vfolder:data owner operations + on that specific vfolder. Same self-scope pattern — does not leak to + user-owned vfolders within P via the walker. """ insert_query = sa.text(""" - WITH role_scopes AS ( + WITH project_role_vfolders AS ( SELECT DISTINCT p.role_id, - r.name AS role_name, - p.scope_type, - p.scope_id - FROM permissions p - JOIN roles r ON p.role_id = r.id - ), - role_operations AS ( - SELECT - rs.role_id, - rs.scope_type, - rs.scope_id, - unnest(CAST(:owner_ops AS text[])) AS operation - FROM role_scopes rs - WHERE rs.role_name NOT LIKE :member_pattern - AND rs.scope_type = ANY(CAST(:org_scopes AS text[])) + v.id::text AS vfolder_id + FROM vfolders v + JOIN permissions p + ON p.scope_type = :project_scope + AND p.scope_id = v."group"::text + JOIN roles r ON r.id = p.role_id + WHERE v.ownership_type = 'group' + AND v."group" IS NOT NULL + AND r.name NOT LIKE :member_pattern ) INSERT INTO permissions (role_id, scope_type, scope_id, entity_type, operation) SELECT - role_id, - scope_type, - scope_id, + prv.role_id, + :scope_type AS scope_type, + prv.vfolder_id AS scope_id, :entity_type AS entity_type, - operation - FROM role_operations + unnest(CAST(:owner_ops AS text[])) AS operation + FROM project_role_vfolders prv ON CONFLICT (role_id, scope_type, scope_id, entity_type, operation) DO NOTHING """) db_conn.execute( insert_query, { - "owner_ops": OWNER_OPERATIONS, - "member_pattern": f"%{MEMBER_ROLE_SUFFIX}", - "org_scopes": ORG_SCOPE_TYPES, + "project_scope": PROJECT_SCOPE_TYPE, + "scope_type": VFOLDER_SCOPE_TYPE, "entity_type": ENTITY_TYPE, + "owner_ops": OWNER_OPERATIONS, + "member_pattern": MEMBER_ROLE_PATTERN, }, ) -def _seed_invitation_permissions(db_conn: Connection) -> None: - """Migrate vfolder_permissions invitations to vfolder:data permissions. - - Uses the modern entity-as-scope pattern (matches RBACGranter): - for each (invited user, vfolder, mount permission), insert - permissions(role_id=, scope_type='vfolder', - scope_id=, entity_type='vfolder:data', - operation=). +def _seed_invitation_grants(db_conn: Connection) -> None: + """Migrate vfolder_permissions invitations to per-entity vfolder:data grants. - No `association_scopes_entities` rows are inserted: vfolder:data - inherits scope from the parent vfolder edge created earlier, and the - unique constraint `uq_scope_id_entity_id` (scope_type, scope_id, - entity_id) would conflict with the existing vfolder edges anyway. + For each (invited user, vfolder, mount permission), grant the invitee's + user-scope role the operations corresponding to their mount permission + (`ro`→{read}, `rw`→{read,update}, `wd`→{read,update,hard-delete}). + Same entity-as-scope pattern as the owner grants. """ last_id = UUID("00000000-0000-0000-0000-000000000000") while True: - # Resolve each invited user's user-scope ("system") role. - # A user's system role is identified by holding any permission whose - # scope is the user's own user-scope. query = sa.text(""" SELECT vp.id AS row_id, @@ -176,8 +198,9 @@ def _seed_invitation_permissions(db_conn: Connection) -> None: def upgrade() -> None: conn = op.get_bind() - _seed_entity_type_permissions(conn) - _seed_invitation_permissions(conn) + _seed_user_owned_vfolder_grants(conn) + _seed_project_owned_vfolder_grants(conn) + _seed_invitation_grants(conn) def downgrade() -> None: From c79176d0dd76f5f0b2a28fd0b680b31d9d8d711e Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Sun, 3 May 2026 02:38:29 +0900 Subject: [PATCH 5/8] fix(BA-5380): correct down_revision for vfolder:data data migration Co-Authored-By: Claude Opus 4.7 (1M context) --- .../versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py index f33986f2ffc..1fafcc50097 100644 --- a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -1,7 +1,7 @@ """migrate_vfolder_data_to_rbac Revision ID: 6e5a7a62a687 -Revises: 8c1f7d3a9e2b +Revises: aa596a09c091 Create Date: 2026-05-01 00:00:00.000000 """ @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision = "6e5a7a62a687" -down_revision = "8c1f7d3a9e2b" +down_revision = "aa596a09c091" branch_labels = None depends_on = None From 5d31085511decaeb952c12b0c0190286ce9a810f Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 6 May 2026 14:55:26 +0900 Subject: [PATCH 6/8] update alembic revision path --- .../versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py index 1fafcc50097..243bf6f8a41 100644 --- a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -1,7 +1,7 @@ """migrate_vfolder_data_to_rbac Revision ID: 6e5a7a62a687 -Revises: aa596a09c091 +Revises: ba5923b1f4a7 Create Date: 2026-05-01 00:00:00.000000 """ @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision = "6e5a7a62a687" -down_revision = "aa596a09c091" +down_revision = "ba5923b1f4a7" branch_labels = None depends_on = None From 3184f9bdd8bb75b215e778101e960ac414d0fe56 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 6 May 2026 23:50:08 +0900 Subject: [PATCH 7/8] chore(BA-5380): rebase on 46e007d9b237 and log unknown mount permissions Repoint the vfolder:data data migration onto the current main head to resolve divergent alembic heads, and replace the silent skip for unknown vfolder_permissions.permission values with a warning log so operators can investigate stray rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6e5a7a62a687_migrate_vfolder_data_to_rbac.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py index 243bf6f8a41..e5d91c1a8eb 100644 --- a/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/6e5a7a62a687_migrate_vfolder_data_to_rbac.py @@ -1,11 +1,12 @@ """migrate_vfolder_data_to_rbac Revision ID: 6e5a7a62a687 -Revises: ba5923b1f4a7 +Revises: 46e007d9b237 Create Date: 2026-05-01 00:00:00.000000 """ +import logging from uuid import UUID import sqlalchemy as sa @@ -14,10 +15,12 @@ # revision identifiers, used by Alembic. revision = "6e5a7a62a687" -down_revision = "ba5923b1f4a7" +down_revision = "46e007d9b237" branch_labels = None depends_on = None +logger = logging.getLogger("alembic.runtime.migration") + # Part of: 26.5.0 # Constants @@ -172,8 +175,14 @@ def _seed_invitation_grants(db_conn: Connection) -> None: for row in rows: ops = MOUNT_PERMISSION_TO_OPERATIONS.get(row.mount_permission) if not ops: - # Unknown mount permission value — skip silently rather than - # fail the migration. + logger.warning( + "Skipping vfolder_permissions row %s: unknown mount permission %r" + " (vfolder=%s, role=%s)", + row.row_id, + row.mount_permission, + row.vfolder_id, + row.role_id, + ) continue for operation in ops: values_list.append({ From ab38733d8cae113f3f73fc78e4fd22f482c97fa9 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 7 May 2026 11:30:43 +0900 Subject: [PATCH 8/8] chore(BA-5380): align session entity type with EntityType enum Rename `session:app` to `session:app_service` in the data migration seed and changelog to match `EntityType.SESSION_APP_SERVICE` defined in `common/data/permission/types.py`. Without this, seeded permission rows would never resolve at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- changes/11457.feature.md | 2 +- .../3632aad9d5d9_migrate_session_app_to_rbac.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/changes/11457.feature.md b/changes/11457.feature.md index 4402783a0e3..993aaef5b16 100644 --- a/changes/11457.feature.md +++ b/changes/11457.feature.md @@ -1 +1 @@ -Add Alembic data migrations that seed `vfolder:data` and `session:app` RBAC permissions on existing roles in domain/project/user scopes, and migrate existing vfolder share invitations to per-entity `vfolder:data` grants using the entity-as-scope pattern. +Add Alembic data migrations that seed `vfolder:data` and `session:app_service` RBAC permissions on existing roles in domain/project/user scopes, and migrate existing vfolder share invitations to per-entity `vfolder:data` grants using the entity-as-scope pattern. diff --git a/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py index 2c43677ecb0..ffdbd2ac2ba 100644 --- a/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py +++ b/src/ai/backend/manager/models/alembic/versions/3632aad9d5d9_migrate_session_app_to_rbac.py @@ -20,15 +20,15 @@ # Constants MEMBER_ROLE_PATTERN = "%member" -ENTITY_TYPE = "session:app" +ENTITY_TYPE = "session:app_service" USER_SCOPE_TYPE = "user" PROJECT_SCOPE_TYPE = "project" SESSION_SCOPE_TYPE = "session" READ_OPERATION = "read" # Sessions in these terminal/error states no longer expose a usable app -# endpoint, so granting `session:app` permissions on them would be wasted -# rows that never resolve at the runtime. +# endpoint, so granting `session:app_service` permissions on them would be +# wasted rows that never resolve at the runtime. DEAD_SESSION_STATUSES = ["TERMINATING", "TERMINATED", "CANCELLED", "ERROR"] @@ -36,7 +36,7 @@ def _seed_user_session_grants(db_conn: Connection) -> None: """Per-entity grants for the session creator. For each live session created by user U, grant U's user-scope - ("system") role read on that specific `session:app` via the + ("system") role read on that specific `session:app_service` via the entity-as-scope pattern. Lands in the resolver's self-scope branch only — no leak via scope-walker. """ @@ -78,7 +78,7 @@ def _seed_project_session_grants(db_conn: Connection) -> None: """Per-entity grants for the project's owner/admin roles. For each live session in project P (sessions always carry group_id), - grant P's non-member roles read on that specific `session:app`. + grant P's non-member roles read on that specific `session:app_service`. """ insert_query = sa.text(""" WITH project_role_sessions AS ( @@ -124,9 +124,9 @@ def upgrade() -> None: def downgrade() -> None: - # Intentionally a no-op. Once the runtime starts using `session:app`, + # Intentionally a no-op. Once the runtime starts using `session:app_service`, # operators may grant/revoke additional permissions on this entity type. - # A blanket DELETE WHERE entity_type='session:app' would erase those + # A blanket DELETE WHERE entity_type='session:app_service' would erase those # operator-managed rows together with the seed, so this migration is # forward-only by design. pass