Skip to content
Closed
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
128 changes: 127 additions & 1 deletion src/maasapiserver/v3/api/public/handlers/usergroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,39 @@
)
from maasapiserver.v3.api import services
from maasapiserver.v3.api.public.models.requests.query import PaginationParams
from maasapiserver.v3.api.public.models.requests.usergroup_members import (
UserGroupMemberRequest,
)
from maasapiserver.v3.api.public.models.requests.usergroups import (
UserGroupRequest,
)
from maasapiserver.v3.api.public.models.responses.base import (
OPENAPI_ETAG_HEADER,
)
from maasapiserver.v3.api.public.models.responses.usergroup_members import (
UserGroupMemberResponse,
UserGroupMembersListResponse,
)
from maasapiserver.v3.api.public.models.responses.usergroups import (
UserGroupResponse,
UserGroupsListResponse,
)
from maasapiserver.v3.auth.base import check_permissions
from maasapiserver.v3.constants import V3_API_PREFIX
from maasservicelayer.auth.jwt import UserRole
from maasservicelayer.exceptions.catalog import NotFoundException
from maasservicelayer.exceptions.catalog import (
BaseExceptionDetail,
ConflictException,
NotFoundException,
)
from maasservicelayer.exceptions.constants import (
UNIQUE_CONSTRAINT_VIOLATION_TYPE,
)
from maasservicelayer.services import ServiceCollectionV3
from maasservicelayer.services.usergroups import (
UserAlreadyInGroup,
UserGroupNotFound,
)


class UserGroupsHandler(Handler):
Expand Down Expand Up @@ -200,3 +218,111 @@ async def delete_group(
) -> Response:
await services.usergroups.delete_by_id(group_id, etag_if_match)
return Response(status_code=status.HTTP_204_NO_CONTENT)

# Membership endpoints

@handler(
path="/groups/{group_id}/members",
methods=["GET"],
tags=TAGS,
responses={
200: {
"model": UserGroupMembersListResponse,
},
404: {"model": NotFoundBodyResponse},
},
response_model_exclude_none=True,
status_code=200,
dependencies=[
Depends(check_permissions(required_roles={UserRole.USER}))
],
)
async def list_group_members(
self,
group_id: int,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupMembersListResponse:
group = await services.usergroups.get_by_id(group_id)
if not group:
raise NotFoundException()

members = await services.usergroups.list_usergroup_members(group_id)
return UserGroupMembersListResponse(
items=[
UserGroupMemberResponse.from_model(member)
for member in members
],
)

@handler(
path="/groups/{group_id}/members",
methods=["POST"],
tags=TAGS,
responses={
200: {
"model": UserGroupMembersListResponse,
},
404: {"model": NotFoundBodyResponse},
409: {"model": ConflictBodyResponse},
},
response_model_exclude_none=True,
status_code=200,
dependencies=[
Depends(check_permissions(required_roles={UserRole.ADMIN}))
],
)
async def add_group_member(
self,
group_id: int,
member_request: UserGroupMemberRequest,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> UserGroupMembersListResponse:
try:
await services.usergroups.add_user_to_group_by_id(
member_request.user_id, group_id
)
except UserGroupNotFound as err:
raise NotFoundException() from err
except UserAlreadyInGroup as err:
raise ConflictException(
details=[
BaseExceptionDetail(
type=UNIQUE_CONSTRAINT_VIOLATION_TYPE,
message=str(err),
)
]
) from err

members = await services.usergroups.list_usergroup_members(group_id)
return UserGroupMembersListResponse(
items=[
UserGroupMemberResponse.from_model(member)
for member in members
],
)

@handler(
path="/groups/{group_id}/members/{user_id}",
methods=["DELETE"],
tags=TAGS,
responses={
204: {},
404: {"model": NotFoundBodyResponse},
},
status_code=204,
dependencies=[
Depends(check_permissions(required_roles={UserRole.ADMIN}))
],
)
async def remove_group_member(
self,
group_id: int,
user_id: int,
services: ServiceCollectionV3 = Depends(services), # noqa: B008
) -> Response:
group = await services.usergroups.get_by_id(group_id)
if not group:
raise NotFoundException()

await services.usergroups.remove_user_from_group(group_id, user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copyright 2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from pydantic import BaseModel, Field


class UserGroupMemberRequest(BaseModel):
user_id: int = Field(description="The ID of the user to add to the group.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright 2026 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).

from typing import Optional, Self

from pydantic import BaseModel

from maasservicelayer.models.usergroup_members import UserGroupMember


class UserGroupMemberResponse(BaseModel):
kind = "UserGroupMember"
user_id: int
username: str
email: str

@classmethod
def from_model(cls, member: UserGroupMember) -> Self:
return cls(
user_id=member.id,
username=member.username,
email=member.email,
)


class UserGroupMembersListResponse(BaseModel):
kind = "UserGroupMembersList"
items: list[UserGroupMemberResponse]
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func addUsersToGroup(ctx context.Context, tx *sql.Tx, administratorGroupID int64
selectStmt, selectArgs, err := builder.
Select("id", "is_superuser").
From("auth_user").
Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}). // Ignore the internal users
ToSql()
if err != nil {
return err
Expand Down
Loading
Loading