-
Notifications
You must be signed in to change notification settings - Fork 1
Launchpad MP (501141) - r00ta/openfga-groups-endpoints #432
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| # Copyright 2026 Canonical Ltd. This software is licensed under the | ||
| # GNU Affero General Public License version 3 (see the file LICENSE). | ||
|
|
||
| from typing import Union | ||
|
|
||
| from fastapi import Depends, Header, Response | ||
| from starlette import status | ||
|
|
||
| from maasapiserver.common.api.base import Handler, handler | ||
| from maasapiserver.common.api.models.responses.errors import ( | ||
| ConflictBodyResponse, | ||
| NotFoundBodyResponse, | ||
| ) | ||
| from maasapiserver.v3.api import services | ||
| from maasapiserver.v3.api.public.models.requests.query import PaginationParams | ||
| 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.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.services import ServiceCollectionV3 | ||
|
|
||
|
|
||
| class UserGroupsHandler(Handler): | ||
| """User Groups API handler.""" | ||
|
|
||
| TAGS = ["UserGroups"] | ||
|
|
||
| @handler( | ||
| path="/groups", | ||
| methods=["GET"], | ||
| tags=TAGS, | ||
| responses={ | ||
| 200: { | ||
| "model": UserGroupsListResponse, | ||
| }, | ||
| }, | ||
| response_model_exclude_none=True, | ||
| status_code=200, | ||
| dependencies=[ | ||
| Depends(check_permissions(required_roles={UserRole.USER})) | ||
| ], | ||
| ) | ||
| async def list_groups( | ||
| self, | ||
| pagination_params: PaginationParams = Depends(), # noqa: B008 | ||
| services: ServiceCollectionV3 = Depends(services), # noqa: B008 | ||
| ) -> UserGroupsListResponse: | ||
| groups = await services.usergroups.list( | ||
| page=pagination_params.page, | ||
| size=pagination_params.size, | ||
| ) | ||
| next_link = None | ||
| if groups.has_next(pagination_params.page, pagination_params.size): | ||
| next_link = ( | ||
| f"{V3_API_PREFIX}/groups?" | ||
| f"{pagination_params.to_next_href_format()}" | ||
| ) | ||
|
|
||
| return UserGroupsListResponse( | ||
| items=[ | ||
| UserGroupResponse.from_model( | ||
| usergroup=group, | ||
| self_base_hyperlink=f"{V3_API_PREFIX}/groups", | ||
| ) | ||
| for group in groups.items | ||
| ], | ||
| total=groups.total, | ||
| next=next_link, | ||
| ) | ||
|
|
||
| @handler( | ||
| path="/groups", | ||
| methods=["POST"], | ||
| tags=TAGS, | ||
| responses={ | ||
| 201: { | ||
| "model": UserGroupResponse, | ||
| "headers": {"ETag": OPENAPI_ETAG_HEADER}, | ||
| }, | ||
| 409: {"model": ConflictBodyResponse}, | ||
| }, | ||
| response_model_exclude_none=True, | ||
| status_code=201, | ||
| dependencies=[ | ||
| Depends(check_permissions(required_roles={UserRole.ADMIN})) | ||
| ], | ||
| ) | ||
| async def create_group( | ||
| self, | ||
| response: Response, | ||
| group_request: UserGroupRequest, | ||
| services: ServiceCollectionV3 = Depends(services), # noqa: B008 | ||
| ) -> UserGroupResponse: | ||
| group = await services.usergroups.create(group_request.to_builder()) | ||
| response.headers["ETag"] = group.etag() | ||
| return UserGroupResponse.from_model( | ||
| usergroup=group, | ||
| self_base_hyperlink=f"{V3_API_PREFIX}/groups", | ||
| ) | ||
|
|
||
| @handler( | ||
| path="/groups/{group_id}", | ||
| methods=["GET"], | ||
| tags=TAGS, | ||
| responses={ | ||
| 200: { | ||
| "model": UserGroupResponse, | ||
| "headers": {"ETag": OPENAPI_ETAG_HEADER}, | ||
| }, | ||
| 404: {"model": NotFoundBodyResponse}, | ||
| }, | ||
| response_model_exclude_none=True, | ||
| status_code=200, | ||
| dependencies=[ | ||
| Depends(check_permissions(required_roles={UserRole.USER})) | ||
| ], | ||
| ) | ||
| async def get_group( | ||
| self, | ||
| group_id: int, | ||
| response: Response, | ||
| services: ServiceCollectionV3 = Depends(services), # noqa: B008 | ||
| ) -> UserGroupResponse: | ||
| group = await services.usergroups.get_by_id(group_id) | ||
| if not group: | ||
| raise NotFoundException() | ||
|
|
||
| response.headers["ETag"] = group.etag() | ||
| return UserGroupResponse.from_model( | ||
| usergroup=group, | ||
| self_base_hyperlink=f"{V3_API_PREFIX}/groups", | ||
| ) | ||
|
|
||
| @handler( | ||
| path="/groups/{group_id}", | ||
| methods=["PUT"], | ||
| tags=TAGS, | ||
| responses={ | ||
| 200: { | ||
| "model": UserGroupResponse, | ||
| "headers": {"ETag": OPENAPI_ETAG_HEADER}, | ||
| }, | ||
| 404: {"model": NotFoundBodyResponse}, | ||
| }, | ||
| response_model_exclude_none=True, | ||
| status_code=200, | ||
| dependencies=[ | ||
| Depends(check_permissions(required_roles={UserRole.ADMIN})) | ||
| ], | ||
| ) | ||
| async def update_group( | ||
| self, | ||
| group_id: int, | ||
| group_request: UserGroupRequest, | ||
| response: Response, | ||
| services: ServiceCollectionV3 = Depends(services), # noqa: B008 | ||
| ) -> UserGroupResponse: | ||
| group = await services.usergroups.update_by_id( | ||
| group_id, group_request.to_builder() | ||
| ) | ||
| if not group: | ||
| raise NotFoundException() | ||
|
|
||
| response.headers["ETag"] = group.etag() | ||
| return UserGroupResponse.from_model( | ||
| usergroup=group, | ||
| self_base_hyperlink=f"{V3_API_PREFIX}/groups", | ||
| ) | ||
|
|
||
| @handler( | ||
| path="/groups/{group_id}", | ||
| methods=["DELETE"], | ||
| tags=TAGS, | ||
| responses={ | ||
| 204: {}, | ||
| 404: {"model": NotFoundBodyResponse}, | ||
| }, | ||
| status_code=204, | ||
| dependencies=[ | ||
| Depends(check_permissions(required_roles={UserRole.ADMIN})) | ||
| ], | ||
| ) | ||
| async def delete_group( | ||
| self, | ||
| group_id: int, | ||
| etag_if_match: Union[str, None] = Header( | ||
| alias="if-match", default=None | ||
| ), | ||
| services: ServiceCollectionV3 = Depends(services), # noqa: B008 | ||
| ) -> Response: | ||
| await services.usergroups.delete_by_id(group_id, etag_if_match) | ||
| return Response(status_code=status.HTTP_204_NO_CONTENT) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # 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 | ||
|
|
||
| from pydantic import Field | ||
|
|
||
| from maasapiserver.v3.api.public.models.requests.base import NamedBaseModel | ||
| from maasservicelayer.builders.usergroups import UserGroupBuilder | ||
|
|
||
|
|
||
| class UserGroupRequest(NamedBaseModel): | ||
| description: Optional[str] = Field( | ||
| description="The description of the group.", default=None | ||
| ) | ||
|
|
||
| def to_builder(self) -> UserGroupBuilder: | ||
| return UserGroupBuilder(name=self.name, description=self.description) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # 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 maasapiserver.v3.api.public.models.responses.base import ( | ||
| BaseHal, | ||
| BaseHref, | ||
| HalResponse, | ||
| PaginatedResponse, | ||
| ) | ||
| from maasservicelayer.models.usergroups import UserGroup | ||
|
|
||
|
|
||
| class UserGroupResponse(HalResponse[BaseHal]): | ||
| kind = "UserGroup" | ||
| id: int | ||
| name: str | ||
| description: str | ||
|
|
||
| @classmethod | ||
| def from_model( | ||
| cls, usergroup: UserGroup, self_base_hyperlink: str | ||
| ) -> Self: | ||
| return cls( | ||
| id=usergroup.id, | ||
| name=usergroup.name, | ||
| description=usergroup.description, | ||
| hal_links=BaseHal( # pyright: ignore [reportCallIssue] | ||
| self=BaseHref( | ||
| href=f"{self_base_hyperlink.rstrip('/')}/{usergroup.id}" | ||
| ) | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| class UserGroupsListResponse(PaginatedResponse[UserGroupResponse]): | ||
| kind = "UserGroupsList" |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,8 +28,8 @@ import ( | |||||||
| ) | ||||||||
|
|
||||||||
| const ( | ||||||||
| administratorGroupName = "administrators" | ||||||||
| usersGroupName = "users" | ||||||||
| administratorGroupName = "Administrators" | ||||||||
| usersGroupName = "Users" | ||||||||
| ) | ||||||||
|
|
||||||||
| func init() { | ||||||||
|
|
@@ -114,6 +114,26 @@ func createPools(ctx context.Context, tx *sql.Tx) error { | |||||||
| func createGroup(ctx context.Context, tx *sql.Tx, groupName string, relations *[]string) error { | ||||||||
| builder := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) | ||||||||
|
|
||||||||
| selectStmt, selectArgs, err := builder. | ||||||||
| Select("id"). | ||||||||
| From("maasserver_usergroup"). | ||||||||
| Where(sq.Eq{"name": groupName}). | ||||||||
| ToSql() | ||||||||
| if err != nil { | ||||||||
| return err | ||||||||
| } | ||||||||
|
|
||||||||
| // Get the group ID if it already exists, otherwise panic. The maasserver migrations are supposed to be executed first, | ||||||||
| // so we can assume that the groups already exist. | ||||||||
| var groupID int64 | ||||||||
|
|
||||||||
| err = tx.QueryRowContext(ctx, selectStmt, selectArgs...).Scan(&groupID) | ||||||||
| if err != nil { | ||||||||
| if err == sql.ErrNoRows { | ||||||||
| return fmt.Errorf("group '%s' does not exist", groupName) | ||||||||
| } | ||||||||
|
||||||||
| } | |
| } | |
| return err |
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.
descriptiondefaults toNoneandto_builder()passes that through, but the DB column isNOT NULL(defaulting to empty string). A request that omitsdescription(or sendsnull) will attempt to insert/updateNULLand fail with an integrity error. Align this with other request models (e.g. Fabrics/Spaces) by defaulting to "" and/or coercingNoneto "" before building the service-layer builder.