diff --git a/changes/6004.feature.md b/changes/6004.feature.md new file mode 100644 index 00000000000..e8d3b48551d --- /dev/null +++ b/changes/6004.feature.md @@ -0,0 +1 @@ +Add Global scope to simplify access control management for superadmin and monitor users diff --git a/changes/6006.feature.md b/changes/6006.feature.md new file mode 100644 index 00000000000..a330fc8762b --- /dev/null +++ b/changes/6006.feature.md @@ -0,0 +1 @@ +Add superadmin and monitor role fixtures and migrate existing admin and monitor data to RBAC DB diff --git a/fixtures/manager/example-roles.json b/fixtures/manager/example-roles.json index 3230a94bf8a..7ad2bda9021 100644 --- a/fixtures/manager/example-roles.json +++ b/fixtures/manager/example-roles.json @@ -1,5 +1,7 @@ { "roles": [ + {"id": "942d7613-27f1-49c5-944a-5c79045346a1", "name": "role_superadmin", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, + {"id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "name": "role_monitor", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "name": "role_user_user2", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "name": "role_user_monitor", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "name": "role_user_domain-admin", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, @@ -13,6 +15,8 @@ {"id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "name": "role_domain_default_member", "description": "", "source": "custom", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null} ], "user_roles": [ + {"id": "29f8e1cd-f3e8-46d6-8140-51c2c20d94cb", "user_id": "f38dea23-50fa-42a0-b5ae-338f5f4693f4", "role_id": "942d7613-27f1-49c5-944a-5c79045346a1", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, + {"id": "6b3e31a7-507f-49ab-bd6c-510bd8fc4a0a", "user_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6", "role_id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "c247af29-63da-4fa9-91fd-a5e4c8f7dce9", "user_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "f0222c16-e666-4dde-8421-ed7bcd60f812", "user_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6", "role_id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "e24244d7-dad1-43f8-bf99-1b93976eac59", "user_id": "4f13d193-f646-425a-a340-270c4d2b9860", "role_id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, @@ -29,6 +33,9 @@ {"id": "63d98ac0-20af-4f85-8fec-66a5eb71a6b1", "user_id": "dfa9da54-4b28-432f-be29-c0d680c7a412", "role_id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"} ], "permission_groups": [ + {"id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "role_id": "942d7613-27f1-49c5-944a-5c79045346a1", "scope_type": "global", "scope_id": ""}, + {"id": "df8d2c87-12f3-4147-a88e-33de5146f3d3", "role_id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "scope_type": "global", "scope_id": ""}, + {"id": "83154bad-db1d-4257-870c-b130c6978200", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "scope_type": "user", "scope_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040"}, {"id": "83154bad-db1d-4257-870c-b130c6978200", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "scope_type": "user", "scope_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040"}, {"id": "57b52295-41e2-47b5-a384-5e89d969c0a2", "role_id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "scope_type": "user", "scope_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6"}, {"id": "9495c816-18c6-4647-93b7-72f0c9aaeaba", "role_id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "scope_type": "user", "scope_id": "4f13d193-f646-425a-a340-270c4d2b9860"}, @@ -42,6 +49,17 @@ {"id": "0b2d07af-2409-4585-98d8-c1848d7d30ea", "role_id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "scope_type": "domain", "scope_id": "default"} ], "permissions": [ + {"id": "c90a7514-68f2-40c0-88f9-159dce6856cd", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:all"}, + {"id": "6dc1c67d-bc50-4778-936e-0630dfb83a8a", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "read"}, + {"id": "1d3ed696-904f-4af2-a326-5e35ecc71695", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "hard-delete"}, + {"id": "c1109646-9d08-4ccb-acc6-9438830c2fff", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:read"}, + {"id": "6540be54-c760-4fee-bc5a-ec72ef50ea49", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:hard-delete"}, + {"id": "b057323f-9f91-4d3d-9bdd-70883672f8b0", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:soft-delete"}, + {"id": "9639fa62-3751-48e1-8454-86319a945832", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "update"}, + {"id": "4f5c7bf1-45dc-4ac5-8866-0c472147af09", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "soft-delete"}, + {"id": "7b0b5bfe-aae9-4887-9fa7-a78ba9243883", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:update"}, + {"id": "831b4e1c-427a-4ea9-b882-7753d1daa721", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "create"}, + {"id": "5e983bd3-77cb-4271-9629-3b311b9f43d9", "permission_group_id": "df8d2c87-12f3-4147-a88e-33de5146f3d3", "entity_type": "user", "operation": "read"}, {"id": "064b0ba0-93bd-4f69-a5e9-80db199586a7", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "soft-delete"}, {"id": "dc96e05d-29e7-4a90-ba50-2e3e7ea4a65b", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "read"}, {"id": "a70798b6-aad2-448e-b438-c82930444252", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "hard-delete"}, @@ -196,7 +214,6 @@ {"id": "90172aa9-231f-4691-aae5-68b67106f43b", "permission_group_id": "916314f7-6cd6-441d-a16f-865a43102fed", "entity_type": "vfolder", "operation": "grant:soft-delete"} ], "association_scopes_entities": [ - {"id": "e096b03c-579e-4e13-9550-cd9ad2072352", "scope_type": "user", "scope_id": "f38dea23-50fa-42a0-b5ae-338f5f4693f4", "entity_type": "vfolder", "entity_id": "c5f0b0a4-e0ff-4b61-8d93-0f37694d7576", "registered_at": "2025-09-04 08:27:17.905121+00"}, {"id": "bc5a998f-479c-4f1b-85ac-f71a6ce55c0c", "scope_type": "project", "scope_id": "2de2b969-1d04-48a6-af16-0bc8adb3c831", "entity_type": "user", "entity_id": "4f13d193-f646-425a-a340-270c4d2b9860", "registered_at": "2025-09-12 09:51:58.265582+00"}, {"id": "8dba017e-9e94-4c8f-88d3-649806b94610", "scope_type": "project", "scope_id": "8e32dd28-d319-4e3b-8851-ea37837699a5", "entity_type": "user", "entity_id": "4f13d193-f646-425a-a340-270c4d2b9860", "registered_at": "2025-09-12 09:51:58.265582+00"}, {"id": "fd06bdf3-da3a-4db5-8ff9-235312735846", "scope_type": "project", "scope_id": "2de2b969-1d04-48a6-af16-0bc8adb3c831", "entity_type": "user", "entity_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040", "registered_at": "2025-09-12 09:51:58.265582+00"}, diff --git a/src/ai/backend/install/fixtures/example-roles.json b/src/ai/backend/install/fixtures/example-roles.json index 3230a94bf8a..7ad2bda9021 100644 --- a/src/ai/backend/install/fixtures/example-roles.json +++ b/src/ai/backend/install/fixtures/example-roles.json @@ -1,5 +1,7 @@ { "roles": [ + {"id": "942d7613-27f1-49c5-944a-5c79045346a1", "name": "role_superadmin", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, + {"id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "name": "role_monitor", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "name": "role_user_user2", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "name": "role_user_monitor", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, {"id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "name": "role_user_domain-admin", "description": "", "source": "system", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null}, @@ -13,6 +15,8 @@ {"id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "name": "role_domain_default_member", "description": "", "source": "custom", "status": "active", "created_at": "2025-09-12 09:51:58.265582+00", "updated_at": null, "deleted_at": null} ], "user_roles": [ + {"id": "29f8e1cd-f3e8-46d6-8140-51c2c20d94cb", "user_id": "f38dea23-50fa-42a0-b5ae-338f5f4693f4", "role_id": "942d7613-27f1-49c5-944a-5c79045346a1", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, + {"id": "6b3e31a7-507f-49ab-bd6c-510bd8fc4a0a", "user_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6", "role_id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "c247af29-63da-4fa9-91fd-a5e4c8f7dce9", "user_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "f0222c16-e666-4dde-8421-ed7bcd60f812", "user_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6", "role_id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, {"id": "e24244d7-dad1-43f8-bf99-1b93976eac59", "user_id": "4f13d193-f646-425a-a340-270c4d2b9860", "role_id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"}, @@ -29,6 +33,9 @@ {"id": "63d98ac0-20af-4f85-8fec-66a5eb71a6b1", "user_id": "dfa9da54-4b28-432f-be29-c0d680c7a412", "role_id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "granted_by": null, "granted_at": "2025-09-12 09:51:58.265582+00"} ], "permission_groups": [ + {"id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "role_id": "942d7613-27f1-49c5-944a-5c79045346a1", "scope_type": "global", "scope_id": ""}, + {"id": "df8d2c87-12f3-4147-a88e-33de5146f3d3", "role_id": "02d084f3-49e1-4fcb-bc62-e9d06637e60d", "scope_type": "global", "scope_id": ""}, + {"id": "83154bad-db1d-4257-870c-b130c6978200", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "scope_type": "user", "scope_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040"}, {"id": "83154bad-db1d-4257-870c-b130c6978200", "role_id": "31f74f6d-04f7-418b-b375-ab0c39fa3058", "scope_type": "user", "scope_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040"}, {"id": "57b52295-41e2-47b5-a384-5e89d969c0a2", "role_id": "5e96b4ee-2296-495b-90b4-0fc845d13c67", "scope_type": "user", "scope_id": "2e10157d-20ca-4bd0-9806-3f909cbcd0e6"}, {"id": "9495c816-18c6-4647-93b7-72f0c9aaeaba", "role_id": "cb0768f1-3b35-415d-beef-8916a5c3c88b", "scope_type": "user", "scope_id": "4f13d193-f646-425a-a340-270c4d2b9860"}, @@ -42,6 +49,17 @@ {"id": "0b2d07af-2409-4585-98d8-c1848d7d30ea", "role_id": "bfb69730-1374-4ba4-bc26-0ea0b8dd2708", "scope_type": "domain", "scope_id": "default"} ], "permissions": [ + {"id": "c90a7514-68f2-40c0-88f9-159dce6856cd", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:all"}, + {"id": "6dc1c67d-bc50-4778-936e-0630dfb83a8a", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "read"}, + {"id": "1d3ed696-904f-4af2-a326-5e35ecc71695", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "hard-delete"}, + {"id": "c1109646-9d08-4ccb-acc6-9438830c2fff", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:read"}, + {"id": "6540be54-c760-4fee-bc5a-ec72ef50ea49", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:hard-delete"}, + {"id": "b057323f-9f91-4d3d-9bdd-70883672f8b0", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:soft-delete"}, + {"id": "9639fa62-3751-48e1-8454-86319a945832", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "update"}, + {"id": "4f5c7bf1-45dc-4ac5-8866-0c472147af09", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "soft-delete"}, + {"id": "7b0b5bfe-aae9-4887-9fa7-a78ba9243883", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "grant:update"}, + {"id": "831b4e1c-427a-4ea9-b882-7753d1daa721", "permission_group_id": "99229fe4-14cc-491e-ab22-03b59a47fe53", "entity_type": "user", "operation": "create"}, + {"id": "5e983bd3-77cb-4271-9629-3b311b9f43d9", "permission_group_id": "df8d2c87-12f3-4147-a88e-33de5146f3d3", "entity_type": "user", "operation": "read"}, {"id": "064b0ba0-93bd-4f69-a5e9-80db199586a7", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "soft-delete"}, {"id": "dc96e05d-29e7-4a90-ba50-2e3e7ea4a65b", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "read"}, {"id": "a70798b6-aad2-448e-b438-c82930444252", "permission_group_id": "83154bad-db1d-4257-870c-b130c6978200", "entity_type": "user", "operation": "hard-delete"}, @@ -196,7 +214,6 @@ {"id": "90172aa9-231f-4691-aae5-68b67106f43b", "permission_group_id": "916314f7-6cd6-441d-a16f-865a43102fed", "entity_type": "vfolder", "operation": "grant:soft-delete"} ], "association_scopes_entities": [ - {"id": "e096b03c-579e-4e13-9550-cd9ad2072352", "scope_type": "user", "scope_id": "f38dea23-50fa-42a0-b5ae-338f5f4693f4", "entity_type": "vfolder", "entity_id": "c5f0b0a4-e0ff-4b61-8d93-0f37694d7576", "registered_at": "2025-09-04 08:27:17.905121+00"}, {"id": "bc5a998f-479c-4f1b-85ac-f71a6ce55c0c", "scope_type": "project", "scope_id": "2de2b969-1d04-48a6-af16-0bc8adb3c831", "entity_type": "user", "entity_id": "4f13d193-f646-425a-a340-270c4d2b9860", "registered_at": "2025-09-12 09:51:58.265582+00"}, {"id": "8dba017e-9e94-4c8f-88d3-649806b94610", "scope_type": "project", "scope_id": "8e32dd28-d319-4e3b-8851-ea37837699a5", "entity_type": "user", "entity_id": "4f13d193-f646-425a-a340-270c4d2b9860", "registered_at": "2025-09-12 09:51:58.265582+00"}, {"id": "fd06bdf3-da3a-4db5-8ff9-235312735846", "scope_type": "project", "scope_id": "2de2b969-1d04-48a6-af16-0bc8adb3c831", "entity_type": "user", "entity_id": "009fb1a4-487c-4f6e-9b1b-228b94b1d040", "registered_at": "2025-09-12 09:51:58.265582+00"}, diff --git a/src/ai/backend/manager/data/permission/id.py b/src/ai/backend/manager/data/permission/id.py index dc905a7e96d..e4263888709 100644 --- a/src/ai/backend/manager/data/permission/id.py +++ b/src/ai/backend/manager/data/permission/id.py @@ -4,7 +4,7 @@ from .types import EntityType, ScopeType -@dataclass +@dataclass(frozen=True) class ScopeId: scope_type: ScopeType scope_id: str @@ -18,7 +18,7 @@ def to_str(self) -> str: return f"{self.scope_type}:{self.scope_id}" -@dataclass +@dataclass(frozen=True) class ObjectId: entity_type: EntityType entity_id: str diff --git a/src/ai/backend/manager/data/permission/permission_group.py b/src/ai/backend/manager/data/permission/permission_group.py index 331548a4f25..501f3685aed 100644 --- a/src/ai/backend/manager/data/permission/permission_group.py +++ b/src/ai/backend/manager/data/permission/permission_group.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from .id import ScopeId +from .permission import PermissionData @dataclass @@ -26,3 +27,5 @@ class PermissionGroupData: id: uuid.UUID role_id: uuid.UUID scope_id: ScopeId + + permissions: list[PermissionData] diff --git a/src/ai/backend/manager/data/permission/role.py b/src/ai/backend/manager/data/permission/role.py index 87d91fc36a9..d01be6a8d0d 100644 --- a/src/ai/backend/manager/data/permission/role.py +++ b/src/ai/backend/manager/data/permission/role.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from dataclasses import dataclass, field from datetime import datetime @@ -10,12 +12,12 @@ ObjectPermissionCreateInputBeforeRoleCreation, ObjectPermissionData, ) -from .permission_group import PermissionGroupCreatorBeforeRoleCreation +from .permission_group import PermissionGroupCreatorBeforeRoleCreation, PermissionGroupData from .status import RoleStatus from .types import EntityType, OperationType, RoleSource -@dataclass +@dataclass(frozen=True) class RoleCreateInput: name: str source: RoleSource = RoleSource.CUSTOM @@ -28,7 +30,7 @@ class RoleCreateInput: ) -@dataclass +@dataclass(frozen=True) class RoleUpdateInput(PartialModifier): id: uuid.UUID name: OptionalState[str] @@ -46,13 +48,13 @@ def fields_to_update(self) -> dict[str, Any]: return to_update -@dataclass +@dataclass(frozen=True) class RoleDeleteInput: id: uuid.UUID _status: RoleStatus = RoleStatus.DELETED -@dataclass +@dataclass(frozen=True) class RoleData: id: uuid.UUID name: str @@ -64,13 +66,14 @@ class RoleData: description: Optional[str] = None -@dataclass +@dataclass(frozen=True) class RoleDataWithPermissions: id: uuid.UUID name: str source: RoleSource status: RoleStatus + permission_groups: list[PermissionGroupData] object_permissions: list[ObjectPermissionData] created_at: datetime @@ -79,7 +82,7 @@ class RoleDataWithPermissions: description: Optional[str] = None -@dataclass +@dataclass(frozen=True) class ScopePermissionCheckInput: user_id: uuid.UUID target_entity_type: EntityType @@ -87,14 +90,21 @@ class ScopePermissionCheckInput: operation: OperationType -@dataclass +@dataclass(frozen=True) class SingleEntityPermissionCheckInput: user_id: uuid.UUID target_object_id: ObjectId operation: OperationType -@dataclass +@dataclass(frozen=True) +class BatchEntityPermissionCheckInput: + user_id: uuid.UUID + target_object_ids: list[ObjectId] + operation: OperationType + + +@dataclass(frozen=True) class UserRoleAssignmentInput: """ Input to create a new user-role association. @@ -105,7 +115,7 @@ class UserRoleAssignmentInput: granted_by: Optional[uuid.UUID] = None -@dataclass +@dataclass(frozen=True) class UserRoleAssignmentData: user_id: uuid.UUID role_id: uuid.UUID diff --git a/src/ai/backend/manager/data/permission/types.py b/src/ai/backend/manager/data/permission/types.py index 52aab2c9713..83cbb78c834 100644 --- a/src/ai/backend/manager/data/permission/types.py +++ b/src/ai/backend/manager/data/permission/types.py @@ -118,3 +118,7 @@ class ScopeType(enum.StrEnum): DOMAIN = "domain" PROJECT = "project" USER = "user" + GLOBAL = "global" + + +GLOBAL_SCOPE_ID = "global" diff --git a/src/ai/backend/manager/models/alembic/versions/09206ac04fd3_create_global_scope_roles.py b/src/ai/backend/manager/models/alembic/versions/09206ac04fd3_create_global_scope_roles.py new file mode 100644 index 00000000000..e9b65d6c889 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/09206ac04fd3_create_global_scope_roles.py @@ -0,0 +1,162 @@ +"""create global roles + +Revision ID: 09206ac04fd3 +Revises: de1032a11cca +Create Date: 2025-09-22 19:44:13.067238 + +""" + +import enum +import uuid +from typing import Any + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.engine import Connection, Row + +from ai.backend.manager.models.base import EnumValueType, IDColumn +from ai.backend.manager.models.rbac_models.migration.enums import ( + EntityType, + OperationType, +) +from ai.backend.manager.models.rbac_models.migration.models import ( + get_user_roles_table, + mapper_registry, +) +from ai.backend.manager.models.rbac_models.migration.types import ( + UserRoleCreateInput, +) +from ai.backend.manager.models.rbac_models.migration.user import ( + get_monitor_role_creation_input, + get_superadmin_role_creation_input, +) +from ai.backend.manager.models.rbac_models.migration.utils import ( + PermissionUpdateUtil, + insert_skip_on_conflict, +) + +# revision identifiers, used by Alembic. +revision = "09206ac04fd3" +down_revision = "de1032a11cca" +branch_labels = None +depends_on = None + + +class UserRole(enum.StrEnum): + """ + User's role. + """ + + SUPERADMIN = "superadmin" + ADMIN = "admin" + USER = "user" + MONITOR = "monitor" + + +ENTITY_TYPES = { + EntityType.USER, + EntityType.PROJECT, + EntityType.DOMAIN, + EntityType.VFOLDER, +} + + +class Tables: + @staticmethod + def get_users_table() -> sa.Table: + users_table = sa.Table( + "users", + mapper_registry.metadata, + IDColumn("uuid"), + sa.Column("username", sa.String(length=64), unique=True), + sa.Column("role", EnumValueType(UserRole), default=UserRole.USER), + extend_existing=True, + ) + return users_table + + +class RoleCreator: + @classmethod + def _create_superadmin_role(cls, db_conn: Connection) -> uuid.UUID: + role_input = get_superadmin_role_creation_input() + role_id = PermissionUpdateUtil.get_or_create_role(db_conn, role_input) + permission_group_id, exist = PermissionUpdateUtil.get_or_create_global_permission_group( + db_conn, role_id + ) + if not exist: + PermissionUpdateUtil.create_permissions( + db_conn, permission_group_id, ENTITY_TYPES, OperationType.admin_operations() + ) + return role_id + + @classmethod + def _create_monitor_role(cls, db_conn: Connection) -> uuid.UUID: + role_input = get_monitor_role_creation_input() + role_id = PermissionUpdateUtil.get_or_create_role(db_conn, role_input) + permission_group_id, exist = PermissionUpdateUtil.get_or_create_global_permission_group( + db_conn, role_id + ) + if not exist: + PermissionUpdateUtil.create_permissions( + db_conn, permission_group_id, ENTITY_TYPES, OperationType.monitor_operations() + ) + return role_id + + @classmethod + def _query_user_row_by_role( + cls, db_conn: Connection, role: UserRole, offset: int, page_size: int + ) -> list[Row]: + """ + Query all user rows with pagination. + """ + users_table = Tables.get_users_table() + user_query = ( + sa.select( + users_table.c.uuid, + users_table.c.username, + users_table.c.role, + ) + .offset(offset) + .limit(page_size) + .where(users_table.c.role == role) + .order_by(users_table.c.uuid) + ) + return db_conn.execute(user_query).all() + + @classmethod + def _map_role_to_users( + cls, db_conn: Connection, user_role: UserRole, role_id: uuid.UUID + ) -> None: + user_roles_table = get_user_roles_table() + offset = 0 + page_size = 1000 + while True: + rows = cls._query_user_row_by_role(db_conn, user_role, offset, page_size) + offset += page_size + if not rows: + break + + user_role_inputs: list[dict[str, Any]] = [ + UserRoleCreateInput(user_id=row.uuid, role_id=role_id).to_dict() for row in rows + ] + insert_skip_on_conflict(db_conn, user_roles_table, user_role_inputs) + + @classmethod + def create_and_map_superadmin_role(cls, db_conn: Connection) -> None: + superadmin_role_id = cls._create_superadmin_role(db_conn) + cls._map_role_to_users(db_conn, UserRole.SUPERADMIN, superadmin_role_id) + + @classmethod + def create_and_map_monitor_role(cls, db_conn: Connection) -> None: + monitor_role_id = cls._create_monitor_role(db_conn) + cls._map_role_to_users(db_conn, UserRole.MONITOR, monitor_role_id) + + +def upgrade() -> None: + conn = op.get_bind() + RoleCreator.create_and_map_superadmin_role(conn) + RoleCreator.create_and_map_monitor_role(conn) + + +def downgrade() -> None: + pass diff --git a/src/ai/backend/manager/models/rbac_models/association_scopes_entities.py b/src/ai/backend/manager/models/rbac_models/association_scopes_entities.py index 64eb6dc683c..7ec99a8832f 100644 --- a/src/ai/backend/manager/models/rbac_models/association_scopes_entities.py +++ b/src/ai/backend/manager/models/rbac_models/association_scopes_entities.py @@ -51,9 +51,6 @@ class AssociationScopesEntitiesRow(Base): ) def object_id(self) -> ObjectId: - """ - Convert the association to a tuple of ScopeId and ObjectId. - """ return ObjectId(entity_type=self.entity_type, entity_id=self.entity_id) def parsed_scope_id(self) -> ScopeId: diff --git a/src/ai/backend/manager/models/rbac_models/migration/enums.py b/src/ai/backend/manager/models/rbac_models/migration/enums.py index 563125af45b..239d1749d98 100644 --- a/src/ai/backend/manager/models/rbac_models/migration/enums.py +++ b/src/ai/backend/manager/models/rbac_models/migration/enums.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import enum from ai.backend.manager.data.permission.status import ( @@ -54,7 +56,7 @@ def to_original(self) -> OriginalOperationType: return OriginalOperationType(self.value) @classmethod - def owner_operations(cls) -> set["OperationType"]: + def owner_operations(cls) -> set[OperationType]: """ Returns a set of operations that are considered owner operations. Owner operations are those that allow full control over an entity. @@ -62,7 +64,7 @@ def owner_operations(cls) -> set["OperationType"]: return {op for op in cls} @classmethod - def admin_operations(cls) -> set["OperationType"]: + def admin_operations(cls) -> set[OperationType]: """ Returns a set of operations that are considered admin operations. Admin operations are those that allow management of entities, including creation and deletion. @@ -70,7 +72,7 @@ def admin_operations(cls) -> set["OperationType"]: return {op for op in cls} @classmethod - def member_operations(cls) -> set["OperationType"]: + def member_operations(cls) -> set[OperationType]: """ Returns a set of operations that are considered member operations. Member operations are those that allow read access. @@ -79,12 +81,24 @@ def member_operations(cls) -> set["OperationType"]: cls.READ, } + @classmethod + def monitor_operations(cls) -> set[OperationType]: + """ + Returns a set of operations that are considered monitor operations. + Monitor operations are those that allow read access. + """ + return { + cls.READ, + } + class ScopeType(enum.StrEnum): DOMAIN = "domain" PROJECT = "project" USER = "user" + GLOBAL = "global" + def to_original(self) -> OriginalScopeType: return OriginalScopeType(self.value) diff --git a/src/ai/backend/manager/models/rbac_models/migration/types.py b/src/ai/backend/manager/models/rbac_models/migration/types.py index 33980f62789..67f3b8601ae 100644 --- a/src/ai/backend/manager/models/rbac_models/migration/types.py +++ b/src/ai/backend/manager/models/rbac_models/migration/types.py @@ -3,6 +3,7 @@ from typing import Any from ai.backend.manager.data.permission.id import ObjectId, ScopeId +from ai.backend.manager.data.permission.types import GLOBAL_SCOPE_ID as ORIGINAL_GLOBAL_SCOPE_ID from .enums import RoleSource, RoleStatus, ScopeType @@ -10,6 +11,9 @@ ADMIN_ROLE_NAME_SUFFIX = "_admin" +GLOBAL_SCOPE_ID = ORIGINAL_GLOBAL_SCOPE_ID + + @dataclass class UserRoleCreateInput: user_id: uuid.UUID diff --git a/src/ai/backend/manager/models/rbac_models/migration/user.py b/src/ai/backend/manager/models/rbac_models/migration/user.py index 09914795688..fda13945231 100644 --- a/src/ai/backend/manager/models/rbac_models/migration/user.py +++ b/src/ai/backend/manager/models/rbac_models/migration/user.py @@ -25,6 +25,8 @@ OperationType.GRANT_READ, OperationType.GRANT_UPDATE, } +SUPERADMIN_OPERATIONS = OPERATIONS_IN_ROLE +MONITOR_OPERATIONS = {OperationType.READ} class UserRole(enum.StrEnum): @@ -68,3 +70,27 @@ def get_user_self_role_creation_input(user: UserData) -> RoleCreateInput: source=RoleSource.SYSTEM, ) return role_input + + +def get_superadmin_role_creation_input() -> RoleCreateInput: + """ + Create a superadmin role and permissions. + This role allows full access to all entities. + """ + role_input = RoleCreateInput( + name=f"{ROLE_NAME_PREFIX}superadmin", + source=RoleSource.SYSTEM, + ) + return role_input + + +def get_monitor_role_creation_input() -> RoleCreateInput: + """ + Create a monitor role and permissions. + This role allows read-only access to all entities. + """ + role_input = RoleCreateInput( + name=f"{ROLE_NAME_PREFIX}monitor", + source=RoleSource.SYSTEM, + ) + return role_input diff --git a/src/ai/backend/manager/models/rbac_models/migration/utils.py b/src/ai/backend/manager/models/rbac_models/migration/utils.py index a1a8b225820..4368e3c331d 100644 --- a/src/ai/backend/manager/models/rbac_models/migration/utils.py +++ b/src/ai/backend/manager/models/rbac_models/migration/utils.py @@ -1,14 +1,24 @@ import uuid from collections.abc import Collection +from typing import Any import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.engine import Connection from sqlalchemy.engine.row import Row +from .enums import EntityType, OperationType, ScopeType from .models import ( get_permission_groups_table, + get_permissions_table, get_roles_table, ) +from .types import ( + GLOBAL_SCOPE_ID, + PermissionCreateInput, + PermissionGroupCreateInput, + RoleCreateInput, +) def insert_if_data_exists(db_conn: Connection, row_type, data: Collection) -> None: @@ -16,6 +26,22 @@ def insert_if_data_exists(db_conn: Connection, row_type, data: Collection) -> No db_conn.execute(sa.insert(row_type), data) +def insert_skip_on_conflict(db_conn: Connection, row_type, data: Collection) -> None: + if data: + stmt = pg_insert(row_type, data).on_conflict_do_nothing() + db_conn.execute(stmt) + + +def insert_and_returning_id( + db_conn: Connection, + row_type, + data: Any, +) -> uuid.UUID: + stmt = sa.insert(row_type).values(data).returning(row_type.c.id) + result = db_conn.execute(stmt) + return result.scalar_one() + + def query_role_rows_by_name(db_conn: Connection, role_names: Collection[str]) -> list[Row]: """ Query role rows by their names. @@ -36,3 +62,81 @@ def query_permission_groups_by_scope_ids( permission_groups_table.c.scope_id.in_(scope_ids) ) return db_conn.scalars(query).all() + + +class PermissionUpdateUtil: + @staticmethod + def get_or_create_role(db_conn: Connection, role_input: RoleCreateInput) -> uuid.UUID: + roles_table = get_roles_table() + result = db_conn.execute( + sa.select(roles_table).where( + sa.and_( + roles_table.c.name == role_input.name, + roles_table.c.source == role_input.source, + ) + ) + ) + role_row = result.fetchone() + if role_row is not None: + return role_row.id + else: + role_id = insert_and_returning_id( + db_conn, + roles_table, + role_input.to_dict(), + ) + return role_id + + @staticmethod + def get_or_create_global_permission_group( + db_conn: Connection, role_id: uuid.UUID + ) -> tuple[uuid.UUID, bool]: + """ + Get or create a global permission group for the given role ID. + Returns a tuple of (permission_group_id, already_exists). + """ + permission_groups_table = get_permission_groups_table() + result = db_conn.execute( + sa.select(permission_groups_table.c.id).where( + sa.and_( + permission_groups_table.c.role_id == role_id, + permission_groups_table.c.scope_id == GLOBAL_SCOPE_ID, + ) + ) + ) + permission_group_row = result.fetchone() + if permission_group_row is not None: + return permission_group_row.id, True + else: + input = ( + PermissionGroupCreateInput( + role_id=role_id, + scope_type=ScopeType.GLOBAL, + scope_id=GLOBAL_SCOPE_ID, + ) + ).to_dict() + permission_group_id = insert_and_returning_id( + db_conn, + permission_groups_table, + input, + ) + return permission_group_id, False + + @staticmethod + def create_permissions( + db_conn: Connection, + permission_group_id: uuid.UUID, + entity_types: Collection[EntityType], + operations: Collection[OperationType], + ) -> None: + permissions_table = get_permissions_table() + permission_inputs: list[dict[str, Any]] = [] + for entity_type in entity_types: + for operation in operations: + input = PermissionCreateInput( + permission_group_id=permission_group_id, + entity_type=entity_type, + operation=operation, + ) + permission_inputs.append(input.to_dict()) + insert_if_data_exists(db_conn, permissions_table, permission_inputs) diff --git a/src/ai/backend/manager/models/rbac_models/permission/permission_group.py b/src/ai/backend/manager/models/rbac_models/permission/permission_group.py index a194e3206f5..29c8bb24160 100644 --- a/src/ai/backend/manager/models/rbac_models/permission/permission_group.py +++ b/src/ai/backend/manager/models/rbac_models/permission/permission_group.py @@ -23,6 +23,7 @@ ) if TYPE_CHECKING: + from ..association_scopes_entities import AssociationScopesEntitiesRow from ..role import RoleRow from .permission import PermissionRow @@ -45,6 +46,11 @@ class PermissionGroupRow(Base): back_populates="permission_group_rows", primaryjoin="RoleRow.id == foreign(PermissionGroupRow.role_id)", ) + mapped_entities: list[AssociationScopesEntitiesRow] = relationship( + "AssociationScopesEntitiesRow", + primaryjoin="PermissionGroupRow.scope_id == foreign(AssociationScopesEntitiesRow.scope_id)", + viewonly=True, + ) permission_rows: list[PermissionRow] = relationship( "PermissionRow", back_populates="permission_group_row", @@ -67,4 +73,5 @@ def to_data(self) -> PermissionGroupData: id=self.id, role_id=self.role_id, scope_id=ScopeId(scope_type=self.scope_type, scope_id=self.scope_id), + permissions=[permission.to_data() for permission in self.permission_rows], ) diff --git a/src/ai/backend/manager/models/rbac_models/role.py b/src/ai/backend/manager/models/rbac_models/role.py index 5fbee51f0ee..722561dcd81 100644 --- a/src/ai/backend/manager/models/rbac_models/role.py +++ b/src/ai/backend/manager/models/rbac_models/role.py @@ -16,6 +16,7 @@ from ai.backend.manager.data.permission.role import ( RoleCreateInput, RoleData, + RoleDataWithPermissions, ) from ai.backend.manager.data.permission.status import ( RoleStatus, @@ -93,6 +94,20 @@ def to_data(self) -> RoleData: description=self.description, ) + def to_data_with_permissions(self) -> RoleDataWithPermissions: + return RoleDataWithPermissions( + id=self.id, + name=self.name, + source=self.source, + status=self.status, + created_at=self.created_at, + updated_at=self.updated_at, + deleted_at=self.deleted_at, + description=self.description, + permission_groups=[pg_row.to_data() for pg_row in self.permission_group_rows], + object_permissions=[op_row.to_data() for op_row in self.object_permission_rows], + ) + @classmethod def from_input(cls, data: RoleCreateInput) -> Self: return cls( diff --git a/src/ai/backend/manager/repositories/permission_controller/db_source.py b/src/ai/backend/manager/repositories/permission_controller/db_source.py index cd40e3624d1..c4ff94cd596 100644 --- a/src/ai/backend/manager/repositories/permission_controller/db_source.py +++ b/src/ai/backend/manager/repositories/permission_controller/db_source.py @@ -1,11 +1,12 @@ import uuid +from collections.abc import Iterable from typing import Optional, cast import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession as SASession -from sqlalchemy.orm import contains_eager +from sqlalchemy.orm import contains_eager, selectinload -from ...data.permission.id import ObjectId +from ...data.permission.id import ObjectId, ScopeId from ...data.permission.role import ( RoleCreateInput, RoleDeleteInput, @@ -15,6 +16,7 @@ from ...data.permission.status import ( RoleStatus, ) +from ...data.permission.types import OperationType, ScopeType from ...errors.common import ObjectNotFound from ...models.rbac_models.association_scopes_entities import AssociationScopesEntitiesRow from ...models.rbac_models.permission.object_permission import ObjectPermissionRow @@ -142,3 +144,163 @@ async def get_entity_mapped_scopes( ) result = await db_session.scalars(stmt) return result.all() + + async def check_scope_permission_exist( + self, + user_id: uuid.UUID, + scope_id: ScopeId, + operation: OperationType, + ) -> bool: + role_query = ( + sa.select(sa.func.exist()) + .select_from( + sa.join(UserRoleRow, RoleRow.id == UserRoleRow.role_id) + .join(PermissionGroupRow, RoleRow.id == PermissionGroupRow.role_id) + .join(PermissionRow, PermissionGroupRow.id == PermissionRow.permission_group_id) + ) + .where( + sa.and_( + RoleRow.status == RoleStatus.ACTIVE, + UserRoleRow.user_id == user_id, + sa.or_( + PermissionGroupRow.scope_type == ScopeType.GLOBAL, + PermissionGroupRow.scope_id == scope_id.scope_id, + ), + PermissionRow.operation == operation, + ) + ) + .options( + contains_eager(RoleRow.permission_group_rows).options( + selectinload(PermissionGroupRow.permission_rows) + ) + ) + ) + async with self._db.begin_readonly_session() as db_session: + result = await db_session.scalar(role_query) + return result + + def _make_query_statement_for_object_permission( + self, + user_id: uuid.UUID, + object_ids: Iterable[ObjectId], + ) -> sa.sql.Select: + object_id_for_cond = [obj_id.entity_id for obj_id in object_ids] + return ( + sa.select(RoleRow) + .select_from( + sa.join(UserRoleRow, RoleRow.id == UserRoleRow.role_id) + .join(PermissionGroupRow, RoleRow.id == PermissionGroupRow.role_id) + .join( + AssociationScopesEntitiesRow, + PermissionGroupRow.scope_id == AssociationScopesEntitiesRow.scope_id, + ) + .join(ObjectPermissionRow, RoleRow.id == ObjectPermissionRow.role_id) + ) + .where( + sa.and_( + RoleRow.status == RoleStatus.ACTIVE, + UserRoleRow.user_id == user_id, + sa.or_( + PermissionGroupRow.scope_type == ScopeType.GLOBAL, + AssociationScopesEntitiesRow.entity_id.in_(object_id_for_cond), # type: ignore[attr-defined] + ObjectPermissionRow.entity_id.in_(object_id_for_cond), # type: ignore[attr-defined] + ), + ) + ) + .options( + contains_eager(RoleRow.permission_group_rows).options( + contains_eager(PermissionGroupRow.mapped_entities), + selectinload(PermissionGroupRow.permission_rows), + ), + contains_eager(RoleRow.object_permission_rows), + ) + ) + + def _make_query_statement_for_object_permissions( + self, + user_id: uuid.UUID, + object_ids: Iterable[ObjectId], + operation: OperationType, + ) -> sa.sql.Select: + object_id_for_cond = [obj_id.entity_id for obj_id in object_ids] + return ( + sa.select(RoleRow) + .select_from( + sa.join(UserRoleRow, RoleRow.id == UserRoleRow.role_id) + .join(PermissionGroupRow, RoleRow.id == PermissionGroupRow.role_id) + .join( + AssociationScopesEntitiesRow, + PermissionGroupRow.scope_id == AssociationScopesEntitiesRow.scope_id, + ) + .join(PermissionRow, PermissionGroupRow.id == PermissionRow.permission_group_id) + .join(ObjectPermissionRow, RoleRow.id == ObjectPermissionRow.role_id) + ) + .where( + sa.and_( + RoleRow.status == RoleStatus.ACTIVE, + UserRoleRow.user_id == user_id, + sa.or_( + sa.and_( + PermissionGroupRow.scope_type == ScopeType.GLOBAL, + PermissionRow.operation == operation, + ), + sa.and_( + AssociationScopesEntitiesRow.entity_id.in_(object_id_for_cond), # type: ignore[attr-defined] + PermissionRow.operation == operation, + ), + sa.and_( + ObjectPermissionRow.entity_id.in_(object_id_for_cond), # type: ignore[attr-defined] + ObjectPermissionRow.operation == operation, + ), + ), + ) + ) + .options( + contains_eager(RoleRow.permission_group_rows).options( + contains_eager(PermissionGroupRow.mapped_entities), + contains_eager(PermissionGroupRow.permission_rows), + ), + contains_eager(RoleRow.object_permission_rows), + ) + ) + + async def check_object_permission_exist( + self, + user_id: uuid.UUID, + object_id: ObjectId, + operation: OperationType, + ) -> bool: + role_query = self._make_query_statement_for_object_permissions( + user_id, [object_id], operation + ) + async with self._db.begin_readonly_session() as db_session: + result = await db_session.scalars(role_query) + role_rows = cast(list[RoleRow], result.all()) + return len(role_rows) > 0 + + async def check_batch_object_permission_exist( + self, + user_id: uuid.UUID, + object_ids: Iterable[ObjectId], + operation: OperationType, + ) -> dict[ObjectId, bool]: + result: dict[ObjectId, bool] = {object_id: False for object_id in object_ids} + role_query = self._make_query_statement_for_object_permissions( + user_id, object_ids, operation + ) + async with self._db.begin_readonly_session() as db_session: + role_rows = await db_session.scalars(role_query) + role_rows = cast(list[RoleRow], role_rows.all()) + + for role in role_rows: + for op in role.object_permission_rows: + object_id = op.object_id() + result[object_id] = True + for pg in role.permission_group_rows: + if pg.scope_type == ScopeType.GLOBAL: + return {obj_id: True for obj_id in object_ids} + else: + for object in pg.mapped_entities: + object_id = object.object_id() + result[object_id] = True + return result diff --git a/src/ai/backend/manager/repositories/permission_controller/repository.py b/src/ai/backend/manager/repositories/permission_controller/repository.py index 24651738050..87754ad6cd9 100644 --- a/src/ai/backend/manager/repositories/permission_controller/repository.py +++ b/src/ai/backend/manager/repositories/permission_controller/repository.py @@ -1,4 +1,5 @@ import uuid +from collections.abc import Mapping from typing import Optional from ai.backend.common.exception import BackendAIError @@ -7,7 +8,9 @@ from ai.backend.common.resilience.policies.retry import BackoffStrategy, RetryArgs, RetryPolicy from ai.backend.common.resilience.resilience import Resilience +from ...data.permission.id import ObjectId from ...data.permission.role import ( + BatchEntityPermissionCheckInput, RoleCreateInput, RoleData, RoleDeleteInput, @@ -97,13 +100,19 @@ async def check_permission_of_entity(self, data: SingleEntityPermissionCheckInpu @permission_controller_repository_resilience.apply() async def check_permission_in_scope(self, data: ScopePermissionCheckInput) -> bool: - target_scope_id = data.target_scope_id - role_rows = await self._db_source.get_user_roles(data.user_id) - for role in role_rows: - for permission_group in role.permission_group_rows: - if permission_group.parsed_scope_id() != target_scope_id: - continue - for permission in permission_group.permission_rows: - if permission.operation == data.operation: - return True - return False + return await self._db_source.check_scope_permission_exist( + data.user_id, data.target_scope_id, data.operation + ) + + @permission_controller_repository_resilience.apply() + async def check_permission_of_entities( + self, + data: BatchEntityPermissionCheckInput, + ) -> Mapping[ObjectId, bool]: + """ + Check if the user has the requested operation permission on the given entity IDs. + Returns a mapping of entity ID to a boolean indicating permission. + """ + return await self._db_source.check_batch_object_permission_exist( + data.user_id, data.target_object_ids, data.operation + )