-
Notifications
You must be signed in to change notification settings - Fork 164
feat(BA-2478): Add RBAC Global scope #6004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
Adds a new GLOBAL RBAC scope and introduces system-level superadmin and monitor roles, along with a migration to create and map these roles to existing users. Key changes include extending enums with a GLOBAL scope, adding role creation helpers, and an Alembic migration to seed global-scope roles and permissions.
- Added GLOBAL scope and related constants to RBAC enums and permission types
- Introduced superadmin and monitor role creation helpers
- New migration to create global roles, permission groups, and map users by legacy role
Reviewed Changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
File | Description |
---|---|
models/rbac_models/migration/user.py | Adds constants and helper functions for superadmin and monitor role creation |
models/rbac_models/migration/enums.py | Extends OperationType and ScopeType with monitor/global concepts and introduces GLOBAL_SCOPE_ID |
models/alembic/versions/09206ac04fd3_create_global_scope_roles.py | Migration to create global-scope roles, permissions, and map users |
data/permission/types.py | Adds GLOBAL scope and duplicates GLOBAL_SCOPE_ID constant |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
OperationType.GRANT_READ, | ||
OperationType.GRANT_UPDATE, | ||
} | ||
SUPERADMIN_OPERATIONS = OPERATIONS_IN_ROLE |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] SUPERADMIN_OPERATIONS references the same set object as OPERATIONS_IN_ROLE; if either is mutated later, both change unintentionally. Use a shallow copy to decouple: SUPERADMIN_OPERATIONS = OPERATIONS_IN_ROLE.copy().
SUPERADMIN_OPERATIONS = OPERATIONS_IN_ROLE | |
SUPERADMIN_OPERATIONS = OPERATIONS_IN_ROLE.copy() |
Copilot uses AI. Check for mistakes.
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, |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docstring states this creates the role and permissions, but the function only returns a RoleCreateInput without creating permissions. Either adjust the wording (e.g., "Return RoleCreateInput for superadmin") or implement permission creation here for consistency.
Copilot uses AI. Check for mistakes.
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", |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same mismatch as superadmin helper: the function returns only a RoleCreateInput and does not create permissions. Update docstring or extend functionality for clarity and consistency.
Copilot uses AI. Check for mistakes.
return OriginalScopeType(self.value) | ||
|
||
|
||
GLOBAL_SCOPE_ID = "" |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] GLOBAL_SCOPE_ID is an empty string with no explanatory comment; using a sentinel empty string can be error-prone (e.g., falsy checks). Provide a comment explaining why an empty string is valid and consider using a clearly descriptive constant (e.g., "GLOBAL") to reduce ambiguity.
GLOBAL_SCOPE_ID = "" | |
# Sentinel value for the global scope. Use a descriptive string to avoid ambiguity and errors with falsy checks. | |
GLOBAL_SCOPE_ID = "GLOBAL" |
Copilot uses AI. Check for mistakes.
GLOBAL = "global" # Global scope | ||
|
||
|
||
GLOBAL_SCOPE_ID = "" |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] GLOBAL_SCOPE_ID duplicates the same empty-string constant defined elsewhere (enums module). Centralize this definition to avoid divergence and document why an empty string is used.
GLOBAL_SCOPE_ID = "" | |
from ai.backend.manager.data.enums import GLOBAL_SCOPE_ID # Centralized definition; see enums module for documentation. |
Copilot uses AI. Check for mistakes.
input = ( | ||
PermissionGroupCreateInput( | ||
role_id=role_id, | ||
scope_type=ScopeType.GLOBAL, | ||
scope_id=GLOBAL_SCOPE_ID, | ||
) | ||
).to_dict() |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable name input shadows the built-in function input, which can confuse readers and tools. Rename to permission_group_data or pg_input for clarity.
Copilot uses AI. Check for mistakes.
input = PermissionCreateInput( | ||
permission_group_id=permission_group_id, | ||
entity_type=entity_type, | ||
operation=operation, | ||
) | ||
permission_inputs.append(input.to_dict()) |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same shadowing of built-in input here; rename variable (e.g., perm_input) to avoid overshadowing and improve readability.
Copilot uses AI. Check for mistakes.
class UserRole(enum.StrEnum): | ||
""" | ||
User's role. | ||
""" | ||
|
||
SUPERADMIN = "superadmin" | ||
ADMIN = "admin" | ||
USER = "user" | ||
MONITOR = "monitor" |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Redefining UserRole inside the migration duplicates existing role definitions elsewhere, increasing risk of drift if roles change. Consider importing the canonical enum or inlining plain string literals to avoid maintaining parallel enums in migrations.
Copilot uses AI. Check for mistakes.
|
||
|
||
def downgrade() -> None: | ||
pass |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing downgrade logic means this migration is irreversible, which can hinder debugging or rollback scenarios. Implement removal of inserted roles, permission groups, permissions, and user-role mappings (or explicitly document why downgrade is intentionally unsupported).
pass | |
conn = op.get_bind() | |
roles_table = get_roles_table() | |
user_roles_table = get_user_roles_table() | |
permissions_table = get_permissions_table() | |
permission_groups_table = get_permission_groups_table() | |
# List of role names to remove | |
role_names = [UserRole.SUPERADMIN, UserRole.MONITOR] | |
# Find role IDs for these roles | |
role_rows = query_role_rows_by_name(conn, role_names) | |
role_ids = [row.id for row in role_rows] | |
if role_ids: | |
# Remove user-role mappings | |
conn.execute( | |
user_roles_table.delete().where( | |
user_roles_table.c.role_id.in_(role_ids) | |
) | |
) | |
# Remove permissions associated with these roles | |
conn.execute( | |
permissions_table.delete().where( | |
permissions_table.c.role_id.in_(role_ids) | |
) | |
) | |
# Remove permission groups associated with these roles | |
conn.execute( | |
permission_groups_table.delete().where( | |
permission_groups_table.c.role_id.in_(role_ids) | |
) | |
) | |
# Remove the roles themselves | |
conn.execute( | |
roles_table.delete().where( | |
roles_table.c.id.in_(role_ids) | |
) | |
) |
Copilot uses AI. Check for mistakes.
""" | ||
return { | ||
cls.READ, | ||
} |
Copilot
AI
Sep 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] monitor_operations duplicates member_operations logic (both return only READ). If the semantic distinction is intentional, add a clarifying comment; otherwise consider reusing member_operations to avoid duplication.
""" | |
return { | |
cls.READ, | |
} | |
This method reuses member_operations for consistency and to avoid duplication. | |
""" | |
return cls.member_operations() |
Copilot uses AI. Check for mistakes.
7e5435c
to
c5407ac
Compare
def is_global(self) -> bool: | ||
return self.scope_type == ScopeType.GLOBAL |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m not sure if this is really meaningful.
@dataclass | ||
class PermissionCheckInput: | ||
class MergedPermissionData: | ||
scope_permissions: Mapping[ScopeId, set[OperationType]] | ||
global_permissions: set[OperationType] | ||
object_permissions: Mapping[ObjectId, set[OperationType]] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like it would be good to separate it. However, I think we need to consider how to separate it a little bit, so I'll take a look and suggest some ideas.
c5407ac
to
d200694
Compare
d0e4b56
to
88a06b5
Compare
88a06b5
to
bd85f26
Compare
7c18773
to
46daf25
Compare
scope_id: str | ||
|
||
def __hash__(self) -> int: | ||
return hash((self.scope_type, self.scope_id)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about setting the frozen attribute of the dataclass?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems better to make values immutable rather than making mutable values hashable.
@dataclass | ||
class BatchEntityPermissionCheckInput: | ||
user_id: uuid.UUID | ||
target_object_ids: list[ObjectId] | ||
operation: OperationType | ||
|
||
|
||
@dataclass | ||
class ScopePermissionSet: | ||
scope_id: ScopeId | ||
scope_permissions: set[OperationType] | ||
global_permissions: Optional[set[OperationType]] | ||
|
||
|
||
@dataclass | ||
class ObjectPermissionSet: | ||
object_id: ObjectId | ||
object_permissions: set[OperationType] | ||
mapped_scopes: dict[ScopeId, set[OperationType]] | ||
global_permissions: Optional[set[OperationType]] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be good to keep most of the values frozen.
sa.or_( | ||
PermissionGroupRow.scope_type == ScopeType.GLOBAL, | ||
PermissionGroupRow.scope_id == scope_id.scope_id, | ||
), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to check the scope type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should also receive the value for the operation.
scope_permissions: set[OperationType] = set() | ||
global_permissions: Optional[set[OperationType]] = None | ||
for role in role_rows: | ||
for pg in role.permission_group_rows: | ||
if pg.parsed_scope_id() != scope_id: | ||
continue | ||
if pg.scope_type == ScopeType.GLOBAL: | ||
global_permissions = {perm.operation for perm in pg.permission_rows} | ||
else: | ||
scope_permissions |= {perm.operation for perm in pg.permission_rows} | ||
|
||
return ScopePermissionSet(scope_id, scope_permissions, global_permissions) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There doesn't seem to be a need to separate global permission specifically. What's actually needed is whether or not there's permission for the operation.
async def check_scope_permission_exist( | ||
self, | ||
user_id: uuid.UUID, | ||
scope_id: ScopeId, | ||
operation: OperationType, | ||
) -> bool: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are returning the permission set anyway, it doesn't seem necessary to have a separate check function.
@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 await self._db_source.check_scope_permission_exist( | ||
data.user_id, data.target_scope_id, data.operation | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Due to the situation where a considerable amount of computation is performed in the DB, a cache layer is essential. As a follow-up task, please also apply a cache source.
|
||
GLOBAL = "global" # Global scope |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove line breaks.
async def get_object_permissions( | ||
self, | ||
user_id: uuid.UUID, | ||
object_id: ObjectId, | ||
) -> ObjectPermissionSet: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You missed resilience decorator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a db source module, not a repository module
46daf25
to
e5d55cd
Compare
Pull Request is not mergeable
resolves #6003 (BA-2478)
Checklist: (if applicable)
ai.backend.test
docs
directory