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 Self

from pydantic import BaseModel

from maasservicelayer.models.usergroup_members import UserGroupMember


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

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserGroupMemberResponse.email is typed as str, but member emails can be null (auth_user.email is nullable). This will cause response validation errors when serializing members without an email. Change the field to str | None (and keep from_model passing through the value).

Suggested change
email: str
email: str | None

Copilot uses AI. Check for mistakes.

@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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}) condition is logically always true (every row is != at least one of those values), so internal users will not be filtered out. Use an AND of the two NotEq conditions or sq.NotEq{"username": []string{"MAAS","maas-init-node"}}/sq.NotIn to exclude both usernames correctly.

Suggested change
Where(sq.Or{sq.NotEq{"username": "MAAS"}, sq.NotEq{"username": "maas-init-node"}}). // Ignore the internal users
Where(sq.NotEq{"username": []string{"MAAS", "maas-init-node"}}). // Ignore the internal users

Copilot uses AI. Check for mistakes.
ToSql()
if err != nil {
return err
Expand Down
Loading
Loading