Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/10688.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add required `role_id` parameter to the assign-users-to-project API so that users receive a project role upon assignment.
1 change: 1 addition & 0 deletions src/ai/backend/common/dto/manager/v2/group/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ class AssignUsersToProjectInput(BaseRequestModel):
"""Input for assigning users to a project."""

user_ids: list[UUID] = Field(description="List of user UUIDs to assign to the project.")
role_id: UUID = Field(description="UUID of the project role to assign to the users.")


class UnassignUsersFromProjectInput(BaseRequestModel):
Expand Down
4 changes: 3 additions & 1 deletion src/ai/backend/manager/api/adapters/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ async def assign_users(
) -> AssignUsersToProjectPayload:
"""Assign users to a project."""
result = await self._processors.group.assign_users_to_project.wait_for_complete(
AssignUsersToProjectAction(project_id=project_id, user_ids=input.user_ids)
AssignUsersToProjectAction(
project_id=project_id, user_ids=input.user_ids, role_id=input.role_id
)
)
return AssignUsersToProjectPayload(
items=[UserAdapter._user_data_to_node(u) for u in result.assigned_users],
Expand Down
21 changes: 19 additions & 2 deletions src/ai/backend/manager/repositories/group/db_source/db_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from ai.backend.manager.data.group.types import GroupData, UnassignUserFailure, UnassignUsersResult
from ai.backend.manager.data.permission.types import RBACElementRef
from ai.backend.manager.data.user.types import UserData
from ai.backend.manager.errors.permission import RoleNotFound
from ai.backend.manager.errors.resource import (
ProjectHasActiveEndpointsError,
ProjectHasActiveKernelsError,
Expand All @@ -45,6 +46,7 @@
KernelRow,
kernels,
)
from ai.backend.manager.models.rbac_models.role import RoleRow
from ai.backend.manager.models.resource_policy import project_resource_policies
from ai.backend.manager.models.resource_usage import fetch_resource_usage
from ai.backend.manager.models.routing import RoutingRow
Expand All @@ -59,7 +61,7 @@
vfolder_status_map,
vfolders,
)
from ai.backend.manager.repositories.base.creator import Creator
from ai.backend.manager.repositories.base.creator import BulkCreator, Creator, execute_bulk_creator
from ai.backend.manager.repositories.base.purger import BatchPurger, execute_batch_purger
from ai.backend.manager.repositories.base.querier import BatchQuerier, execute_batch_querier
from ai.backend.manager.repositories.base.rbac.entity_creator import (
Expand Down Expand Up @@ -92,6 +94,7 @@
GroupSearchResult,
UserProjectSearchScope,
)
from ai.backend.manager.repositories.permission_controller.creators import UserRoleCreatorSpec
from ai.backend.manager.repositories.permission_controller.role_manager import RoleManager

log = BraceStyleAdapter(logging.getLogger(__spec__.name))
Expand Down Expand Up @@ -588,20 +591,28 @@ async def _delete_group_endpoints(self, session: SASession, group_id: uuid.UUID)
)

async def assign_users_to_project(
self, project_id: UUID, user_ids: list[UUID]
self, project_id: UUID, user_ids: list[UUID], role_id: UUID
) -> list[UserData]:
"""Assign users to a project with domain validation and RBAC scope binding.

Validates that the project exists, users are in the same domain and active,
and filters out already-assigned users. Creates both the business association
(association_groups_users) and the RBAC scope association atomically.
Also creates user-role mappings for the specified role.

Returns the list of newly assigned users.
"""
if not user_ids:
return []

async with self._db.begin_session_read_committed() as session:
# TODO: https://github.com/lablup/backend.ai/issues/10687
# Replace this inline query with the role repository's get method
# once the role repository is implemented.
role_exists = await session.scalar(sa.select(sa.exists().where(RoleRow.id == role_id)))
if not role_exists:
raise RoleNotFound(f"Role not found: {role_id}")

# Find assignable users in a single query:
# same domain as the project and not already assigned
# TODO: This pre-filtering can be removed once execute_rbac_scope_binder_partial
Expand Down Expand Up @@ -641,6 +652,12 @@ async def assign_users_to_project(
]
await execute_rbac_scope_binder(session, RBACScopeBinder(pairs=pairs))

# Create user-role mappings for the assigned users
user_role_specs = [
UserRoleCreatorSpec(user_id=row.uuid, role_id=role_id) for row in new_user_rows
]
await execute_bulk_creator(session, BulkCreator(specs=user_role_specs))

return [row.to_data() for row in new_user_rows]

async def unassign_users_from_project(
Expand Down
4 changes: 2 additions & 2 deletions src/ai/backend/manager/repositories/group/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ async def purge_group(self, group_id: uuid.UUID) -> bool:

@group_repository_resilience.apply()
async def assign_users_to_project(
self, project_id: UUID, user_ids: list[UUID]
self, project_id: UUID, user_ids: list[UUID], role_id: UUID
) -> list[UserData]:
"""Assign users to a project with domain validation and RBAC scope binding.

Returns the list of newly assigned users.
"""
return await self._db_source.assign_users_to_project(project_id, user_ids)
return await self._db_source.assign_users_to_project(project_id, user_ids, role_id)

@group_repository_resilience.apply()
async def unassign_users_from_project(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
class AssignUsersToProjectAction(GroupSingleEntityAction):
project_id: UUID
user_ids: list[UUID]
role_id: UUID

@override
@classmethod
Expand Down
2 changes: 1 addition & 1 deletion src/ai/backend/manager/services/group/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ async def assign_users_to_project(
self, action: AssignUsersToProjectAction
) -> AssignUsersToProjectActionResult:
assigned_users = await self._group_repository.assign_users_to_project(
action.project_id, action.user_ids
action.project_id, action.user_ids, action.role_id
)
return AssignUsersToProjectActionResult(
project_id=action.project_id, assigned_users=assigned_users
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,22 @@ async def cross_domain_user(
db_with_cleanup, other_domain, user_resource_policy, test_password_info
)

@pytest.fixture
async def test_role(
self,
db_with_cleanup: ExtendedAsyncSAEngine,
) -> uuid.UUID:
role_id = uuid.uuid4()
async with db_with_cleanup.begin_session() as session:
session.add(
RoleRow(
id=role_id,
name=f"test-role-{role_id.hex[:8]}",
)
)
await session.commit()
return role_id

@pytest.fixture
def group_db_source(
self,
Expand All @@ -260,12 +276,13 @@ async def test_assign_users_success(
db_with_cleanup: ExtendedAsyncSAEngine,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
same_domain_user_1: uuid.UUID,
same_domain_user_2: uuid.UUID,
) -> None:
"""Active users in same domain are assigned successfully."""
result = await group_db_source.assign_users_to_project(
test_project, [same_domain_user_1, same_domain_user_2]
test_project, [same_domain_user_1, same_domain_user_2], test_role
)

assert len(result) == 2
Expand All @@ -285,26 +302,28 @@ async def test_assign_empty_list_returns_empty(
self,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
) -> None:
"""Empty user_ids list returns empty result without DB access."""
result = await group_db_source.assign_users_to_project(test_project, [])
result = await group_db_source.assign_users_to_project(test_project, [], test_role)
assert result == []

async def test_assign_filters_already_assigned_users(
self,
db_with_cleanup: ExtendedAsyncSAEngine,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
same_domain_user_1: uuid.UUID,
same_domain_user_2: uuid.UUID,
) -> None:
"""Already-assigned users are excluded; only new users are returned."""
# Pre-assign user_1
await group_db_source.assign_users_to_project(test_project, [same_domain_user_1])
await group_db_source.assign_users_to_project(test_project, [same_domain_user_1], test_role)

# Assign both — only user_2 should be returned
result = await group_db_source.assign_users_to_project(
test_project, [same_domain_user_1, same_domain_user_2]
test_project, [same_domain_user_1, same_domain_user_2], test_role
)

assert len(result) == 1
Expand All @@ -323,12 +342,13 @@ async def test_assign_filters_cross_domain_users(
self,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
same_domain_user_1: uuid.UUID,
cross_domain_user: uuid.UUID,
) -> None:
"""Users from a different domain are silently excluded."""
result = await group_db_source.assign_users_to_project(
test_project, [same_domain_user_1, cross_domain_user]
test_project, [same_domain_user_1, cross_domain_user], test_role
)

assert len(result) == 1
Expand All @@ -338,22 +358,24 @@ async def test_assign_filters_nonexistent_users(
self,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
) -> None:
"""Non-existent user UUIDs are silently excluded."""
fake_user = uuid.uuid4()
result = await group_db_source.assign_users_to_project(test_project, [fake_user])
result = await group_db_source.assign_users_to_project(test_project, [fake_user], test_role)
assert result == []

async def test_assign_all_invalid_returns_empty(
self,
group_db_source: GroupDBSource,
test_project: uuid.UUID,
test_role: uuid.UUID,
cross_domain_user: uuid.UUID,
) -> None:
"""When all users are invalid (wrong domain, nonexistent), return empty."""
fake_user = uuid.uuid4()

result = await group_db_source.assign_users_to_project(
test_project, [cross_domain_user, fake_user]
test_project, [cross_domain_user, fake_user], test_role
)
assert result == []
Loading