From 713175250770258b66590190133e767108da3cd1 Mon Sep 17 00:00:00 2001 From: r00tabot Date: Mon, 2 Mar 2026 15:22:13 +0000 Subject: [PATCH] enjoy this from r00ta --- .../v3/api/public/handlers/__init__.py | 4 +- .../v3/api/public/handlers/usergroups.py | 202 +++++++++ .../api/public/models/requests/usergroups.py | 18 + .../api/public/models/responses/usergroups.py | 38 ++ src/maascommon/openfga/async_client.py | 54 +-- .../migrations/00002_migrate_environments.go | 26 +- src/maasserver/api/tests/test_usergroups.py | 395 ++++++++++++++++++ src/maasserver/api/usergroups.py | 151 +++++++ .../models/signals/tests/test_users.py | 13 +- src/maasserver/models/signals/users.py | 9 +- src/maasserver/testing/initial.maas_test.sql | 121 ++++-- src/maasserver/urls_api.py | 13 + .../builders/openfga_tuple.py | 52 +-- src/maasservicelayer/builders/usergroups.py | 22 + .../versions/0019_create_usergroup_table.py | 55 +++ src/maasservicelayer/db/repositories/base.py | 15 +- .../db/repositories/usergroups.py | 29 ++ src/maasservicelayer/db/tables.py | 15 + src/maasservicelayer/models/usergroups.py | 15 + src/maasservicelayer/services/__init__.py | 8 + src/maasservicelayer/services/base.py | 6 +- .../services/openfga_tuples.py | 23 + src/maasservicelayer/services/usergroups.py | 54 +++ src/tests/e2e/test_openfga_integration.py | 130 +++--- src/tests/fixtures/factories/usergroups.py | 27 ++ .../v3/api/public/handlers/test_usergroups.py | 257 ++++++++++++ .../maasservicelayer/db/repositories/base.py | 1 + .../db/repositories/test_base.py | 22 +- .../db/repositories/test_usergroups.py | 130 ++++++ src/tests/maasservicelayer/services/base.py | 12 +- .../services/test_usergroups.py | 242 +++++++++++ 31 files changed, 1989 insertions(+), 170 deletions(-) create mode 100644 src/maasapiserver/v3/api/public/handlers/usergroups.py create mode 100644 src/maasapiserver/v3/api/public/models/requests/usergroups.py create mode 100644 src/maasapiserver/v3/api/public/models/responses/usergroups.py create mode 100644 src/maasserver/api/tests/test_usergroups.py create mode 100644 src/maasserver/api/usergroups.py create mode 100644 src/maasservicelayer/builders/usergroups.py create mode 100644 src/maasservicelayer/db/alembic/versions/0019_create_usergroup_table.py create mode 100644 src/maasservicelayer/db/repositories/usergroups.py create mode 100644 src/maasservicelayer/models/usergroups.py create mode 100644 src/maasservicelayer/services/usergroups.py create mode 100644 src/tests/fixtures/factories/usergroups.py create mode 100644 src/tests/maasapiserver/v3/api/public/handlers/test_usergroups.py create mode 100644 src/tests/maasservicelayer/db/repositories/test_usergroups.py create mode 100644 src/tests/maasservicelayer/services/test_usergroups.py diff --git a/src/maasapiserver/v3/api/public/handlers/__init__.py b/src/maasapiserver/v3/api/public/handlers/__init__.py index bfc07b5d5e..4061fed16a 100644 --- a/src/maasapiserver/v3/api/public/handlers/__init__.py +++ b/src/maasapiserver/v3/api/public/handlers/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024-2025 Canonical Ltd. This software is licensed under the +# Copyright 2024-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from maasapiserver.common.api.base import API @@ -47,6 +47,7 @@ UISubnetsHandler, ) from maasapiserver.v3.api.public.handlers.tags import TagsHandler +from maasapiserver.v3.api.public.handlers.usergroups import UserGroupsHandler from maasapiserver.v3.api.public.handlers.users import UsersHandler from maasapiserver.v3.api.public.handlers.vlans import VlansHandler from maasapiserver.v3.api.public.handlers.zones import ZonesHandler @@ -81,6 +82,7 @@ SSLKeysHandler(), SubnetsHandler(), TagsHandler(), + UserGroupsHandler(), UsersHandler(), VlansHandler(), ZonesHandler(), diff --git a/src/maasapiserver/v3/api/public/handlers/usergroups.py b/src/maasapiserver/v3/api/public/handlers/usergroups.py new file mode 100644 index 0000000000..89cbf9981f --- /dev/null +++ b/src/maasapiserver/v3/api/public/handlers/usergroups.py @@ -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) diff --git a/src/maasapiserver/v3/api/public/models/requests/usergroups.py b/src/maasapiserver/v3/api/public/models/requests/usergroups.py new file mode 100644 index 0000000000..0f60e90f21 --- /dev/null +++ b/src/maasapiserver/v3/api/public/models/requests/usergroups.py @@ -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) diff --git a/src/maasapiserver/v3/api/public/models/responses/usergroups.py b/src/maasapiserver/v3/api/public/models/responses/usergroups.py new file mode 100644 index 0000000000..a962030d0e --- /dev/null +++ b/src/maasapiserver/v3/api/public/models/responses/usergroups.py @@ -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 Optional, 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: Optional[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" diff --git a/src/maascommon/openfga/async_client.py b/src/maascommon/openfga/async_client.py index 337b97b548..b8a16e0103 100644 --- a/src/maascommon/openfga/async_client.py +++ b/src/maascommon/openfga/async_client.py @@ -28,7 +28,7 @@ def _init_client(self) -> httpx.AsyncClient: async def close(self): await self.client.aclose() - async def _check(self, user_id: str, relation: str, obj: str) -> bool: + async def _check(self, user_id: int, relation: str, obj: str) -> bool: response = await self.client.post( f"/stores/{OPENFGA_STORE_ID}/check", json={ @@ -44,7 +44,7 @@ async def _check(self, user_id: str, relation: str, obj: str) -> bool: return response.json().get("allowed", False) async def _list_objects( - self, user_id: str, relation: str, obj_type: str + self, user_id: int, relation: str, obj_type: str ) -> list[int]: response = await self.client.post( f"/stores/{OPENFGA_STORE_ID}/list-objects", @@ -59,139 +59,139 @@ async def _list_objects( return self._parse_list_objects(response.json()) # Machine & Pool Permissions - async def can_edit_machines(self, user_id: str) -> bool: + async def can_edit_machines(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_machines", self.MAAS_GLOBAL_OBJ ) async def can_edit_machines_in_pool( - self, user_id: str, pool_id: int + self, user_id: int, pool_id: int ) -> bool: return await self._check( user_id, "can_edit_machines", self._format_pool(pool_id) ) async def can_deploy_machines_in_pool( - self, user_id: str, pool_id: int + self, user_id: int, pool_id: int ) -> bool: return await self._check( user_id, "can_deploy_machines", self._format_pool(pool_id) ) async def can_view_machines_in_pool( - self, user_id: str, pool_id: int + self, user_id: int, pool_id: int ) -> bool: return await self._check( user_id, "can_view_machines", self._format_pool(pool_id) ) async def can_view_available_machines_in_pool( - self, user_id: str, pool_id: int + self, user_id: int, pool_id: int ) -> bool: return await self._check( user_id, "can_view_available_machines", self._format_pool(pool_id) ) # Global Permissions - async def can_edit_global_entities(self, user_id: str) -> bool: + async def can_edit_global_entities(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_global_entities", self.MAAS_GLOBAL_OBJ ) - async def can_view_global_entities(self, user_id: str) -> bool: + async def can_view_global_entities(self, user_id: int) -> bool: return await self._check( user_id, "can_view_global_entities", self.MAAS_GLOBAL_OBJ ) - async def can_edit_controllers(self, user_id: str) -> bool: + async def can_edit_controllers(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_controllers", self.MAAS_GLOBAL_OBJ ) - async def can_view_controllers(self, user_id: str) -> bool: + async def can_view_controllers(self, user_id: int) -> bool: return await self._check( user_id, "can_view_controllers", self.MAAS_GLOBAL_OBJ ) - async def can_edit_identities(self, user_id: str) -> bool: + async def can_edit_identities(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_identities", self.MAAS_GLOBAL_OBJ ) - async def can_view_identities(self, user_id: str) -> bool: + async def can_view_identities(self, user_id: int) -> bool: return await self._check( user_id, "can_view_identities", self.MAAS_GLOBAL_OBJ ) - async def can_edit_configurations(self, user_id: str) -> bool: + async def can_edit_configurations(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_configurations", self.MAAS_GLOBAL_OBJ ) - async def can_view_configurations(self, user_id: str) -> bool: + async def can_view_configurations(self, user_id: int) -> bool: return await self._check( user_id, "can_view_configurations", self.MAAS_GLOBAL_OBJ ) - async def can_edit_notifications(self, user_id: str) -> bool: + async def can_edit_notifications(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_notifications", self.MAAS_GLOBAL_OBJ ) - async def can_view_notifications(self, user_id: str) -> bool: + async def can_view_notifications(self, user_id: int) -> bool: return await self._check( user_id, "can_view_notifications", self.MAAS_GLOBAL_OBJ ) - async def can_edit_boot_entities(self, user_id: str) -> bool: + async def can_edit_boot_entities(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_boot_entities", self.MAAS_GLOBAL_OBJ ) - async def can_view_boot_entities(self, user_id: str) -> bool: + async def can_view_boot_entities(self, user_id: int) -> bool: return await self._check( user_id, "can_view_boot_entities", self.MAAS_GLOBAL_OBJ ) - async def can_edit_license_keys(self, user_id: str) -> bool: + async def can_edit_license_keys(self, user_id: int) -> bool: return await self._check( user_id, "can_edit_license_keys", self.MAAS_GLOBAL_OBJ ) - async def can_view_license_keys(self, user_id: str) -> bool: + async def can_view_license_keys(self, user_id: int) -> bool: return await self._check( user_id, "can_view_license_keys", self.MAAS_GLOBAL_OBJ ) - async def can_view_devices(self, user_id: str) -> bool: + async def can_view_devices(self, user_id: int) -> bool: return await self._check( user_id, "can_view_devices", self.MAAS_GLOBAL_OBJ ) - async def can_view_ipaddresses(self, user_id: str) -> bool: + async def can_view_ipaddresses(self, user_id: int) -> bool: return await self._check( user_id, "can_view_ipaddresses", self.MAAS_GLOBAL_OBJ ) # List Methods async def list_pools_with_view_machines_access( - self, user_id: str + self, user_id: int ) -> list[int]: return await self._list_objects(user_id, "can_view_machines", "pool") async def list_pools_with_view_available_machines_access( - self, user_id: str + self, user_id: int ) -> list[int]: return await self._list_objects( user_id, "can_view_available_machines", "pool" ) async def list_pool_with_deploy_machines_access( - self, user_id: str + self, user_id: int ) -> list[int]: return await self._list_objects(user_id, "can_deploy_machines", "pool") async def list_pools_with_edit_machines_access( - self, user_id: str + self, user_id: int ) -> list[int]: return await self._list_objects(user_id, "can_edit_machines", "pool") diff --git a/src/maasopenfga/internal/migrations/00002_migrate_environments.go b/src/maasopenfga/internal/migrations/00002_migrate_environments.go index 453bed04c4..da436490c1 100644 --- a/src/maasopenfga/internal/migrations/00002_migrate_environments.go +++ b/src/maasopenfga/internal/migrations/00002_migrate_environments.go @@ -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) + } + } + for _, relation := range *relations { userGroupStmt, userGroupArgs, err := builder. Insert("openfga.tuple"). @@ -129,7 +149,7 @@ func createGroup(ctx context.Context, tx *sql.Tx, groupName string, relations *[ ). Values( storeID, - fmt.Sprintf("group:%s#member", groupName), + fmt.Sprintf("group:%d#member", groupID), "userset", relation, "maas", diff --git a/src/maasserver/api/tests/test_usergroups.py b/src/maasserver/api/tests/test_usergroups.py new file mode 100644 index 0000000000..2eadcb5702 --- /dev/null +++ b/src/maasserver/api/tests/test_usergroups.py @@ -0,0 +1,395 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for `UserGroup` API.""" + +import http.client +import json + +from django.conf import settings +from django.urls import reverse + +from maasserver.auth.tests.test_auth import OpenFGAMockMixin +from maasserver.testing.api import APITestCase + + +def _parse(response): + return json.loads(response.content.decode(settings.DEFAULT_CHARSET)) + + +def _create_group(client, name, description=None): + """Helper to create a group and return the parsed response dict.""" + data = {"name": name} + if description is not None: + data["description"] = description + resp = client.post(reverse("usergroups_handler"), data) + return _parse(resp), resp + + +class TestUserGroupsAPI(APITestCase.ForUser): + """Tests for the UserGroups collection endpoint.""" + + def test_handler_path(self): + self.assertEqual( + "/MAAS/api/2.0/groups/", reverse("usergroups_handler") + ) + + def test_list_default_groups(self): + self.become_admin() + response = self.client.get(reverse("usergroups_handler")) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.assertCountEqual( + [ + { + "description": "Default administrators group", + "id": 1, + "name": "Administrators", + "resource_uri": "/MAAS/api/2.0/groups/1/", + }, + { + "description": "Default users group", + "id": 2, + "name": "Users", + "resource_uri": "/MAAS/api/2.0/groups/2/", + }, + ], + _parse(response), + ) + + def test_list_returns_groups(self): + self.become_admin() + _create_group(self.client, "group-a", "Group A") + _create_group(self.client, "group-b", "Group B") + response = self.client.get(reverse("usergroups_handler")) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + parsed = _parse(response) + names = {g["name"] for g in parsed} + self.assertIn("group-a", names) + self.assertIn("group-b", names) + + def test_list_includes_resource_uri(self): + self.become_admin() + created, _ = _create_group(self.client, "uri-group") + response = self.client.get(reverse("usergroups_handler")) + parsed = _parse(response) + group = [g for g in parsed if g["name"] == "uri-group"][0] + self.assertEqual( + f"/MAAS/api/2.0/groups/{created['id']}/", + group["resource_uri"], + ) + + def test_create_requires_admin(self): + response = self.client.post( + reverse("usergroups_handler"), + {"name": "test-group"}, + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_create(self): + self.become_admin() + created, response = _create_group( + self.client, "new-group", "A new group" + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.assertEqual("new-group", created["name"]) + self.assertEqual("A new group", created["description"]) + self.assertIn("id", created) + self.assertIn("resource_uri", created) + + def test_create_without_description(self): + self.become_admin() + created, response = _create_group(self.client, "no-desc-group") + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.assertEqual("no-desc-group", created["name"]) + self.assertEqual("", created["description"]) + + def test_create_requires_name(self): + self.become_admin() + response = self.client.post( + reverse("usergroups_handler"), + {"description": "no name"}, + ) + self.assertEqual( + http.client.BAD_REQUEST, response.status_code, response.content + ) + + +class TestUserGroupAPI(APITestCase.ForUser): + """Tests for the UserGroup single-resource endpoint.""" + + def test_handler_path(self): + self.assertEqual( + "/MAAS/api/2.0/groups/1/", + reverse("usergroup_handler", args=[1]), + ) + + def test_read(self): + self.become_admin() + created, _ = _create_group(self.client, "read-group", "Readable") + group_id = created["id"] + + response = self.client.get( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + parsed = _parse(response) + self.assertEqual("read-group", parsed["name"]) + self.assertEqual("Readable", parsed["description"]) + self.assertEqual( + f"/MAAS/api/2.0/groups/{group_id}/", parsed["resource_uri"] + ) + + def test_read_404(self): + self.become_admin() + response = self.client.get(reverse("usergroup_handler", args=[99999])) + self.assertEqual( + http.client.NOT_FOUND, response.status_code, response.content + ) + + def test_update_requires_admin(self): + self.become_admin() + created, _ = _create_group(self.client, "update-group") + group_id = created["id"] + + self.user.is_superuser = False + self.user.save() + + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"name": "new-name"}, + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_update(self): + self.become_admin() + created, _ = _create_group(self.client, "old-name", "old desc") + group_id = created["id"] + + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"name": "new-name", "description": "new desc"}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + parsed = _parse(response) + self.assertEqual("new-name", parsed["name"]) + self.assertEqual("new desc", parsed["description"]) + + def test_update_name_only(self): + self.become_admin() + created, _ = _create_group(self.client, "partial-name", "keep this") + group_id = created["id"] + + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"name": "changed-name"}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + parsed = _parse(response) + self.assertEqual("changed-name", parsed["name"]) + self.assertEqual("keep this", parsed["description"]) + + def test_update_description_only(self): + self.become_admin() + created, _ = _create_group(self.client, "keep-name", "old") + group_id = created["id"] + + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"description": "updated"}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + parsed = _parse(response) + self.assertEqual("keep-name", parsed["name"]) + self.assertEqual("updated", parsed["description"]) + + def test_update_404(self): + self.become_admin() + response = self.client.put( + reverse("usergroup_handler", args=[99999]), + {"name": "nope"}, + ) + self.assertEqual( + http.client.NOT_FOUND, response.status_code, response.content + ) + + def test_delete_requires_admin(self): + self.become_admin() + created, _ = _create_group(self.client, "delete-group") + group_id = created["id"] + + self.user.is_superuser = False + self.user.save() + + response = self.client.delete( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_delete(self): + self.become_admin() + created, _ = _create_group(self.client, "to-delete") + group_id = created["id"] + + response = self.client.delete( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.content + ) + + # Verify it's gone + response = self.client.get( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.NOT_FOUND, response.status_code, response.content + ) + + def test_delete_404(self): + self.become_admin() + response = self.client.delete( + reverse("usergroup_handler", args=[99999]) + ) + self.assertEqual( + http.client.NOT_FOUND, response.status_code, response.content + ) + + +class TestUserGroupsOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def _create_group_as_admin(self, name, description="test"): + """Create a group using edit permission, then return its id.""" + self.openfga_client.can_edit_identities.return_value = True + created, resp = _create_group(self.client, name, description) + self.assertEqual(http.client.OK, resp.status_code, resp.content) + self.openfga_client.reset_mock() + return created["id"] + + def test_list_requires_can_view_identities(self): + self.openfga_client.can_view_identities.return_value = True + response = self.client.get(reverse("usergroups_handler")) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_view_identities.assert_called_once_with( + self.user + ) + + def test_list_denied_without_view_permission(self): + self.openfga_client.can_view_identities.return_value = False + response = self.client.get(reverse("usergroups_handler")) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_create_requires_can_edit_identities(self): + self.openfga_client.can_edit_identities.return_value = True + _, response = _create_group(self.client, "openfga-group", "test") + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_identities.assert_called_once_with( + self.user + ) + + def test_create_denied_without_edit_permission(self): + self.openfga_client.can_edit_identities.return_value = False + response = self.client.post( + reverse("usergroups_handler"), + {"name": "denied-group"}, + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_read_requires_can_view_identities(self): + group_id = self._create_group_as_admin("view-group") + self.openfga_client.can_view_identities.return_value = True + response = self.client.get( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_view_identities.assert_called_once_with( + self.user + ) + + def test_read_denied_without_view_permission(self): + group_id = self._create_group_as_admin("no-view-group") + self.openfga_client.can_view_identities.return_value = False + response = self.client.get( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_update_requires_can_edit_identities(self): + group_id = self._create_group_as_admin("edit-group") + self.openfga_client.can_edit_identities.return_value = True + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"name": "edited"}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_identities.assert_called_once_with( + self.user + ) + + def test_update_denied_without_edit_permission(self): + group_id = self._create_group_as_admin("no-edit-group") + self.openfga_client.can_edit_identities.return_value = False + response = self.client.put( + reverse("usergroup_handler", args=[group_id]), + {"name": "nope"}, + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + + def test_delete_requires_can_edit_identities(self): + group_id = self._create_group_as_admin("del-group") + self.openfga_client.can_edit_identities.return_value = True + response = self.client.delete( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.content + ) + self.openfga_client.can_edit_identities.assert_called_once_with( + self.user + ) + + def test_delete_denied_without_edit_permission(self): + group_id = self._create_group_as_admin("no-del-group") + self.openfga_client.can_edit_identities.return_value = False + response = self.client.delete( + reverse("usergroup_handler", args=[group_id]) + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) diff --git a/src/maasserver/api/usergroups.py b/src/maasserver/api/usergroups.py new file mode 100644 index 0000000000..f97ae30628 --- /dev/null +++ b/src/maasserver/api/usergroups.py @@ -0,0 +1,151 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""API handlers: `UserGroup`.""" + +from piston3.utils import rc + +from maasserver.api.support import check_permission, OperationsHandler +from maasserver.exceptions import MAASAPIBadRequest, MAASAPINotFound +from maasserver.sqlalchemy import service_layer +from maasservicelayer.builders.usergroups import UserGroupBuilder + +DISPLAYED_USERGROUP_FIELDS = ("id", "name", "description") + + +class UserGroupHandler(OperationsHandler): + """Manage a user group.""" + + api_doc_section_name = "UserGroup" + create = None + + @classmethod + def resource_uri(cls, usergroup=None): + group_id = "id" + if usergroup is not None: + group_id = ( + usergroup["id"] + if isinstance(usergroup, dict) + else usergroup.id + ) + return ("usergroup_handler", [group_id]) + + @check_permission("can_view_identities") + def read(self, request, id): + """@description Returns a user group. + @param (url-string) "{id}" [required=true] A group ID. + + @success (http-status-code) "server_success" 200 + @success (json) "content_success" A JSON object containing group + information. + + @error (http-status-code) "404" 404 + @error (content) "notfound" The group is not found. + """ + group = service_layer.services.usergroups.get_by_id(int(id)) + if group is None: + raise MAASAPINotFound(f"UserGroup with id {id} not found.") + return { + "id": group.id, + "name": group.name, + "description": group.description, + "resource_uri": f"/MAAS/api/2.0/groups/{group.id}/", + } + + @check_permission("can_edit_identities") + def update(self, request, id): + """@description Updates a user group. + @param (url-string) "{id}" [required=true] A group ID. + @param (string) "name" [required=false] The group name. + @param (string) "description" [required=false] The group description. + + @success (http-status-code) "server_success" 200 + + @error (http-status-code) "404" 404 + @error (content) "notfound" The group is not found. + """ + group = service_layer.services.usergroups.get_by_id(int(id)) + if group is None: + raise MAASAPINotFound(f"UserGroup with id {id} not found.") + + builder = UserGroupBuilder() + if "name" in request.data: + builder.name = request.data["name"] + if "description" in request.data: + builder.description = request.data["description"] + + updated = service_layer.services.usergroups.update_by_id( + int(id), builder + ) + return { + "id": updated.id, + "name": updated.name, + "description": updated.description, + "resource_uri": f"/MAAS/api/2.0/groups/{updated.id}/", + } + + @check_permission("can_edit_identities") + def delete(self, request, id): + """@description Deletes a user group. + @param (url-string) "{id}" [required=true] A group ID. + + @success (http-status-code) "server_success" 204 + """ + group = service_layer.services.usergroups.get_by_id(int(id)) + if group is None: + raise MAASAPINotFound(f"UserGroup with id {id} not found.") + service_layer.services.usergroups.delete_by_id(int(id)) + return rc.DELETED + + +class UserGroupsHandler(OperationsHandler): + """Manage user groups.""" + + api_doc_section_name = "UserGroups" + update = delete = None + + @classmethod + def resource_uri(cls, *args, **kwargs): + return ("usergroups_handler", []) + + @check_permission("can_edit_identities") + def create(self, request): + """@description Creates a new user group. + @param (string) "name" [required=true] The group name. + @param (string) "description" [required=false] The group description. + + @success (http-status-code) "server_success" 200 + + @error (http-status-code) "400" 400 + @error (content) "badrequest" Name is required. + """ + name = request.data.get("name") + if not name: + raise MAASAPIBadRequest("Name is required.") + description = request.data.get("description", "") + + builder = UserGroupBuilder(name=name, description=description) + group = service_layer.services.usergroups.create(builder) + return { + "id": group.id, + "name": group.name, + "description": group.description, + "resource_uri": f"/MAAS/api/2.0/groups/{group.id}/", + } + + @check_permission("can_view_identities") + def read(self, request): + """@description Lists all user groups. + + @success (http-status-code) "server_success" 200 + """ + result = service_layer.services.usergroups.list_all() + return [ + { + "id": group.id, + "name": group.name, + "description": group.description, + "resource_uri": f"/MAAS/api/2.0/groups/{group.id}/", + } + for group in result + ] diff --git a/src/maasserver/models/signals/tests/test_users.py b/src/maasserver/models/signals/tests/test_users.py index e9cf6c630f..1010d4e987 100644 --- a/src/maasserver/models/signals/tests/test_users.py +++ b/src/maasserver/models/signals/tests/test_users.py @@ -5,8 +5,11 @@ from django.db import connection +from maasserver.sqlalchemy import service_layer from maasserver.testing.factory import factory from maasserver.testing.testcase import MAASServerTestCase +from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.repositories.usergroups import UserGroupsClauseFactory class TestUserUsername(MAASServerTestCase): @@ -31,6 +34,14 @@ class TestPostSaveUserSignal(MAASServerTestCase): def test_save_creates_openfga_tuple(self): user = self.user_factory() + group = service_layer.services.usergroups.get_one( + QuerySpec( + where=UserGroupsClauseFactory.with_name( + "Administrators" if user.is_superuser else "Users" + ) + ) + ) + with connection.cursor() as cursor: cursor.execute( "SELECT object_type, object_id, relation FROM openfga.tuple WHERE _user = 'user:%s'", @@ -40,7 +51,7 @@ def test_save_creates_openfga_tuple(self): self.assertEqual("group", openfga_tuple[0]) self.assertEqual( - "administrators" if user.is_superuser else "users", + str(group.id), openfga_tuple[1], ) self.assertEqual("member", openfga_tuple[2]) diff --git a/src/maasserver/models/signals/users.py b/src/maasserver/models/signals/users.py index c34cdd562e..88f55c8568 100644 --- a/src/maasserver/models/signals/users.py +++ b/src/maasserver/models/signals/users.py @@ -9,7 +9,6 @@ from maasserver.models import Event from maasserver.sqlalchemy import service_layer from maasserver.utils.signals import SignalsManager -from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder signals = SignalsManager() @@ -27,11 +26,9 @@ def pre_delete_set_event_username(sender, instance, **kwargs): def post_created_user(sender, instance, created, **kwargs): if created: # Guarantee backwards compatibility and assign users to pre-defined groups (users/administrators) - service_layer.services.openfga_tuples.create( - OpenFGATupleBuilder.build_user_member_group( - instance.id, - "administrators" if instance.is_superuser else "users", - ) + service_layer.services.usergroups.add_user_to_group( + instance.id, + "Administrators" if instance.is_superuser else "Users", ) diff --git a/src/maasserver/testing/initial.maas_test.sql b/src/maasserver/testing/initial.maas_test.sql index 90d44e62f4..9244b65cae 100644 --- a/src/maasserver/testing/initial.maas_test.sql +++ b/src/maasserver/testing/initial.maas_test.sql @@ -7444,6 +7444,33 @@ CREATE VIEW public.maasserver_ui_subnet_view AS LEFT JOIN public.maasserver_space space ON ((vlan.space_id = space.id))); +-- +-- Name: maasserver_usergroup; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.maasserver_usergroup ( + id bigint NOT NULL, + created timestamp with time zone NOT NULL, + updated timestamp with time zone NOT NULL, + name character varying(256) NOT NULL, + description text DEFAULT ''::text NOT NULL +); + + +-- +-- Name: maasserver_usergroup_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.maasserver_usergroup ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.maasserver_usergroup_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + -- -- Name: maasserver_userprofile; Type: TABLE; Schema: public; Owner: - -- @@ -8480,7 +8507,7 @@ COPY openfga.assertion (store, authorization_model_id, assertions) FROM stdin; -- COPY openfga.authorization_model (store, authorization_model_id, type, type_definition, schema_version, serialized_protobuf) FROM stdin; -00000000000000000000000000 00000000000000000000000000 \N 1.1 \\x0a1a30303030303030303030303030303030303030303030303030301203312e311a060a04757365721a2b0a0567726f7570120c0a066d656d62657212020a001a140a120a066d656d62657212080a060a04757365721afe0d0a046d61617312360a1363616e5f766965775f6964656e746974696573121f221d0a020a000a171215121363616e5f656469745f6964656e746974696573121c0a1663616e5f656469745f626f6f745f656e74697469657312020a00121b0a1563616e5f656469745f6c6963656e73655f6b65797312020a0012160a1063616e5f766965775f6465766963657312020a0012320a1163616e5f766965775f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e6573121e0a1863616e5f656469745f676c6f62616c5f656e74697469657312020a0012380a1463616e5f766965775f636f6e74726f6c6c6572731220221e0a020a000a181216121463616e5f656469745f636f6e74726f6c6c657273121c0a1663616e5f656469745f6e6f74696669636174696f6e7312020a0012170a1163616e5f656469745f6d616368696e657312020a0012340a1363616e5f6465706c6f795f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e657312400a1863616e5f766965775f676c6f62616c5f656e746974696573122422220a020a000a1c121a121863616e5f656469745f676c6f62616c5f656e74697469657312190a1363616e5f656469745f6964656e74697469657312020a00123c0a1663616e5f766965775f6e6f74696669636174696f6e73122222200a020a000a1a1218121663616e5f656469745f6e6f74696669636174696f6e73123c0a1663616e5f766965775f626f6f745f656e746974696573122222200a020a000a1a1218121663616e5f656469745f626f6f745f656e746974696573123a0a1563616e5f766965775f6c6963656e73655f6b6579731221221f0a020a000a191217121563616e5f656469745f6c6963656e73655f6b657973121a0a1463616e5f766965775f697061646472657373657312020a0012530a1b63616e5f766965775f617661696c61626c655f6d616368696e6573123422320a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e6573121a0a1463616e5f656469745f636f6e74726f6c6c65727312020a00121d0a1763616e5f656469745f636f6e66696775726174696f6e7312020a00123e0a1763616e5f766965775f636f6e66696775726174696f6e73122322210a020a000a1b1219121763616e5f656469745f636f6e66696775726174696f6e731aee060a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f766965775f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f656469745f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f656469745f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f656469745f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a2a0a1563616e5f766965775f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f697061646472657373657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f766965775f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a280a1363616e5f656469745f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f766965775f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a2a0a1563616e5f656469745f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a250a1063616e5f766965775f6465766963657312110a0f0a0567726f757012066d656d6265721acc040a04706f6f6c120c0a06706172656e7412020a00123e0a1163616e5f656469745f6d616368696e6573122922270a020a000a211a1f0a081206706172656e741213121163616e5f656469745f6d616368696e657312590a1363616e5f6465706c6f795f6d616368696e6573124222400a020a000a151213121163616e5f656469745f6d616368696e65730a231a210a081206706172656e741215121363616e5f6465706c6f795f6d616368696e657312550a1163616e5f766965775f6d616368696e65731240223e0a020a000a151213121163616e5f656469745f6d616368696e65730a211a1f0a081206706172656e741213121163616e5f766965775f6d616368696e65731280010a1b63616e5f766965775f617661696c61626c655f6d616368696e65731261225f0a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e65730a2b1a290a081206706172656e74121d121b63616e5f766965775f617661696c61626c655f6d616368696e65731ac0010a120a06706172656e7412080a060a046d6161730a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d626572 +00000000000000000000000000 00000000000000000000000000 \N 1.1 \\x0a1a30303030303030303030303030303030303030303030303030301203312e311a060a04757365721a2b0a0567726f7570120c0a066d656d62657212020a001a140a120a066d656d62657212080a060a04757365721afe0d0a046d616173121b0a1563616e5f656469745f6c6963656e73655f6b65797312020a00121a0a1463616e5f656469745f636f6e74726f6c6c65727312020a0012380a1463616e5f766965775f636f6e74726f6c6c6572731220221e0a020a000a181216121463616e5f656469745f636f6e74726f6c6c657273121a0a1463616e5f766965775f697061646472657373657312020a0012530a1b63616e5f766965775f617661696c61626c655f6d616368696e6573123422320a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e657312190a1363616e5f656469745f6964656e74697469657312020a0012360a1363616e5f766965775f6964656e746974696573121f221d0a020a000a171215121363616e5f656469745f6964656e746974696573121c0a1663616e5f656469745f6e6f74696669636174696f6e7312020a00121c0a1663616e5f656469745f626f6f745f656e74697469657312020a00123c0a1663616e5f766965775f626f6f745f656e746974696573122222200a020a000a1a1218121663616e5f656469745f626f6f745f656e74697469657312340a1363616e5f6465706c6f795f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e657312320a1163616e5f766965775f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e6573121e0a1863616e5f656469745f676c6f62616c5f656e74697469657312020a0012400a1863616e5f766965775f676c6f62616c5f656e746974696573122422220a020a000a1c121a121863616e5f656469745f676c6f62616c5f656e746974696573121d0a1763616e5f656469745f636f6e66696775726174696f6e7312020a00123c0a1663616e5f766965775f6e6f74696669636174696f6e73122222200a020a000a1a1218121663616e5f656469745f6e6f74696669636174696f6e73123a0a1563616e5f766965775f6c6963656e73655f6b6579731221221f0a020a000a191217121563616e5f656469745f6c6963656e73655f6b65797312160a1063616e5f766965775f6465766963657312020a0012170a1163616e5f656469745f6d616368696e657312020a00123e0a1763616e5f766965775f636f6e66696775726174696f6e73122322210a020a000a1b1219121763616e5f656469745f636f6e66696775726174696f6e731aee060a2a0a1563616e5f656469745f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f697061646472657373657312110a0f0a0567726f757012066d656d6265720a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f656469745f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f766965775f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f656469745f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a2a0a1563616e5f766965775f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f766965775f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f766965775f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a250a1063616e5f766965775f6465766963657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f656469745f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f656469745f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265721acc040a04706f6f6c12550a1163616e5f766965775f6d616368696e65731240223e0a020a000a151213121163616e5f656469745f6d616368696e65730a211a1f0a081206706172656e741213121163616e5f766965775f6d616368696e65731280010a1b63616e5f766965775f617661696c61626c655f6d616368696e65731261225f0a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e65730a2b1a290a081206706172656e74121d121b63616e5f766965775f617661696c61626c655f6d616368696e6573120c0a06706172656e7412020a00123e0a1163616e5f656469745f6d616368696e6573122922270a020a000a211a1f0a081206706172656e741213121163616e5f656469745f6d616368696e657312590a1363616e5f6465706c6f795f6d616368696e6573124222400a020a000a151213121163616e5f656469745f6d616368696e65730a231a210a081206706172656e741215121363616e5f6465706c6f795f6d616368696e65731ac0010a120a06706172656e7412080a060a046d6161730a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d626572 \. @@ -8497,9 +8524,9 @@ COPY openfga.changelog (store, object_type, object_id, relation, _user, operatio -- COPY openfga.goose_app_db_version (id, version_id, is_applied, tstamp) FROM stdin; -1 0 t 2026-02-25 12:51:32.115213 -2 1 t 2026-02-25 12:51:32.117442 -3 2 t 2026-02-25 12:51:32.120864 +1 0 t 2026-02-27 12:48:13.027603 +2 1 t 2026-02-27 12:48:13.029035 +3 2 t 2026-02-27 12:48:13.031245 \. @@ -8508,13 +8535,13 @@ COPY openfga.goose_app_db_version (id, version_id, is_applied, tstamp) FROM stdi -- COPY openfga.goose_db_version (id, version_id, is_applied, tstamp) FROM stdin; -1 0 t 2026-02-25 12:51:32.082624 -2 1 t 2026-02-25 12:51:32.088433 -3 2 t 2026-02-25 12:51:32.09556 -4 3 t 2026-02-25 12:51:32.096702 -5 4 t 2026-02-25 12:51:32.097391 -6 5 t 2026-02-25 12:51:32.097902 -7 6 t 2026-02-25 12:51:32.099461 +1 0 t 2026-02-27 12:48:13.007115 +2 1 t 2026-02-27 12:48:13.009436 +3 2 t 2026-02-27 12:48:13.013147 +4 3 t 2026-02-27 12:48:13.013477 +5 4 t 2026-02-27 12:48:13.013961 +6 5 t 2026-02-27 12:48:13.01413 +7 6 t 2026-02-27 12:48:13.015471 \. @@ -8523,7 +8550,7 @@ COPY openfga.goose_db_version (id, version_id, is_applied, tstamp) FROM stdin; -- COPY openfga.store (id, name, created_at, updated_at, deleted_at) FROM stdin; -00000000000000000000000000 MAAS 2026-02-25 12:51:32.117442+00 2026-02-25 12:51:32.117442+00 \N +00000000000000000000000000 MAAS 2026-02-27 12:48:13.029035+00 2026-02-27 12:48:13.029035+00 \N \. @@ -8532,20 +8559,20 @@ COPY openfga.store (id, name, created_at, updated_at, deleted_at) FROM stdin; -- COPY openfga.tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at, condition_name, condition_context) FROM stdin; -00000000000000000000000000 pool 0 parent maas:0 user 01KJADNJ4SQYZSNWW30K6YGPZ1 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_machines group:administrators#member userset 01KJADNJ4SQYZSNWW30N6HNBM1 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_global_entities group:administrators#member userset 01KJADNJ4TZ5DWA3JMXJF7SNP6 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_controllers group:administrators#member userset 01KJADNJ4TZ5DWA3JMXKQZJNFV 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_identities group:administrators#member userset 01KJADNJ4TZ5DWA3JMXPGX4N7Z 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_configurations group:administrators#member userset 01KJADNJ4TZ5DWA3JMXR8T4GMZ 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_notifications group:administrators#member userset 01KJADNJ4TZ5DWA3JMXT1TCEC6 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_boot_entities group:administrators#member userset 01KJADNJ4TZ5DWA3JMXT3VP822 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_edit_license_keys group:administrators#member userset 01KJADNJ4TZ5DWA3JMXWR1MT1T 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_view_devices group:administrators#member userset 01KJADNJ4TZ5DWA3JMXYF4WPB6 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_view_ipaddresses group:administrators#member userset 01KJADNJ4TZ5DWA3JMY0FDHD3V 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_deploy_machines group:users#member userset 01KJADNJ4TZ5DWA3JMY3C31QDA 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_view_deployable_machines group:users#member userset 01KJADNJ4V2G8EET0FFS0RKY3T 2026-02-25 12:51:32.120864+00 \N \N -00000000000000000000000000 maas 0 can_view_global_entities group:users#member userset 01KJADNJ4V2G8EET0FFSY67QG0 2026-02-25 12:51:32.120864+00 \N \N +00000000000000000000000000 pool 0 parent maas:0 user 01KJFJ8XQ7DQSY256RVCWBF2RW 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_machines group:1#member userset 01KJFJ8XQ8ZGJASX7YX6HFGTQY 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_global_entities group:1#member userset 01KJFJ8XQ8ZGJASX7YX6PJ4Q3B 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_controllers group:1#member userset 01KJFJ8XQ8ZGJASX7YX9EN9KKQ 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_identities group:1#member userset 01KJFJ8XQ8ZGJASX7YXAKRRK0Y 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_configurations group:1#member userset 01KJFJ8XQ8ZGJASX7YXBJ1E1RC 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_notifications group:1#member userset 01KJFJ8XQ8ZGJASX7YXFG5FWWH 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_boot_entities group:1#member userset 01KJFJ8XQ8ZGJASX7YXHJZDXEQ 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_edit_license_keys group:1#member userset 01KJFJ8XQ8ZGJASX7YXMJ51J35 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_view_devices group:1#member userset 01KJFJ8XQ8ZGJASX7YXQC8SMD2 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_view_ipaddresses group:1#member userset 01KJFJ8XQ8ZGJASX7YXSVZXPTA 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_deploy_machines group:2#member userset 01KJFJ8XQ8ZGJASX7YXT5JZMYF 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_view_deployable_machines group:2#member userset 01KJFJ8XQ8ZGJASX7YXVA0RAFK 2026-02-27 12:48:13.031245+00 \N \N +00000000000000000000000000 maas 0 can_view_global_entities group:2#member userset 01KJFJ8XQ8ZGJASX7YXVH02BWV 2026-02-27 12:48:13.031245+00 \N \N \. @@ -8554,7 +8581,7 @@ COPY openfga.tuple (store, object_type, object_id, relation, _user, user_type, u -- COPY public.alembic_version (version_num) FROM stdin; -0018 +0019 \. @@ -9904,6 +9931,16 @@ COPY public.maasserver_template (id, created, updated, filename, default_version \. +-- +-- Data for Name: maasserver_usergroup; Type: TABLE DATA; Schema: public; Owner: - +-- + +COPY public.maasserver_usergroup (id, created, updated, name, description) FROM stdin; +1 2026-02-27 12:48:12.946997+00 2026-02-27 12:48:12.946997+00 Administrators Default administrators group +2 2026-02-27 12:48:12.946997+00 2026-02-27 12:48:12.946997+00 Users Default users group +\. + + -- -- Data for Name: maasserver_userprofile; Type: TABLE DATA; Schema: public; Owner: - -- @@ -10976,6 +11013,13 @@ SELECT pg_catalog.setval('public.maasserver_tag_id_seq', 1, false); SELECT pg_catalog.setval('public.maasserver_template_id_seq', 1, false); +-- +-- Name: maasserver_usergroup_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.maasserver_usergroup_id_seq', 2, true); + + -- -- Name: maasserver_userprofile_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - -- @@ -12518,6 +12562,22 @@ ALTER TABLE ONLY public.maasserver_template ADD CONSTRAINT maasserver_template_pkey PRIMARY KEY (id); +-- +-- Name: maasserver_usergroup maasserver_usergroup_name_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.maasserver_usergroup + ADD CONSTRAINT maasserver_usergroup_name_key UNIQUE (name); + + +-- +-- Name: maasserver_usergroup maasserver_usergroup_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.maasserver_usergroup + ADD CONSTRAINT maasserver_usergroup_pkey PRIMARY KEY (id); + + -- -- Name: maasserver_userprofile maasserver_userprofile_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -14165,6 +14225,13 @@ CREATE INDEX maasserver_template_filename_aba74d61_like ON public.maasserver_tem CREATE INDEX maasserver_template_version_id_78c8754e ON public.maasserver_template USING btree (version_id); +-- +-- Name: maasserver_usergroup_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX maasserver_usergroup_name_idx ON public.maasserver_usergroup USING btree (name); + + -- -- Name: maasserver_vaultsecret_path_4127e219_like; Type: INDEX; Schema: public; Owner: - -- diff --git a/src/maasserver/urls_api.py b/src/maasserver/urls_api.py index 5daa59ba59..71a6058e01 100644 --- a/src/maasserver/urls_api.py +++ b/src/maasserver/urls_api.py @@ -104,6 +104,7 @@ RestrictedResource, ) from maasserver.api.tags import TagHandler, TagsHandler +from maasserver.api.usergroups import UserGroupHandler, UserGroupsHandler from maasserver.api.users import UserHandler, UsersHandler from maasserver.api.version import VersionHandler from maasserver.api.virtualmachine import ( @@ -255,6 +256,12 @@ ) tag_handler = RestrictedResource(TagHandler, authentication=api_auth) tags_handler = RestrictedResource(TagsHandler, authentication=api_auth) +usergroup_handler = RestrictedResource( + UserGroupHandler, authentication=api_auth +) +usergroups_handler = RestrictedResource( + UserGroupsHandler, authentication=api_auth +) version_handler = OperationsResource(VersionHandler) # Allow anon. node_results_handler = RestrictedResource( NodeResultsHandler, authentication=api_auth @@ -587,6 +594,12 @@ def re_path(route, view, name=None, **kwargs): ), re_path(r"^tags/(?P[^/]+)/$", tag_handler, name="tag_handler"), re_path(r"^tags/$", tags_handler, name="tags_handler"), + re_path( + r"^groups/(?P[^/]+)/$", + usergroup_handler, + name="usergroup_handler", + ), + re_path(r"^groups/$", usergroups_handler, name="usergroups_handler"), re_path( r"^commissioning-results/$", node_results_handler, diff --git a/src/maasservicelayer/builders/openfga_tuple.py b/src/maasservicelayer/builders/openfga_tuple.py index 4a96f52dcd..3830847917 100644 --- a/src/maasservicelayer/builders/openfga_tuple.py +++ b/src/maasservicelayer/builders/openfga_tuple.py @@ -23,19 +23,19 @@ class OpenFGATupleBuilder(ResourceBuilder): @classmethod def build_user_member_group( - cls, user_id: str, group_id: str + cls, user_id: int, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"user:{user_id}", user_type="user", relation="member", - object_id=group_id, + object_id=str(group_id), object_type="group", ) @classmethod def build_group_can_edit_machines_in_pool( - cls, group_id: str, pool_id: str + cls, group_id: int, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -47,7 +47,7 @@ def build_group_can_edit_machines_in_pool( @classmethod def build_group_can_view_machines_in_pool( - cls, group_id: str, pool_id: str + cls, group_id: int, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -59,7 +59,7 @@ def build_group_can_view_machines_in_pool( @classmethod def build_group_can_view_available_machines_in_pool( - cls, group_id: str, pool_id: str + cls, group_id: int, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -71,7 +71,7 @@ def build_group_can_view_available_machines_in_pool( @classmethod def build_group_can_deploy_machines_in_pool( - cls, group_id: str, pool_id: str + cls, group_id: int, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -83,7 +83,7 @@ def build_group_can_deploy_machines_in_pool( @classmethod def build_group_can_edit_machines( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -95,7 +95,7 @@ def build_group_can_edit_machines( @classmethod def build_group_can_view_machines( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -107,7 +107,7 @@ def build_group_can_view_machines( @classmethod def build_group_can_view_available_machines( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -119,7 +119,7 @@ def build_group_can_view_available_machines( @classmethod def build_group_can_deploy_machines( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -131,7 +131,7 @@ def build_group_can_deploy_machines( @classmethod def build_group_can_view_global_entities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -143,7 +143,7 @@ def build_group_can_view_global_entities( @classmethod def build_group_can_edit_global_entities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -155,7 +155,7 @@ def build_group_can_edit_global_entities( @classmethod def build_group_can_edit_controllers( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -167,7 +167,7 @@ def build_group_can_edit_controllers( @classmethod def build_group_can_view_controllers( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -179,7 +179,7 @@ def build_group_can_view_controllers( @classmethod def build_group_can_view_identities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -191,7 +191,7 @@ def build_group_can_view_identities( @classmethod def build_group_can_edit_identities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -203,7 +203,7 @@ def build_group_can_edit_identities( @classmethod def build_group_can_view_boot_entities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -215,7 +215,7 @@ def build_group_can_view_boot_entities( @classmethod def build_group_can_edit_boot_entities( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -227,7 +227,7 @@ def build_group_can_edit_boot_entities( @classmethod def build_group_can_view_configurations( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -239,7 +239,7 @@ def build_group_can_view_configurations( @classmethod def build_group_can_edit_configurations( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -251,7 +251,7 @@ def build_group_can_edit_configurations( @classmethod def build_group_can_edit_notifications( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -263,7 +263,7 @@ def build_group_can_edit_notifications( @classmethod def build_group_can_view_notifications( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -275,7 +275,7 @@ def build_group_can_view_notifications( @classmethod def build_group_can_edit_license_keys( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -287,7 +287,7 @@ def build_group_can_edit_license_keys( @classmethod def build_group_can_view_license_keys( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -299,7 +299,7 @@ def build_group_can_view_license_keys( @classmethod def build_group_can_view_devices( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", @@ -311,7 +311,7 @@ def build_group_can_view_devices( @classmethod def build_group_can_view_ipaddresses( - cls, group_id: str + cls, group_id: int ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", diff --git a/src/maasservicelayer/builders/usergroups.py b/src/maasservicelayer/builders/usergroups.py new file mode 100644 index 0000000000..99215a5b86 --- /dev/null +++ b/src/maasservicelayer/builders/usergroups.py @@ -0,0 +1,22 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from datetime import datetime +from typing import Union + +from pydantic import Field + +from maasservicelayer.models.base import ResourceBuilder, UNSET, Unset + + +class UserGroupBuilder(ResourceBuilder): + """Autogenerated from utilities/generate_builders.py. + + You can still add your custom methods here, they won't be overwritten by + the generated code. + """ + + created: Union[datetime, Unset] = Field(default=UNSET, required=False) + description: Union[str, None, Unset] = Field(default=UNSET, required=False) + name: Union[str, Unset] = Field(default=UNSET, required=False) + updated: Union[datetime, Unset] = Field(default=UNSET, required=False) diff --git a/src/maasservicelayer/db/alembic/versions/0019_create_usergroup_table.py b/src/maasservicelayer/db/alembic/versions/0019_create_usergroup_table.py new file mode 100644 index 0000000000..875f6f8442 --- /dev/null +++ b/src/maasservicelayer/db/alembic/versions/0019_create_usergroup_table.py @@ -0,0 +1,55 @@ +"""create_usergroup_table + +Revision ID: 0019 +Revises: 0018 +Create Date: 2026-02-27 07:49:00.000000+00:00 + +""" + +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0019" +down_revision: str | None = "0018" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "maasserver_usergroup", + sa.Column( + "id", sa.BigInteger(), sa.Identity(always=False), nullable=False + ), + sa.Column("created", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated", sa.DateTime(timezone=True), nullable=False), + sa.Column("name", sa.String(length=256), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index( + "maasserver_usergroup_name_idx", + "maasserver_usergroup", + ["name"], + unique=True, + ) + + # Create the users/administrator group automatically. + op.execute(""" + INSERT INTO maasserver_usergroup (created, updated, name, description) + VALUES (now(), now(), 'Administrators', 'Default administrators group'); + """) + + op.execute(""" + INSERT INTO maasserver_usergroup (created, updated, name, description) + VALUES (now(), now(), 'Users', 'Default users group'); + """) + + +def downgrade() -> None: + # We do not support migration downgrade + pass diff --git a/src/maasservicelayer/db/repositories/base.py b/src/maasservicelayer/db/repositories/base.py index a1ad30b167..78801dfe17 100644 --- a/src/maasservicelayer/db/repositories/base.py +++ b/src/maasservicelayer/db/repositories/base.py @@ -1,5 +1,5 @@ -# Copyright 2024-2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). +# Copyright 2024-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). from abc import ABC, abstractmethod from collections.abc import Iterable @@ -235,6 +235,17 @@ async def list( total=total, ) + async def list_all(self, query: QuerySpec | None = None) -> List[T]: + # Please, prefer not to use this method. It's here just as a utility for v2 endpoints that need to use the service layer. + stmt = self.select_all_statement().order_by( + desc(self.get_repository_table().c.id) + ) + if query: + stmt = query.enrich_stmt(stmt) + + result = (await self.execute_stmt(stmt)).all() + return [self.get_model_factory()(**row._asdict()) for row in result] + class BaseRepository(ReadOnlyRepository[T], Generic[T]): def __init__(self, context: Context): diff --git a/src/maasservicelayer/db/repositories/usergroups.py b/src/maasservicelayer/db/repositories/usergroups.py new file mode 100644 index 0000000000..d66211f79e --- /dev/null +++ b/src/maasservicelayer/db/repositories/usergroups.py @@ -0,0 +1,29 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from typing import Type + +from sqlalchemy import Table + +from maasservicelayer.db.filters import Clause, ClauseFactory +from maasservicelayer.db.repositories.base import BaseRepository +from maasservicelayer.db.tables import UserGroupTable +from maasservicelayer.models.usergroups import UserGroup + + +class UserGroupsClauseFactory(ClauseFactory): + @classmethod + def with_ids(cls, ids: list[int]) -> Clause: + return Clause(condition=UserGroupTable.c.id.in_(ids)) + + @classmethod + def with_name(cls, name: str) -> Clause: + return Clause(condition=UserGroupTable.c.name == name) + + +class UserGroupsRepository(BaseRepository[UserGroup]): + def get_repository_table(self) -> Table: + return UserGroupTable + + def get_model_factory(self) -> Type[UserGroup]: + return UserGroup diff --git a/src/maasservicelayer/db/tables.py b/src/maasservicelayer/db/tables.py index 143902df86..1992b439c2 100644 --- a/src/maasservicelayer/db/tables.py +++ b/src/maasservicelayer/db/tables.py @@ -2338,6 +2338,21 @@ Index("piston3_token_consumer_id_b178993d", "consumer_id"), ) +UserGroupTable = Table( + "maasserver_usergroup", + METADATA, + Column("id", BigInteger, Identity(), primary_key=True), + Column("name", String(256), nullable=False, unique=True), + Column("description", Text, nullable=False), + Column("created", DateTime(timezone=True), nullable=False), + Column("updated", DateTime(timezone=True), nullable=False), + Index( + "maasserver_usergroup_name_idx", + "name", + unique=True, + ), +) + UserProfileTable = Table( "maasserver_userprofile", METADATA, diff --git a/src/maasservicelayer/models/usergroups.py b/src/maasservicelayer/models/usergroups.py new file mode 100644 index 0000000000..8a1c56029d --- /dev/null +++ b/src/maasservicelayer/models/usergroups.py @@ -0,0 +1,15 @@ +# 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 maasservicelayer.models.base import ( + generate_builder, + MaasTimestampedBaseModel, +) + + +@generate_builder() +class UserGroup(MaasTimestampedBaseModel): + name: str + description: Optional[str] diff --git a/src/maasservicelayer/services/__init__.py b/src/maasservicelayer/services/__init__.py index 78fd0c1da7..394bc7ca46 100644 --- a/src/maasservicelayer/services/__init__.py +++ b/src/maasservicelayer/services/__init__.py @@ -111,6 +111,7 @@ TokensRepository, ) from maasservicelayer.db.repositories.ui_subnets import UISubnetsRepository +from maasservicelayer.db.repositories.usergroups import UserGroupsRepository from maasservicelayer.db.repositories.users import UsersRepository from maasservicelayer.db.repositories.vlans import VlansRepository from maasservicelayer.db.repositories.vmcluster import VmClustersRepository @@ -207,6 +208,7 @@ TokensService, ) from maasservicelayer.services.ui_subnets import UISubnetsService +from maasservicelayer.services.usergroups import UserGroupsService from maasservicelayer.services.users import UsersService from maasservicelayer.services.vlans import VlansService from maasservicelayer.services.vmcluster import VmClustersService @@ -304,6 +306,7 @@ class ServiceCollectionV3: tags: TagsService temporal: TemporalService tokens: TokensService + usergroups: UserGroupsService users: UsersService v3dnsrrsets: V3DNSResourceRecordSetsService v3subnet_utilization: V3SubnetUtilizationService @@ -488,6 +491,11 @@ async def produce( resource_pools_repository=ResourcePoolRepository(context), openfga_tuples_service=services.openfga_tuples, ) + services.usergroups = UserGroupsService( + context=context, + usergroups_repository=UserGroupsRepository(context), + openfga_tuples_service=services.openfga_tuples, + ) services.machines = MachinesService( context=context, secrets_service=services.secrets, diff --git a/src/maasservicelayer/services/base.py b/src/maasservicelayer/services/base.py index 1d38709628..e435574b5e 100644 --- a/src/maasservicelayer/services/base.py +++ b/src/maasservicelayer/services/base.py @@ -1,4 +1,4 @@ -# Copyright 2023-2025 Canonical Ltd. This software is licensed under the +# Copyright 2023-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from abc import ABC @@ -141,6 +141,10 @@ async def list( ) -> ListResult[M]: return await self.repository.list(page=page, size=size, query=query) + async def list_all(self, query: QuerySpec | None = None) -> List[M]: + # Please, prefer not to use this method. It's here just as a utility for v2 endpoints that need to use the service layer. + return await self.repository.list_all(query=query) + class BaseService(ReadOnlyService[M, BR], ABC, Generic[M, BR, B]): """The base class for all the services that have a `BaseRepository`. diff --git a/src/maasservicelayer/services/openfga_tuples.py b/src/maasservicelayer/services/openfga_tuples.py index 79b4507f81..520b26b8c1 100644 --- a/src/maasservicelayer/services/openfga_tuples.py +++ b/src/maasservicelayer/services/openfga_tuples.py @@ -70,3 +70,26 @@ async def delete_user(self, user_id: int) -> None: ) ) await self.delete_many(query) + + async def delete_group(self, group_id: int) -> None: + # Delete users who are members of this group AND entitlement tuples associated with this group + membership_query = QuerySpec( + where=OpenFGATuplesClauseFactory.or_clauses( + [ + OpenFGATuplesClauseFactory.and_clauses( + [ + OpenFGATuplesClauseFactory.with_object_type( + "group" + ), + OpenFGATuplesClauseFactory.with_object_id( + str(group_id) + ), + ] + ), + OpenFGATuplesClauseFactory.with_user( + f"group:{group_id}#member" + ), + ] + ) + ) + await self.delete_many(membership_query) diff --git a/src/maasservicelayer/services/usergroups.py b/src/maasservicelayer/services/usergroups.py new file mode 100644 index 0000000000..855302c73b --- /dev/null +++ b/src/maasservicelayer/services/usergroups.py @@ -0,0 +1,54 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from typing import List + +from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder +from maasservicelayer.builders.usergroups import UserGroupBuilder +from maasservicelayer.context import Context +from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.repositories.usergroups import ( + UserGroupsClauseFactory, + UserGroupsRepository, +) +from maasservicelayer.models.usergroups import UserGroup +from maasservicelayer.services.base import BaseService +from maasservicelayer.services.openfga_tuples import OpenFGATupleService + + +class UserGroupNotFound(Exception): + """Raised when a user group is not found in the database.""" + + +class UserGroupsService( + BaseService[UserGroup, UserGroupsRepository, UserGroupBuilder] +): + resource_logging_name = "usergroup" + + def __init__( + self, + context: Context, + usergroups_repository: UserGroupsRepository, + openfga_tuples_service: OpenFGATupleService, + ): + super().__init__(context, usergroups_repository) + self.openfga_tuples_service = openfga_tuples_service + + async def add_user_to_group(self, user_id: int, group_name: str): + group = await self.get_one( + QuerySpec(where=UserGroupsClauseFactory.with_name(group_name)) + ) + if group is None: + raise UserGroupNotFound() + + await self.openfga_tuples_service.create( + OpenFGATupleBuilder.build_user_member_group(user_id, group.id) + ) + + async def post_delete_hook(self, resource: UserGroup) -> None: + await self.openfga_tuples_service.delete_group(resource.id) + + async def post_delete_many_hook(self, resources: List[UserGroup]) -> None: + raise NotImplementedError( + "Deleting multiple user groups is not supported." + ) diff --git a/src/tests/e2e/test_openfga_integration.py b/src/tests/e2e/test_openfga_integration.py index fda9149187..c7d7251423 100644 --- a/src/tests/e2e/test_openfga_integration.py +++ b/src/tests/e2e/test_openfga_integration.py @@ -28,99 +28,95 @@ async def test_get( OpenFGATupleBuilder.build_pool(str(i)) ) - # team A can edit and view everything + # group 1000 can edit and view everything await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_machines(group_id="teamA") + OpenFGATupleBuilder.build_group_can_edit_machines(group_id=1000) ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_global_entities( - group_id="teamA" + group_id=1000 ) ) await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_controllers( - group_id="teamA" - ) + OpenFGATupleBuilder.build_group_can_edit_controllers(group_id=1000) ) await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_identities( - group_id="teamA" - ) + OpenFGATupleBuilder.build_group_can_edit_identities(group_id=1000) ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_configurations( - group_id="teamA" + group_id=1000 ) ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_boot_entities( - group_id="teamA" + group_id=1000 ) ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_notifications( - group_id="teamA" + group_id=1000 ) ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_license_keys( - group_id="teamA" + group_id=1000 ) ) await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_view_devices(group_id="teamA") + OpenFGATupleBuilder.build_group_can_view_devices(group_id=1000) ) - # alice belongs to group team A + # user 1000 belongs to group 1000 await services.openfga_tuples.create( OpenFGATupleBuilder.build_user_member_group( - user_id="alice", group_id="teamA" + user_id=1000, group_id=1000 ) ) # team B can_edit_machines and can_view_machines in pool:0 await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_machines_in_pool( - group_id="teamB", pool_id="0" + group_id=2000, pool_id="0" ) ) - # bob belongs to group team B + # user 2000 belongs to group 2000 await services.openfga_tuples.create( OpenFGATupleBuilder.build_user_member_group( - user_id="bob", group_id="teamB" + user_id=2000, group_id=2000 ) ) - # team C can_view_machines in pool:0 + # group 3000 can_view_machines in pool:0 await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_deploy_machines_in_pool( - group_id="teamC", pool_id="0" + group_id=3000, pool_id="0" ) ) - # carl belongs to group team C + # user 3000 belongs to group 3000 await services.openfga_tuples.create( OpenFGATupleBuilder.build_user_member_group( - user_id="carl", group_id="teamC" + user_id=3000, group_id=3000 ) ) - # team D can_view_machines in pool:0 + # team 4000 can_view_machines in pool:0 await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_view_available_machines_in_pool( - group_id="teamD", pool_id="0" + group_id=4000, pool_id="0" ) ) - # carl belongs to group team C + # user 4000 belongs to group 4000 await services.openfga_tuples.create( OpenFGATupleBuilder.build_user_member_group( - user_id="dingo", group_id="teamD" + user_id=4000, group_id=4000 ) ) await db_connection.commit() client = OpenFGAClient(str(openfga_socket_path)) - # alice should have all permissions on pool1 because of teamA's system rights + # user 1000 should have all permissions on pool1 because of group 1000's system rights for i in range(0, 3): assert ( await client.can_edit_machines_in_pool( @@ -160,97 +156,87 @@ async def test_get( assert (await client.can_edit_license_keys(user_id="alice")) is True assert (await client.can_view_devices(user_id="alice")) is True - # bob should just have edit,view and deploy permissions on pool1 because of teamB's rights + # user 2000 should just have edit,view and deploy permissions on pool1 because of group 2000's rights assert ( - await client.can_edit_machines_in_pool(user_id="bob", pool_id="0") + await client.can_edit_machines_in_pool(user_id=2000, pool_id="0") ) is True assert ( - await client.can_view_machines_in_pool(user_id="bob", pool_id="0") + await client.can_view_machines_in_pool(user_id=2000, pool_id="0") ) is True assert ( await client.can_view_available_machines_in_pool( - user_id="bob", pool_id="0" + user_id=2000, pool_id="0" ) ) is True assert ( - await client.can_deploy_machines_in_pool( - user_id="bob", pool_id="0" - ) + await client.can_deploy_machines_in_pool(user_id=2000, pool_id="0") ) is True for i in range(1, 3): assert ( await client.can_edit_machines_in_pool( - user_id="bob", pool_id=str(i) + user_id=2000, pool_id=str(i) ) ) is False assert ( await client.can_view_machines_in_pool( - user_id="bob", pool_id=str(i) + user_id=2000, pool_id=str(i) ) ) is False assert ( await client.can_view_available_machines_in_pool( - user_id="bob", pool_id=str(i) + user_id=2000, pool_id=str(i) ) ) is False assert ( await client.can_deploy_machines_in_pool( - user_id="bob", pool_id=str(i) + user_id=2000, pool_id=str(i) ) ) is False - assert (await client.can_edit_machines(user_id="bob")) is False - assert (await client.can_view_global_entities(user_id="bob")) is False - assert (await client.can_edit_global_entities(user_id="bob")) is False - assert (await client.can_edit_identities(user_id="bob")) is False - assert (await client.can_view_identities(user_id="bob")) is False - assert (await client.can_edit_configurations(user_id="bob")) is False - assert (await client.can_view_configurations(user_id="bob")) is False - assert (await client.can_view_notifications(user_id="bob")) is False - assert (await client.can_edit_notifications(user_id="bob")) is False - assert (await client.can_view_boot_entities(user_id="bob")) is False - assert (await client.can_edit_boot_entities(user_id="bob")) is False - assert (await client.can_view_license_keys(user_id="bob")) is False - assert (await client.can_edit_license_keys(user_id="bob")) is False - assert (await client.can_view_devices(user_id="bob")) is False - - # carl should just have deploy permissions on pool0 because of teamC's rights + assert (await client.can_edit_machines(user_id=2000)) is False + assert (await client.can_view_global_entities(user_id=2000)) is False + assert (await client.can_edit_global_entities(user_id=2000)) is False + assert (await client.can_edit_identities(user_id=2000)) is False + assert (await client.can_view_identities(user_id=2000)) is False + assert (await client.can_edit_configurations(user_id=2000)) is False + assert (await client.can_view_configurations(user_id=2000)) is False + assert (await client.can_view_notifications(user_id=2000)) is False + assert (await client.can_edit_notifications(user_id=2000)) is False + assert (await client.can_view_boot_entities(user_id=2000)) is False + assert (await client.can_edit_boot_entities(user_id=2000)) is False + assert (await client.can_view_license_keys(user_id=2000)) is False + assert (await client.can_edit_license_keys(user_id=2000)) is False + assert (await client.can_view_devices(user_id=2000)) is False + + # user 3000 should just have deploy permissions on pool0 because of group 3000's rights assert ( - await client.can_edit_machines_in_pool(user_id="carl", pool_id="0") + await client.can_edit_machines_in_pool(user_id=3000, pool_id="0") ) is False assert ( - await client.can_view_machines_in_pool(user_id="carl", pool_id="0") + await client.can_view_machines_in_pool(user_id=3000, pool_id="0") ) is False assert ( await client.can_view_available_machines_in_pool( - user_id="carl", pool_id="0" + user_id=3000, pool_id="0" ) ) is False assert ( - await client.can_deploy_machines_in_pool( - user_id="carl", pool_id="0" - ) + await client.can_deploy_machines_in_pool(user_id=3000, pool_id="0") ) is True - # dingo should just view permissions on pool0 because of teamD's rights + # user 4000 should just view permissions on pool0 because of group 4000's rights assert ( - await client.can_edit_machines_in_pool( - user_id="dingo", pool_id="0" - ) + await client.can_edit_machines_in_pool(user_id=4000, pool_id="0") ) is False assert ( - await client.can_view_machines_in_pool( - user_id="dingo", pool_id="0" - ) + await client.can_view_machines_in_pool(user_id=4000, pool_id="0") ) is False assert ( await client.can_view_available_machines_in_pool( - user_id="dingo", pool_id="0" + user_id=4000, pool_id="0" ) ) is True assert ( - await client.can_deploy_machines_in_pool( - user_id="dingo", pool_id="0" - ) + await client.can_deploy_machines_in_pool(user_id=4000, pool_id="0") ) is False diff --git a/src/tests/fixtures/factories/usergroups.py b/src/tests/fixtures/factories/usergroups.py new file mode 100644 index 0000000000..ee8dc87f26 --- /dev/null +++ b/src/tests/fixtures/factories/usergroups.py @@ -0,0 +1,27 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from datetime import datetime, timezone +from typing import Any + +from maasservicelayer.models.usergroups import UserGroup +from tests.maasapiserver.fixtures.db import Fixture + + +async def create_test_usergroup( + fixture: Fixture, **extra_details: Any +) -> UserGroup: + created_at = datetime.now(timezone.utc).astimezone() + updated_at = datetime.now(timezone.utc).astimezone() + usergroup = { + "name": "my_group", + "description": "", + "created": created_at, + "updated": updated_at, + } + usergroup.update(extra_details) + [created_group] = await fixture.create( + "maasserver_usergroup", + [usergroup], + ) + return UserGroup(**created_group) diff --git a/src/tests/maasapiserver/v3/api/public/handlers/test_usergroups.py b/src/tests/maasapiserver/v3/api/public/handlers/test_usergroups.py new file mode 100644 index 0000000000..20d9637e56 --- /dev/null +++ b/src/tests/maasapiserver/v3/api/public/handlers/test_usergroups.py @@ -0,0 +1,257 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from unittest.mock import Mock + +from fastapi.encoders import jsonable_encoder +from httpx import AsyncClient +import pytest + +from maasapiserver.common.api.models.responses.errors import ErrorBodyResponse +from maasapiserver.v3.api.public.models.requests.usergroups import ( + UserGroupRequest, +) +from maasapiserver.v3.api.public.models.responses.usergroups import ( + UserGroupResponse, + UserGroupsListResponse, +) +from maasapiserver.v3.constants import V3_API_PREFIX +from maasservicelayer.exceptions.catalog import ( + AlreadyExistsException, + BaseExceptionDetail, + PreconditionFailedException, +) +from maasservicelayer.exceptions.constants import ( + ETAG_PRECONDITION_VIOLATION_TYPE, + UNIQUE_CONSTRAINT_VIOLATION_TYPE, +) +from maasservicelayer.models.base import ListResult +from maasservicelayer.models.usergroups import UserGroup +from maasservicelayer.services import ServiceCollectionV3 +from maasservicelayer.services.usergroups import UserGroupsService +from maasservicelayer.utils.date import utcnow +from tests.maasapiserver.v3.api.public.handlers.base import ( + ApiCommonTests, + Endpoint, +) + +TEST_GROUP = UserGroup( + id=1, + name="test_group", + description="test_description", + created=utcnow(), + updated=utcnow(), +) + + +class TestUserGroupsApi(ApiCommonTests): + BASE_PATH = f"{V3_API_PREFIX}/groups" + + @pytest.fixture + def user_endpoints(self) -> list[Endpoint]: + return [ + Endpoint(method="GET", path=f"{self.BASE_PATH}"), + Endpoint(method="GET", path=f"{self.BASE_PATH}/1"), + ] + + @pytest.fixture + def admin_endpoints(self) -> list[Endpoint]: + return [ + Endpoint(method="POST", path=f"{self.BASE_PATH}"), + Endpoint(method="PUT", path=f"{self.BASE_PATH}/1"), + Endpoint(method="DELETE", path=f"{self.BASE_PATH}/1"), + ] + + # GET /groups + async def test_list_other_page( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_user: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.list.return_value = ListResult[UserGroup]( + items=[TEST_GROUP], total=2 + ) + response = await mocked_api_client_user.get(f"{self.BASE_PATH}?size=1") + assert response.status_code == 200 + groups_response = UserGroupsListResponse(**response.json()) + assert len(groups_response.items) == 1 + assert groups_response.total == 2 + assert groups_response.next == f"{self.BASE_PATH}?page=2&size=1" + + async def test_list_no_other_page( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_user: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.list.return_value = ListResult[UserGroup]( + items=[TEST_GROUP], total=1 + ) + response = await mocked_api_client_user.get(f"{self.BASE_PATH}?size=1") + assert response.status_code == 200 + groups_response = UserGroupsListResponse(**response.json()) + assert len(groups_response.items) == 1 + assert groups_response.total == 1 + assert groups_response.next is None + + # GET /groups/{group_id} + async def test_get( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_user: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.get_by_id.return_value = TEST_GROUP + response = await mocked_api_client_user.get( + f"{self.BASE_PATH}/{TEST_GROUP.id}" + ) + assert response.status_code == 200 + assert len(response.headers["ETag"]) > 0 + group_response = UserGroupResponse(**response.json()) + assert group_response.id == TEST_GROUP.id + assert group_response.name == TEST_GROUP.name + + async def test_get_404( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_user: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.get_by_id.return_value = None + response = await mocked_api_client_user.get(f"{self.BASE_PATH}/100") + assert response.status_code == 404 + error_response = ErrorBodyResponse(**response.json()) + assert error_response.kind == "Error" + assert error_response.code == 404 + + # POST /groups + async def test_post_201( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + group_request = UserGroupRequest( + name=TEST_GROUP.name, description=TEST_GROUP.description + ) + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.create.return_value = TEST_GROUP + response = await mocked_api_client_admin.post( + self.BASE_PATH, json=jsonable_encoder(group_request) + ) + assert response.status_code == 201 + assert len(response.headers["ETag"]) > 0 + group_response = UserGroupResponse(**response.json()) + assert group_response.name == group_request.name + assert group_response.description == group_request.description + assert ( + group_response.hal_links.self.href + == f"{self.BASE_PATH}/{group_response.id}" + ) + + async def test_post_409( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + group_request = UserGroupRequest(name="duplicate_group") + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.create.side_effect = AlreadyExistsException( + details=[ + BaseExceptionDetail( + type=UNIQUE_CONSTRAINT_VIOLATION_TYPE, + message="A resource with such identifiers already exist.", + ) + ] + ) + response = await mocked_api_client_admin.post( + self.BASE_PATH, json=jsonable_encoder(group_request) + ) + assert response.status_code == 409 + error_response = ErrorBodyResponse(**response.json()) + assert error_response.kind == "Error" + assert error_response.code == 409 + + # PUT /groups/{group_id} + async def test_put( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + updated_group = UserGroup( + id=TEST_GROUP.id, + name="new_name", + description="new_description", + created=utcnow(), + updated=utcnow(), + ) + update_request = UserGroupRequest( + name="new_name", description="new_description" + ) + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.update_by_id.return_value = updated_group + + response = await mocked_api_client_admin.put( + f"{self.BASE_PATH}/{TEST_GROUP.id}", + json=jsonable_encoder(update_request), + ) + assert response.status_code == 200 + assert len(response.headers["ETag"]) > 0 + group_response = UserGroupResponse(**response.json()) + assert group_response.name == "new_name" + assert group_response.description == "new_description" + + async def test_put_404( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.update_by_id.return_value = None + update_request = UserGroupRequest(name="new_name") + response = await mocked_api_client_admin.put( + f"{self.BASE_PATH}/99", + json=jsonable_encoder(update_request), + ) + assert response.status_code == 404 + + # DELETE /groups/{group_id} + async def test_delete( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.delete_by_id.side_effect = None + response = await mocked_api_client_admin.delete(f"{self.BASE_PATH}/1") + assert response.status_code == 204 + + async def test_delete_with_etag( + self, + services_mock: ServiceCollectionV3, + mocked_api_client_admin: AsyncClient, + ) -> None: + services_mock.usergroups = Mock(UserGroupsService) + services_mock.usergroups.delete_by_id.side_effect = [ + PreconditionFailedException( + details=[ + BaseExceptionDetail( + type=ETAG_PRECONDITION_VIOLATION_TYPE, + message="The resource etag 'wrong' did not match 'correct'.", + ) + ] + ), + None, + ] + + failed_response = await mocked_api_client_admin.delete( + f"{self.BASE_PATH}/1", + headers={"if-match": "wrong"}, + ) + assert failed_response.status_code == 412 + + response = await mocked_api_client_admin.delete( + f"{self.BASE_PATH}/1", + headers={"if-match": "correct"}, + ) + assert response.status_code == 204 diff --git a/src/tests/maasservicelayer/db/repositories/base.py b/src/tests/maasservicelayer/db/repositories/base.py index 7d2eef9d4c..147e6855a4 100644 --- a/src/tests/maasservicelayer/db/repositories/base.py +++ b/src/tests/maasservicelayer/db/repositories/base.py @@ -300,6 +300,7 @@ async def test_delete_many( deleted_resources = await repository_instance.delete_many( query=QuerySpec() ) + print(deleted_resources) assert len(deleted_resources) == num_objects resources = await repository_instance.get_many(query=QuerySpec()) assert len(resources) == 0 diff --git a/src/tests/maasservicelayer/db/repositories/test_base.py b/src/tests/maasservicelayer/db/repositories/test_base.py index 47ec99efec..282f35380f 100644 --- a/src/tests/maasservicelayer/db/repositories/test_base.py +++ b/src/tests/maasservicelayer/db/repositories/test_base.py @@ -1,5 +1,5 @@ -# Copyright 2024-2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). +# Copyright 2024-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). from datetime import datetime from operator import eq @@ -336,3 +336,21 @@ async def test_delete_many(self, db_connection: AsyncConnection) -> None: repo = MyRepository(Context(connection=db_connection)) deleted_resources = await repo.delete_many(QuerySpec()) assert len(deleted_resources) == 2 + + async def test_list_all(self, db_connection: AsyncConnection) -> None: + repo = MyRepository(Context(connection=db_connection)) + resources = await repo.list_all() + assert len(resources) == 2 + + async def test_list_all_with_query( + self, db_connection: AsyncConnection + ) -> None: + repo = MyRepository(Context(connection=db_connection)) + resources = await repo.list_all( + query=QuerySpec( + Clause( + condition=eq(A.c.data, "foo"), + ) + ) + ) + assert len(resources) == 1 diff --git a/src/tests/maasservicelayer/db/repositories/test_usergroups.py b/src/tests/maasservicelayer/db/repositories/test_usergroups.py new file mode 100644 index 0000000000..1da6768ef2 --- /dev/null +++ b/src/tests/maasservicelayer/db/repositories/test_usergroups.py @@ -0,0 +1,130 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from datetime import datetime, timezone + +import pytest +from sqlalchemy.ext.asyncio import AsyncConnection + +from maasservicelayer.builders.usergroups import UserGroupBuilder +from maasservicelayer.context import Context +from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.repositories.usergroups import ( + UserGroupsClauseFactory, + UserGroupsRepository, +) +from maasservicelayer.models.usergroups import UserGroup +from tests.fixtures.factories.usergroups import create_test_usergroup +from tests.maasapiserver.fixtures.db import Fixture +from tests.maasservicelayer.db.repositories.base import RepositoryCommonTests + + +class TestUserGroupsClauseFactory: + def test_with_ids(self): + clause = UserGroupsClauseFactory.with_ids([1, 2]) + assert ( + str( + clause.condition.compile( + compile_kwargs={"literal_binds": True} + ) + ) + == "maasserver_usergroup.id IN (1, 2)" + ) + + def test_with_name(self): + clause = UserGroupsClauseFactory.with_name("test-group") + assert ( + str( + clause.condition.compile( + compile_kwargs={"literal_binds": True} + ) + ) + == "maasserver_usergroup.name = 'test-group'" + ) + + +class TestUserGroupsRepository(RepositoryCommonTests[UserGroup]): + @pytest.fixture + def repository_instance( + self, db_connection: AsyncConnection + ) -> UserGroupsRepository: + return UserGroupsRepository(Context(connection=db_connection)) + + @pytest.fixture + async def _setup_test_list( + self, fixture: Fixture, num_objects: int + ) -> list[UserGroup]: + # The default groups are created by the migrations, they have the following timestamp hardcoded in the test sql dump + created_resource_pools = [ + UserGroup( + id=1, + name="Administrators", + description="Default administrators group", + created=datetime( + 2026, 2, 27, 12, 48, 12, 946997, tzinfo=timezone.utc + ), + updated=datetime( + 2026, 2, 27, 12, 48, 12, 946997, tzinfo=timezone.utc + ), + ), + UserGroup( + id=2, + name="Users", + description="Default users group", + created=datetime( + 2026, 2, 27, 12, 48, 12, 946997, tzinfo=timezone.utc + ), + updated=datetime( + 2026, 2, 27, 12, 48, 12, 946997, tzinfo=timezone.utc + ), + ), + ] + + created_resource_pools.extend( + [ + await create_test_usergroup( + fixture, name=f"group-{i}", description=f"desc-{i}" + ) + for i in range(num_objects - 2) + ] + ) + return created_resource_pools + + @pytest.fixture + async def created_instance(self, fixture: Fixture) -> UserGroup: + return await create_test_usergroup( + fixture, name="mygroup", description="description" + ) + + @pytest.fixture + async def instance_builder_model(self) -> type[UserGroupBuilder]: + return UserGroupBuilder + + @pytest.fixture + async def instance_builder(self) -> UserGroupBuilder: + return UserGroupBuilder(name="name", description="description") + + async def test_list_with_filters( + self, + repository_instance: UserGroupsRepository, + fixture: Fixture, + ) -> None: + group1 = await create_test_usergroup( + fixture, name="group-a", description="a" + ) + group2 = await create_test_usergroup( + fixture, name="group-b", description="b" + ) + + query = QuerySpec(where=UserGroupsClauseFactory.with_ids([group1.id])) + groups = await repository_instance.list(1, 20, query) + assert len(groups.items) == 1 + assert groups.total == 1 + assert groups.items[0].id == group1.id + + query = QuerySpec( + where=UserGroupsClauseFactory.with_ids([group1.id, group2.id]) + ) + groups = await repository_instance.list(1, 20, query) + assert len(groups.items) == 2 + assert groups.total == 2 diff --git a/src/tests/maasservicelayer/services/base.py b/src/tests/maasservicelayer/services/base.py index db8481e85d..6bf983af11 100644 --- a/src/tests/maasservicelayer/services/base.py +++ b/src/tests/maasservicelayer/services/base.py @@ -1,5 +1,5 @@ -# Copyright 2024-2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). +# Copyright 2024-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). import abc @@ -100,6 +100,14 @@ async def test_list(self, service_instance): page=1, size=10, query=QuerySpec() ) + async def test_list_all(self, service_instance): + service_instance.repository.list_all.return_value = [] + objects = await service_instance.list_all(query=QuerySpec()) + assert objects == [] + service_instance.repository.list_all.assert_awaited_once_with( + query=QuerySpec() + ) + async def test_update_many( self, service_instance, test_instance: MaasBaseModel, builder_model ): diff --git a/src/tests/maasservicelayer/services/test_usergroups.py b/src/tests/maasservicelayer/services/test_usergroups.py new file mode 100644 index 0000000000..6b37aaf398 --- /dev/null +++ b/src/tests/maasservicelayer/services/test_usergroups.py @@ -0,0 +1,242 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from unittest.mock import Mock + +import pytest +from sqlalchemy import and_ +from sqlalchemy.sql.operators import eq + +from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder +from maasservicelayer.builders.usergroups import UserGroupBuilder +from maasservicelayer.context import Context +from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.repositories.usergroups import ( + UserGroupsClauseFactory, + UserGroupsRepository, +) +from maasservicelayer.db.tables import OpenFGATupleTable +from maasservicelayer.models.base import MaasBaseModel +from maasservicelayer.models.usergroups import UserGroup +from maasservicelayer.services import ServiceCollectionV3 +from maasservicelayer.services.base import BaseService +from maasservicelayer.services.openfga_tuples import OpenFGATupleService +from maasservicelayer.services.usergroups import ( + UserGroupNotFound, + UserGroupsService, +) +from maasservicelayer.utils.date import utcnow +from tests.fixtures.factories.openfga_tuples import create_openfga_tuple +from tests.fixtures.factories.user import create_test_user +from tests.fixtures.factories.usergroups import create_test_usergroup +from tests.maasapiserver.fixtures.db import Fixture +from tests.maasservicelayer.services.base import ServiceCommonTests + +TEST_GROUP = UserGroup( + id=1, + name="test_group", + description="test_description", + created=utcnow(), + updated=utcnow(), +) + + +@pytest.mark.asyncio +class TestCommonUserGroupsService(ServiceCommonTests): + @pytest.fixture + def service_instance(self) -> BaseService: + return UserGroupsService( + context=Context(), + usergroups_repository=Mock(UserGroupsRepository), + openfga_tuples_service=Mock(OpenFGATupleService), + ) + + @pytest.fixture + def test_instance(self) -> MaasBaseModel: + return TEST_GROUP + + async def test_delete_many( + self, service_instance, test_instance: MaasBaseModel + ): + with pytest.raises(NotImplementedError): + await super().test_delete_many(service_instance, test_instance) + + +@pytest.mark.asyncio +class TestUserGroupsService: + async def test_add_user_to_group(self) -> None: + usergroups_repository = Mock(UserGroupsRepository) + usergroups_repository.get_one.return_value = TEST_GROUP + openfga_tuples_service = Mock(OpenFGATupleService) + + service = UserGroupsService( + context=Context(), + usergroups_repository=usergroups_repository, + openfga_tuples_service=openfga_tuples_service, + ) + + await service.add_user_to_group(1, TEST_GROUP.name) + usergroups_repository.get_one.assert_awaited_once() + openfga_tuples_service.create.assert_awaited_once_with( + OpenFGATupleBuilder.build_user_member_group(1, TEST_GROUP.id) + ) + + async def test_delete_by_id(self) -> None: + usergroups_repository = Mock(UserGroupsRepository) + usergroups_repository.delete_by_id.return_value = TEST_GROUP + usergroups_repository.get_by_id.side_effect = [TEST_GROUP, None] + openfga_tuples_service = Mock(OpenFGATupleService) + + service = UserGroupsService( + context=Context(), + usergroups_repository=usergroups_repository, + openfga_tuples_service=openfga_tuples_service, + ) + + await service.delete_by_id(TEST_GROUP.id) + usergroups_repository.delete_by_id.assert_awaited_once_with( + id=TEST_GROUP.id + ) + openfga_tuples_service.delete_group.assert_awaited_once_with( + TEST_GROUP.id + ) + + +@pytest.mark.asyncio +class TestIntegrationUserGroupsService: + async def test_add_user_to_group( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + group = await create_test_usergroup( + fixture, name="integration-group", description="test" + ) + user = await create_test_user(fixture) + + await services.usergroups.add_user_to_group(user.id, group.name) + + membership_tuples = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c._user, f"user:{user.id}"), + eq(OpenFGATupleTable.c.relation, "member"), + eq(OpenFGATupleTable.c.object_type, "group"), + eq(OpenFGATupleTable.c.object_id, str(group.id)), + ), + ) + assert len(membership_tuples) == 1 + + async def test_add_user_to_nonexistent_group( + self, services: ServiceCollectionV3 + ): + with pytest.raises(UserGroupNotFound): + await services.usergroups.add_user_to_group(1, "foo") + + async def test_delete_by_id_cleans_openfga_tuples( + self, + fixture: Fixture, + services: ServiceCollectionV3, + ) -> None: + group = await create_test_usergroup( + fixture, name="integration-group", description="test" + ) + + # add users to group + await create_openfga_tuple( + fixture, "user:10", "user", "member", "group", str(group.id) + ) + await create_openfga_tuple( + fixture, "user:20", "user", "member", "group", str(group.id) + ) + + # grant permissions to group + await create_openfga_tuple( + fixture, + f"group:{group.id}#member", + "group", + "can_edit", + "machine", + "100", + ) + await create_openfga_tuple( + fixture, + f"group:{group.id}#member", + "group", + "can_view", + "pool", + "200", + ) + + # create another group and tuple that should not be affected by the deletion of the first group + await create_openfga_tuple( + fixture, "user:99", "user", "member", "group", "9999" + ) + + await services.usergroups.delete_by_id(group.id) + + # openfga tuples are gone + membership_tuples = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "group"), + eq(OpenFGATupleTable.c.object_id, str(group.id)), + ), + ) + assert len(membership_tuples) == 0 + + entitlement_tuples = await fixture.get( + OpenFGATupleTable.fullname, + eq(OpenFGATupleTable.c._user, f"group:{group.id}#member"), + ) + assert len(entitlement_tuples) == 0 + + # other tuples are unaffected + unrelated_tuples = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "group"), + eq(OpenFGATupleTable.c.object_id, "9999"), + eq(OpenFGATupleTable.c._user, "user:99"), + ), + ) + assert len(unrelated_tuples) == 1 + + async def test_create_group( + self, + fixture: Fixture, + services: ServiceCollectionV3, + ) -> None: + group = await services.usergroups.create( + UserGroupBuilder( + name="created-via-service", + description="integration test", + ) + ) + assert group.name == "created-via-service" + assert group.description == "integration test" + assert group.id is not None + + retrieved = await services.usergroups.get_by_id(group.id) + assert retrieved is not None + assert retrieved.name == "created-via-service" + + async def test_default_user_group_exists( + self, + fixture: Fixture, + services: ServiceCollectionV3, + ) -> None: + exists = await services.usergroups.exists( + query=QuerySpec(where=UserGroupsClauseFactory.with_name("Users")) + ) + assert exists is True + + async def test_default_administrator_group_exists( + self, + fixture: Fixture, + services: ServiceCollectionV3, + ) -> None: + exists = await services.usergroups.exists( + query=QuerySpec( + where=UserGroupsClauseFactory.with_name("Administrators") + ) + ) + assert exists is True