diff --git a/Makefile b/Makefile index 785eb71e8..c3c1f327c 100644 --- a/Makefile +++ b/Makefile @@ -374,7 +374,7 @@ cleandb: .PHONY: cleandb sampledata: SAMPLEDATA_MACHINES ?= 100 -sampledata: syncdb bin/maas-sampledata +sampledata: build syncdb bin/maas-sampledata $(dbrun) bin/maas-sampledata --machine $(SAMPLEDATA_MACHINES) .PHONY: sampledata diff --git a/src/maascommon/openfga/async_client.py b/src/maascommon/openfga/async_client.py new file mode 100644 index 000000000..62d196876 --- /dev/null +++ b/src/maascommon/openfga/async_client.py @@ -0,0 +1,197 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import httpx + +from maascommon.enums.openfga import ( + OPENFGA_AUTHORIZATION_MODEL_ID, + OPENFGA_STORE_ID, +) +from maascommon.openfga.base import BaseOpenFGAClient + + +class OpenFGAClient(BaseOpenFGAClient): + """Asynchronous client for interacting with OpenFGA API.""" + + def __init__(self, unix_socket: str | None = None): + super().__init__(unix_socket) + self.client = self._init_client() + + def _init_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + timeout=httpx.Timeout(10), + headers=self.HEADERS, + base_url="http://unix/", + transport=httpx.AsyncHTTPTransport(uds=self.socket_path), + ) + + async def close(self): + await self.client.aclose() + + async def _check(self, user_id: str, relation: str, obj: str) -> bool: + response = await self.client.post( + f"/stores/{OPENFGA_STORE_ID}/check", + json={ + "tuple_key": { + "user": f"user:{user_id}", + "relation": relation, + "object": obj, + }, + "authorization_model_id": OPENFGA_AUTHORIZATION_MODEL_ID, + }, + ) + response.raise_for_status() + return response.json().get("allowed", False) + + async def _list_objects( + self, user_id: str, relation: str, obj_type: str + ) -> list[int]: + response = await self.client.post( + f"/stores/{OPENFGA_STORE_ID}/list-objects", + json={ + "authorization_model_id": OPENFGA_AUTHORIZATION_MODEL_ID, + "user": f"user:{user_id}", + "relation": relation, + "type": obj_type, + }, + ) + response.raise_for_status() + return self._parse_list_objects(response.json()) + + # Machine & Pool Permissions + async def can_edit_machines(self, user_id: str) -> 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 + ) -> 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 + ) -> 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 + ) -> 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 + ) -> 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: + 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: + return await self._check( + user_id, "can_view_global_entities", self.MAAS_GLOBAL_OBJ + ) + + async def can_edit_controllers(self, user_id: str) -> bool: + return await self._check( + user_id, "can_edit_controllers", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_controllers(self, user_id: str) -> bool: + return await self._check( + user_id, "can_view_controllers", self.MAAS_GLOBAL_OBJ + ) + + async def can_edit_identities(self, user_id: str) -> bool: + return await self._check( + user_id, "can_edit_identities", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_identities(self, user_id: str) -> bool: + return await self._check( + user_id, "can_view_identities", self.MAAS_GLOBAL_OBJ + ) + + async def can_edit_configurations(self, user_id: str) -> bool: + return await self._check( + user_id, "can_edit_configurations", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_configurations(self, user_id: str) -> bool: + return await self._check( + user_id, "can_view_configurations", self.MAAS_GLOBAL_OBJ + ) + + async def can_edit_notifications(self, user_id: str) -> bool: + return await self._check( + user_id, "can_edit_notifications", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_notifications(self, user_id: str) -> 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: + 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: + 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: + 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: + return await self._check( + user_id, "can_view_license_keys", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_devices(self, user_id: str) -> bool: + return await self._check( + user_id, "can_view_devices", self.MAAS_GLOBAL_OBJ + ) + + async def can_view_ipaddresses(self, user_id: str) -> 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 + ) -> list[int]: + return await self._list_objects(user_id, "can_view_machines", "pool") + + async def list_pools_with_view_deployable_machines_access( + self, user_id: str + ) -> 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 + ) -> 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 + ) -> list[int]: + return await self._list_objects(user_id, "can_edit_machines", "pool") diff --git a/src/maascommon/openfga/base.py b/src/maascommon/openfga/base.py new file mode 100644 index 000000000..b47392ebd --- /dev/null +++ b/src/maascommon/openfga/base.py @@ -0,0 +1,34 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import os +from pathlib import Path +from typing import Any + +from maascommon.path import get_maas_data_path + + +class BaseOpenFGAClient: + """Abstract base for sync/async OpenFGA clients.""" + + HEADERS = {"User-Agent": "maas-openfga-client/1.0"} + MAAS_GLOBAL_OBJ = "maas:0" + + def __init__(self, unix_socket: str | None = None): + self.socket_path = unix_socket or self._get_default_socket_path() + + def _get_default_socket_path(self) -> str: + return str( + Path( + os.getenv( + "MAAS_OPENFGA_HTTP_SOCKET_PATH", + get_maas_data_path("openfga-http.sock"), + ) + ) + ) + + def _format_pool(self, pool_id: int) -> str: + return f"pool:{pool_id}" + + def _parse_list_objects(self, data: dict[str, Any]) -> list[int]: + return [int(item.split(":")[1]) for item in data.get("objects", [])] diff --git a/src/maascommon/openfga/client/__init__.py b/src/maascommon/openfga/client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/maascommon/openfga/client/client.py b/src/maascommon/openfga/client/client.py deleted file mode 100644 index 7773183ca..000000000 --- a/src/maascommon/openfga/client/client.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright 2026 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). - -import os -from pathlib import Path - -import httpx - -from maascommon.enums.openfga import ( - OPENFGA_AUTHORIZATION_MODEL_ID, - OPENFGA_STORE_ID, -) -from maascommon.path import get_maas_data_path - - -class OpenFGAClient: - """Client for interacting with OpenFGA API.""" - - HEADERS = {"User-Agent": "maas-openfga-client/1.0"} - - def __init__(self, unix_socket: str | None = None): - self.client = self._create_client(unix_socket) - - def _create_client( - self, unix_socket: str | None = None - ) -> httpx.AsyncClient: - if unix_socket is None: - unix_socket = str(self._openfga_service_socket_path()) - - return httpx.AsyncClient( - timeout=httpx.Timeout(10), - headers=self.HEADERS, - base_url="http://unix/", - transport=httpx.AsyncHTTPTransport(uds=unix_socket), - ) - - def _openfga_service_socket_path(self) -> Path: - """Return the path of the socket for the service.""" - return Path( - os.getenv( - "MAAS_OPENFGA_HTTP_SOCKET_PATH", - get_maas_data_path("openfga-http.sock"), - ) - ) - - async def _check(self, tuple_key: dict): - response = await self.client.post( - f"/stores/{OPENFGA_STORE_ID}/check", - json={ - "tuple_key": tuple_key, - "authorization_model_id": OPENFGA_AUTHORIZATION_MODEL_ID, - }, - ) - response.raise_for_status() - data = response.json() - return data.get("allowed", False) - - async def can_edit_pools(self, user_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_edit_pools", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_view_pools(self, user_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_view_pools", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_edit_machines(self, user_id: str, pool_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_edit_machines", - "object": f"pool:{pool_id}", - } - return await self._check(tuple_key) - - async def can_deploy_machines(self, user_id: str, pool_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_deploy_machines", - "object": f"pool:{pool_id}", - } - return await self._check(tuple_key) - - async def can_view_machines(self, user_id: str, pool_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_view_machines", - "object": f"pool:{pool_id}", - } - return await self._check(tuple_key) - - async def can_view_global_entities(self, user_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_view_global_entities", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_edit_global_entities(self, user_id): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_edit_global_entities", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_view_permissions(self, user_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_view_permissions", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_edit_permissions(self, user_id): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_edit_permissions", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_view_configurations(self, user_id: str): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_view_configurations", - "object": "maas:0", - } - return await self._check(tuple_key) - - async def can_edit_configurations(self, user_id): - tuple_key = { - "user": f"user:{user_id}", - "relation": "can_edit_configurations", - "object": "maas:0", - } - return await self._check(tuple_key) diff --git a/src/maascommon/openfga/sync_client.py b/src/maascommon/openfga/sync_client.py new file mode 100644 index 000000000..cee8230e8 --- /dev/null +++ b/src/maascommon/openfga/sync_client.py @@ -0,0 +1,161 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import httpx + +from maascommon.enums.openfga import ( + OPENFGA_AUTHORIZATION_MODEL_ID, + OPENFGA_STORE_ID, +) +from maascommon.openfga.base import BaseOpenFGAClient + + +class SyncOpenFGAClient(BaseOpenFGAClient): + """Synchronous client for interacting with OpenFGA API.""" + + def __init__(self, unix_socket: str | None = None): + super().__init__(unix_socket) + self.client = self._init_client() + + def _init_client(self) -> httpx.Client: + return httpx.Client( + timeout=httpx.Timeout(10), + headers=self.HEADERS, + base_url="http://unix/", + transport=httpx.HTTPTransport(uds=self.socket_path), + ) + + def close(self): + self.client.close() + + def _check(self, user, relation: str, obj: str) -> bool: + response = self.client.post( + f"/stores/{OPENFGA_STORE_ID}/check", + json={ + "tuple_key": { + "user": f"user:{user.id}", # type: ignore[reportAttributeAccessIssue] + "relation": relation, + "object": obj, + }, + "authorization_model_id": OPENFGA_AUTHORIZATION_MODEL_ID, + }, + ) + response.raise_for_status() + return response.json().get("allowed", False) + + def _list_objects(self, user, relation: str, obj_type: str) -> list[int]: + response = self.client.post( + f"/stores/{OPENFGA_STORE_ID}/list-objects", + json={ + "authorization_model_id": OPENFGA_AUTHORIZATION_MODEL_ID, + "user": f"user:{user.id}", # type: ignore[reportAttributeAccessIssue] + "relation": relation, + "type": obj_type, + }, + ) + response.raise_for_status() + return self._parse_list_objects(response.json()) + + # Machine & Pool Permissions + def can_edit_machines(self, user) -> bool: + return self._check(user, "can_edit_machines", self.MAAS_GLOBAL_OBJ) + + def can_edit_machines_in_pool(self, user, pool_id: int) -> bool: + return self._check( + user, "can_edit_machines", self._format_pool(pool_id) + ) + + def can_deploy_machines_in_pool(self, user, pool_id: int) -> bool: + return self._check( + user, "can_deploy_machines", self._format_pool(pool_id) + ) + + def can_view_machines_in_pool(self, user, pool_id: int) -> bool: + return self._check( + user, "can_view_machines", self._format_pool(pool_id) + ) + + def can_view_available_machines_in_pool(self, user, pool_id: int) -> bool: + return self._check( + user, "can_view_available_machines", self._format_pool(pool_id) + ) + + # Global Permissions + def can_edit_global_entities(self, user) -> bool: + return self._check( + user, "can_edit_global_entities", self.MAAS_GLOBAL_OBJ + ) + + def can_view_global_entities(self, user) -> bool: + return self._check( + user, "can_view_global_entities", self.MAAS_GLOBAL_OBJ + ) + + def can_edit_controllers(self, user) -> bool: + return self._check(user, "can_edit_controllers", self.MAAS_GLOBAL_OBJ) + + def can_view_controllers(self, user) -> bool: + return self._check(user, "can_view_controllers", self.MAAS_GLOBAL_OBJ) + + def can_edit_identities(self, user) -> bool: + return self._check(user, "can_edit_identities", self.MAAS_GLOBAL_OBJ) + + def can_view_identities(self, user) -> bool: + return self._check(user, "can_view_identities", self.MAAS_GLOBAL_OBJ) + + def can_edit_configurations(self, user) -> bool: + return self._check( + user, "can_edit_configurations", self.MAAS_GLOBAL_OBJ + ) + + def can_view_configurations(self, user) -> bool: + return self._check( + user, "can_view_configurations", self.MAAS_GLOBAL_OBJ + ) + + def can_edit_notifications(self, user) -> bool: + return self._check( + user, "can_edit_notifications", self.MAAS_GLOBAL_OBJ + ) + + def can_view_notifications(self, user) -> bool: + return self._check( + user, "can_view_notifications", self.MAAS_GLOBAL_OBJ + ) + + def can_edit_boot_entities(self, user) -> bool: + return self._check( + user, "can_edit_boot_entities", self.MAAS_GLOBAL_OBJ + ) + + def can_view_boot_entities(self, user) -> bool: + return self._check( + user, "can_view_boot_entities", self.MAAS_GLOBAL_OBJ + ) + + def can_edit_license_keys(self, user) -> bool: + return self._check(user, "can_edit_license_keys", self.MAAS_GLOBAL_OBJ) + + def can_view_license_keys(self, user) -> bool: + return self._check(user, "can_view_license_keys", self.MAAS_GLOBAL_OBJ) + + def can_view_devices(self, user) -> bool: + return self._check(user, "can_view_devices", self.MAAS_GLOBAL_OBJ) + + def can_view_ipaddresses(self, user) -> bool: + return self._check(user, "can_view_ipaddresses", self.MAAS_GLOBAL_OBJ) + + # List Methods + def list_pools_with_view_machines_access(self, user) -> list[int]: + return self._list_objects(user, "can_view_machines", "pool") + + def list_pools_with_view_deployable_machines_access( + self, user + ) -> list[int]: + return self._list_objects(user, "can_view_available_machines", "pool") + + def list_pool_with_deploy_machines_access(self, user) -> list[int]: + return self._list_objects(user, "can_deploy_machines", "pool") + + def list_pools_with_edit_machines_access(self, user) -> list[int]: + return self._list_objects(user, "can_edit_machines", "pool") diff --git a/src/maasopenfga/go.mod b/src/maasopenfga/go.mod index 62387b7f2..e6a78c67b 100644 --- a/src/maasopenfga/go.mod +++ b/src/maasopenfga/go.mod @@ -7,6 +7,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 github.com/jackc/pgx/v5 v5.7.6 + github.com/oklog/ulid/v2 v2.1.1 github.com/openfga/api/proto v0.0.0-20251105142303-feed3db3d69d github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c github.com/openfga/openfga v1.11.2 @@ -52,7 +53,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/natefinch/wrap v0.2.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/src/maasopenfga/internal/migrations/00001_add_model.go b/src/maasopenfga/internal/migrations/00001_add_model.go index 67ab74d52..7caa91650 100644 --- a/src/maasopenfga/internal/migrations/00001_add_model.go +++ b/src/maasopenfga/internal/migrations/00001_add_model.go @@ -66,32 +66,44 @@ type group type maas relations - - define can_edit_pools: [group#member] - define can_view_pools: [group#member] or can_edit_pools - define can_edit_machines: [group#member] define can_deploy_machines: [group#member] or can_edit_machines - + define can_view_machines: [group#member] or can_edit_machines + define can_view_available_machines: [group#member] or can_edit_machines or can_view_machines + define can_edit_global_entities: [group#member] define can_view_global_entities: [group#member] or can_edit_global_entities + + define can_edit_controllers: [group#member] + define can_view_controllers: [group#member] or can_edit_controllers - define can_edit_permissions: [group#member] - define can_view_permissions: [group#member] or can_edit_permissions + define can_edit_identities: [group#member] + define can_view_identities: [group#member] or can_edit_identities define can_edit_configurations: [group#member] define can_view_configurations: [group#member] or can_edit_configurations + define can_edit_notifications: [group#member] + define can_view_notifications: [group#member] or can_edit_notifications + + define can_edit_boot_entities: [group#member] + define can_view_boot_entities: [group#member] or can_edit_boot_entities + + define can_edit_license_keys: [group#member] + define can_view_license_keys: [group#member] or can_edit_license_keys + + define can_view_devices: [group#member] + + define can_view_ipaddresses: [group#member] + type pool relations define parent: [maas] - define can_edit: [group#member] or can_edit_pools from parent - define can_view: [group#member] or can_edit or can_view_pools from parent - - define can_edit_machines: [group#member] or can_edit or can_edit_machines from parent - define can_deploy_machines: [group#member] or can_edit or can_edit_machines or can_deploy_machines from parent - define can_view_machines: [group#member] or can_deploy_machines or can_edit_machines or can_view or can_edit + define can_edit_machines: [group#member] or can_edit_machines from parent + define can_deploy_machines: [group#member] or can_edit_machines or can_deploy_machines from parent + define can_view_machines: [group#member] or can_edit_machines or can_view_machines from parent + define can_view_available_machines: [group#member] or can_edit_machines or can_view_machines or can_view_available_machines from parent ` model, err := parser.TransformDSLToProto(modelDSL) diff --git a/src/maasopenfga/internal/migrations/00002_migrate_environments.go b/src/maasopenfga/internal/migrations/00002_migrate_environments.go new file mode 100644 index 000000000..453bed04c --- /dev/null +++ b/src/maasopenfga/internal/migrations/00002_migrate_environments.go @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + "database/sql" + "fmt" + "log" + "strconv" + + sq "github.com/Masterminds/squirrel" + "github.com/oklog/ulid/v2" + "github.com/pressly/goose/v3" +) + +const ( + administratorGroupName = "administrators" + usersGroupName = "users" +) + +func init() { + goose.AddMigrationContext(Up00002, Down00002) +} + +// Create a new maas:0 -> parent -> pool:id for every pool in the database. +func createPools(ctx context.Context, tx *sql.Tx) error { + builder := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + selectStmt, selectArgs, err := builder. + Select("id"). + From("maasserver_resourcepool"). + ToSql() + if err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, selectStmt, selectArgs...) + if err != nil { + return err + } + + defer func() { + if err := rows.Close(); err != nil { + log.Printf("failed to close rows: %v", err) + } + }() + + var poolIDs []int64 + + for rows.Next() { + var poolID int64 + if err := rows.Scan(&poolID); err != nil { + return err + } + + poolIDs = append(poolIDs, poolID) + } + + if err := rows.Err(); err != nil { + return err + } + + for _, poolID := range poolIDs { + insertStmt, insertArgs, err := builder. + Insert("openfga.tuple"). + Columns( + "store", + "_user", + "user_type", + "relation", + "object_type", + "object_id", + "ulid", + "inserted_at", + ). + Values( + storeID, + "maas:0", + "user", + "parent", + "pool", + strconv.FormatInt(poolID, 10), + ulid.Make().String(), + sq.Expr("NOW()"), + ). + ToSql() + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, insertStmt, insertArgs...); err != nil { + return err + } + } + + return nil +} + +// Create a new group with relations to the maas:0 object. +func createGroup(ctx context.Context, tx *sql.Tx, groupName string, relations *[]string) error { + builder := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + for _, relation := range *relations { + userGroupStmt, userGroupArgs, err := builder. + Insert("openfga.tuple"). + Columns( + "store", + "_user", + "user_type", + "relation", + "object_type", + "object_id", + "ulid", + "inserted_at", + ). + Values( + storeID, + fmt.Sprintf("group:%s#member", groupName), + "userset", + relation, + "maas", + "0", + ulid.Make().String(), + sq.Expr("NOW()"), + ). + ToSql() + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, userGroupStmt, userGroupArgs...); err != nil { + return err + } + } + + return nil +} + +// For every user in auth_users, add them to the users group. if is_superuser is true, +// also add them to the administrators group. If false, add them to the users group. +func addUsersToGroup(ctx context.Context, tx *sql.Tx) error { + builder := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + + selectStmt, selectArgs, err := builder. + Select("id", "is_superuser"). + From("auth_user"). + ToSql() + if err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, selectStmt, selectArgs...) + if err != nil { + return err + } + + defer func() { + if err := rows.Close(); err != nil { + log.Printf("failed to close rows: %v", err) + } + }() + + type user struct { + id int64 + isSuperUser bool + } + + var users []user + + for rows.Next() { + var u user + if err := rows.Scan(&u.id, &u.isSuperUser); err != nil { + return err + } + + users = append(users, u) + } + + if err := rows.Err(); err != nil { + return err + } + + for _, u := range users { + groupName := usersGroupName + if u.isSuperUser { + groupName = administratorGroupName + } + + insertStmt, insertArgs, err := builder. + Insert("openfga.tuple"). + Columns( + "store", + "_user", + "user_type", + "relation", + "object_type", + "object_id", + "ulid", + "inserted_at", + ). + Values( + storeID, + fmt.Sprintf("user:%d", u.id), + "user", + "member", + "group", + groupName, + ulid.Make().String(), + sq.Expr("NOW()"), + ). + ToSql() + if err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, insertStmt, insertArgs...); err != nil { + return err + } + } + + return nil +} + +func Up00002(ctx context.Context, tx *sql.Tx) error { + if err := createPools(ctx, tx); err != nil { + return fmt.Errorf("failed to create pools: %w", err) + } + + relations := []string{"can_edit_machines", "can_edit_global_entities", "can_edit_controllers", "can_edit_identities", + "can_edit_configurations", "can_edit_notifications", "can_edit_boot_entities", "can_edit_license_keys", + "can_view_devices", + "can_view_ipaddresses"} + if err := createGroup(ctx, tx, administratorGroupName, &relations); err != nil { + return fmt.Errorf("failed to create administrators group: %w", err) + } + + relations = []string{"can_deploy_machines", "can_view_deployable_machines", "can_view_global_entities"} + if err := createGroup(ctx, tx, usersGroupName, &relations); err != nil { + return fmt.Errorf("failed to create users group: %w", err) + } + + if err := addUsersToGroup(ctx, tx); err != nil { + return fmt.Errorf("failed to add users to groups: %w", err) + } + + return nil +} + +func Down00002(ctx context.Context, tx *sql.Tx) error { + return fmt.Errorf("downgrade not supported") +} diff --git a/src/maasserver/api/agent.py b/src/maasserver/api/agent.py index 33f9f35e7..9866c492a 100644 --- a/src/maasserver/api/agent.py +++ b/src/maasserver/api/agent.py @@ -1,7 +1,7 @@ -# Copyright 2023 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 maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import internal_method, OperationsHandler from maasserver.dhcp import generate_dhcp_configuration from maasserver.models.node import RackController @@ -33,7 +33,7 @@ def resource_uri(cls, system_id=None, service_name=None): ), ) - @admin_method + @internal_method def read(self, request, system_id, service_name): if service_name == "dhcp": agent = RackController.objects.get(system_id=system_id) diff --git a/src/maasserver/api/blockdevices.py b/src/maasserver/api/blockdevices.py index dfc97054d..a78839222 100644 --- a/src/maasserver/api/blockdevices.py +++ b/src/maasserver/api/blockdevices.py @@ -1,4 +1,4 @@ -# Copyright 2015-2018 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `BlockDevice`.""" @@ -6,8 +6,9 @@ from django.core.exceptions import PermissionDenied from piston3.utils import rc -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import operation, OperationsHandler from maasserver.api.utils import get_mandatory_param +from maasserver.authorization import can_edit_machine_in_pool from maasserver.enum import NODE_STATUS from maasserver.exceptions import ( MAASAPIBadRequest, @@ -63,7 +64,9 @@ def raise_error_for_invalid_state_on_allocated_operations( "Cannot %s block device because the machine is not Ready " "or Allocated." % operation ) - if node.status == NODE_STATUS.READY and not user.is_superuser: + if node.status == NODE_STATUS.READY and not can_edit_machine_in_pool( + user, node.pool_id + ): raise PermissionDenied( "Cannot %s block device because you don't have the " "permissions on a Ready machine." % operation @@ -103,7 +106,6 @@ def read(self, request, system_id): ) return machine.current_config.blockdevice_set.all() - @admin_method def create(self, request, system_id): """@description-title Create a block device @description Create a physical block device. diff --git a/src/maasserver/api/boot_resources.py b/src/maasserver/api/boot_resources.py index f2ddeab81..d1ba0a45c 100644 --- a/src/maasserver/api/boot_resources.py +++ b/src/maasserver/api/boot_resources.py @@ -1,4 +1,4 @@ -# Copyright 2014-2025 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `BootResouce`.""" @@ -25,7 +25,11 @@ SYNC_BOOTRESOURCES_WORKFLOW_NAME, SyncRequestParam, ) -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.bootresources import ( import_resources, is_import_resources_running, @@ -197,7 +201,7 @@ def read(self, request): status=int(http.client.OK), ) - @admin_method + @check_permission("can_edit_boot_entities") def create(self, request): """@description-title Create a new boot resource @description Creates a new boot resource. The file upload must be done @@ -252,7 +256,7 @@ def create(self, request): status=int(http.client.CREATED), ) - @admin_method + @check_permission("can_edit_boot_entities") @operation(idempotent=False, exported_as="import") def import_resources(self, request): """@description-title Import boot resources @@ -268,7 +272,7 @@ def import_resources(self, request): content_type=("text/plain; charset=%s" % settings.DEFAULT_CHARSET), ) - @admin_method + @check_permission("can_edit_boot_entities") @operation(idempotent=False) def stop_import(self, request): """@description-title Stop import boot resources @@ -341,7 +345,7 @@ def read(self, request, id): status=int(http.client.OK), ) - @admin_method + @check_permission("can_edit_boot_entities") def delete(self, request, id): """@description-title Delete a boot resource @description Delete a boot resource by id. @@ -376,7 +380,7 @@ class BootResourceFileUploadHandler(OperationsHandler): api_doc_section_name = "Boot resource file upload" read = create = delete = None - @admin_method + @check_permission("can_edit_boot_entities") def update(self, request, resource_id, id): """@description-title Upload chunk of boot resource file. @description Uploads a chunk of boot resource file diff --git a/src/maasserver/api/boot_source_selections.py b/src/maasserver/api/boot_source_selections.py index 813f586c7..a180dd66a 100644 --- a/src/maasserver/api/boot_source_selections.py +++ b/src/maasserver/api/boot_source_selections.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `BootSourceSelection`""" @@ -7,7 +7,7 @@ from piston3.utils import rc from maascommon.logging.security import CREATED, DELETED -from maasserver.api.support import OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.audit import create_audit_event from maasserver.enum import ENDPOINT from maasserver.exceptions import MAASAPIValidationError @@ -35,6 +35,7 @@ class BootSourceSelectionHandler(OperationsHandler): model = BootSourceSelection fields = DISPLAYED_BOOTSOURCESELECTION_FIELDS + @check_permission("can_view_boot_entities") def read(self, request, boot_source_id, id): """@description-title Read a boot source selection @description Read a boot source selection with the given id. @@ -59,6 +60,7 @@ def read(self, request, boot_source_id, id): BootSourceSelection, boot_source=boot_source, id=id ) + @check_permission("can_edit_boot_entities") def update(self, request, boot_source_id, id): """@description-title Update a boot-source selection @description Update a boot source selection with the given id. @@ -112,6 +114,7 @@ def update(self, request, boot_source_id, id): else: raise MAASAPIValidationError(form.errors) + @check_permission("can_edit_boot_entities") def delete(self, request, boot_source_id, id): """@description-title Delete a boot source @description Delete a boot source with the given id. @@ -170,6 +173,7 @@ def resource_uri(cls, boot_source=None): boot_source_id = boot_source.id return ("boot_source_selections_handler", [boot_source_id]) + @check_permission("can_view_boot_entities") def read(self, request, boot_source_id): """@description-title List boot-source selections @description List all available boot-source selections. @@ -190,6 +194,7 @@ def read(self, request, boot_source_id): boot_source = get_object_or_404(BootSource, id=boot_source_id) return BootSourceSelection.objects.filter(boot_source=boot_source) + @check_permission("can_edit_boot_entities") def create(self, request, boot_source_id): """@description-title Create a boot-source selection @description Create a new boot source selection. diff --git a/src/maasserver/api/boot_sources.py b/src/maasserver/api/boot_sources.py index 999164247..3626a5265 100644 --- a/src/maasserver/api/boot_sources.py +++ b/src/maasserver/api/boot_sources.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `BootSource`.""" @@ -13,7 +13,7 @@ from piston3.utils import rc from maascommon.logging.security import CREATED, DELETED, UPDATED -from maasserver.api.support import OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.audit import create_audit_event from maasserver.enum import ENDPOINT from maasserver.exceptions import MAASAPIValidationError @@ -40,6 +40,7 @@ class BootSourceHandler(OperationsHandler): model = BootSource fields = DISPLAYED_BOOTSOURCE_FIELDS + @check_permission("can_view_boot_entities") def read(self, request, id): """@description-title Read a boot source @description Read a boot source with the given id. @@ -59,6 +60,7 @@ def read(self, request, id): """ return get_object_or_404(BootSource, id=id) + @check_permission("can_edit_boot_entities") def update(self, request, id): """@description-title Update a boot source @description Update a boot source with the given id. @@ -108,6 +110,7 @@ def update(self, request, id): else: raise MAASAPIValidationError(form.errors) + @check_permission("can_edit_boot_entities") def delete(self, request, id): """@description-title Delete a boot source @description Delete a boot source with the given id. @@ -157,6 +160,7 @@ class BootSourcesHandler(OperationsHandler): def resource_uri(cls): return ("boot_sources_handler", []) + @check_permission("can_view_boot_entities") def read(self, request): """@description-title List boot sources @description List all boot sources. @@ -169,6 +173,7 @@ def read(self, request): """ return BootSource.objects.all() + @check_permission("can_edit_boot_entities") def create(self, request): """@description-title Create a boot source @description Create a new boot source. Note that in addition to diff --git a/src/maasserver/api/dhcpsnippets.py b/src/maasserver/api/dhcpsnippets.py index c7c7f4541..b99a3a43c 100644 --- a/src/maasserver/api/dhcpsnippets.py +++ b/src/maasserver/api/dhcpsnippets.py @@ -1,4 +1,4 @@ -# Copyright 2016-2018 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `DHCPSnippet`.""" @@ -10,7 +10,7 @@ from maascommon.logging.security import DELETED from maasserver.api.reservedip import ReservedIpHandler, ReservedIpsHandler from maasserver.api.support import ( - admin_method, + check_permission, deprecated, operation, OperationsHandler, @@ -95,7 +95,7 @@ def read(self, request, id): """ return DHCPSnippet.objects.get_dhcp_snippet_or_404(id) - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, id): """@description-title Update a DHCP snippet @description Update a DHCP snippet with the given id. @@ -141,7 +141,7 @@ def update(self, request, id): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, id): """@description-title Delete a DHCP snippet @description Delete a DHCP snippet with the given id. @@ -168,7 +168,7 @@ def delete(self, request, id): ) return rc.DELETED - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def revert(self, request, id): """@description-title Revert DHCP snippet to earlier version @@ -252,7 +252,7 @@ def read(self, request): "value", "subnet", "node" ) - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a DHCP snippet @description Creates a DHCP snippet. diff --git a/src/maasserver/api/dnsresourcerecords.py b/src/maasserver/api/dnsresourcerecords.py index 8c0450967..be3316a38 100644 --- a/src/maasserver/api/dnsresourcerecords.py +++ b/src/maasserver/api/dnsresourcerecords.py @@ -1,11 +1,11 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `DNSData`.""" from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.exceptions import MAASAPIBadRequest, MAASAPIValidationError from maasserver.forms.dnsdata import DNSDataForm from maasserver.forms.dnsresource import DNSResourceForm @@ -83,7 +83,7 @@ def read(self, request): query = query.filter(rrtype=rrtype) return query - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a DNS resource record @description Create a new DNS resource record. diff --git a/src/maasserver/api/dnsresources.py b/src/maasserver/api/dnsresources.py index 188f9e2ce..f40039614 100644 --- a/src/maasserver/api/dnsresources.py +++ b/src/maasserver/api/dnsresources.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `DNSResource`.""" @@ -7,7 +7,7 @@ from formencode.validators import StringBool from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.api.utils import get_optional_param from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.dnsresource import DNSResourceForm @@ -176,7 +176,7 @@ def read(self, request): user = request.user return get_dnsresource_queryset(_all, domainname, name, rrtype, user) - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a DNS resource @description Create a DNS resource. diff --git a/src/maasserver/api/domains.py b/src/maasserver/api/domains.py index ca70c6336..be22a5046 100644 --- a/src/maasserver/api/domains.py +++ b/src/maasserver/api/domains.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Domain`.""" @@ -6,8 +6,8 @@ from piston3.utils import rc from maasserver.api.support import ( - admin_method, AnonymousOperationsHandler, + check_permission, operation, OperationsHandler, ) @@ -52,7 +52,7 @@ def read(self, request): """ return Domain.objects.get_all_with_resource_record_count() - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a domain @description Create a domain. @@ -76,7 +76,7 @@ def create(self, request): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def set_serial(self, request): """@description-title Set the SOA serial number diff --git a/src/maasserver/api/fabrics.py b/src/maasserver/api/fabrics.py index 599bf4ffd..b4d929013 100644 --- a/src/maasserver/api/fabrics.py +++ b/src/maasserver/api/fabrics.py @@ -1,11 +1,11 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Fabric`.""" from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.fabric import FabricForm from maasserver.models import Fabric @@ -49,7 +49,7 @@ def read(self, request): """ return prefetch_queryset(Fabric.objects.all(), FABRIC_PREFETCH) - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a fabric @description Create a fabric. diff --git a/src/maasserver/api/image_sync.py b/src/maasserver/api/image_sync.py index c94373a89..451b7049e 100644 --- a/src/maasserver/api/image_sync.py +++ b/src/maasserver/api/image_sync.py @@ -1,10 +1,10 @@ -# Copyright 2023 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 django.db.models import F from django.shortcuts import get_object_or_404 -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import internal_method, OperationsHandler from maasserver.api.utils import get_optional_param from maasserver.models.bootresourcefile import BootResourceFile from maasserver.models.node import RegionController @@ -20,7 +20,7 @@ class ImagesSyncProgressHandler(OperationsHandler): def resource_uri(cls, *args, **kwargs): return ("images_sync_progress_handler", []) - @admin_method + @internal_method def read(self, request): with_sources = get_optional_param(request.GET, "sources", True) qs = ( @@ -46,7 +46,7 @@ def read(self, request): for file in qs } - @admin_method + @internal_method def create(self, request): data = request.data region = get_object_or_404( @@ -88,7 +88,7 @@ def resource_uri(cls, file_id=None, system_id=None): ), ) - @admin_method + @internal_method def update(self, request, file_id, system_id): data = request.data size = data.get("size", 0) @@ -99,7 +99,7 @@ def update(self, request, file_id, system_id): region=region, ) - @admin_method + @internal_method def read(self, request, file_id, system_id): boot_file = get_object_or_404(BootResourceFile, id=file_id) region = get_object_or_404(RegionController, system_id=system_id) diff --git a/src/maasserver/api/ip_addresses.py b/src/maasserver/api/ip_addresses.py index 8a43705f6..3bbdf48cd 100644 --- a/src/maasserver/api/ip_addresses.py +++ b/src/maasserver/api/ip_addresses.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handler: `StaticIPAddress`.""" @@ -13,6 +13,10 @@ from maasserver.api.interfaces import DISPLAYED_INTERFACE_FIELDS from maasserver.api.support import operation, OperationsHandler from maasserver.api.utils import get_mandatory_param, get_optional_param +from maasserver.authorization import ( + can_edit_global_entities, + can_view_ipaddresses, +) from maasserver.enum import INTERFACE_LINK_TYPE, INTERFACE_TYPE, IPADDRESS_TYPE from maasserver.exceptions import ( MAASAPIBadRequest, @@ -261,7 +265,7 @@ def release(self, request): request.POST, "discovered", default=False, validator=StringBool ) - if force is True and not request.user.is_superuser: + if force is True and not can_edit_global_entities(request.user): return HttpResponseForbidden( content_type="text/plain", content="Force-releasing an IP address requires admin " @@ -369,12 +373,13 @@ def read(self, request): # automatic address to , but it isn't deployed at # the moment". query = StaticIPAddress.objects.exclude(ip__isnull=True) - if _all and not request.user.is_superuser: + user_can_view_ipaddresses = can_view_ipaddresses(request.user) + if _all and not user_can_view_ipaddresses: return HttpResponseForbidden( content_type="text/plain", content="Listing all IP addresses requires admin privileges.", ) - if owner is not None and not request.user.is_superuser: + if owner is not None and not user_can_view_ipaddresses: return HttpResponseForbidden( content_type="text/plain", content="Listing another user's IP addresses requires admin " @@ -382,7 +387,7 @@ def read(self, request): ) # Add additional filters based on permissions, and based on the # request parameters. - if not request.user.is_superuser: + if not user_can_view_ipaddresses: # If the requesting user isn't an admin, always filter by the # currently-logged-in API user. query = query.filter(user=request.user) diff --git a/src/maasserver/api/ipranges.py b/src/maasserver/api/ipranges.py index e456ce900..f2e073ac6 100644 --- a/src/maasserver/api/ipranges.py +++ b/src/maasserver/api/ipranges.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `ip-ranges`.""" @@ -6,6 +6,7 @@ from piston3.utils import rc from maasserver.api.support import OperationsHandler +from maasserver.authorization import can_edit_global_entities from maasserver.enum import IPRANGE_TYPE from maasserver.exceptions import MAASAPIForbidden, MAASAPIValidationError from maasserver.forms.iprange import IPRangeForm @@ -23,7 +24,7 @@ def raise_error_if_not_owner(iprange, user): - if not user.is_superuser and iprange.user_id != user.id: + if not can_edit_global_entities(user) and iprange.user_id != user.id: raise MAASAPIForbidden( "Unable to modify IP range. You don't own the IP range." ) @@ -83,7 +84,7 @@ def create(self, request): if ( "type" in request.data and request.data["type"] == IPRANGE_TYPE.DYNAMIC - and not request.user.is_superuser + and not can_edit_global_entities(request.user) ): raise MAASAPIForbidden( "Unable to create dynamic IP range. " diff --git a/src/maasserver/api/license_keys.py b/src/maasserver/api/license_keys.py index 4044ad05d..d61eb6f04 100644 --- a/src/maasserver/api/license_keys.py +++ b/src/maasserver/api/license_keys.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `LicenseKey`.""" @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404 from piston3.utils import rc -from maasserver.api.support import OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.exceptions import MAASAPIValidationError from maasserver.forms import LicenseKeyForm from maasserver.models import LicenseKey @@ -20,6 +20,7 @@ class LicenseKeysHandler(OperationsHandler): update = delete = None + @check_permission("can_view_license_keys") def read(self, request): """@description-title List license keys @description List all available license keys. @@ -32,6 +33,7 @@ def read(self, request): """ return LicenseKey.objects.all().order_by("osystem", "distro_series") + @check_permission("can_edit_license_keys") def create(self, request): """@description-title Define a license key @description Define a license key. @@ -95,6 +97,7 @@ class LicenseKeyHandler(OperationsHandler): # Creation happens on the LicenseKeysHandler. create = None + @check_permission("can_view_license_keys") def read(self, request, osystem, distro_series): """@description-title Read license key @description Read a license key for the given operating sytem and @@ -122,6 +125,7 @@ def read(self, request, osystem, distro_series): LicenseKey, osystem=osystem, distro_series=distro_series ) + @check_permission("can_edit_license_keys") def update(self, request, osystem, distro_series): """@description-title Update license key @description Update a license key for the given operating system and @@ -159,6 +163,7 @@ def update(self, request, osystem, distro_series): raise MAASAPIValidationError(form.errors) return form.save() + @check_permission("can_edit_license_keys") def delete(self, request, osystem, distro_series): """@description-title Delete license key @description Delete license key for the given operation system and diff --git a/src/maasserver/api/maas.py b/src/maasserver/api/maas.py index 398abe34c..64258d1a6 100644 --- a/src/maasserver/api/maas.py +++ b/src/maasserver/api/maas.py @@ -1,4 +1,4 @@ -# Copyright 2014-2018 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handler: MAAS.""" @@ -9,7 +9,11 @@ from formencode import validators from piston3.utils import rc -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import get_mandatory_param from maasserver.enum import ENDPOINT from maasserver.exceptions import MAASAPIValidationError @@ -67,7 +71,7 @@ class MaasHandler(OperationsHandler): api_doc_section_name = "MAAS server" create = read = update = delete = None - @admin_method + @check_permission("can_edit_configurations") @operation(idempotent=False) def set_config(self, request): """@description-title Set a configuration value diff --git a/src/maasserver/api/machines.py b/src/maasserver/api/machines.py index 56af42287..2904eba72 100644 --- a/src/maasserver/api/machines.py +++ b/src/maasserver/api/machines.py @@ -1,4 +1,4 @@ -# Copyright 2015-2021 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). __all__ = [ @@ -39,12 +39,13 @@ PowersMixin, WorkloadAnnotationsMixin, ) -from maasserver.api.support import admin_method, operation +from maasserver.api.support import check_permission, operation from maasserver.api.utils import ( get_mandatory_param, get_optional_list, get_optional_param, ) +from maasserver.authorization import can_edit_machines from maasserver.clusterrpc.driver_parameters import get_all_power_types from maasserver.enum import ( BMC_TYPE, @@ -66,7 +67,6 @@ from maasserver.forms import ( AdminMachineForm, get_machine_create_form, - get_machine_edit_form, MachineForm, ) from maasserver.forms.clone import CloneForm @@ -666,8 +666,7 @@ def update(self, request, system_id): system_id=system_id, user=request.user, perm=NodePermission.admin ) - Form = get_machine_edit_form(request.user) - form = Form(data=request.data, instance=machine) + form = AdminMachineForm(data=request.data, instance=machine) if form.is_valid(): return form.save() @@ -865,8 +864,7 @@ def deploy(self, request, system_id): if not series: series = Config.objects.get_config("default_distro_series") - Form = get_machine_edit_form(request.user) - form = Form(instance=machine, data={}) + form = MachineForm(instance=machine, data={}) form.set_distro_series(series=series) if license_key is not None: form.set_license_key(license_key=license_key) @@ -2098,7 +2096,7 @@ def create(self, request): request.data, "deployed", default=False, validator=StringBool ) machine = create_machine(request) - if request.user.is_superuser and commission and not deployed: + if can_edit_machines(request.user) and commission and not deployed: form = CommissionForm( instance=machine, user=request.user, data=request.data ) @@ -2700,7 +2698,7 @@ def _get_chassis_param(self, request): return chassis_type - @admin_method + @check_permission("can_edit_machines") @operation(idempotent=False) def add_chassis(self, request): """@description-title Add special hardware diff --git a/src/maasserver/api/networks.py b/src/maasserver/api/networks.py index 8400b6983..3d4017239 100644 --- a/src/maasserver/api/networks.py +++ b/src/maasserver/api/networks.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Network`.""" @@ -8,7 +8,7 @@ from maasserver.api.subnets import SubnetHandler, SubnetsHandler from maasserver.api.support import ( - admin_method, + check_permission, deprecated, operation, OperationsHandler, @@ -59,7 +59,7 @@ def read(self, request, name): Subnet.objects.get_object_by_specifiers_or_raise(name) ) - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, name): """Update network definition. @@ -80,7 +80,7 @@ def update(self, request, name): """ return rc.NOT_HERE - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, name): """Delete network definition. @@ -89,7 +89,7 @@ def delete(self, request, name): """ return rc.NOT_HERE - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def connect_macs(self, request, name): """Connect the given MAC addresses to this network. @@ -99,7 +99,7 @@ def connect_macs(self, request, name): """ return rc.NOT_HERE - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def disconnect_macs(self, request, name): """Disconnect the given MAC addresses from this network. @@ -175,7 +175,7 @@ def read(self, request): raise MAASAPIValidationError(form.errors) return render_networks_json(form.filter_subnets(Subnet.objects.all())) - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """Define a network. diff --git a/src/maasserver/api/nodedevices.py b/src/maasserver/api/nodedevices.py index bf1f29d81..9c17d1c21 100644 --- a/src/maasserver/api/nodedevices.py +++ b/src/maasserver/api/nodedevices.py @@ -1,4 +1,4 @@ -# Copyright 2020 Canonical Ltd. This software is licensed under the +# Copyright 2020-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `NodeDevice`.""" @@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404 from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.api.utils import get_optional_param from maasserver.exceptions import MAASAPIValidationError from maasserver.models import Node, NodeDevice @@ -227,7 +227,7 @@ def read(self, request, system_id, id): """ return self._get_node_device(request, system_id, id) - @admin_method + @check_permission("can_edit_machines") def delete(self, request, system_id, id): """@description-title Delete a node device @description Delete a node device with the given system_id and id. diff --git a/src/maasserver/api/nodes.py b/src/maasserver/api/nodes.py index c11a58bcf..f8b4dec8e 100644 --- a/src/maasserver/api/nodes.py +++ b/src/maasserver/api/nodes.py @@ -1,4 +1,4 @@ -# Copyright 2012-2021 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). __all__ = [ @@ -18,8 +18,8 @@ from maascommon.fields import MAC_FIELD_RE, normalise_macaddress from maasserver.api.support import ( - admin_method, AnonymousOperationsHandler, + check_permission, deprecated, operation, OperationsHandler, @@ -1280,7 +1280,7 @@ def abort(self, request, system_id): class PowersMixin: """Mixin which adds power commands to a nodes type.""" - @admin_method + @check_permission("can_edit_machines") @operation(idempotent=True) def power_parameters(self, request): """@description-title Get power parameters diff --git a/src/maasserver/api/notification.py b/src/maasserver/api/notification.py index af86c5977..e918aa79d 100644 --- a/src/maasserver/api/notification.py +++ b/src/maasserver/api/notification.py @@ -1,11 +1,16 @@ -# Copyright 2017 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from django.shortcuts import get_object_or_404 from piston3.utils import rc -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) +from maasserver.authorization import can_view_notifications from maasserver.exceptions import MAASAPIForbidden, MAASAPIValidationError from maasserver.forms.notification import NotificationForm from maasserver.models.notification import Notification @@ -50,7 +55,7 @@ def read(self, request): """ return Notification.objects.find_for_user(request.user).order_by("id") - @admin_method + @check_permission("can_edit_notifications") def create(self, request): """@description-title Create a notification @description Create a new notification. @@ -124,15 +129,14 @@ def read(self, request, id): No Notification matches the given query. """ notification = get_object_or_404(Notification, id=id) - if ( - notification.is_relevant_to(request.user) - or request.user.is_superuser + if notification.is_relevant_to(request.user) or can_view_notifications( + request.user ): return notification else: raise MAASAPIForbidden() - @admin_method + @check_permission("can_edit_notifications") def update(self, request, id): """@description-title Update a notification @description Update a notification with a given id. @@ -189,7 +193,7 @@ def update(self, request, id): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_notifications") def delete(self, request, id): """@description-title Delete a notification @description Delete a notification with a given id. diff --git a/src/maasserver/api/packagerepositories.py b/src/maasserver/api/packagerepositories.py index b1dbb89d7..5dbbe65c8 100644 --- a/src/maasserver/api/packagerepositories.py +++ b/src/maasserver/api/packagerepositories.py @@ -1,11 +1,11 @@ -# Copyright 2016-2018 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from piston3.utils import rc from maascommon.logging.security import CREATED, DELETED -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.audit import create_audit_event from maasserver.enum import ENDPOINT from maasserver.exceptions import MAASAPIValidationError @@ -69,7 +69,7 @@ def read(self, request, id): """ return PackageRepository.objects.get_object_or_404(id) - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, id): """@description-title Update a package repository @description Update the package repository with the given id. @@ -128,7 +128,7 @@ def update(self, request, id): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, id): """@description-title Delete a package repository @description Delete a package repository with the given id. @@ -181,7 +181,7 @@ def read(self, request): """ return PackageRepository.objects.all() - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a package repository @description Create a new package repository. diff --git a/src/maasserver/api/partitions.py b/src/maasserver/api/partitions.py index 8fed3a1e6..51e82600e 100644 --- a/src/maasserver/api/partitions.py +++ b/src/maasserver/api/partitions.py @@ -1,4 +1,4 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Partition`.""" @@ -10,6 +10,7 @@ from maasserver.api.support import operation, OperationsHandler from maasserver.api.utils import get_mandatory_param +from maasserver.authorization import can_edit_machine_in_pool from maasserver.enum import NODE_STATUS from maasserver.exceptions import ( MAASAPIBadRequest, @@ -58,7 +59,9 @@ def raise_error_for_invalid_state_on_allocated_operations( "Cannot %s partition because the node is not Ready " "or Allocated." % operation ) - if node.status == NODE_STATUS.READY and not user.is_superuser: + if node.status == NODE_STATUS.READY and not can_edit_machine_in_pool( + user, node.pool_id + ): raise PermissionDenied( "Cannot %s partition because you don't have the " "permissions on a Ready node." % operation diff --git a/src/maasserver/api/pods.py b/src/maasserver/api/pods.py index 867dda2e0..161654afc 100644 --- a/src/maasserver/api/pods.py +++ b/src/maasserver/api/pods.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Pod`.""" @@ -8,12 +8,7 @@ from formencode.validators import String from piston3.utils import rc -from maasserver.api.support import ( - admin_method, - deprecated, - operation, - OperationsHandler, -) +from maasserver.api.support import deprecated, operation, OperationsHandler from maasserver.api.utils import get_mandatory_param from maasserver.exceptions import MAASAPIValidationError, PodProblem from maasserver.forms.pods import ComposeMachineForm, DeletePodForm, PodForm @@ -115,7 +110,6 @@ def host(cls, pod): # object has more data associated with it. return {"system_id": system_id, "__incomplete__": True} - @admin_method def update(self, request, id): """@description-title Update a specific VM host @description Update a specific VM host by ID. @@ -165,7 +159,6 @@ def update(self, request, id): _try_sync_and_save(pod, request.user) return pod - @admin_method def delete(self, request, id): """@description-title Deletes a VM host @description Deletes a VM host with the given ID. @@ -196,7 +189,6 @@ def delete(self, request, id): pod.delete_and_wait(decompose=form.cleaned_data["decompose"]) return rc.DELETED - @admin_method @operation(idempotent=False) def refresh(self, request, id): """@description-title Refresh a VM host @@ -222,7 +214,6 @@ def refresh(self, request, id): pod = Pod.objects.get_pod_or_404(id, request.user, PodPermission.edit) return discover_and_sync_vmhost(pod, request.user) - @admin_method @operation(idempotent=True) def parameters(self, request, id): """@description-title Obtain VM host parameters @@ -253,7 +244,6 @@ def parameters(self, request, id): pod = Pod.objects.get_pod_or_404(id, request.user, PodPermission.edit) return pod.get_power_parameters() - @admin_method @operation(idempotent=False) def compose(self, request, id): """@description-title Compose a virtual machine on the host. @@ -351,7 +341,6 @@ def compose(self, request, id): else: raise MAASAPIValidationError(form.errors) - @admin_method @operation(idempotent=False) def add_tag(self, request, id): """@description-title Add a tag to a VM host @@ -386,7 +375,6 @@ def add_tag(self, request, id): pod.save() return pod - @admin_method @operation(idempotent=False) def remove_tag(self, request, id): """@description-title Remove a tag from a VM host @@ -466,7 +454,6 @@ def read(self, request): "id" ) - @admin_method def create(self, request): """@description-title Create a VM host @description Create or discover a new VM host. diff --git a/src/maasserver/api/rackcontrollers.py b/src/maasserver/api/rackcontrollers.py index d86a0d854..2f0f75b05 100644 --- a/src/maasserver/api/rackcontrollers.py +++ b/src/maasserver/api/rackcontrollers.py @@ -1,7 +1,7 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). - +from django.shortcuts import get_object_or_404 from formencode.validators import StringBool from piston3.utils import rc @@ -11,13 +11,12 @@ PowerMixin, PowersMixin, ) -from maasserver.api.support import admin_method, operation +from maasserver.api.support import check_permission, operation from maasserver.api.utils import get_optional_param from maasserver.clusterrpc.driver_parameters import get_all_power_types from maasserver.exceptions import MAASAPIValidationError from maasserver.forms import ControllerForm from maasserver.models import BootResource, RackController -from maasserver.permissions import NodePermission # Rack controller's fields exposed on the API. DISPLAYED_RACK_CONTROLLER_FIELDS = ( @@ -79,6 +78,7 @@ class RackControllerHandler(NodeHandler, PowerMixin): model = RackController fields = DISPLAYED_RACK_CONTROLLER_FIELDS + @check_permission("can_edit_controllers") def delete(self, request, system_id): """@description-title Delete a rack controller @description Deletes a rack controller with the given system_id. A @@ -114,15 +114,13 @@ def delete(self, request, system_id): currently set as a primary rack controller on VLANs fabric-0.untagged and no other rack controller can provide DHCP. """ - node = self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.admin - ) - node.as_self().delete( + rack = get_object_or_404(self.model, system_id=system_id) + rack.as_self().delete( force=get_optional_param(request.GET, "force", False, StringBool) ) return rc.DELETED - @admin_method + @check_permission("can_edit_controllers") def update(self, request, system_id): """@description-title Update a rack controller @description Updates a rack controller with the given system_id. @@ -167,9 +165,7 @@ def update(self, request, system_id): @error (http-status-code) "403" 403 @error (content) "no-perms" This method is reserved for admin users. """ - rack = self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.admin - ) + rack = get_object_or_404(self.model, system_id=system_id) form = ControllerForm(data=request.data, instance=rack) if form.is_valid(): @@ -177,7 +173,7 @@ def update(self, request, system_id): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_controllers") @operation(idempotent=False) def import_boot_images(self, request, system_id): """@description-title Import boot images @@ -196,12 +192,10 @@ def import_boot_images(self, request, system_id): @error-example "not-found" No RackController matches the given query. """ - self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.admin - ) + get_object_or_404(self.model, system_id=system_id) return rc.ACCEPTED - @admin_method + @check_permission("can_view_controllers") @operation(idempotent=True) def list_boot_images(self, request, system_id): """@description-title List available boot images @@ -218,9 +212,7 @@ def list_boot_images(self, request, system_id): @error-example "not-found" No RackController matches the given query. """ - self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.view - ) + get_object_or_404(self.model, system_id=system_id) images = [] for res in BootResource.objects.all(): arch, subarch = res.split_arch() @@ -259,7 +251,7 @@ class RackControllersHandler(NodesHandler, PowersMixin): api_doc_section_name = "RackControllers" base_model = RackController - @admin_method + @check_permission("can_edit_controllers") @operation(idempotent=False) def import_boot_images(self, request): """@description-title Import boot images on all rack controllers @@ -270,7 +262,7 @@ def import_boot_images(self, request): """ return rc.ACCEPTED - @admin_method + @check_permission("can_view_controllers") @operation(idempotent=True) def describe_power_types(self, request): """@description-title Get power information from rack controllers diff --git a/src/maasserver/api/regioncontrollers.py b/src/maasserver/api/regioncontrollers.py index cf0bafe81..3c8c9cbf6 100644 --- a/src/maasserver/api/regioncontrollers.py +++ b/src/maasserver/api/regioncontrollers.py @@ -1,17 +1,16 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). - +from django.shortcuts import get_object_or_404 from formencode.validators import StringBool from piston3.utils import rc from maasserver.api.nodes import NodeHandler, NodesHandler -from maasserver.api.support import admin_method +from maasserver.api.support import check_permission from maasserver.api.utils import get_optional_param from maasserver.exceptions import MAASAPIValidationError from maasserver.forms import ControllerForm from maasserver.models import RegionController -from maasserver.permissions import NodePermission # Region controller's fields exposed on the API. DISPLAYED_REGION_CONTROLLER_FIELDS = ( @@ -72,6 +71,7 @@ class RegionControllerHandler(NodeHandler): model = RegionController fields = DISPLAYED_REGION_CONTROLLER_FIELDS + @check_permission("can_edit_controllers") def delete(self, request, system_id): """@description-title Delete a region controller @description Deletes a region controller with the given system_id. @@ -103,15 +103,13 @@ def delete(self, request, system_id): @error (content) "cannot-delete" If MAAS is unable to delete the region controller. """ - node = self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.admin - ) + node = get_object_or_404(self.model, system_id=system_id) node.as_self().delete( force=get_optional_param(request.GET, "force", False, StringBool) ) return rc.DELETED - @admin_method + @check_permission("can_edit_controllers") def update(self, request, system_id): """@description-title Update a region controller @description Updates a region controller with the given system_id. @@ -159,9 +157,7 @@ def update(self, request, system_id): @error-example "no-perms" This method is reserved for admin users. """ - region = self.model.objects.get_node_or_404( - system_id=system_id, user=request.user, perm=NodePermission.admin - ) + region = get_object_or_404(self.model, system_id=system_id) form = ControllerForm(data=request.data, instance=region) if form.is_valid(): diff --git a/src/maasserver/api/reservedip.py b/src/maasserver/api/reservedip.py index 30af9d4d7..17b533bcb 100644 --- a/src/maasserver/api/reservedip.py +++ b/src/maasserver/api/reservedip.py @@ -1,10 +1,10 @@ -# Copyright 2024 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 django.core.handlers.wsgi import WSGIRequest from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.dhcp import configure_dhcp_on_agents from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.reservedip import ReservedIPForm @@ -42,7 +42,7 @@ def read(self, request: WSGIRequest): """ return ReservedIP.objects.all() - @admin_method + @check_permission("can_edit_global_entities") def create(self, request: WSGIRequest): """@description-title Create a Reserved IP @description Create a new Reserved IP. @@ -115,7 +115,7 @@ def read(self, request: WSGIRequest, id: int): reserved_ip = ReservedIP.objects.get_reserved_ip_or_404(id) return reserved_ip - @admin_method + @check_permission("can_edit_global_entities") def update(self, request: WSGIRequest, id: int): """@description-title Update a reserved IP @description Update a reserved IP given its ID. @@ -149,7 +149,7 @@ def update(self, request: WSGIRequest, id: int): else: raise MAASAPIValidationError(form.errors) - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request: WSGIRequest, id: int): """@description-title Delete a reserved IP @description Delete a reserved IP given its ID. diff --git a/src/maasserver/api/scriptresults.py b/src/maasserver/api/scriptresults.py index da1b1ff25..bb3be0443 100644 --- a/src/maasserver/api/scriptresults.py +++ b/src/maasserver/api/scriptresults.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `ScriptResults`.""" @@ -18,7 +18,11 @@ from formencode.validators import Bool, String, StringBool from piston3.utils import rc -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import get_optional_param from maasserver.exceptions import MAASAPIValidationError from maasserver.models import Node, ScriptSet @@ -326,7 +330,7 @@ def read(self, request, system_id, id): script_set.hardware_type = hardware_type return script_set - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, system_id, id): """@description-title Delete script results @description Delete script results from the given system_id with the @@ -509,7 +513,7 @@ def download(self, request, system_id, id): 'Unknown filetype "%s" must be txt or tar.xz' % filetype ) - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, system_id, id): """@description-title Update specific script result @description Update a set of test results for a given system_id and diff --git a/src/maasserver/api/scripts.py b/src/maasserver/api/scripts.py index a0e2509bb..8ef4aedb0 100644 --- a/src/maasserver/api/scripts.py +++ b/src/maasserver/api/scripts.py @@ -1,4 +1,4 @@ -# Copyright 2017-2019 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Script`.""" @@ -13,7 +13,11 @@ from piston3.utils import rc from maascommon.logging.security import DELETED, UPDATED -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import get_mandatory_param, get_optional_param from maasserver.audit import create_audit_event from maasserver.enum import ENDPOINT @@ -42,7 +46,7 @@ class NodeScriptsHandler(OperationsHandler): def resource_uri(cls): return ("scripts_handler", []) - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a new script @description Create a new script. @@ -278,7 +282,7 @@ def read(self, request, name): ) return script - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, name): """@description-title Delete a script @description Deletes a script with the given name. @@ -312,7 +316,7 @@ def delete(self, request, name): ) return rc.DELETED - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, name): """@description-title Update a script @description Update a script with the given name. @@ -440,7 +444,7 @@ def download(self, request, name): script.script.data, content_type="application/binary" ) - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def revert(self, request, name): """@description-title Revert a script version @@ -493,7 +497,7 @@ def gc_hook(value): except ValueError as e: raise MAASAPIValidationError(e.args[0]) # noqa: B904 - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def add_tag(self, request, name): """@description-title Add a tag @@ -537,7 +541,7 @@ def add_tag(self, request, name): ) return script - @admin_method + @check_permission("can_edit_global_entities") @operation(idempotent=False) def remove_tag(self, request, name): """@description-title Remove a tag diff --git a/src/maasserver/api/spaces.py b/src/maasserver/api/spaces.py index c575035f7..c28cb50fc 100644 --- a/src/maasserver/api/spaces.py +++ b/src/maasserver/api/spaces.py @@ -1,4 +1,4 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Space`.""" @@ -6,7 +6,7 @@ from django.db.models.query import QuerySet from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.exceptions import MAASAPIBadRequest, MAASAPIValidationError from maasserver.forms.space import SpaceForm from maasserver.models import Space, Subnet, VLAN @@ -76,7 +76,7 @@ def read(self, request): spaces_query.__class__ = SpacesQuerySet return spaces_query - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a space @description Create a new space. diff --git a/src/maasserver/api/ssh_keys.py b/src/maasserver/api/ssh_keys.py index 9d6abca0e..eeb233287 100644 --- a/src/maasserver/api/ssh_keys.py +++ b/src/maasserver/api/ssh_keys.py @@ -1,4 +1,4 @@ -# Copyright 2014-2018 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `SSHKey`.""" @@ -19,6 +19,7 @@ from maasserver.api.support import operation, OperationsHandler from maasserver.api.utils import get_optional_param from maasserver.audit import create_audit_event +from maasserver.authorization import can_edit_global_entities from maasserver.enum import ENDPOINT, KEYS_PROTOCOL_TYPE from maasserver.exceptions import MAASAPIBadRequest, MAASAPIValidationError from maasserver.forms import SSHKeyForm @@ -70,7 +71,8 @@ def create(self, request): """ user = request.user username = get_optional_param(request.POST, "user") - if username is not None and request.user.is_superuser: + user_can_edit_global_entities = can_edit_global_entities(request.user) + if username is not None and user_can_edit_global_entities: supplied_user = get_one(User.objects.filter(username=username)) if supplied_user is not None: user = supplied_user @@ -80,7 +82,7 @@ def create(self, request): raise MAASAPIValidationError( "Supplied username does not match any current users." ) - elif username is not None and not request.user.is_superuser: + elif username is not None and not user_can_edit_global_entities: raise MAASAPIValidationError( "Only administrators can specify a user" " when creating an SSH key." diff --git a/src/maasserver/api/staticroutes.py b/src/maasserver/api/staticroutes.py index 9eb083357..72ba38f30 100644 --- a/src/maasserver/api/staticroutes.py +++ b/src/maasserver/api/staticroutes.py @@ -1,11 +1,11 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `StaticRoute`.""" from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import check_permission, OperationsHandler from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.staticroute import StaticRouteForm from maasserver.models import StaticRoute @@ -44,7 +44,7 @@ def read(self, request): """ return StaticRoute.objects.all() - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a static route @description Creates a static route. diff --git a/src/maasserver/api/subnets.py b/src/maasserver/api/subnets.py index fecfb70f2..c18fa2e54 100644 --- a/src/maasserver/api/subnets.py +++ b/src/maasserver/api/subnets.py @@ -1,4 +1,4 @@ -# Copyright 2015-2021 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Subnet`.""" @@ -7,7 +7,11 @@ from piston3.utils import rc from maascommon.utils.network import IPRangeStatistics -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import get_optional_param from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.subnet import SubnetForm @@ -55,7 +59,7 @@ def read(self, request): """ return Subnet.objects.all() - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a subnet @description Creates a new subnet. diff --git a/src/maasserver/api/support.py b/src/maasserver/api/support.py index 0e5b8d0ea..ce2e52a57 100644 --- a/src/maasserver/api/support.py +++ b/src/maasserver/api/support.py @@ -1,10 +1,10 @@ -# Copyright 2012-2016 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Supporting infrastructure for Piston-based APIs in MAAS.""" __all__ = [ - "admin_method", + "check_permission", "AnonymousOperationsHandler", "ModelCollectionOperationsHandler", "ModelOperationsHandler", @@ -26,7 +26,10 @@ from piston3.resource import Resource from piston3.utils import HttpStatusCode, rc +from maascommon.constants import MAAS_USER_USERNAME +from maasserver import openfga from maasserver.api.doc import get_api_description +from maasserver.authorization import can_edit_global_entities from maasserver.exceptions import ( MAASAPIBadRequest, MAASAPIValidationError, @@ -37,6 +40,8 @@ log = LegacyLogger() +UNAUTHORIZED_MESSAGE = "User is not allowed access to this API." + class OperationsResource(Resource): """A resource supporting operation dispatch. @@ -110,7 +115,7 @@ def __init__(self, handler, *, authentication): def authenticate(self, request, rm): actor, anonymous = super().authenticate(request, rm) if not anonymous and not request.user.is_active: - raise PermissionDenied("User is not allowed access to this API.") + raise PermissionDenied(UNAUTHORIZED_MESSAGE) else: return actor, anonymous @@ -120,8 +125,8 @@ class AdminRestrictedResource(RestrictedResource): def authenticate(self, request, rm): actor, anonymous = super().authenticate(request, rm) - if anonymous or not request.user.is_superuser: - raise PermissionDenied("User is not allowed access to this API.") + if anonymous or not can_edit_global_entities(request.user): + raise PermissionDenied(UNAUTHORIZED_MESSAGE) else: return actor, anonymous @@ -200,27 +205,52 @@ def apply_to_methods(cls): return _decorator -METHOD_RESERVED_ADMIN = "This method is reserved for admin users." +METHOD_RESERVED_INTERNAL = ( + "This method is reserved for the MAAS internal user." +) -def admin_method(func): - """Decorator to protect a method from non-admin users. - - If a non-admin tries to call a method decorated with this decorator, - they will get an HTTP "forbidden" error and a message saying the - operation is accessible only to administrators. - """ +def internal_method(func): + """Decorator to protect a method from non-MAAS-internal users.""" @wraps(func) def wrapper(self, request, *args, **kwargs): - if not request.user.is_superuser: - raise PermissionDenied(METHOD_RESERVED_ADMIN) - else: - return func(self, request, *args, **kwargs) + if request.user.username != MAAS_USER_USERNAME: + raise PermissionDenied(METHOD_RESERVED_INTERNAL) + return func(self, request, *args, **kwargs) return wrapper +def check_permission(permission_method_name): + def decorator(func): + @wraps(func) + def wrapper(self, request, *args, **kwargs): + from maasserver.rbac import rbac + + if rbac.is_enabled(): + if request.user.is_superuser: + return func(self, request, *args, **kwargs) + raise PermissionDenied( + f"User does not have permission to perform this operation. The user should have permission '{permission_method_name}'" + ) + + client = openfga.get_openfga_client() + + permission_func = getattr(client, permission_method_name) + + if not permission_func(request.user): + raise PermissionDenied( + f"User does not have permission to perform this operation. The user should have permission '{permission_method_name}'" + ) + + return func(self, request, *args, **kwargs) + + return wrapper + + return decorator + + class OperationsHandlerType(HandlerMetaClass): """Type for handlers that dispatch operations. @@ -486,7 +516,7 @@ def read(self, request, **kwargs): raise PermissionDenied() return instance - @admin_method + @check_permission("can_edit_global_entities") def update(self, request, **kwargs): """PUT request. Update a model instance. @@ -501,7 +531,7 @@ def update(self, request, **kwargs): raise MAASAPIValidationError(form.errors) return form.save() - @admin_method + @check_permission("can_edit_global_entities") def delete(self, request, **kwargs): """DELETE request. Delete a model instance.""" filters = {self.id_field: kwargs[self.id_field]} diff --git a/src/maasserver/api/tags.py b/src/maasserver/api/tags.py index ba1e7a290..f439df665 100644 --- a/src/maasserver/api/tags.py +++ b/src/maasserver/api/tags.py @@ -1,4 +1,4 @@ -# Copyright 2014-2022 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Tag`.""" @@ -14,7 +14,11 @@ from maascommon.logging.security import CREATED, DELETED, UPDATED from maasserver.api.nodes import NODES_PREFETCH, NODES_SELECT_RELATED -from maasserver.api.support import operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import ( extract_oauth_key, get_list_from_dict_or_multidict, @@ -60,13 +64,6 @@ def check_rack_controller_access(request, rack_controller): ) -def get_tag_or_404(name, user, to_edit=False): - """Fetch a Tag by name or raise an Http404 exception.""" - if to_edit and not user.is_superuser: - raise PermissionDenied() - return get_object_or_404(Tag, name=name) - - class TagHandler(OperationsHandler): """ Tags are properties that can be associated with a Node and serve as @@ -98,8 +95,9 @@ def read(self, request, name): @error-example "not-found" No Tag matches the given query. """ - return get_tag_or_404(name=name, user=request.user) + return get_object_or_404(Tag, name=name) + @check_permission("can_edit_global_entities") def update(self, request, name): """@description-title Update a tag @description Update elements of a given tag. @@ -128,7 +126,7 @@ def update(self, request, name): @error-example "not-found" No Tag matches the given query. """ - tag = get_tag_or_404(name=name, user=request.user, to_edit=True) + tag = get_object_or_404(Tag, name=name) name = tag.name form = TagForm(request.data, instance=tag) if not form.is_valid(): @@ -154,6 +152,7 @@ def update(self, request, name): ) return new_tag + @check_permission("can_edit_global_entities") def delete(self, request, name): """@description-title Delete a tag @description Deletes a tag by name. @@ -168,7 +167,7 @@ def delete(self, request, name): @error-example "not-found" No Tag matches the given query. """ - tag = get_tag_or_404(name=name, user=request.user, to_edit=True) + tag = get_object_or_404(Tag, name=name) tag.delete() create_audit_event( EVENT_TYPES.TAG, @@ -187,7 +186,7 @@ def _get_node_type(self, model, request, name): # This is done because this operation actually returns a list of nodes # and not a list of tags as this handler is defined to return. self.fields = None - tag = get_tag_or_404(name=name, user=request.user) + tag = get_object_or_404(Tag, name=name) nodes = model.objects.get_nodes( request.user, NodePermission.view, from_nodes=tag.node_set.all() ) @@ -312,6 +311,7 @@ def _get_nodes_for(self, request, param): nodes = Node.objects.none() return nodes + @check_permission("can_edit_global_entities") @operation(idempotent=False) def rebuild(self, request, name): """@description-title Trigger a tag-node mapping rebuild @@ -332,10 +332,11 @@ def rebuild(self, request, name): @error-example "not-found" No Tag matches the given query. """ - tag = get_tag_or_404(name=name, user=request.user, to_edit=True) + tag = get_object_or_404(Tag, name=name) tag.populate_nodes() return {"rebuilding": tag.name} + @check_permission("can_edit_global_entities") @operation(idempotent=False) def update_nodes(self, request, name): """@description-title Update nodes associated with this tag @@ -385,10 +386,7 @@ def update_nodes(self, request, name): @error-example "not-found" No Tag matches the given query. """ - if not request.user.is_superuser: - raise PermissionDenied() - - tag = get_tag_or_404(name=name, user=request.user) + tag = get_object_or_404(Tag, name=name) definition = request.data.get("definition", None) if definition is not None and tag.definition != definition: return HttpResponse( @@ -424,6 +422,7 @@ class TagsHandler(OperationsHandler): api_doc_section_name = "Tags" update = delete = None + @check_permission("can_edit_global_entities") def create(self, request): """@description-title Create a new tag @description Create a new tag. @@ -459,9 +458,6 @@ def create(self, request): @error-example "no-perms" No content """ - if not request.user.is_superuser: - raise PermissionDenied() - form = TagForm(request.data) if not form.is_valid(): raise MAASAPIValidationError(form.errors) diff --git a/src/maasserver/api/tests/test_blockdevice.py b/src/maasserver/api/tests/test_blockdevice.py index 6fb432601..0bf5853bd 100644 --- a/src/maasserver/api/tests/test_blockdevice.py +++ b/src/maasserver/api/tests/test_blockdevice.py @@ -1,4 +1,4 @@ -# Copyright 2015-2022 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import http.client @@ -7,6 +7,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import FILESYSTEM_GROUP_TYPE, FILESYSTEM_TYPE, NODE_STATUS from maasserver.models.blockdevice import MIN_BLOCK_DEVICE_SIZE from maasserver.testing.api import APITestCase @@ -1191,3 +1192,101 @@ def test_set_boot_disk_sets_boot_disk_for_admin(self): ) node = reload_object(node) self.assertEqual(block_device, node.boot_disk) + + +class TestBlockDeviceAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_read_calls_openfga(self): + self.openfga_client.can_view_machines_in_pool.return_value = True + + machine_owner = factory.make_User() + node = factory.make_Node(owner=machine_owner) + block_device = factory.make_PhysicalBlockDevice(node=node) + factory.make_Filesystem(block_device=block_device) + uri = get_blockdevices_uri(node) + response = self.client.get(uri) + + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_view_machines_in_pool.assert_called_once_with( + self.user, node.pool.id + ) + + def test_create_physicalblockdevice_calls_openfga(self): + self.openfga_client.can_edit_machines_in_pool.return_value = True + node = factory.make_Node(with_boot_disk=False) + uri = get_blockdevices_uri(node) + response = self.client.post( + uri, + { + "name": "sda", + "block_size": 1024, + "size": MIN_BLOCK_DEVICE_SIZE, + "path": "/dev/sda", + "model": "A2M0003", + "serial": "42", + }, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + self.user, node.pool.id + ) + + def test_format_calls_openfga(self): + self.openfga_client.can_edit_machines_in_pool.return_value = True + node = factory.make_Node(status=NODE_STATUS.READY) + block_device = factory.make_VirtualBlockDevice(node=node) + fstype = factory.pick_filesystem_type() + fsuuid = "%s" % uuid.uuid4() + uri = get_blockdevice_uri(block_device) + response = self.client.post( + uri, {"op": "format", "fstype": fstype, "uuid": fsuuid} + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + self.user, node.pool.id + ) + + def test_unformat_calls_openfga(self): + self.openfga_client.can_edit_machines_in_pool.return_value = True + node = factory.make_Node(status=NODE_STATUS.READY) + block_device = factory.make_VirtualBlockDevice(node=node) + factory.make_Filesystem(block_device=block_device) + uri = get_blockdevice_uri(block_device) + response = self.client.post(uri, {"op": "unformat"}) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + self.user, node.pool.id + ) + + def test_mount_calls_openfga(self): + self.openfga_client.can_edit_machines_in_pool.return_value = True + node = factory.make_Node(status=NODE_STATUS.READY) + block_device = factory.make_VirtualBlockDevice(node=node) + factory.make_Filesystem(block_device=block_device) + mount_point = factory.make_absolute_path() + mount_options = factory.make_name("mount-options") + uri = get_blockdevice_uri(block_device) + response = self.client.post( + uri, + { + "op": "mount", + "mount_point": mount_point, + "mount_options": mount_options, + }, + ) + + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + self.user, node.pool.id + ) diff --git a/src/maasserver/api/tests/test_boot_resources.py b/src/maasserver/api/tests/test_boot_resources.py index 713dd2333..d03ed7385 100644 --- a/src/maasserver/api/tests/test_boot_resources.py +++ b/src/maasserver/api/tests/test_boot_resources.py @@ -1,4 +1,4 @@ -# Copyright 2014-2025 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the `Boot Resources` API.""" @@ -22,6 +22,7 @@ boot_resource_to_dict, filestore_add_file, ) +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import ( BOOT_RESOURCE_FILE_TYPE, BOOT_RESOURCE_TYPE, @@ -614,3 +615,53 @@ def test_PUT_with_multiple_requests_and_large_content(self): ) self.assertEqual(content, self.read_content(rfile)) mock_filestore.assert_called_once() + + +class TestBootResourceFileUploadAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_PUT_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + params = { + "name": factory.make_name("name"), + "architecture": make_usable_architecture(self), + "content": (factory.make_file_upload(content=sample_binary_data)), + } + response = self.client.post(reverse("boot_resources_handler"), params) + self.assertNotEqual(http.client.FORBIDDEN, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + def test_stop_import_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + self.patch(boot_resources, "stop_import_resources") + response = self.client.post( + reverse("boot_resources_handler"), {"op": "stop_import"} + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + def test_import_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + self.patch(boot_resources, "import_resources") + response = self.client.post( + reverse("boot_resources_handler"), {"op": "import"} + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + def test_DELETE_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + + resource = factory.make_BootResource() + response = self.client.delete(get_boot_resource_uri(resource)) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.assertIsNone(reload_object(resource)) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_boot_source_selections.py b/src/maasserver/api/tests/test_boot_source_selections.py index 47059673f..c657cca50 100644 --- a/src/maasserver/api/tests/test_boot_source_selections.py +++ b/src/maasserver/api/tests/test_boot_source_selections.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the `Boot Source Selections` API.""" @@ -11,6 +11,7 @@ DISPLAYED_BOOTSOURCESELECTION_FIELDS, ) from maasserver.audit import Event +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import BootSourceSelection from maasserver.models.signals import bootsources from maasserver.testing.api import APITestCase @@ -327,3 +328,68 @@ def test_POST_requires_admin(self): params, ) self.assertEqual(http.client.FORBIDDEN, response.status_code) + + +class TestBootSourceSelectionOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_GET_requires_can_view_boot_entities(self): + self.openfga_client.can_view_boot_entities.return_value = True + boot_source_selection = factory.make_BootSourceSelection() + response = self.client.get( + get_boot_source_selection_uri(boot_source_selection) + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_boot_entities.assert_called_once_with( + self.user + ) + + def test_PUT_requires_can_view_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + boot_source_selection = factory.make_BootSourceSelection() + new_values = {"release": factory.make_name("release")} + response = self.client.put( + get_boot_source_selection_uri(boot_source_selection), new_values + ) + self.assertNotEqual(http.client.FORBIDDEN, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + def test_DELETE_requires_can_view_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + boot_source_selection = factory.make_BootSourceSelection() + response = self.client.delete( + get_boot_source_selection_uri(boot_source_selection) + ) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + +class TestBootSourceSelectionsOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_GET_requires_can_view_boot_entities(self): + self.openfga_client.can_view_boot_entities.return_value = True + boot_source = factory.make_BootSource() + response = self.client.get( + reverse("boot_source_selections_handler", args=[boot_source.id]) + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_boot_entities.assert_called_once_with( + self.user + ) + + def test_POST_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + boot_source = factory.make_BootSource() + response = self.client.post( + reverse("boot_source_selections_handler", args=[boot_source.id]), + {}, + ) + self.assertNotEqual(http.client.FORBIDDEN, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_boot_sources.py b/src/maasserver/api/tests/test_boot_sources.py index 4ae5d857c..995a64375 100644 --- a/src/maasserver/api/tests/test_boot_sources.py +++ b/src/maasserver/api/tests/test_boot_sources.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the `Boot Sources` API.""" @@ -9,6 +9,7 @@ from maasserver.api.boot_sources import DISPLAYED_BOOTSOURCE_FIELDS from maasserver.audit import Event +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import BootSource from maasserver.models.signals import bootsources from maasserver.testing.api import APITestCase @@ -260,3 +261,63 @@ def test_POST_requires_admin(self): } response = self.client.post(reverse("boot_sources_handler"), params) self.assertEqual(http.client.FORBIDDEN, response.status_code) + + +class TestBootSourceOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_GET_requires_can_view_boot_entities(self): + self.openfga_client.can_view_boot_entities.return_value = True + factory.make_BootSource() + response = self.client.get(reverse("boot_sources_handler")) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_boot_entities.assert_called_once_with( + self.user + ) + + def test_PUT_requires_can_view_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + boot_source = factory.make_BootSource() + new_values = { + "url": "http://example.com/", + "keyring_filename": factory.make_name("filename"), + } + response = self.client.put( + get_boot_source_uri(boot_source), new_values + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + def test_DELETE_requires_can_view_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + boot_source = factory.make_BootSource() + response = self.client.delete(get_boot_source_uri(boot_source)) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) + + +class TestBootSourcesOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_GET_requires_can_view_boot_entities(self): + self.openfga_client.can_view_boot_entities.return_value = True + response = self.client.get(reverse("boot_sources_handler")) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_boot_entities.assert_called_once_with( + self.user + ) + + def test_POST_requires_can_edit_boot_entities(self): + self.openfga_client.can_edit_boot_entities.return_value = True + params = { + "url": "http://example.com/", + "keyring_filename": "", + "keyring_data": ( + factory.make_file_upload(content=sample_binary_data) + ), + } + response = self.client.post(reverse("boot_sources_handler"), params) + self.assertEqual(http.client.CREATED, response.status_code) + self.openfga_client.can_edit_boot_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_devices.py b/src/maasserver/api/tests/test_devices.py index 84c179f43..613dfcae6 100644 --- a/src/maasserver/api/tests/test_devices.py +++ b/src/maasserver/api/tests/test_devices.py @@ -1,4 +1,4 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). @@ -8,6 +8,7 @@ from django.urls import reverse from maasserver.api import auth +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import NODE_STATUS, NODE_TYPE from maasserver.models import Device, Domain from maasserver.models import node as node_module @@ -433,3 +434,43 @@ def test_restore_default_configuration_requires_admin(self): self.assertEqual( http.client.FORBIDDEN, response.status_code, response.content ) + + +class TestDevicesAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_list_can_view_own_devices(self): + self.openfga_client.can_view_devices.return_value = False + + d1 = factory.make_Device(owner=self.user) + d2 = factory.make_Device(owner=self.user) + factory.make_Device(owner=factory.make_User()) + + response = self.client.get(reverse("devices_handler")) + + self.assertEqual(http.client.OK, response.status_code) + self.assertCountEqual( + {d1.system_id, d2.system_id}, + { + device.get("system_id") + for device in json_load_bytes(response.content) + }, + ) + self.openfga_client.can_view_devices.assert_called_once_with(self.user) + + def test_list_can_view_all_devices(self): + self.openfga_client.can_view_devices.return_value = True + + d1 = factory.make_Device(owner=self.user) + d2 = factory.make_Device(owner=self.user) + d3 = factory.make_Device(owner=factory.make_User()) + + response = self.client.get(reverse("devices_handler")) + + self.assertEqual(http.client.OK, response.status_code) + self.assertCountEqual( + {d1.system_id, d2.system_id, d3.system_id}, + { + device.get("system_id") + for device in json_load_bytes(response.content) + }, + ) + self.openfga_client.can_view_devices.assert_called_once_with(self.user) diff --git a/src/maasserver/api/tests/test_dnsresourcerecords.py b/src/maasserver/api/tests/test_dnsresourcerecords.py index 36f52fc87..b6b9eaf54 100644 --- a/src/maasserver/api/tests/test_dnsresourcerecords.py +++ b/src/maasserver/api/tests/test_dnsresourcerecords.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for DNSResourceRecord API.""" @@ -10,6 +10,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.dnsdata import DNSData from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -399,3 +400,23 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestDNSResourceRecordAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + dnsresource_name = factory.make_name("dnsresource") + domain = factory.make_Domain() + fqdn = f"{dnsresource_name}.{domain.name}" + uri = get_dnsresourcerecords_uri() + response = self.client.post( + uri, {"fqdn": fqdn, "rrtype": "TXT", "rrdata": "Sample Text."} + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_dnsresources.py b/src/maasserver/api/tests/test_dnsresources.py index 36cd0626f..b5b73af7b 100644 --- a/src/maasserver/api/tests/test_dnsresources.py +++ b/src/maasserver/api/tests/test_dnsresources.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for DNSResource API.""" @@ -11,6 +11,7 @@ from django.urls import reverse from maasserver.api.dnsresources import get_dnsresource_queryset +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import NODE_STATUS from maasserver.models.dnsdata import DNSData from maasserver.models.dnsresource import DNSResource @@ -513,3 +514,28 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestDNSResourcesAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + dnsresource_name = factory.make_name("dnsresource") + domain = factory.make_Domain() + sip = factory.make_StaticIPAddress() + uri = get_dnsresources_uri() + response = self.client.post( + uri, + { + "name": dnsresource_name, + "domain": domain.id, + "ip_addresses": str(sip.ip), + }, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_domains.py b/src/maasserver/api/tests/test_domains.py index ce5385d60..0d035ca74 100644 --- a/src/maasserver/api/tests/test_domains.py +++ b/src/maasserver/api/tests/test_domains.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for Domain API.""" @@ -10,6 +10,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import GlobalDefault from maasserver.models.dnspublication import zone_serial from maasserver.models.domain import Domain @@ -250,3 +251,31 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestDomainAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + domain_name = factory.make_name("domain") + uri = get_domains_uri() + response = self.client.post(uri, {"name": domain_name}) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_can_set_serial(self): + self.openfga_client.can_edit_global_entities.return_value = True + uri = get_domains_uri() + serial = random.randint(1, INT_MAX) + response = self.client.post( + uri, {"op": "set_serial", "serial": str(serial)} + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_fabrics.py b/src/maasserver/api/tests/test_fabrics.py index 79bdf11b4..dd84016a6 100644 --- a/src/maasserver/api/tests/test_fabrics.py +++ b/src/maasserver/api/tests/test_fabrics.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for Fabric API.""" @@ -8,6 +8,7 @@ from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.fabric import Fabric from maasserver.models.signals import vlan as vlan_signals_module from maasserver.testing.api import APITestCase @@ -197,3 +198,17 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestFabricsAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + fabric_name = factory.make_name("fabric") + uri = get_fabrics_uri() + response = self.client.post(uri, {"name": fabric_name}) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_image_sync.py b/src/maasserver/api/tests/test_image_sync.py index 8804e041a..ce1f53555 100644 --- a/src/maasserver/api/tests/test_image_sync.py +++ b/src/maasserver/api/tests/test_image_sync.py @@ -1,4 +1,4 @@ -# Copyright 2023 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). @@ -17,7 +17,7 @@ def get_image_sync_uri(file_id, system_id): return reverse("image_sync_progress_handler", args=[file_id, system_id]) -class TestImageSyncProgressHandler(APITestCase.ForAdmin): +class TestImageSyncProgressHandler(APITestCase.ForInternalUser): def test_handler_path(self): self.assertEqual( "/MAAS/api/2.0/images-sync-progress/1/a/", @@ -141,7 +141,7 @@ def test_read_nonexistent_file(self): ) -class TestImagesSyncProgressHandler(APITestCase.ForAdmin): +class TestImagesSyncProgressHandler(APITestCase.ForInternalUser): def test_read(self): regions = [factory.make_RegionController() for _ in range(3)] resource = factory.make_BootResource(rtype=BOOT_RESOURCE_TYPE.UPLOADED) diff --git a/src/maasserver/api/tests/test_licensekey.py b/src/maasserver/api/tests/test_licensekey.py index 89e62e8f7..8f0ddba5e 100644 --- a/src/maasserver/api/tests/test_licensekey.py +++ b/src/maasserver/api/tests/test_licensekey.py @@ -1,4 +1,4 @@ -# Copyright 2014-2025 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the `LicenseKey` API.""" @@ -11,6 +11,7 @@ from maascommon.osystem import OperatingSystemRegistry, WindowsOS from maascommon.osystem.windows import REQUIRE_LICENSE_KEY from maasserver import forms +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import BOOT_RESOURCE_TYPE from maasserver.models.licensekey import LicenseKey from maasserver.testing.api import APITestCase @@ -35,32 +36,41 @@ def make_os(testcase): return osystem, release +def get_license_key_url(osystem, distro_series): + """Return the URL for the license key of the given osystem and + distro_series.""" + return reverse("license_key_handler", args=[osystem, distro_series]) + + +def make_license_key_with_os(testcase, license_key=None): + osystem, release = make_os(testcase) + license_key = factory.make_LicenseKey( + osystem=osystem, distro_series=release, license_key=license_key + ) + return license_key + + class TestLicenseKey(APITestCase.ForUser): def get_url(self, osystem, distro_series): """Return the URL for the license key of the given osystem and distro_series.""" return reverse("license_key_handler", args=[osystem, distro_series]) - def make_license_key_with_os(self, license_key=None): - osystem, release = make_os(self) - license_key = factory.make_LicenseKey( - osystem=osystem, distro_series=release, license_key=license_key - ) - return license_key - def test_handler_path(self): osystem = factory.make_name("osystem") distro_series = factory.make_name("series") self.assertEqual( f"/MAAS/api/2.0/license-key/{osystem}/{distro_series}", - self.get_url(osystem, distro_series), + get_license_key_url(osystem, distro_series), ) def test_POST_is_prohibited(self): self.become_admin() license_key = factory.make_LicenseKey() response = self.client.post( - self.get_url(license_key.osystem, license_key.distro_series), + get_license_key_url( + license_key.osystem, license_key.distro_series + ), {"osystem": "New osystem"}, ) self.assertEqual(http.client.METHOD_NOT_ALLOWED, response.status_code) @@ -70,7 +80,7 @@ def test_GET_returns_license_key(self): license_key = factory.make_LicenseKey() response = self.client.get( - self.get_url(license_key.osystem, license_key.distro_series) + get_license_key_url(license_key.osystem, license_key.distro_series) ) self.assertEqual(http.client.OK, response.status_code) @@ -92,25 +102,29 @@ def test_GET_returns_404_for_unknown_os_and_series(self): self.become_admin() self.assertEqual( http.client.NOT_FOUND, - self.client.get(self.get_url("noneos", "noneseries")).status_code, + self.client.get( + get_license_key_url("noneos", "noneseries") + ).status_code, ) def test_GET_requires_admin(self): license_key = factory.make_LicenseKey() response = self.client.get( - self.get_url(license_key.osystem, license_key.distro_series) + get_license_key_url(license_key.osystem, license_key.distro_series) ) self.assertEqual(http.client.FORBIDDEN, response.status_code) def test_PUT_updates_license_key(self): self.become_admin() - license_key = self.make_license_key_with_os() + license_key = make_license_key_with_os(self) self.patch_autospec(forms, "validate_license_key").return_value = True new_key = factory.make_name("key") new_values = {"license_key": new_key} response = self.client.put( - self.get_url(license_key.osystem, license_key.distro_series), + get_license_key_url( + license_key.osystem, license_key.distro_series + ), new_values, ) self.assertEqual(http.client.OK, response.status_code) @@ -119,9 +133,11 @@ def test_PUT_updates_license_key(self): def test_PUT_requires_admin(self): key = factory.make_name("key") - license_key = self.make_license_key_with_os(license_key=key) + license_key = make_license_key_with_os(self, license_key=key) response = self.client.put( - self.get_url(license_key.osystem, license_key.distro_series), + get_license_key_url( + license_key.osystem, license_key.distro_series + ), {"license_key": factory.make_name("key")}, ) self.assertEqual(http.client.FORBIDDEN, response.status_code) @@ -131,14 +147,16 @@ def test_PUT_returns_404_for_unknown_os_and_series(self): self.become_admin() self.assertEqual( http.client.NOT_FOUND, - self.client.put(self.get_url("noneos", "noneseries")).status_code, + self.client.put( + get_license_key_url("noneos", "noneseries") + ).status_code, ) def test_DELETE_deletes_license_key(self): self.become_admin() license_key = factory.make_LicenseKey() response = self.client.delete( - self.get_url(license_key.osystem, license_key.distro_series) + get_license_key_url(license_key.osystem, license_key.distro_series) ) self.assertEqual(http.client.NO_CONTENT, response.status_code) self.assertIsNone(reload_object(license_key)) @@ -146,7 +164,7 @@ def test_DELETE_deletes_license_key(self): def test_DELETE_requires_admin(self): license_key = factory.make_LicenseKey() response = self.client.delete( - self.get_url(license_key.osystem, license_key.distro_series) + get_license_key_url(license_key.osystem, license_key.distro_series) ) self.assertEqual(http.client.FORBIDDEN, response.status_code) self.assertIsNotNone(reload_object(license_key)) @@ -155,8 +173,8 @@ def test_DELETE_is_idempotent(self): osystem = factory.make_name("no-os") series = factory.make_name("no-series") self.become_admin() - response1 = self.client.delete(self.get_url(osystem, series)) - response2 = self.client.delete(self.get_url(osystem, series)) + response1 = self.client.delete(get_license_key_url(osystem, series)) + response2 = self.client.delete(get_license_key_url(osystem, series)) self.assertEqual(response1.status_code, response2.status_code) @@ -271,3 +289,75 @@ def test_POST_requires_admin(self): ) ) ) + + +class TestLicenseKeysAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_read_requires_can_view_license_keys(self): + self.openfga_client.can_view_license_keys.return_value = True + factory.make_LicenseKey() + response = self.client.get(reverse("license_keys_handler")) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_license_keys.assert_called_once_with( + self.user + ) + + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_license_keys.return_value = True + osystem, release = make_os(self) + self.patch_autospec(forms, "validate_license_key").return_value = True + params = { + "osystem": osystem, + "distro_series": release, + "license_key": factory.make_name("key"), + } + response = self.client.post(reverse("license_keys_handler"), params) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_license_keys.assert_called_once_with( + self.user + ) + + +class TestLicenseKeyAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_read_requires_can_view_license_keys(self): + self.openfga_client.can_view_license_keys.return_value = True + license_key = factory.make_LicenseKey() + response = self.client.get( + get_license_key_url(license_key.osystem, license_key.distro_series) + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_license_keys.assert_called_once_with( + self.user + ) + + def test_update_requires_can_edit_license_keys(self): + self.openfga_client.can_edit_license_keys.return_value = True + license_key = make_license_key_with_os(self) + self.patch_autospec(forms, "validate_license_key").return_value = True + new_key = factory.make_name("key") + new_values = {"license_key": new_key} + + response = self.client.put( + get_license_key_url( + license_key.osystem, license_key.distro_series + ), + new_values, + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_license_keys.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_license_keys(self): + self.openfga_client.can_edit_license_keys.return_value = True + license_key = factory.make_LicenseKey() + response = self.client.delete( + get_license_key_url(license_key.osystem, license_key.distro_series) + ) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_license_keys.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_maas.py b/src/maasserver/api/tests/test_maas.py index 9afcf1601..52b15e3c8 100644 --- a/src/maasserver/api/tests/test_maas.py +++ b/src/maasserver/api/tests/test_maas.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import http.client @@ -8,6 +8,7 @@ from django.urls import reverse from testtools.content import text_content +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import PackageRepository from maasserver.models.config import Config from maasserver.testing.api import APITestCase @@ -333,3 +334,17 @@ def test_set_config_maas_proxy_port(self): self.assertEqual( http.client.BAD_REQUEST, response.status_code, response.content ) + + +class TestMAASAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_set_config_requires_can_edit_configurations(self): + self.openfga_client.can_edit_configurations.return_value = True + ntp_servers = factory.make_hostname() + " " + factory.make_hostname() + response = self.client.post( + reverse("maas_handler"), + {"op": "set_config", "name": "ntp_server", "value": ntp_servers}, + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_configurations.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_machine.py b/src/maasserver/api/tests/test_machine.py index dc1c32aac..caf2357ec 100644 --- a/src/maasserver/api/tests/test_machine.py +++ b/src/maasserver/api/tests/test_machine.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from base64 import b64encode import http.client @@ -19,6 +19,7 @@ from maasserver import forms from maasserver.api import auth from maasserver.api import machines as machines_module +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import ( BRIDGE_TYPE, FILESYSTEM_FORMAT_TYPE_CHOICES, @@ -4274,3 +4275,53 @@ def test_exit_rescue_mode_changes_state(self): self.assertEqual( NODE_STATUS.EXITING_RESCUE_MODE, reload_object(machine).status ) + + +class TestMachineHandlerOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_commission_succees_if_edit_access(self): + self.patch(node_module.Node, "_start").return_value = defer.succeed( + None + ) + + p = factory.make_ResourcePool() + self.openfga_client.can_edit_machines_in_pool.return_value = True + + machine = factory.make_Node( + status=NODE_STATUS.READY, + owner=factory.make_User(), + power_state=POWER_STATE.OFF, + interface=True, + pool=p, + ) + + machine_uri = reverse("machine_handler", args=[machine.system_id]) + + response = self.client.post(machine_uri, {"op": "commission"}) + self.assertEqual(http.client.OK, response.status_code) + self.assertEqual( + NODE_STATUS.COMMISSIONING, reload_object(machine).status + ) + + def test_commission_403(self): + self.patch(node_module.Node, "_start").return_value = defer.succeed( + None + ) + + p = factory.make_ResourcePool() + self.openfga_client.can_edit_machines_in_pool.return_value = False + + machine = factory.make_Node( + status=NODE_STATUS.READY, + owner=factory.make_User(), + power_state=POWER_STATE.OFF, + interface=True, + pool=p, + ) + + machine_uri = reverse("machine_handler", args=[machine.system_id]) + + response = self.client.post(machine_uri, {"op": "commission"}) + self.assertEqual(http.client.FORBIDDEN, response.status_code) + self.assertEqual(NODE_STATUS.READY, reload_object(machine).status) diff --git a/src/maasserver/api/tests/test_machines.py b/src/maasserver/api/tests/test_machines.py index 9f896ef19..72b764981 100644 --- a/src/maasserver/api/tests/test_machines.py +++ b/src/maasserver/api/tests/test_machines.py @@ -1,7 +1,6 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). - import http.client import json import random @@ -15,6 +14,7 @@ from maasserver.api import auth from maasserver.api import machines as machines_module from maasserver.api.machines import AllocationOptions, get_allocation_options +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import BMC_TYPE, BRIDGE_TYPE, INTERFACE_TYPE, NODE_STATUS import maasserver.forms as forms_module from maasserver.forms.pods import ComposeMachineForm, ComposeMachineForPodsForm @@ -540,7 +540,7 @@ def exec_request(): expected_counts = [1, 2, 3] self.assertEqual(machines_count, expected_counts) - base_count = 94 + base_count = 92 for idx, machine_count in enumerate(machines_count): self.assertEqual( queries_count[idx], base_count + (machine_count * 7) @@ -3697,3 +3697,225 @@ def test_non_defaults(self): enable_hw_sync=True, ) self.assertEqual(expected_options, options) + + +class TestMachinesAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def setUp(self): + super().setUp() + self.machines_url = reverse("machines_handler") + + def test_read_filters_machines(self): + p1 = factory.make_ResourcePool() + p2 = factory.make_ResourcePool() + + n1 = factory.make_Node(pool=p1) + n2 = factory.make_Node(pool=p2) + + self.openfga_client.list_pools_with_view_deployable_machines_access.return_value = [ + p1.id, + p2.id, + ] + + response = self.client.get(self.machines_url) + + self.assertEqual(response.status_code, http.client.OK) + response_data = json.loads( + response.content.decode(settings.DEFAULT_CHARSET) + ) + machine_ids = [machine["system_id"] for machine in response_data] + self.assertIn(n1.system_id, machine_ids) + self.assertIn(n2.system_id, machine_ids) + + self.openfga_client.list_pools_with_view_deployable_machines_access.assert_called_once_with( + self.user + ) + + def test_read_filters_machines_owned_by_other_users(self): + p1 = factory.make_ResourcePool() + p2 = factory.make_ResourcePool() + p3 = factory.make_ResourcePool() + + n1 = factory.make_Node(pool=p1) + other_user = factory.make_User() + n2 = factory.make_Node(pool=p2, owner=other_user) + n3 = factory.make_Node(pool=p3) + + self.openfga_client.list_pools_with_view_deployable_machines_access.return_value = [ + p1.id, + p2.id, + ] + + response = self.client.get(self.machines_url) + + self.assertEqual(response.status_code, http.client.OK) + response_data = json.loads( + response.content.decode(settings.DEFAULT_CHARSET) + ) + machine_ids = [machine["system_id"] for machine in response_data] + self.assertIn(n1.system_id, machine_ids) + self.assertNotIn(n2.system_id, machine_ids) + self.assertNotIn(n3.system_id, machine_ids) + + self.openfga_client.list_pools_with_view_deployable_machines_access.assert_called_once_with( + self.user + ) + + def test_read_includes_machines_owned_by_other_users_if_view_all(self): + p1 = factory.make_ResourcePool() + p2 = factory.make_ResourcePool() + p3 = factory.make_ResourcePool() + + n1 = factory.make_Node(pool=p1) + other_user = factory.make_User() + n2 = factory.make_Node(pool=p2, owner=other_user) + n3 = factory.make_Node(pool=p3) + + self.openfga_client.list_pools_with_view_deployable_machines_access.return_value = [ + p1.id, + p2.id, + ] + self.openfga_client.list_pools_with_view_machines_access.return_value = [ + p1.id, + p2.id, + ] + + response = self.client.get(self.machines_url) + + self.assertEqual(response.status_code, http.client.OK) + response_data = json.loads( + response.content.decode(settings.DEFAULT_CHARSET) + ) + machine_ids = [machine["system_id"] for machine in response_data] + self.assertIn(n1.system_id, machine_ids) + self.assertIn(n2.system_id, machine_ids) + self.assertNotIn(n3.system_id, machine_ids) + + self.openfga_client.list_pools_with_view_deployable_machines_access.assert_called_once_with( + self.user + ) + self.openfga_client.list_pools_with_view_machines_access.assert_called_once_with( + self.user + ) + + def test_allocate_checks_view_deployable_machines_access(self): + p = factory.make_ResourcePool() + + self.openfga_client.list_pool_with_deploy_machines_access.return_value = [ + p.id + ] + + available_status = NODE_STATUS.READY + machine = factory.make_Node( + status=available_status, owner=None, with_boot_disk=True, pool=p + ) + response = self.client.post(self.machines_url, {"op": "allocate"}) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + machine = Machine.objects.get(system_id=machine.system_id) + self.assertEqual(self.user, machine.owner) + self.openfga_client.list_pool_with_deploy_machines_access.assert_called_once() + + def test_allocate_returns_409_if_not_deployable(self): + p = factory.make_ResourcePool() + + self.openfga_client.list_pool_with_deploy_machines_access.return_value = [] + + available_status = NODE_STATUS.READY + factory.make_Node( + status=available_status, owner=None, with_boot_disk=True, pool=p + ) + response = self.client.post(self.machines_url, {"op": "allocate"}) + self.assertEqual( + http.client.CONFLICT, response.status_code, response.content + ) + self.openfga_client.list_pool_with_deploy_machines_access.assert_called_once() + + def test_release_returns_403_if_owned_by_another_user(self): + p = factory.make_ResourcePool() + + self.openfga_client.list_pool_with_deploy_machines_access.return_value = [ + p.id + ] + + other_user = factory.make_User() + machine = factory.make_Node( + status=NODE_STATUS.DEPLOYED, + owner=other_user, + with_boot_disk=True, + pool=p, + ) + response = self.client.post( + self.machines_url, + {"op": "release", "machines": [machine.system_id]}, + ) + self.assertEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + self.openfga_client.list_pool_with_deploy_machines_access.assert_called_once() + + def test_release_success_if_can_edit_machines(self): + self.patch(Machine, "_stop") + self.patch(Machine, "_set_status") + + p = factory.make_ResourcePool() + + self.openfga_client.list_pool_with_deploy_machines_access.return_value = [ + p.id + ] + self.openfga_client.list_pools_with_edit_machines_access.return_value = [ + p.id + ] + + other_user = factory.make_User() + machine = factory.make_Node( + status=NODE_STATUS.ALLOCATED, + owner=other_user, + with_boot_disk=True, + pool_id=p.id, + ) + response = self.client.post( + self.machines_url, + {"op": "release", "machines": {machine.system_id}}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.list_pool_with_deploy_machines_access.assert_called_once() + self.openfga_client.list_pools_with_edit_machines_access.assert_called_once() + + def test_release_success_if_is_owner(self): + self.patch(Machine, "_stop") + self.patch(Machine, "_set_status") + + p = factory.make_ResourcePool() + + self.openfga_client.list_pool_with_deploy_machines_access.return_value = [ + p.id + ] + self.openfga_client.list_pools_with_edit_machines_access.return_value = [] + + machine = factory.make_Node( + status=NODE_STATUS.ALLOCATED, + owner=self.user, + with_boot_disk=True, + pool_id=p.id, + ) + response = self.client.post( + self.machines_url, + {"op": "release", "machines": [machine.system_id]}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.list_pool_with_deploy_machines_access.assert_called_once() + + def test_add_chassis_requires_can_edit_machines(self): + self.openfga_client.can_edit_machines.return_value = True + response = self.client.post(self.machines_url, {"op": "add_chassis"}) + self.assertNotEqual( + http.client.FORBIDDEN, response.status_code, response.content + ) + self.openfga_client.can_edit_machines.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_node.py b/src/maasserver/api/tests/test_node.py index a5dca2f3b..3958cce6f 100644 --- a/src/maasserver/api/tests/test_node.py +++ b/src/maasserver/api/tests/test_node.py @@ -1,4 +1,4 @@ -# Copyright 2013-2020 Canonical Ltd. This software is licensed under the +# Copyright 2013-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from functools import partial @@ -12,6 +12,7 @@ from twisted.internet.defer import succeed from maasserver.api import auth +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import NODE_STATUS, NODE_STATUS_CHOICES from maasserver.models import Config, Node, NodeKey from maasserver.models import node as node_module @@ -1010,3 +1011,19 @@ def test_abort_handles_missing_comment(self): node_method = self.patch(node_module.Node, "abort_operation") self.client.post(self.get_node_uri(node), {"op": "abort"}) node_method.mock_called_once_with(self.user, None) + + +class TestPowerMixinOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_get_power_parameters_requires_can_edit_machines(self): + self.openfga_client.can_edit_machines.return_value = True + power_parameters = {factory.make_string(): factory.make_string()} + factory.make_Node(power_parameters=power_parameters) + response = self.client.get( + reverse("machines_handler"), {"op": "power_parameters"} + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_machines.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_notification.py b/src/maasserver/api/tests/test_notification.py index df5339997..f04af8edd 100644 --- a/src/maasserver/api/tests/test_notification.py +++ b/src/maasserver/api/tests/test_notification.py @@ -1,4 +1,4 @@ -# Copyright 2017 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for Notification API.""" @@ -9,6 +9,7 @@ from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.notification import Notification, NotificationDismissal from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -300,3 +301,50 @@ def test_delete_is_for_admins_only(self): uri = get_notification_uri(notification) response = self.client.delete(uri) self.assertEqual(response.status_code, http.client.UNAUTHORIZED) + + +class TestNotificationsOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_create_requires_can_edit_notifications(self): + self.openfga_client.can_edit_notifications.return_value = True + uri = get_notifications_uri() + response = self.client.post(uri, {"message": factory.make_name()}) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_notifications.assert_called_once_with( + self.user + ) + + +class TestNotificationOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_view_all_if_can_view_notifications(self): + self.openfga_client.can_view_notifications.return_value = True + other = factory.make_User() + notification = factory.make_Notification(user=other) + uri = get_notification_uri(notification) + response = self.client.get(uri) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_notifications.assert_called() + + def test_update_requires_can_edit_notifications(self): + self.openfga_client.can_edit_notifications.return_value = True + notification = factory.make_Notification() + message_new = factory.make_name("message") + uri = get_notification_uri(notification) + response = self.client.put(uri, {"message": message_new}) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_notifications.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_notifications(self): + self.openfga_client.can_edit_notifications.return_value = True + notification = factory.make_Notification() + uri = get_notification_uri(notification) + response = self.client.delete(uri) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_notifications.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_packagerepositories.py b/src/maasserver/api/tests/test_packagerepositories.py index ff90caea0..33dac3a23 100644 --- a/src/maasserver/api/tests/test_packagerepositories.py +++ b/src/maasserver/api/tests/test_packagerepositories.py @@ -1,4 +1,4 @@ -# Copyright 2016-2018 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the Package Repositories API.""" @@ -13,33 +13,37 @@ from maasserver.api.packagerepositories import ( DISPLAYED_PACKAGE_REPOSITORY_FIELDS, ) +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import Event, PackageRepository from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory from maasserver.utils.orm import reload_object +def get_package_repository_uri(package_repository): + """Return the Package Repository's URI on the API.""" + return reverse("package_repository_handler", args=[package_repository.id]) + + +def get_package_repositories_uri(): + """Return the Package Repositories URI on the API.""" + return reverse("package_repositories_handler", args=[]) + + class TestPackageRepositoryAPI(APITestCase.ForUser): """Tests for /api/2.0/package-repositories//.""" - @staticmethod - def get_package_repository_uri(package_repository): - """Return the Package Repository's URI on the API.""" - return reverse( - "package_repository_handler", args=[package_repository.id] - ) - def test_handler_path(self): package_repository = factory.make_PackageRepository() self.assertEqual( "/MAAS/api/2.0/package-repositories/%s/" % package_repository.id, - self.get_package_repository_uri(package_repository), + get_package_repository_uri(package_repository), ) def test_read_by_id(self): package_repository = factory.make_PackageRepository() response = self.client.get( - self.get_package_repository_uri(package_repository) + get_package_repository_uri(package_repository) ) self.assertEqual( http.client.OK, response.status_code, response.content @@ -47,7 +51,7 @@ def test_read_by_id(self): parsed_package_repository = json.loads(response.content.decode()) self.assertEqual( parsed_package_repository["resource_uri"], - self.get_package_repository_uri(package_repository), + get_package_repository_uri(package_repository), ) del parsed_package_repository["resource_uri"] self.assertEqual( @@ -67,7 +71,7 @@ def test_read_by_name(self): parsed_package_repository = json.loads(response.content.decode()) self.assertEqual( parsed_package_repository["resource_uri"], - self.get_package_repository_uri(package_repository), + get_package_repository_uri(package_repository), ) del parsed_package_repository["resource_uri"] self.assertEqual( @@ -103,7 +107,7 @@ def test_update_custom_repository(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.OK, response.status_code, response.content @@ -140,7 +144,7 @@ def test_update_custom_repository_fails_if_disabled_components(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.BAD_REQUEST, response.status_code, response.content @@ -166,7 +170,7 @@ def test_update_ubuntu_mirror(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.OK, response.status_code, response.content @@ -204,7 +208,7 @@ def test_update_ubuntu_mirror_fail_with_invalid_disabled_pockets(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.BAD_REQUEST, response.status_code, response.content @@ -229,7 +233,7 @@ def test_update_ubuntu_mirror_fail_with_invalid_disabled_components(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.BAD_REQUEST, response.status_code, response.content @@ -259,7 +263,7 @@ def test_update_ubuntu_mirror_fails_if_components_are_passed(self): ], } response = self.client.put( - self.get_package_repository_uri(package_repository), new_values + get_package_repository_uri(package_repository), new_values ) self.assertEqual( http.client.BAD_REQUEST, response.status_code, response.content @@ -268,7 +272,7 @@ def test_update_ubuntu_mirror_fails_if_components_are_passed(self): def test_update_admin_only(self): package_repository = factory.make_PackageRepository() response = self.client.put( - self.get_package_repository_uri(package_repository), + get_package_repository_uri(package_repository), {"url": factory.make_url(scheme="http")}, ) self.assertEqual( @@ -279,7 +283,7 @@ def test_delete_deletes_package_repository(self): self.become_admin() package_repository = factory.make_PackageRepository() response = self.client.delete( - self.get_package_repository_uri(package_repository) + get_package_repository_uri(package_repository) ) self.assertEqual( http.client.NO_CONTENT, response.status_code, response.content @@ -295,7 +299,7 @@ def test_delete_deletes_package_repository(self): def test_delete_admin_only(self): package_repository = factory.make_PackageRepository() response = self.client.delete( - self.get_package_repository_uri(package_repository) + get_package_repository_uri(package_repository) ) self.assertEqual( http.client.FORBIDDEN, response.status_code, response.content @@ -318,21 +322,16 @@ def test_delete_404_when_invalid_id(self): class TestPackageRepositoriesAPI(APITestCase.ForUser): """Tests for /api/2.0/package-repositories.""" - @staticmethod - def get_package_repositories_uri(): - """Return the Package Repositories URI on the API.""" - return reverse("package_repositories_handler", args=[]) - def test_handler_path(self): self.assertEqual( "/MAAS/api/2.0/package-repositories/", - self.get_package_repositories_uri(), + get_package_repositories_uri(), ) def test_read(self): for _ in range(3): factory.make_PackageRepository() - response = self.client.get(self.get_package_repositories_uri()) + response = self.client.get(get_package_repositories_uri()) self.assertEqual( http.client.OK, response.status_code, response.content @@ -353,9 +352,7 @@ def test_create(self): url = factory.make_url(scheme="http") enabled = factory.pick_bool() params = {"name": name, "url": url, "enabled": enabled} - response = self.client.post( - self.get_package_repositories_uri(), params - ) + response = self.client.post(get_package_repositories_uri(), params) parsed_result = json.loads(response.content.decode()) package_repository = PackageRepository.objects.get( id=parsed_result["id"] @@ -366,8 +363,53 @@ def test_create(self): def test_create_admin_only(self): response = self.client.post( - self.get_package_repositories_uri(), {"url": factory.make_string()} + get_package_repositories_uri(), {"url": factory.make_string()} ) self.assertEqual( http.client.FORBIDDEN, response.status_code, response.content ) + + +class TestPackageRepositoriesOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + name = factory.make_name("name") + url = factory.make_url(scheme="http") + enabled = factory.pick_bool() + params = {"name": name, "url": url, "enabled": enabled} + response = self.client.post(get_package_repositories_uri(), params) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + +class TestPackageRepositoryOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + package_repository = factory.make_PackageRepository() + response = self.client.put( + get_package_repository_uri(package_repository), + {"url": factory.make_url(scheme="http")}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called() + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + package_repository = factory.make_PackageRepository() + response = self.client.delete( + get_package_repository_uri(package_repository), + ) + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called() diff --git a/src/maasserver/api/tests/test_rackcontroller.py b/src/maasserver/api/tests/test_rackcontroller.py index 129be7d1e..f86fd17a0 100644 --- a/src/maasserver/api/tests/test_rackcontroller.py +++ b/src/maasserver/api/tests/test_rackcontroller.py @@ -1,4 +1,4 @@ -# Copyright 2016-2025 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). @@ -8,6 +8,7 @@ from django.utils.http import urlencode from maasserver.api import rackcontrollers +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import BOOT_RESOURCE_FILE_TYPE, BOOT_RESOURCE_TYPE from maasserver.models.bmc import Pod from maasserver.models.signals import vlan as vlan_signals_module @@ -361,3 +362,53 @@ def test_GET_describe_power_types_denied_if_not_admin(self): explain_unexpected_response(http.client.FORBIDDEN, response), ) get_all_power_types.assert_not_called() + + +class TestRegionControllerAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_update_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + rack = factory.make_RackController() + response = self.client.put( + reverse("rackcontroller_handler", args=[rack.system_id]), {} + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) + + def test_list_boot_images_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + rack = factory.make_RackController() + response = self.client.post( + reverse("rackcontroller_handler", args=[rack.system_id]), + {"op": "import_boot_images"}, + ) + self.assertEqual(http.client.ACCEPTED, response.status_code) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) + + def test_list_boot_images_requires_can_view_controllers(self): + self.openfga_client.can_view_controllers.return_value = True + rack = factory.make_RackController() + response = self.client.get( + reverse("rackcontroller_handler", args=[rack.system_id]), + {"op": "list_boot_images"}, + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_view_controllers.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + rack = factory.make_RackController() + response = self.client.delete( + reverse("rackcontroller_handler", args=[rack.system_id]) + ) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_regioncontroller.py b/src/maasserver/api/tests/test_regioncontroller.py index fd6c79039..5c36b7e05 100644 --- a/src/maasserver/api/tests/test_regioncontroller.py +++ b/src/maasserver/api/tests/test_regioncontroller.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import http.client @@ -6,6 +6,7 @@ from django.urls import reverse from django.utils.http import urlencode +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import NODE_TYPE from maasserver.models.bmc import Pod from maasserver.testing.api import APITestCase, explain_unexpected_response @@ -180,3 +181,29 @@ def test_read_returns_limited_fields(self): }, parsed_result[0].keys(), ) + + +class TestRegionControllerAPIOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_update_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + region = factory.make_RegionController() + response = self.client.put( + reverse("regioncontroller_handler", args=[region.system_id]), {} + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + region = factory.make_RegionController() + response = self.client.delete( + reverse("regioncontroller_handler", args=[region.system_id]) + ) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_reservedip.py b/src/maasserver/api/tests/test_reservedip.py index 5e04aab97..e70cf18b0 100644 --- a/src/maasserver/api/tests/test_reservedip.py +++ b/src/maasserver/api/tests/test_reservedip.py @@ -1,4 +1,4 @@ -# Copyright 2024 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). """Tests for ReserveIPs API.""" @@ -9,6 +9,7 @@ from twisted.internet import defer import maasserver.api.reservedip as reservedip_module +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.dhcp import configure_dhcp_on_agents from maasserver.models import ReservedIP from maasserver.models.signals import subnet as subnet_signals_module @@ -244,3 +245,75 @@ def test_delete_requires_admin(self): self.assertEqual( http.client.FORBIDDEN, response.status_code, response.content ) + + +class TestReservedIPsOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def setUp(self): + super().setUp() + d = defer.succeed(None) + self.patch(reservedip_module, "post_commit_do").return_value = d + self.patch(subnet_signals_module, "start_workflow") + + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + uri = reverse("reservedips_handler") + subnet = factory.make_Subnet(cidr="10.0.0.0/24") + + response = self.client.post( + uri, + { + "ip": "10.0.0.70", + "subnet": subnet.id, + "mac_address": "01:02:03:04:05:06", + }, + ) + + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + +class TestReservedIPOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def setUp(self): + super().setUp() + d = defer.succeed(None) + self.patch(reservedip_module, "post_commit_do").return_value = d + self.patch(subnet_signals_module, "start_workflow") + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + reserved_ip = factory.make_ReservedIP() + uri = reverse("reservedip_handler", args=[reserved_ip.id]) + + response = self.client.put( + uri, + { + "ip": reserved_ip.ip, + "mac_address": reserved_ip.mac_address, + "comment": "updated comment", + }, + ) + + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + reserved_ip = factory.make_ReservedIP() + uri = reverse("reservedip_handler", args=[reserved_ip.id]) + + response = self.client.delete(uri) + + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_scripts.py b/src/maasserver/api/tests/test_scripts.py index be299584e..f3a52ff5a 100644 --- a/src/maasserver/api/tests/test_scripts.py +++ b/src/maasserver/api/tests/test_scripts.py @@ -1,4 +1,4 @@ -# Copyright 2017-2018 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for the script API.""" @@ -12,6 +12,7 @@ from django.urls import reverse from maascommon.events import AUDIT +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import Event, Script, VersionedTextFile from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -24,16 +25,16 @@ ) +def get_scripts_uri(): + """Return the script's URI on the API.""" + return reverse("scripts_handler", args=[]) + + class TestScriptsAPI(APITestCase.ForUser): """Tests for /api/2.0/scripts/.""" - @staticmethod - def get_scripts_uri(): - """Return the script's URI on the API.""" - return reverse("scripts_handler", args=[]) - def test_hander_path(self): - self.assertEqual("/MAAS/api/2.0/scripts/", self.get_scripts_uri()) + self.assertEqual("/MAAS/api/2.0/scripts/", get_scripts_uri()) def test_POST(self): self.become_admin() @@ -51,7 +52,7 @@ def test_POST(self): comment = factory.make_name("comment") response = self.client.post( - self.get_scripts_uri(), + get_scripts_uri(), { "name": name, "title": title, @@ -102,7 +103,7 @@ def test_POST_gets_name_from_filename(self): comment = factory.make_name("comment") response = self.client.post( - self.get_scripts_uri(), + get_scripts_uri(), { "title": title, "description": description, @@ -135,12 +136,12 @@ def test_POST_gets_name_from_filename(self): self.assertEqual(comment, script.script.comment) def test_POST_requires_admin(self): - response = self.client.post(self.get_scripts_uri()) + response = self.client.post(get_scripts_uri()) self.assertEqual(response.status_code, http.client.FORBIDDEN) def test_GET(self): scripts = [factory.make_Script() for _ in range(3)] - response = self.client.get(self.get_scripts_uri()) + response = self.client.get(get_scripts_uri()) self.assertEqual(response.status_code, http.client.OK) parsed_results = response.json() @@ -159,7 +160,7 @@ def test_GET_filters_by_script_type(self): ) response = self.client.get( - self.get_scripts_uri(), {"type": script.script_type} + get_scripts_uri(), {"type": script.script_type} ) self.assertEqual(response.status_code, http.client.OK) parsed_results = json_load_bytes(response.content) @@ -178,7 +179,7 @@ def test_GET_filters_by_hardware_type(self): ) response = self.client.get( - self.get_scripts_uri(), {"hardware_type": script.hardware_type} + get_scripts_uri(), {"hardware_type": script.hardware_type} ) self.assertEqual(response.status_code, http.client.OK) parsed_results = json_load_bytes(response.content) @@ -196,7 +197,7 @@ def test_GET_filters(self): factory.make_Script() response = self.client.get( - self.get_scripts_uri(), + get_scripts_uri(), {"filters": f"{random.choice(tags)},{name_script.name}"}, ) self.assertEqual(response.status_code, http.client.OK) @@ -213,9 +214,7 @@ def test_GET_include_script(self): script = factory.make_Script() scripts[script.name] = script - response = self.client.get( - self.get_scripts_uri(), {"include_script": True} - ) + response = self.client.get(get_scripts_uri(), {"include_script": True}) self.assertEqual(response.status_code, http.client.OK) parsed_results = response.json() @@ -676,3 +675,65 @@ def test_remove_tag_admin_only(self): {"op": "remove_tag", "tag": random.choice(script.tags)}, ) self.assertEqual(response.status_code, http.client.FORBIDDEN) + + +class TestScriptsAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + response = self.client.post(get_scripts_uri()) + self.assertEqual(response.status_code, http.client.BAD_REQUEST) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + +class TestScriptAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def get_script_uri(self, script): + return reverse("script_handler", args=[script.id]) + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + script = factory.make_Script() + response = self.client.put(self.get_script_uri(script)) + self.assertEqual(response.status_code, http.client.OK) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_revert_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + script = factory.make_Script() + response = self.client.post( + self.get_script_uri(script), {"op": "revert"} + ) + self.assertEqual(response.status_code, http.client.BAD_REQUEST) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_add_tag_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + script = factory.make_Script() + response = self.client.post( + self.get_script_uri(script), + {"op": "add_tag", "tag": factory.make_name("tag")}, + ) + self.assertEqual(response.status_code, http.client.OK) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + script = factory.make_Script() + response = self.client.delete(self.get_script_uri(script)) + self.assertEqual(response.status_code, http.client.NO_CONTENT) + self.assertIsNone(reload_object(script)) + event = Event.objects.get(type__level=AUDIT) + self.assertIsNotNone(event) + self.assertEqual( + event.description, "Deleted script '%s'." % script.name + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_spaces.py b/src/maasserver/api/tests/test_spaces.py index 78ad07517..ce51ccfd7 100644 --- a/src/maasserver/api/tests/test_spaces.py +++ b/src/maasserver/api/tests/test_spaces.py @@ -1,4 +1,4 @@ -# Copyright 2015-2016 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for Space API.""" @@ -10,6 +10,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import VLAN from maasserver.models.space import Space from maasserver.testing.api import APITestCase @@ -288,3 +289,17 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestSpacesOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + space_name = factory.make_name("space") + uri = get_spaces_uri() + response = self.client.post(uri, {"name": space_name}) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_staticroutes.py b/src/maasserver/api/tests/test_staticroutes.py index f5bfd5dc7..f2bce0f73 100644 --- a/src/maasserver/api/tests/test_staticroutes.py +++ b/src/maasserver/api/tests/test_staticroutes.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for static route API.""" @@ -8,6 +8,7 @@ from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory from maasserver.utils.converters import json_load_bytes @@ -170,3 +171,30 @@ def test_delete_404_when_invalid_id(self): self.assertEqual( http.client.NOT_FOUND, response.status_code, response.content ) + + +class TestStaticRoutesOpenFGAIntegration( + OpenFGAMockMixin, APITestCase.ForUser +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + source = factory.make_Subnet() + destination = factory.make_Subnet( + version=source.get_ipnetwork().version + ) + gateway_ip = factory.pick_ip_in_Subnet(source) + uri = get_staticroutes_uri() + response = self.client.post( + uri, + { + "source": source.id, + "destination": destination.id, + "gateway_ip": gateway_ip, + }, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_subnets.py b/src/maasserver/api/tests/test_subnets.py index f91264212..0ce54b86a 100644 --- a/src/maasserver/api/tests/test_subnets.py +++ b/src/maasserver/api/tests/test_subnets.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). @@ -11,6 +11,7 @@ from django.urls import reverse from maascommon.utils.network import inet_ntop, IPRangeStatistics +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import ( IPADDRESS_TYPE, NODE_STATUS, @@ -1072,3 +1073,17 @@ def test_with_deprecated_node_summary_false(self): with_username=True, with_summary=False ) self.assertEqual(expected_result, result) + + +class TestSubnetsOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + subnet_name = factory.make_name("subnet") + uri = get_subnets_uri() + response = self.client.post(uri, {"name": subnet_name}) + self.assertEqual( + http.client.BAD_REQUEST, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_support.py b/src/maasserver/api/tests/test_support.py index 5a24127d0..93d772dd8 100644 --- a/src/maasserver/api/tests/test_support.py +++ b/src/maasserver/api/tests/test_support.py @@ -1,7 +1,6 @@ -# Copyright 2013-2016 Canonical Ltd. This software is licensed under the +# Copyright 2013-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). - from collections import namedtuple import http.client from unittest.mock import call, Mock, sentinel @@ -12,14 +11,15 @@ from maasserver.api.doc import get_api_description from maasserver.api.support import ( - admin_method, AdminRestrictedResource, + check_permission, deprecated, Emitter, OperationsHandlerMixin, OperationsResource, RestrictedResource, ) +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.config import Config, ConfigManager from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -147,31 +147,43 @@ def test_authentication_is_okay(self): self.assertTrue(resource.is_authentication_attempted) -class TestAdminMethodDecorator(MAASServerTestCase): - def test_non_admin_are_rejected(self): +class TestAdminMethodDecorator(OpenFGAMockMixin, MAASServerTestCase): + def test_permission_denied(self): + self.openfga_client.can_edit_global_entities.return_value = False + FakeRequest = namedtuple("FakeRequest", ["user"]) - request = FakeRequest(user=factory.make_User()) + user = factory.make_User() + request = FakeRequest(user=user) mock = Mock() - @admin_method + @check_permission("can_edit_global_entities") def api_method(self, request): return mock() self.assertRaises(PermissionDenied, api_method, "self", request) self.assertEqual([], mock.mock_calls) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_permission_success(self): + self.openfga_client.can_edit_global_entities.return_value = True - def test_admin_can_call_method(self): FakeRequest = namedtuple("FakeRequest", ["user"]) - request = FakeRequest(user=factory.make_admin()) + user = factory.make_User() + request = FakeRequest(user=user) return_value = factory.make_name("return") mock = Mock(return_value=return_value) - @admin_method + @check_permission("can_edit_global_entities") def api_method(self, request): return mock() response = api_method("self", request) self.assertEqual((return_value, [call()]), (response, mock.mock_calls)) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) class TestDeprecatedMethodDecorator(MAASServerTestCase): diff --git a/src/maasserver/api/tests/test_tag.py b/src/maasserver/api/tests/test_tag.py index 87b5db36f..d2f5432b2 100644 --- a/src/maasserver/api/tests/test_tag.py +++ b/src/maasserver/api/tests/test_tag.py @@ -1,4 +1,4 @@ -# Copyright 2013-2022 Canonical Ltd. This software is licensed under the +# Copyright 2013-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import http.client @@ -11,6 +11,7 @@ from apiclient.creds import convert_tuple_to_string from maascommon.events import AUDIT +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import NODE_STATUS from maasserver.models import Event, Tag from maasserver.models.node import generate_node_system_id @@ -32,6 +33,11 @@ def extract_system_ids(parsed_result): return [machine.get("system_id") for machine in parsed_result] +def get_tag_uri(tag): + """Get the API URI for `tag`.""" + return reverse("tag_handler", args=[tag.name]) + + class TestTagAPI(APITestCase.ForUser): """Tests for /api/2.0/tags//.""" @@ -41,27 +47,23 @@ def test_handler_path(self): reverse("tag_handler", args=["tag-name"]), ) - def get_tag_uri(self, tag): - """Get the API URI for `tag`.""" - return reverse("tag_handler", args=[tag.name]) - def test_DELETE_requires_admin(self): tag = factory.make_Tag() - response = self.client.delete(self.get_tag_uri(tag)) + response = self.client.delete(get_tag_uri(tag)) self.assertEqual(http.client.FORBIDDEN, response.status_code) self.assertCountEqual([tag], Tag.objects.filter(id=tag.id)) def test_DELETE_removes_tag(self): self.become_admin() tag = factory.make_Tag() - response = self.client.delete(self.get_tag_uri(tag)) + response = self.client.delete(get_tag_uri(tag)) self.assertEqual(http.client.NO_CONTENT, response.status_code) self.assertFalse(Tag.objects.filter(id=tag.id).exists()) def test_DELETE_creates_event_log(self): self.become_admin() tag = factory.make_Tag() - self.client.delete(self.get_tag_uri(tag)) + self.client.delete(get_tag_uri(tag)) event = Event.objects.get(type__level=AUDIT) self.assertEqual(event.type.name, EVENT_TYPES.TAG) self.assertEqual(event.description, f"Tag '{tag.name}' deleted.") @@ -94,7 +96,7 @@ def test_GET_refuses_to_access_nonexistent_node(self): def test_PUT_refuses_non_superuser(self): tag = factory.make_Tag() response = self.client.put( - self.get_tag_uri(tag), {"comment": "A special comment"} + get_tag_uri(tag), {"comment": "A special comment"} ) self.assertEqual(http.client.FORBIDDEN, response.status_code) @@ -103,7 +105,7 @@ def test_PUT_updates_tag(self): tag = factory.make_Tag() # Note that 'definition' is not being sent response = self.client.put( - self.get_tag_uri(tag), + get_tag_uri(tag), {"name": "new-tag-name", "comment": "A random comment"}, ) @@ -119,7 +121,7 @@ def test_PUT_creates_event_log(self): self.become_admin() tag = factory.make_Tag() self.client.put( - self.get_tag_uri(tag), + get_tag_uri(tag), {"comment": "A random comment"}, ) event = Event.objects.get(type__level=AUDIT) @@ -132,7 +134,7 @@ def test_PUT_creates_event_log_rename(self): old_name = tag.name new_name = factory.make_string() self.client.put( - self.get_tag_uri(tag), + get_tag_uri(tag), {"name": new_name}, ) event = Event.objects.get(type__level=AUDIT) @@ -148,14 +150,14 @@ def test_PUT_updates_node_associations(self): populate_nodes.assert_called_once_with(tag) self.become_admin() response = self.client.put( - self.get_tag_uri(tag), {"definition": "//node/bar"} + get_tag_uri(tag), {"definition": "//node/bar"} ) self.assertEqual(http.client.OK, response.status_code) populate_nodes.assert_has_calls([call(tag), call(tag)]) def test_GET_nodes_with_no_nodes(self): tag = factory.make_Tag() - response = self.client.get(self.get_tag_uri(tag), {"op": "nodes"}) + response = self.client.get(get_tag_uri(tag), {"op": "nodes"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads(response.content.decode("ascii")) @@ -163,17 +165,17 @@ def test_GET_nodes_with_no_nodes(self): def test_GET_nodes_returns_nodes(self): tag = factory.make_Tag() - machine = factory.make_Node() - device = factory.make_Device() + machine = factory.make_Node(owner=self.user) + device = factory.make_Device(owner=self.user) rack = factory.make_RackController() region = factory.make_RegionController() # Create a second node that isn't tagged. - factory.make_Node() + factory.make_Node(owner=self.user) machine.tags.add(tag) device.tags.add(tag) rack.tags.add(tag) region.tags.add(tag) - response = self.client.get(self.get_tag_uri(tag), {"op": "nodes"}) + response = self.client.get(get_tag_uri(tag), {"op": "nodes"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( @@ -194,21 +196,21 @@ def test_GET_nodes_query_count(self): machine = factory.make_Node_with_Interface_on_Subnet(vlan=vlan) machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "nodes"} + self.client.get, get_tag_uri(tag), {"op": "nodes"} ) query_counts.append(num_queries) node_counts.append(len(response.json())) machine = factory.make_Node_with_Interface_on_Subnet() machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "nodes"} + self.client.get, get_tag_uri(tag), {"op": "nodes"} ) query_counts.append(num_queries) node_counts.append(len(response.json())) machine = factory.make_Node_with_Interface_on_Subnet() machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "nodes"} + self.client.get, get_tag_uri(tag), {"op": "nodes"} ) query_counts.append(num_queries) node_counts.append(len(response.json())) @@ -234,7 +236,7 @@ def test_GET_machines_returns_machines(self): device.tags.add(tag) rack.tags.add(tag) region.tags.add(tag) - response = self.client.get(self.get_tag_uri(tag), {"op": "machines"}) + response = self.client.get(get_tag_uri(tag), {"op": "machines"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( @@ -253,21 +255,21 @@ def test_GET_machines_query_count(self): machine = factory.make_Node_with_Interface_on_Subnet() machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "machines"} + self.client.get, get_tag_uri(tag), {"op": "machines"} ) query_counts.append(num_queries) machine_counts.append(len(response.json())) machine = factory.make_Node_with_Interface_on_Subnet() machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "machines"} + self.client.get, get_tag_uri(tag), {"op": "machines"} ) query_counts.append(num_queries) machine_counts.append(len(response.json())) machine = factory.make_Node_with_Interface_on_Subnet() machine.tags.add(tag) num_queries, response = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "machines"} + self.client.get, get_tag_uri(tag), {"op": "machines"} ) query_counts.append(num_queries) machine_counts.append(len(response.json())) @@ -285,17 +287,17 @@ def test_GET_machines_query_count(self): def test_GET_devices_returns_devices(self): tag = factory.make_Tag() - machine = factory.make_Node() - device = factory.make_Device() + machine = factory.make_Node(owner=self.user) + device = factory.make_Device(owner=self.user) rack = factory.make_RackController() region = factory.make_RegionController() # Create a second node that isn't tagged. - factory.make_Node() + factory.make_Node(owner=self.user) machine.tags.add(tag) device.tags.add(tag) rack.tags.add(tag) region.tags.add(tag) - response = self.client.get(self.get_tag_uri(tag), {"op": "devices"}) + response = self.client.get(get_tag_uri(tag), {"op": "devices"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( @@ -308,17 +310,17 @@ def test_GET_devices_returns_devices(self): def test_GET_devices_query_count(self): tag = factory.make_Tag() for _ in range(3): - device = factory.make_Device() + device = factory.make_Device(owner=self.user) device.tags.add(tag) num_queries1, response1 = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "devices"} + self.client.get, get_tag_uri(tag), {"op": "devices"} ) for _ in range(3): - device = factory.make_Device() + device = factory.make_Device(owner=self.user) device.tags.add(tag) num_queries2, response2 = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "devices"} + self.client.get, get_tag_uri(tag), {"op": "devices"} ) # Make sure the responses are ok as it's not useful to compare the @@ -354,7 +356,7 @@ def test_GET_rack_controllers_returns_rack_controllers(self): rack.tags.add(tag) region.tags.add(tag) response = self.client.get( - self.get_tag_uri(tag), {"op": "rack_controllers"} + get_tag_uri(tag), {"op": "rack_controllers"} ) self.assertEqual(http.client.OK, response.status_code) @@ -374,14 +376,14 @@ def test_GET_rack_controllers_query_count(self): rack = factory.make_RackController() rack.tags.add(tag) num_queries1, response1 = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "rack_controllers"} + self.client.get, get_tag_uri(tag), {"op": "rack_controllers"} ) for _ in range(3): rack = factory.make_RackController() rack.tags.add(tag) num_queries2, response2 = count_queries( - self.client.get, self.get_tag_uri(tag), {"op": "rack_controllers"} + self.client.get, get_tag_uri(tag), {"op": "rack_controllers"} ) # Make sure the responses are ok as it's not useful to compare the @@ -416,7 +418,7 @@ def test_GET_rack_controllers_returns_no_rack_controllers_nonadmin(self): rack.tags.add(tag) region.tags.add(tag) response = self.client.get( - self.get_tag_uri(tag), {"op": "rack_controllers"} + get_tag_uri(tag), {"op": "rack_controllers"} ) self.assertEqual(http.client.OK, response.status_code) @@ -439,7 +441,7 @@ def test_GET_region_controllers_returns_region_controllers(self): rack.tags.add(tag) region.tags.add(tag) response = self.client.get( - self.get_tag_uri(tag), {"op": "region_controllers"} + get_tag_uri(tag), {"op": "region_controllers"} ) self.assertEqual(http.client.OK, response.status_code) @@ -459,7 +461,7 @@ def test_GET_region_controllers_query_count(self): region.tags.add(tag) num_queries1, response1 = count_queries( self.client.get, - self.get_tag_uri(tag), + get_tag_uri(tag), {"op": "region_controllers"}, ) @@ -468,7 +470,7 @@ def test_GET_region_controllers_query_count(self): region.tags.add(tag) num_queries2, response2 = count_queries( self.client.get, - self.get_tag_uri(tag), + get_tag_uri(tag), {"op": "region_controllers"}, ) @@ -506,7 +508,7 @@ def test_GET_region_controllers_returns_no_controllers_nonadmin(self): rack.tags.add(tag) region.tags.add(tag) response = self.client.get( - self.get_tag_uri(tag), {"op": "region_controllers"} + get_tag_uri(tag), {"op": "region_controllers"} ) self.assertEqual(http.client.OK, response.status_code) @@ -523,7 +525,7 @@ def test_GET_nodes_hides_invisible_nodes(self): node1.tags.add(tag) node2.tags.add(tag) - response = self.client.get(self.get_tag_uri(tag), {"op": "nodes"}) + response = self.client.get(get_tag_uri(tag), {"op": "nodes"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( @@ -534,7 +536,7 @@ def test_GET_nodes_hides_invisible_nodes(self): ) # The other user can also see his node client2 = MAASSensibleOAuthClient(user2) - response = client2.get(self.get_tag_uri(tag), {"op": "nodes"}) + response = client2.get(get_tag_uri(tag), {"op": "nodes"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( response.content.decode(settings.DEFAULT_CHARSET) @@ -551,7 +553,7 @@ def test_PUT_invalid_definition(self): node.tags.add(tag) self.assertEqual([tag.name], node.tag_names()) response = self.client.put( - self.get_tag_uri(tag), + get_tag_uri(tag), {"name": "bad tag", "definition": "invalid::tag"}, ) @@ -582,7 +584,7 @@ def test_POST_update_nodes_changes_associations(self): node_first.tags.add(tag) self.assertCountEqual([node_first], tag.node_set.all()) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [node_second.system_id], @@ -603,7 +605,7 @@ def test_POST_update_nodes_ignores_unknown_nodes(self): unknown_remove_system_id = generate_node_system_id() self.assertCountEqual([], tag.node_set.all()) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [unknown_add_system_id], @@ -623,7 +625,7 @@ def test_POST_update_nodes_doesnt_require_add_or_remove(self): self.become_admin() self.assertCountEqual([], tag.node_set.all()) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), {"op": "update_nodes", "add": [node.system_id]}, ) self.assertEqual(http.client.OK, response.status_code) @@ -632,7 +634,7 @@ def test_POST_update_nodes_doesnt_require_add_or_remove(self): ) self.assertEqual({"added": 1, "removed": 0}, parsed_result) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), {"op": "update_nodes", "remove": [node.system_id]}, ) self.assertEqual(http.client.OK, response.status_code) @@ -645,7 +647,7 @@ def test_POST_update_nodes_rejects_normal_user(self): tag = factory.make_Tag() node = factory.make_Node() response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), {"op": "update_nodes", "add": [node.system_id]}, ) self.assertEqual(http.client.FORBIDDEN, response.status_code) @@ -665,7 +667,7 @@ def test_POST_update_nodes_allows_rack_controller(self): token.save() creds = convert_tuple_to_string(get_creds_tuple(token)) response = client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [node.system_id], @@ -688,7 +690,7 @@ def test_POST_update_nodes_refuses_non_rack_controller(self): token.save() creds = convert_tuple_to_string(get_creds_tuple(token)) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [node.system_id], @@ -708,7 +710,7 @@ def test_POST_update_nodes_refuses_no_token(self): token.save() creds = convert_tuple_to_string(get_creds_tuple(token)) response = self.client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [node.system_id], @@ -728,7 +730,7 @@ def test_POST_update_nodes_ignores_incorrect_definition(self): tag.definition = "//new/node/definition" tag.save(populate=False) response = client.post( - self.get_tag_uri(tag), + get_tag_uri(tag), { "op": "update_nodes", "add": [node.system_id], @@ -746,7 +748,7 @@ def test_POST_rebuild_rebuilds_node_mapping(self): tag.save() self.become_admin() populate_nodes.assert_called_once_with(tag) - response = self.client.post(self.get_tag_uri(tag), {"op": "rebuild"}) + response = self.client.post(get_tag_uri(tag), {"op": "rebuild"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( response.content.decode(settings.DEFAULT_CHARSET) @@ -760,7 +762,7 @@ def test_POST_rebuild_leaves_manual_tags(self): node.tags.add(tag) self.assertCountEqual([node], tag.node_set.all()) self.become_admin() - response = self.client.post(self.get_tag_uri(tag), {"op": "rebuild"}) + response = self.client.post(get_tag_uri(tag), {"op": "rebuild"}) self.assertEqual(http.client.OK, response.status_code) parsed_result = json.loads( response.content.decode(settings.DEFAULT_CHARSET) @@ -777,7 +779,7 @@ def test_POST_rebuild_unknown_404(self): def test_POST_rebuild_requires_admin(self): tag = factory.make_Tag(definition="/foo/bar") - response = self.client.post(self.get_tag_uri(tag), {"op": "rebuild"}) + response = self.client.post(get_tag_uri(tag), {"op": "rebuild"}) self.assertEqual(http.client.FORBIDDEN, response.status_code) @@ -918,3 +920,65 @@ def test_POST_new_populates_nodes(self): self.assertEqual(tag.name, name) self.assertEqual(tag.comment, comment) self.assertEqual(tag.definition, definition) + + +class TestTagsOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_permissions(self): + self.openfga_client.can_edit_global_entities.return_value = True + self.patch_autospec(Tag, "populate_nodes") + name = factory.make_string() + response = self.client.post( + reverse("tags_handler"), + { + "name": name, + "comment": factory.make_string(), + "definition": factory.make_string(), + }, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + +class TestTagOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_update_requires_can_edit_global_permissions(self): + self.openfga_client.can_edit_global_entities.return_value = True + tag = factory.make_Tag() + response = self.client.put( + get_tag_uri(tag), + {"name": "new-tag-name", "comment": "A random comment"}, + ) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_rebuild_requires_can_edit_global_permissions(self): + self.openfga_client.can_edit_global_entities.return_value = True + tag = factory.make_Tag(definition="") + response = self.client.post(get_tag_uri(tag), {"op": "rebuild"}) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_update_nodes_requires_can_edit_global_permissions(self): + self.openfga_client.can_edit_global_entities.return_value = True + tag = factory.make_Tag(definition="") + response = self.client.post(get_tag_uri(tag), {"op": "update_nodes"}) + self.assertEqual(http.client.OK, response.status_code) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) + + def test_delete_requires_can_edit_global_permissions(self): + self.openfga_client.can_edit_global_entities.return_value = True + tag = factory.make_Tag() + response = self.client.delete(get_tag_uri(tag)) + self.assertEqual(http.client.NO_CONTENT, response.status_code) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_users.py b/src/maasserver/api/tests/test_users.py index 5e2770655..2d833f31b 100644 --- a/src/maasserver/api/tests/test_users.py +++ b/src/maasserver/api/tests/test_users.py @@ -12,6 +12,7 @@ from maascommon.events import AUDIT import maasserver.api.auth +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import IPADDRESS_TYPE, NODE_STATUS from maasserver.models import Node, SSHKey, SSLKey, StaticIPAddress from maasserver.models.event import Event @@ -564,3 +565,38 @@ def test_DELETE_admin_creates_audit_event(self): self.assertEqual( event.description, "Deleted admin '%s'." % user.username ) + + +class TestUsersAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_identities(self): + self.openfga_client.can_edit_identities.return_value = True + response = self.client.post( + reverse("users_handler"), + { + "username": factory.make_name("user"), + "email": factory.make_email_address(), + "password": factory.make_string(), + "is_superuser": "1" if factory.pick_bool() else "0", + }, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_identities.assert_called_once_with( + self.user + ) + + +class TestUserAPIOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_delete_requires_can_edit_identities(self): + self.openfga_client.can_edit_identities.return_value = True + user = factory.make_User() + response = self.client.delete( + reverse("user_handler", args=[user.username]) + ) + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.status_code + ) + self.openfga_client.can_edit_identities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/tests/test_zone.py b/src/maasserver/api/tests/test_zone.py index 90bf98c4d..fcca312de 100644 --- a/src/maasserver/api/tests/test_zone.py +++ b/src/maasserver/api/tests/test_zone.py @@ -9,6 +9,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.defaultresource import DefaultResource from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -159,3 +160,27 @@ def test_DELETE_is_idempotent(self): response = self.client.delete(get_zone_uri(zone)) self.assertEqual(http.client.NO_CONTENT, response.status_code) + + +class TestZoneOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + zone = factory.make_Zone() + new_name = factory.make_name("name") + response = self.client.put( + get_zone_uri(zone), + {"name": new_name}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called() + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + zone = factory.make_Zone() + response = self.client.delete(get_zone_uri(zone)) + self.assertEqual( + http.client.NO_CONTENT, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called() diff --git a/src/maasserver/api/tests/test_zones.py b/src/maasserver/api/tests/test_zones.py index b282cb724..70507fb2f 100644 --- a/src/maasserver/api/tests/test_zones.py +++ b/src/maasserver/api/tests/test_zones.py @@ -9,6 +9,7 @@ from django.conf import settings from django.urls import reverse +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import Zone from maasserver.testing.api import APITestCase from maasserver.testing.factory import factory @@ -88,3 +89,20 @@ def test_list_returns_sorted_zone_list(self): sorted((zone.name for zone in zones), key=lambda s: s.lower()), [zone.get("name") for zone in parsed_result], ) + + +class TestZonesOpenFGAIntegration(OpenFGAMockMixin, APITestCase.ForUser): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + name = factory.make_name("name") + description = factory.make_name("description") + response = self.client.post( + reverse("zones_handler"), + {"name": name, "description": description}, + ) + self.assertEqual( + http.client.OK, response.status_code, response.content + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/api/users.py b/src/maasserver/api/users.py index d1d389d5e..abbe0a3b2 100644 --- a/src/maasserver/api/users.py +++ b/src/maasserver/api/users.py @@ -1,4 +1,4 @@ -# Copyright 2014-2019 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `User`.""" @@ -11,7 +11,11 @@ from maascommon.logging.security import CREATED, DELETED from maasserver.api.ssh_keys import SSHKeysHandler -from maasserver.api.support import admin_method, operation, OperationsHandler +from maasserver.api.support import ( + check_permission, + operation, + OperationsHandler, +) from maasserver.api.utils import extract_bool, get_mandatory_param from maasserver.audit import create_audit_event from maasserver.enum import ENDPOINT @@ -59,7 +63,7 @@ def whoami(self, request): """ return request.user - @admin_method + @check_permission("can_edit_identities") def create(self, request): """@description-title Create a MAAS user account @description Creates a MAAS user account. @@ -152,7 +156,7 @@ def read(self, request, username): """ return get_object_or_404(User, username=username) - @admin_method + @check_permission("can_edit_identities") def delete(self, request, username): """@description-title Delete a user @description Deletes a given username. diff --git a/src/maasserver/api/vmcluster.py b/src/maasserver/api/vmcluster.py index dbc1ead66..4254e1f79 100644 --- a/src/maasserver/api/vmcluster.py +++ b/src/maasserver/api/vmcluster.py @@ -1,11 +1,11 @@ -# Copyright 2021 Canonical Ltd. This software is licensed under the +# Copyright 2021-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `VMCluster`.""" from piston3.utils import rc -from maasserver.api.support import admin_method, OperationsHandler +from maasserver.api.support import OperationsHandler from maasserver.exceptions import MAASAPIValidationError from maasserver.forms.vmcluster import DeleteVMClusterForm, UpdateVMClusterForm from maasserver.models import VMCluster @@ -99,7 +99,6 @@ def storage_pools(cls, cluster): for n, p in pools.items() } - @admin_method def update(self, request, *args, **kwargs): """@description-title Update VMCluster @description Update a specific VMCluster by ID. @@ -132,7 +131,6 @@ def update(self, request, *args, **kwargs): return cluster - @admin_method def delete(self, request, *args, **kwargs): """@description-title Deletes a VM cluster @description Deletes a VM cluster with the given ID. diff --git a/src/maasserver/api/zones.py b/src/maasserver/api/zones.py index 2e3820354..266774f66 100644 --- a/src/maasserver/api/zones.py +++ b/src/maasserver/api/zones.py @@ -1,11 +1,11 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """API handlers: `Zone`.""" from maasserver.api.support import ( - admin_method, AnonymousOperationsHandler, + check_permission, ModelCollectionOperationsHandler, ModelOperationsHandler, ) @@ -126,7 +126,7 @@ class ZonesHandler(ModelCollectionOperationsHandler): handler_url_name = "zones_handler" api_doc_section_name = "Zones" - @admin_method + @check_permission("can_edit_global_entities") def create(self, request): """@description Creates a new zone. @param (string) "name" [required=true] The new zone's name. diff --git a/src/maasserver/auth/local.py b/src/maasserver/auth/local.py index e15d4da56..b662c9675 100644 --- a/src/maasserver/auth/local.py +++ b/src/maasserver/auth/local.py @@ -1,6 +1,13 @@ +# Copyright 2022-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import User +from maasserver.authorization import ( + can_edit_global_entities, + can_edit_machines, +) from maasserver.enum import NODE_TYPE from maasserver.models.blockdevice import BlockDevice from maasserver.models.bmc import Pod @@ -19,6 +26,7 @@ from maasserver.models.tag import Tag from maasserver.models.vlan import VLAN from maasserver.models.vmcluster import VMCluster +from maasserver.openfga import get_openfga_client from maasserver.permissions import ( NodePermission, PodPermission, @@ -107,7 +115,8 @@ def has_perm(self, user, perm, obj=None): if perm == NodePermission.admin and obj is None: # User wants to admin writes to all nodes (aka. create a node), # must be superuser for those permissions. - return user.is_superuser + return can_edit_global_entities(user) + elif perm == NodePermission.view and obj is None: # XXX 2018-11-20 blake_r: View permission without an obj is used # for device create as a standard user. Currently there is no @@ -197,7 +206,7 @@ def has_perm(self, user, perm, obj=None): if node is None: # Doesn't matter the permission level if the interface doesn't # have a node, the user must be a global admin. - return user.is_superuser + return can_edit_machines(user) if perm == NodePermission.view: return self._can_view( rbac_enabled, @@ -234,10 +243,12 @@ def has_perm(self, user, perm, obj=None): # logged-in user; so everyone can view, but only an admin can # do anything else. if perm == NodePermission.view: - return True + if rbac_enabled: + return True + return get_openfga_client().can_view_global_entities(user) elif perm in ADMIN_PERMISSIONS: # Admin permission is solely granted to superusers. - return user.is_superuser + return can_edit_global_entities(user) else: raise NotImplementedError( "Invalid permission check (invalid permission name: %s)." @@ -246,7 +257,7 @@ def has_perm(self, user, perm, obj=None): elif is_instance_or_subclass(obj, ADMIN_RESTRICTED_MODELS): # Only administrators are allowed to read/write these objects. if perm in ADMIN_PERMISSIONS: - return user.is_superuser + return can_edit_global_entities(user) else: raise NotImplementedError( "Invalid permission check (invalid permission name: %s)." @@ -313,7 +324,9 @@ def _can_view( return ( machine.owner_id is None or machine.owner_id == user.id - or user.is_superuser + or get_openfga_client().can_view_machines_in_pool( + user, machine.pool_id + ) ) def _can_edit( @@ -330,15 +343,19 @@ def _can_edit( or can_admin ) return (editable and can_edit) or can_admin - return editable or user.is_superuser + return editable or get_openfga_client().can_edit_machines_in_pool( + user, machine.pool_id + ) def _can_admin(self, rbac_enabled, user, machine, admin_pools): if machine.pool_id is None: # Not a machine to be admin on this must have global admin. - return user.is_superuser + return get_openfga_client().can_edit_machines(user) if rbac_enabled: return machine.pool_id in admin_pools - return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool( + user, machine.pool_id + ) def _perm_resource_pool(self, user, perm, rbac, visible_pools, obj=None): # `create` permissions is called without an `obj`. @@ -349,7 +366,7 @@ def _perm_resource_pool(self, user, perm, rbac, visible_pools, obj=None): ): if rbac_enabled: return rbac.can_admin_resource_pool(user.username) - return user.is_superuser + return get_openfga_client().can_edit_machines(user) # From this point forward the `obj` must be a `ResourcePool`. if not isinstance(obj, ResourcePool): @@ -366,11 +383,13 @@ def _perm_resource_pool(self, user, perm, rbac, visible_pools, obj=None): "edit" ] ) - return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool(user, obj.id) elif perm == ResourcePoolPermission.view: if rbac_enabled: return obj.id in visible_pools - return True + return get_openfga_client().can_view_available_machines_in_pool( + user, obj.id + ) raise ValueError("unknown ResourcePoolPermission value: %s" % perm) @@ -388,7 +407,9 @@ def _perm_pod( # `create` permissions is called without an `obj`. rbac_enabled = rbac.is_enabled() if perm == PodPermission.create: - return user.is_superuser + if rbac_enabled: + return user.is_superuser + return get_openfga_client().can_edit_global_entities(user) # From this point forward the `obj` must be a `ResourcePool`. if not isinstance(obj, Pod): @@ -399,20 +420,29 @@ def _perm_pod( if perm == PodPermission.edit or perm == PodPermission.compose: if rbac_enabled: return obj.pool_id in admin_pools - return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool( + user, obj.pool_id + ) elif perm == PodPermission.dynamic_compose: if rbac_enabled: return ( obj.pool_id in deploy_pools or obj.pool_id in admin_pools ) - return True + return get_openfga_client().can_deploy_machines_in_pool( + user, obj.pool_id + ) or get_openfga_client().can_edit_machines_in_pool( + user, obj.pool_id + ) + elif perm == PodPermission.view: if rbac_enabled: return ( obj.pool_id in visible_pools or obj.pool_id in view_all_pools ) - return True + return get_openfga_client().can_view_available_machines_in_pool( + user, obj.pool_id + ) raise ValueError("unknown PodPermission value: %s" % perm) @@ -438,16 +468,22 @@ def _perm_vmcluster( obj.pool_id in visible_pools or obj.pool_id in view_all_pools ) - return True + return get_openfga_client().can_view_available_machines_in_pool( + user, obj.pool_id + ) if perm == VMClusterPermission.edit: if rbac_enabled: return obj.pool_id in admin_pools - return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool( + user, obj.pool_id + ) if perm == VMClusterPermission.delete: if rbac_enabled: return obj.pool_id in admin_pools - return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool( + user, obj.pool_id + ) raise ValueError("unknown VMClusterPermission value: %s" % perm) diff --git a/src/maasserver/auth/tests/test_auth.py b/src/maasserver/auth/tests/test_auth.py index d1ab9b129..14f7cbe30 100644 --- a/src/maasserver/auth/tests/test_auth.py +++ b/src/maasserver/auth/tests/test_auth.py @@ -1,8 +1,9 @@ -# Copyright 2012-2016 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from functools import partial import http.client +from unittest.mock import MagicMock from django.urls import reverse @@ -18,6 +19,7 @@ from maasserver.rbac import ALL_RESOURCES, FakeRBACClient, rbac from maasserver.secrets import SecretManager from maasserver.testing.factory import factory +from maasserver.testing.fixtures import OpenFGAMock from maasserver.testing.testcase import MAASServerTestCase from metadataserver.nodeinituser import get_node_init_user @@ -78,6 +80,17 @@ def enable_rbac(self): self.rbac_store = client.store +class OpenFGAMockMixin: + """Mixin to disable auto-mocking and set up a custom OpenFGA client mock.""" + + auto_mock_openfga = False + + def setUp(self): + super().setUp() + self.openfga_client = MagicMock() + self.useFixture(OpenFGAMock(client=self.openfga_client)) + + class TestMAASAuthorizationBackend(MAASServerTestCase, EnableRBACMixin): def test_invalid_check_object(self): backend = MAASAuthorizationBackend() @@ -1118,6 +1131,57 @@ def test_admin_is_admin(self): ) +class TestMAASAuthorizationBackendForUnrestrictedReadOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + scenarios = ( + ("dnsdata", {"factory": factory.make_DNSData}), + ("dnsresource", {"factory": factory.make_DNSResource}), + ("domain", {"factory": factory.make_Domain}), + ("fabric", {"factory": factory.make_Fabric}), + ("subnet", {"factory": factory.make_Subnet}), + ("space", {"factory": factory.make_Space}), + ("staticroute", {"factory": factory.make_StaticRoute}), + ("vlan", {"factory": factory.make_VLAN}), + ) + + def test_user_can_view(self): + self.openfga_client.can_view_global_entities.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + self.assertTrue( + backend.has_perm(user, NodePermission.view, self.factory()) + ) + self.openfga_client.can_view_global_entities.assert_called_once_with( + user + ) + + def test_user_can_edit(self): + self.openfga_client.can_edit_global_entities.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + self.assertTrue( + backend.has_perm(user, NodePermission.edit, self.factory()) + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_user_can_admin(self): + self.openfga_client.can_edit_global_entities.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + self.assertTrue( + backend.has_perm(user, NodePermission.admin, self.factory()) + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + class TestMAASAuthorizationBackendForAdminRestricted(MAASServerTestCase): scenarios = (("discovery", {"factory": factory.make_Discovery}),) @@ -1442,3 +1506,155 @@ def test_dynamic_compose_rbac_deploy_admin(self): self.assertTrue( backend.has_perm(user, PodPermission.dynamic_compose, pod3) ) + + +class TestMAASAuthorizationBackendResourcePoolOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_create_requires_can_edit_machines(self): + self.openfga_client.can_edit_machines.return_value = False + + backend = MAASAuthorizationBackend() + user = factory.make_User() + + self.assertFalse(backend.has_perm(user, ResourcePoolPermission.create)) + self.openfga_client.can_edit_machines.assert_called_once_with(user) + + def test_view_always_viewable(self): + self.openfga_client.can_view_available_machines_in_pool.return_value = False + + backend = MAASAuthorizationBackend() + pool = factory.make_ResourcePool() + user = factory.make_User() + self.assertFalse( + backend.has_perm(user, ResourcePoolPermission.view, pool) + ) + self.openfga_client.can_view_available_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + def test_edit_requires_can_edit_machines_in_pool(self): + self.openfga_client.can_edit_machines_in_pool.return_value = False + + backend = MAASAuthorizationBackend() + pool = factory.make_ResourcePool() + user = factory.make_User() + self.assertFalse( + backend.has_perm(user, ResourcePoolPermission.edit, pool) + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + +class TestMAASAuthorizationBackendPodOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = False + + backend = MAASAuthorizationBackend() + user = factory.make_User() + + self.assertFalse(backend.has_perm(user, PodPermission.create)) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_edit_requires_can_edit_machines_in_pool(self): + self.openfga_client.can_edit_machines_in_pool.return_value = False + + backend = MAASAuthorizationBackend() + user = factory.make_User() + pool = factory.make_ResourcePool() + pod = factory.make_Pod(pool=pool) + + self.assertFalse(backend.has_perm(user, PodPermission.edit, pod)) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + def test_dynamic_compose_requires_can_deploy_machines_in_pool(self): + self.openfga_client.can_deploy_machines_in_pool.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + pool = factory.make_ResourcePool() + pod = factory.make_Pod(pool=pool) + + self.assertTrue( + backend.has_perm(user, PodPermission.dynamic_compose, pod) + ) + self.openfga_client.can_deploy_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + def test_dynamic_compose_requires_can_edit_machines_in_pool(self): + self.openfga_client.can_deploy_machines_in_pool.return_value = False + self.openfga_client.can_edit_machines_in_pool.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + pool = factory.make_ResourcePool() + pod = factory.make_Pod(pool=pool) + + self.assertTrue( + backend.has_perm(user, PodPermission.dynamic_compose, pod) + ) + self.openfga_client.can_edit_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + def test_view_requires_can_view_available_machines_in_pool(self): + self.openfga_client.can_view_available_machines_in_pool.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + pool = factory.make_ResourcePool() + pod = factory.make_Pod(pool=pool) + + self.assertTrue(backend.has_perm(user, PodPermission.view, pod)) + self.openfga_client.can_view_available_machines_in_pool.assert_called_once_with( + user, pool.id + ) + + +class TestMAASAuthorizationBackendInterfaceOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_unowned_interface_requires_admin(self): + self.openfga_client.can_edit_machines.return_value = True + + backend = MAASAuthorizationBackend() + interface = factory.make_Interface(INTERFACE_TYPE.UNKNOWN) + user = factory.make_User() + + for perm in [ + NodePermission.view, + NodePermission.edit, + NodePermission.admin, + ]: + self.assertTrue(backend.has_perm(user, perm, interface)) + + def test_user_can_view_if_can_view_machines(self): + self.openfga_client.can_view_machines_in_pool.return_value = True + + backend = MAASAuthorizationBackend() + user = factory.make_User() + node = factory.make_Node(owner=factory.make_User()) + nic = factory.make_Interface(node=node) + self.assertTrue(backend.has_perm(user, NodePermission.view, nic)) + + def test_user_can_view_when_no_node_owner(self): + backend = MAASAuthorizationBackend() + user = factory.make_User() + node = factory.make_Node() + nic = factory.make_Interface(node=node) + self.assertTrue(backend.has_perm(user, NodePermission.view, nic)) + + def test_user_can_view_when_node_owner(self): + backend = MAASAuthorizationBackend() + user = factory.make_User() + node = factory.make_Node(owner=user) + nic = factory.make_Interface(node=node) + self.assertTrue(backend.has_perm(user, NodePermission.view, nic)) diff --git a/src/maasserver/authorization.py b/src/maasserver/authorization.py new file mode 100644 index 000000000..789998dba --- /dev/null +++ b/src/maasserver/authorization.py @@ -0,0 +1,87 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Utility functions for checking user permissions and reduce the `if rbac.is_enabled` burden over all the places.""" + +from django.contrib.auth.models import User + +from maasserver.openfga import get_openfga_client + + +def clear_caches(): + from maasserver.rbac import rbac + + rbac.clear() + get_openfga_client().clear_cache() + + +def can_edit_global_entities(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_edit_global_entities(user) + + +def can_edit_machines(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_edit_machines(user) + + +def can_edit_controllers(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_edit_controllers(user) + + +def can_view_global_entities(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_view_global_entities(user) + + +def can_view_configurations(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_view_configurations(user) + + +def can_edit_configurations(user: User) -> bool: + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_edit_configurations(user) + + +def can_edit_machine_in_pool(user: User, pool_id: int): + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_edit_machines_in_pool(user, pool_id) + + +def can_view_notifications(user: User): + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_view_notifications(user) + + +def can_view_ipaddresses(user: User): + from maasserver.rbac import rbac + + if rbac.is_enabled(): + return user.is_superuser + return get_openfga_client().can_view_ipaddresses(user) diff --git a/src/maasserver/djangosettings/settings.py b/src/maasserver/djangosettings/settings.py index d9c2342f9..516ab7f99 100644 --- a/src/maasserver/djangosettings/settings.py +++ b/src/maasserver/djangosettings/settings.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Django settings for maas project.""" @@ -252,8 +252,8 @@ def _get_default_db_config(config: RegionConfiguration) -> dict: "django.middleware.common.CommonMiddleware", # Used for rendering and logging exceptions. "maasserver.middleware.ExceptionMiddleware", - # Used to clear the RBAC thread-local cache. - "maasserver.middleware.RBACMiddleware", + # Used to clear the RBAC/openfga thread-local cache. + "maasserver.middleware.AuthorizationCacheMiddleware", # Handle errors that should really be handled in application code: # NoConnectionsAvailable, TimeoutError. # FIXME. diff --git a/src/maasserver/forms/__init__.py b/src/maasserver/forms/__init__.py index b4fa29550..7eea97bcc 100644 --- a/src/maasserver/forms/__init__.py +++ b/src/maasserver/forms/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Forms.""" @@ -31,8 +31,6 @@ "FormatBlockDeviceForm", "FormatPartitionForm", "get_machine_create_form", - "get_machine_edit_form", - "get_node_edit_form", "GlobalKernelOptsForm", "KeyForm", "LicenseKeyForm", @@ -97,6 +95,7 @@ ) from maasserver.api.utils import get_optional_param, get_overridden_query_dict from maasserver.audit import create_audit_event +from maasserver.authorization import can_edit_global_entities from maasserver.clusterrpc.driver_parameters import ( get_driver_choices, get_driver_parameters, @@ -1320,20 +1319,6 @@ def save(self, *args, **kwargs): return machine -def get_machine_edit_form(user): - if user.is_superuser: - return AdminMachineForm - else: - return MachineForm - - -def get_node_edit_form(user): - if user.is_superuser: - return AdminNodeForm - else: - return NodeForm - - class KeyForm(MAASModelForm): """Base class for `SSHKeyForm` and `SSLKeyForm`.""" @@ -1603,7 +1588,7 @@ class DeviceWithMACsForm(WithMACAddressesMixin, DeviceForm): def get_machine_create_form(user): - if user.is_superuser: + if can_edit_global_entities(user): return AdminMachineWithMACAddressesForm else: return MachineWithPowerAndMACAddressesForm diff --git a/src/maasserver/forms/tests/test_helpers.py b/src/maasserver/forms/tests/test_helpers.py index 07ac76a3f..aa2f8bf9a 100644 --- a/src/maasserver/forms/tests/test_helpers.py +++ b/src/maasserver/forms/tests/test_helpers.py @@ -1,4 +1,4 @@ -# Copyright 2014-2016 Canonical Ltd. This software is licensed under the +# Copyright 2014-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for forms helpers.""" @@ -7,17 +7,11 @@ from maasserver.enum import BOOT_RESOURCE_TYPE from maasserver.forms import ( - AdminMachineForm, AdminMachineWithMACAddressesForm, - AdminNodeForm, get_machine_create_form, - get_machine_edit_form, - get_node_edit_form, list_all_usable_architectures, MAASModelForm, - MachineForm, MachineWithPowerAndMACAddressesForm, - NodeForm, pick_default_architecture, remove_None_values, ) @@ -112,22 +106,6 @@ def test_remove_None_values_removes_None_values_in_dict(self): def test_remove_None_values_leaves_empty_dict_untouched(self): self.assertEqual({}, remove_None_values({})) - def test_get_machine_edit_form_returns_MachineForm_if_non_admin(self): - user = factory.make_User() - self.assertEqual(MachineForm, get_machine_edit_form(user)) - - def test_get_machine_edit_form_returns_AdminMachineForm_if_admin(self): - admin = factory.make_admin() - self.assertEqual(AdminMachineForm, get_machine_edit_form(admin)) - - def test_get_node_edit_form_returns_NodeForm_if_non_admin(self): - user = factory.make_User() - self.assertEqual(NodeForm, get_node_edit_form(user)) - - def test_get_node_edit_form_returns_AdminNodeForm_if_admin(self): - admin = factory.make_admin() - self.assertEqual(AdminNodeForm, get_node_edit_form(admin)) - def test_get_machine_create_form_if_non_admin(self): user = factory.make_User() self.assertEqual( diff --git a/src/maasserver/middleware.py b/src/maasserver/middleware.py index 4bdfcbff0..089787d51 100644 --- a/src/maasserver/middleware.py +++ b/src/maasserver/middleware.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Access middleware.""" @@ -27,9 +27,10 @@ from maascommon.logging.security import ADMIN, AUTHZ_FAIL, SECURITY, USER from maascommon.tracing import get_trace_id, set_trace_id +from maasserver import openfga +from maasserver.authorization import clear_caches from maasserver.clusterrpc.utils import get_error_message_for_exception from maasserver.exceptions import MAASAPIException -from maasserver.rbac import rbac from maasserver.sqlalchemy import service_layer from maasserver.utils.orm import is_retryable_failure from provisioningserver.logger import LegacyLogger @@ -444,8 +445,8 @@ def __call__(self, request): return self.get_response(request) -class RBACMiddleware: - """Middleware that cleans the RBAC thread-local cache. +class AuthorizationCacheMiddleware: + """Middleware that cleans the RBAC and openfga thread-local cache. At the end of each request the RBAC client that is held in the thread-local @@ -457,8 +458,9 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): + """Clear the cache before and after the request, to ensure that any cached data from a previous request is not used, and that any cached data from this request is not used in the next one.""" + # TODO: Fix RBACFixture and move to clear_caches() + openfga.get_openfga_client().clear_cache() result = self.get_response(request) - # Now that the response has been handled, clear the thread-local - # state of the RBAC connection. - rbac.clear() + clear_caches() return result diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py index cdddde387..8f5b217c0 100644 --- a/src/maasserver/models/node.py +++ b/src/maasserver/models/node.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Node objects.""" @@ -151,6 +151,7 @@ MONITORED_STATUSES, NODE_TRANSITIONS, ) +from maasserver.openfga import get_openfga_client from maasserver.permissions import NodePermission from maasserver.routablepairs import ( get_routable_address_map, @@ -483,25 +484,20 @@ def _filter_visible_nodes(self, nodes, user, perm): # Nonetheless, the code should not crash with corrupt data. if user is None: return nodes.none() - if user.is_superuser and not rbac.is_enabled(): - # Admin is allowed to see all nodes. - return nodes - - # Non-admins aren't allowed to see controllers. - if not user.is_superuser: - nodes = nodes.exclude( - Q( - node_type__in=[ - NODE_TYPE.RACK_CONTROLLER, - NODE_TYPE.REGION_CONTROLLER, - NODE_TYPE.REGION_AND_RACK_CONTROLLER, - ] - ) - ) - visible_pools, view_all_pools = [], [] - deploy_pools, admin_pools = [], [] if rbac.is_enabled(): + # Non-admins aren't allowed to see controllers. + if not user.is_superuser: + nodes = nodes.exclude( + Q( + node_type__in=[ + NODE_TYPE.RACK_CONTROLLER, + NODE_TYPE.REGION_CONTROLLER, + NODE_TYPE.REGION_AND_RACK_CONTROLLER, + ] + ) + ) + fetched_pools = rbac.get_resource_pool_ids( user.username, "view", @@ -514,24 +510,21 @@ def _filter_visible_nodes(self, nodes, user, perm): deploy_pools = fetched_pools["deploy-machines"] admin_pools = fetched_pools["admin-machines"] - if perm == NodePermission.view: - condition = Q(Q(owner__isnull=True) | Q(owner=user)) - if rbac.is_enabled(): + if perm == NodePermission.view: + condition = Q(Q(owner__isnull=True) | Q(owner=user)) condition |= Q(pool_id__in=view_all_pools) - elif perm == NodePermission.edit: - condition = Q(Q(owner__isnull=True) | Q(owner=user)) - if rbac.is_enabled(): + elif perm == NodePermission.edit: + condition = Q(Q(owner__isnull=True) | Q(owner=user)) condition = Q(Q(pool_id__in=deploy_pools) & Q(condition)) - elif perm == NodePermission.admin: - # There is no built-in Q object that represents False, but - # this one does. - condition = Q(id__in=[]) - else: - raise NotImplementedError( - "Invalid permission check (invalid permission name: %s)." - % perm - ) - if rbac.is_enabled(): + elif perm == NodePermission.admin: + # There is no built-in Q object that represents False, but + # this one does. + condition = Q(id__in=[]) + else: + raise NotImplementedError( + "Invalid permission check (invalid permission name: %s)." + % perm + ) # XXX blake_r 2018-12-12 - This should be cleaned up to only use # the `condition` instead of using both `nodes.filter` and # `condition`. The RBAC unit tests cover the expected result. @@ -569,7 +562,59 @@ def _filter_visible_nodes(self, nodes, user, perm): ) | Q(node_type=NODE_TYPE.DEVICE, owner=user) ) + else: + view_all_pools = ( + get_openfga_client().list_pools_with_view_machines_access(user) + ) + visible_pools = get_openfga_client().list_pools_with_view_deployable_machines_access( + user + ) + if perm == NodePermission.view: + # visible pools: free and own machines. + condition = Q( + Q(Q(owner__isnull=True) | Q(owner=user)) + & Q(pool_id__in=visible_pools) + ) + # view all pools: all machines + condition |= Q(pool_id__in=view_all_pools) + elif perm == NodePermission.edit: + deploy_pools = ( + get_openfga_client().list_pool_with_deploy_machines_access( + user + ) + ) + # deploy pools: free and own machines. + condition = Q( + Q(Q(owner__isnull=True) | Q(owner=user)) + & Q(pool_id__in=deploy_pools) + ) + elif perm == NodePermission.admin: + # There is no built-in Q object that represents False, but + # this one does. + condition = Q(pool_id__in=[]) + admin_pools = ( + get_openfga_client().list_pools_with_edit_machines_access(user) + ) + condition |= Q(pool_id__in=admin_pools) + condition = Q(Q(node_type=NODE_TYPE.MACHINE) & condition) + if get_openfga_client().can_view_devices(user): + condition |= Q( + node_type__in=[ + NODE_TYPE.DEVICE, + ] + ) + else: + condition |= Q(Q(node_type=NODE_TYPE.DEVICE) & Q(owner=user)) + + if get_openfga_client().can_view_controllers(user): + condition |= Q( + node_type__in=[ + NODE_TYPE.RACK_CONTROLLER, + NODE_TYPE.REGION_CONTROLLER, + NODE_TYPE.REGION_AND_RACK_CONTROLLER, + ] + ) return nodes.filter(condition) def get_nodes(self, user, perm, ids=None, from_nodes=None): diff --git a/src/maasserver/models/notification.py b/src/maasserver/models/notification.py index 8f1f947ab..a6bf941ec 100644 --- a/src/maasserver/models/notification.py +++ b/src/maasserver/models/notification.py @@ -1,4 +1,4 @@ -# Copyright 2015-2017 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Model for a notification message.""" @@ -19,6 +19,7 @@ ) from markupsafe import Markup +from maasserver.authorization import can_view_notifications from maasserver.models.cleansave import CleanSave from maasserver.models.timestampedmodel import TimestampedModel @@ -170,7 +171,7 @@ def find_for_user(self, user): """ if user is None: return Notification.objects.none() - elif user.is_superuser: + elif can_view_notifications(user): query = self._sql_find_ids_for_admins else: query = self._sql_find_ids_for_users @@ -261,11 +262,16 @@ def render(self): def is_relevant_to(self, user): """Is this notification relevant to the given user?""" - return user is not None and ( - (self.user_id is not None and self.user_id == user.id) - or (self.users and not user.is_superuser) - or (self.admins and user.is_superuser) - ) + if user is not None: + if self.user_id is not None and self.user_id == user.id: + return True + + can_user_view_all_notification = can_view_notifications(user) + if (self.users and not can_user_view_all_notification) or ( + self.admins and can_user_view_all_notification + ): + return True + return False def dismiss(self, user): """Dismiss this notification. diff --git a/src/maasserver/models/signals/__init__.py b/src/maasserver/models/signals/__init__.py index 2796c5f88..f1a63a196 100644 --- a/src/maasserver/models/signals/__init__.py +++ b/src/maasserver/models/signals/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Signals coming off models.""" @@ -17,6 +17,7 @@ "iprange", "nodes", "regionrackrpcconnection", + "resourcepool", "partitions", "podhints", "power", @@ -24,6 +25,7 @@ "services", "staticipaddress", "subnet", + "users", "vlan", ] @@ -44,9 +46,11 @@ podhints, power, regionrackrpcconnection, + resourcepool, scriptresult, services, staticipaddress, subnet, + users, vlan, ) diff --git a/src/maasserver/models/signals/resourcepool.py b/src/maasserver/models/signals/resourcepool.py new file mode 100644 index 000000000..64275e396 --- /dev/null +++ b/src/maasserver/models/signals/resourcepool.py @@ -0,0 +1,31 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Respond to ResourcePool changes.""" + +from django.db.models.signals import post_delete, post_save + +from maasserver.models import ResourcePool +from maasserver.sqlalchemy import service_layer +from maasserver.utils.signals import SignalsManager +from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder + +signals = SignalsManager() + + +def post_created_resourcepool(sender, instance, created, **kwargs): + if created: + service_layer.services.openfga_tuples.create( + OpenFGATupleBuilder.build_pool(str(instance.id)) + ) + + +def post_delete_resourcepool(sender, instance, **kwargs): + service_layer.services.openfga_tuples.delete_pool(instance.id) + + +signals.watch(post_save, post_created_resourcepool, sender=ResourcePool) +signals.watch(post_delete, post_delete_resourcepool, sender=ResourcePool) + +# Enable all signals by default. +signals.enable() diff --git a/src/maasserver/models/signals/tests/test_resourcepool.py b/src/maasserver/models/signals/tests/test_resourcepool.py new file mode 100644 index 000000000..a5c81cc62 --- /dev/null +++ b/src/maasserver/models/signals/tests/test_resourcepool.py @@ -0,0 +1,41 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Test the behaviour of resourcepool signals.""" + +from django.db import connection + +from maasserver.testing.factory import factory +from maasserver.testing.testcase import MAASServerTestCase + + +class TestPostSaveResourcePoolSignal(MAASServerTestCase): + def test_save_creates_openfga_tuple(self): + pool = factory.make_ResourcePool() + + with connection.cursor() as cursor: + cursor.execute( + "SELECT _user, relation FROM openfga.tuple WHERE object_type = 'pool' AND object_id = '%s'", + [pool.id], + ) + openfga_tuple = cursor.fetchone() + + self.assertEqual("maas:0", openfga_tuple[0]) + self.assertEqual("parent", openfga_tuple[1]) + + +class TestPostDeleteResourcePoolSignal(MAASServerTestCase): + def test_delete_removes_openfga_tuple(self): + pool = factory.make_ResourcePool() + pool_id = pool.id + + pool.delete() + + with connection.cursor() as cursor: + cursor.execute( + "SELECT object_type, object_id, relation FROM openfga.tuple WHERE _user = 'pool:%s'", + [pool_id], + ) + openfga_tuple = cursor.fetchone() + + self.assertIsNone(openfga_tuple) diff --git a/src/maasserver/models/signals/tests/test_users.py b/src/maasserver/models/signals/tests/test_users.py index 828b6fa2e..e9cf6c630 100644 --- a/src/maasserver/models/signals/tests/test_users.py +++ b/src/maasserver/models/signals/tests/test_users.py @@ -1,8 +1,10 @@ -# Copyright 2017 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Test the behaviour of user signals.""" +from django.db import connection + from maasserver.testing.factory import factory from maasserver.testing.testcase import MAASServerTestCase @@ -18,3 +20,44 @@ def test_deleting_user_updates_event_username(self): user.delete() for event in events: self.assertEqual(event.username, username) + + +class TestPostSaveUserSignal(MAASServerTestCase): + scenarios = ( + ("user", {"user_factory": factory.make_User}), + ("admin", {"user_factory": factory.make_admin}), + ) + + def test_save_creates_openfga_tuple(self): + user = self.user_factory() + + with connection.cursor() as cursor: + cursor.execute( + "SELECT object_type, object_id, relation FROM openfga.tuple WHERE _user = 'user:%s'", + [user.id], + ) + openfga_tuple = cursor.fetchone() + + self.assertEqual("group", openfga_tuple[0]) + self.assertEqual( + "administrators" if user.is_superuser else "users", + openfga_tuple[1], + ) + self.assertEqual("member", openfga_tuple[2]) + + +class TestPostDeleteUserSignal(MAASServerTestCase): + def test_delete_removes_openfga_tuple(self): + user = factory.make_User() + user_id = user.id + + user.delete() + + with connection.cursor() as cursor: + cursor.execute( + "SELECT object_type, object_id, relation FROM openfga.tuple WHERE _user = 'user:%s'", + [user_id], + ) + openfga_tuple = cursor.fetchone() + + self.assertIsNone(openfga_tuple) diff --git a/src/maasserver/models/signals/users.py b/src/maasserver/models/signals/users.py index 0a3ee92ff..c34cdd562 100644 --- a/src/maasserver/models/signals/users.py +++ b/src/maasserver/models/signals/users.py @@ -1,12 +1,15 @@ -# Copyright 2017 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Respond to user changes.""" from django.contrib.auth.models import User -from django.db.models.signals import pre_delete +from django.db.models.signals import post_delete, post_save, pre_delete +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() @@ -16,14 +19,31 @@ def pre_delete_set_event_username(sender, instance, **kwargs): """Set username for events that reference user being deleted.""" - for event in instance.event_set.all(): + for event in Event.objects.filter(user_id=instance.id).all(): event.username = instance.username event.save() +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", + ) + ) + + +def post_delete_user(sender, instance, **kwargs): + service_layer.services.openfga_tuples.delete_user(instance.id) + + for klass in USER_CLASSES: signals.watch(pre_delete, pre_delete_set_event_username, sender=klass) +signals.watch(post_save, post_created_user, sender=User) +signals.watch(post_delete, post_delete_user, sender=User) # Enable all signals by default. signals.enable() diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py index 971ba7dcf..38a49ba38 100644 --- a/src/maasserver/models/tests/test_node.py +++ b/src/maasserver/models/tests/test_node.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import base64 @@ -35,6 +35,7 @@ ) from maasserver import server_address from maasserver import workflow as workflow_module +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.clusterrpc.driver_parameters import get_driver_choices from maasserver.enum import ( BOOT_RESOURCE_FILE_TYPE, @@ -13098,3 +13099,124 @@ def test_node_with_prefetch(self): with post_commit_hooks: destination.set_networking_configuration_from_node(source) + + +class TestNodeOpenFGAIntegration(OpenFGAMockMixin, MAASServerTestCase): + scenarios = ( + # view Permissions + ( + "view_other_denied", + { + "fga_method": "can_view_machines_in_pool", + "fga_return": False, + "owned_by_user": False, + "permission": NodePermission.view, + "expected_success": False, + }, + ), + ( + "view_owned_allowed", + { + "fga_method": "can_view_machines_in_pool", + "fga_return": False, + "owned_by_user": True, + "permission": NodePermission.view, + "expected_success": True, + }, + ), + ( + "view_other_allowed_by_fga", + { + "fga_method": "can_view_machines_in_pool", + "fga_return": True, + "owned_by_user": False, + "permission": NodePermission.view, + "expected_success": True, + }, + ), + # edit Permissions + ( + "edit_other_denied", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": False, + "owned_by_user": False, + "permission": NodePermission.edit, + "expected_success": False, + }, + ), + ( + "edit_owned_allowed", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": False, + "owned_by_user": True, + "permission": NodePermission.edit, + "expected_success": True, + }, + ), + ( + "edit_other_allowed_by_fga", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": True, + "owned_by_user": False, + "permission": NodePermission.edit, + "expected_success": True, + }, + ), + # admin Permissions + ( + "admin_other_denied", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": False, + "owned_by_user": False, + "permission": NodePermission.admin, + "expected_success": False, + }, + ), + ( + "admin_owned_denied", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": False, + "owned_by_user": True, + "permission": NodePermission.admin, + "expected_success": False, + }, + ), + ( + "admin_allowed_by_fga", + { + "fga_method": "can_edit_machines_in_pool", + "fga_return": True, + "owned_by_user": False, + "permission": NodePermission.admin, + "expected_success": True, + }, + ), + ) + + def test_node_access_logic(self): + getattr( + self.openfga_client, self.fga_method + ).return_value = self.fga_return + + user = factory.make_User() + owner = user if self.owned_by_user else factory.make_User() + node = factory.make_Node(owner=owner) + + if self.expected_success: + result = Node.objects.get_node_or_404( + node.system_id, user, self.permission + ) + self.assertEqual(node, result) + else: + self.assertRaises( + PermissionDenied, + Node.objects.get_node_or_404, + node.system_id, + user, + self.permission, + ) diff --git a/src/maasserver/models/tests/test_vlan.py b/src/maasserver/models/tests/test_vlan.py index 65032efe5..578a3532d 100644 --- a/src/maasserver/models/tests/test_vlan.py +++ b/src/maasserver/models/tests/test_vlan.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import random @@ -213,7 +213,6 @@ def test_subnets_are_reconnected_when_vlan_is_deleted(self): with post_commit_hooks: fabric = factory.make_Fabric() vlan = factory.make_VLAN(fabric=fabric) - print(vlan.dhcp_on) subnet = factory.make_Subnet(vlan=vlan) vlan.delete() self.assertEqual(reload_object(subnet).vlan, fabric.get_default_vlan()) diff --git a/src/maasserver/node_action.py b/src/maasserver/node_action.py index d116b03f7..621d9d081 100644 --- a/src/maasserver/node_action.py +++ b/src/maasserver/node_action.py @@ -1,4 +1,4 @@ -# Copyright 2012-2019 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Node actions. @@ -22,6 +22,7 @@ from maascommon.osystem import LINUX_OSYSTEMS from maasserver import locks from maasserver.audit import create_audit_event +from maasserver.authorization import can_edit_machines from maasserver.enum import ( ENDPOINT, NODE_ACTION_TYPE, @@ -516,7 +517,7 @@ def _execute( ): """See `NodeAction.execute`.""" if install_kvm or register_vmhost: - if not self.user.is_superuser: + if not can_edit_machines(self.user): raise NodeActionError( "You must be a MAAS administrator to deploy a machine " "as a MAAS-managed VM host." diff --git a/src/maasserver/openfga.py b/src/maasserver/openfga.py new file mode 100644 index 000000000..ce2119ed8 --- /dev/null +++ b/src/maasserver/openfga.py @@ -0,0 +1,58 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import functools +import threading +from typing import Any, Dict + +from maascommon.openfga.sync_client import SyncOpenFGAClient + + +class ThreadLocalFGACache: + def __init__(self, client: SyncOpenFGAClient): + self._client = client + self._local = threading.local() + + @property + def _cache(self) -> Dict[tuple, Any]: + """Initialize the cache dict on the local storage if it doesn't exist""" + if not hasattr(self._local, "cache"): + self._local.cache = {} + return self._local.cache + + def clear_cache(self): + """Clears the cache for the current thread.""" + self._cache.clear() + + def __getattr__(self, name: str): + """ + Fallback for any method not defined on this class. + It looks up the attribute on the original client and wraps it. + """ + attr = getattr(self._client, name) + + if callable(attr): + + @functools.wraps(attr) + def wrapper(*args, **kwargs): + # Cache key based on method name and arguments + cache_key = (name, args, tuple(sorted(kwargs.items()))) + + if cache_key not in self._cache: + self._cache[cache_key] = attr(*args, **kwargs) + + return self._cache[cache_key] + + return wrapper + + return attr + + +def _get_client(): + raw_client = SyncOpenFGAClient() + return ThreadLocalFGACache(raw_client) + + +@functools.lru_cache(maxsize=1) +def get_openfga_client(): + return _get_client() diff --git a/src/maasserver/testing/api.py b/src/maasserver/testing/api.py index fe2247bea..f7ce04f54 100644 --- a/src/maasserver/testing/api.py +++ b/src/maasserver/testing/api.py @@ -1,4 +1,4 @@ -# Copyright 2013-2019 Canonical Ltd. This software is licensed under the +# Copyright 2013-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Helpers for API testing.""" @@ -20,6 +20,8 @@ from maasserver.macaroon_auth import external_auth_enabled from maasserver.models.user import create_auth_token from maasserver.testing.factory import factory +from maasserver.testing.fixtures import OpenFGAMock +from maasserver.testing.openfga import OpenFGAClientMock from maasserver.testing.testcase import ( MAASServerTestCase, MAASTransactionServerTestCase, @@ -84,6 +86,19 @@ def ForAdmin(cls): """API test for administrative users only.""" return cls.forUsers(admin=factory.make_admin) + @property + def ForInternalUser(cls): + """API test for administrative users only.""" + + def _make_worker(): + user = get_worker_user() + + # Pre-create the auth token. + create_auth_token(user) + return user + + return cls.forUsers(user=_make_worker) + @property def ForAnonymousAndUserAndAdmin(cls): """API test for anonymous, normal, and administrative users.""" @@ -133,6 +148,9 @@ class APITestCaseBase(MAASTestCase, metaclass=APITestType): # a subclass; it will be set for you. client = None + # Mock openfga automatically for the test. Set to False to disable this behavior and mock it manually in the test. + auto_mock_openfga = True + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Create scenarios for userfactories and clientfactories. @@ -175,6 +193,9 @@ def setUp(self): self.client = self.clientfactory() self.client.login(user=self.user) + if self.auto_mock_openfga: + self.useFixture(OpenFGAMock(client=OpenFGAClientMock())) + def assertIsInstance(self, *args, **kwargs): return unittest.TestCase.assertIsInstance(self, *args, **kwargs) diff --git a/src/maasserver/testing/fixtures.py b/src/maasserver/testing/fixtures.py index 0a78c6fec..c27aa9da6 100644 --- a/src/maasserver/testing/fixtures.py +++ b/src/maasserver/testing/fixtures.py @@ -1,14 +1,16 @@ -# Copyright 2016-2025 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """maasserver fixtures.""" import inspect import logging +from unittest.mock import patch from django.db import connection import fixtures +from maasserver import openfga as openfga_module from maasserver.models import Config from maasserver.rbac import FakeRBACClient, rbac from maasserver.secrets import SecretManager @@ -131,3 +133,20 @@ def cleanup(): rbac.clear() self.addCleanup(cleanup) + + +class OpenFGAMock(fixtures.Fixture): + """Fixture to mock OpenFGA.""" + + def __init__(self, client): + super().__init__() + self.openfga_mock = client + + def _setUp(self): + patcher = patch.object(openfga_module, "_get_client") + mock_client = patcher.start() + + mock_client.return_value = self.openfga_mock + + self.addCleanup(patcher.stop) + self.addCleanup(openfga_module.get_openfga_client.cache_clear) diff --git a/src/maasserver/testing/initial.maas_test.sql b/src/maasserver/testing/initial.maas_test.sql index 909308b29..90d44e62f 100644 --- a/src/maasserver/testing/initial.maas_test.sql +++ b/src/maasserver/testing/initial.maas_test.sql @@ -8480,7 +8480,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 \\x0a1a30303030303030303030303030303030303030303030303030301203312e311a060a04757365721a2b0a0567726f7570120c0a066d656d62657212020a001a140a120a066d656d62657212080a060a04757365721adf060a046d616173122c0a0e63616e5f766965775f706f6f6c73121a22180a020a000a121210120e63616e5f656469745f706f6f6c7312170a1163616e5f656469745f6d616368696e657312020a0012400a1863616e5f766965775f676c6f62616c5f656e746974696573122422220a020a000a1c121a121863616e5f656469745f676c6f62616c5f656e74697469657312380a1463616e5f766965775f7065726d697373696f6e731220221e0a020a000a181216121463616e5f656469745f7065726d697373696f6e73121d0a1763616e5f656469745f636f6e66696775726174696f6e7312020a0012140a0e63616e5f656469745f706f6f6c7312020a0012340a1363616e5f6465706c6f795f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e6573121e0a1863616e5f656469745f676c6f62616c5f656e74697469657312020a00121a0a1463616e5f656469745f7065726d697373696f6e7312020a00123e0a1763616e5f766965775f636f6e66696775726174696f6e73122322210a020a000a1b1219121763616e5f656469745f636f6e66696775726174696f6e731aac030a230a0e63616e5f656469745f706f6f6c7312110a0f0a0567726f757012066d656d6265720a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f656469745f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f7065726d697373696f6e7312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f656469745f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f766965775f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a230a0e63616e5f766965775f706f6f6c7312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f766965775f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f656469745f7065726d697373696f6e7312110a0f0a0567726f757012066d656d6265721af9040a04706f6f6c12670a1163616e5f766965775f6d616368696e6573125222500a020a000a171215121363616e5f6465706c6f795f6d616368696e65730a151213121163616e5f656469745f6d616368696e65730a0c120a120863616e5f766965770a0c120a120863616e5f65646974120c0a06706172656e7412020a0012320a0863616e5f65646974122622240a020a000a1e1a1c0a081206706172656e741210120e63616e5f656469745f706f6f6c7312400a0863616e5f76696577123422320a020a000a0c120a120863616e5f656469740a1e1a1c0a081206706172656e741210120e63616e5f766965775f706f6f6c73124c0a1163616e5f656469745f6d616368696e6573123722350a020a000a0c120a120863616e5f656469740a211a1f0a081206706172656e741213121163616e5f656469745f6d616368696e657312670a1363616e5f6465706c6f795f6d616368696e65731250224e0a020a000a0c120a120863616e5f656469740a151213121163616e5f656469745f6d616368696e65730a231a210a081206706172656e741215121363616e5f6465706c6f795f6d616368696e65731acc010a120a06706172656e7412080a060a046d6161730a1d0a0863616e5f6564697412110a0f0a0567726f757012066d656d6265720a1d0a0863616e5f7669657712110a0f0a0567726f757012066d656d6265720a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d626572 +00000000000000000000000000 00000000000000000000000000 \N 1.1 \\x0a1a30303030303030303030303030303030303030303030303030301203312e311a060a04757365721a2b0a0567726f7570120c0a066d656d62657212020a001a140a120a066d656d62657212080a060a04757365721afe0d0a046d61617312360a1363616e5f766965775f6964656e746974696573121f221d0a020a000a171215121363616e5f656469745f6964656e746974696573121c0a1663616e5f656469745f626f6f745f656e74697469657312020a00121b0a1563616e5f656469745f6c6963656e73655f6b65797312020a0012160a1063616e5f766965775f6465766963657312020a0012320a1163616e5f766965775f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e6573121e0a1863616e5f656469745f676c6f62616c5f656e74697469657312020a0012380a1463616e5f766965775f636f6e74726f6c6c6572731220221e0a020a000a181216121463616e5f656469745f636f6e74726f6c6c657273121c0a1663616e5f656469745f6e6f74696669636174696f6e7312020a0012170a1163616e5f656469745f6d616368696e657312020a0012340a1363616e5f6465706c6f795f6d616368696e6573121d221b0a020a000a151213121163616e5f656469745f6d616368696e657312400a1863616e5f766965775f676c6f62616c5f656e746974696573122422220a020a000a1c121a121863616e5f656469745f676c6f62616c5f656e74697469657312190a1363616e5f656469745f6964656e74697469657312020a00123c0a1663616e5f766965775f6e6f74696669636174696f6e73122222200a020a000a1a1218121663616e5f656469745f6e6f74696669636174696f6e73123c0a1663616e5f766965775f626f6f745f656e746974696573122222200a020a000a1a1218121663616e5f656469745f626f6f745f656e746974696573123a0a1563616e5f766965775f6c6963656e73655f6b6579731221221f0a020a000a191217121563616e5f656469745f6c6963656e73655f6b657973121a0a1463616e5f766965775f697061646472657373657312020a0012530a1b63616e5f766965775f617661696c61626c655f6d616368696e6573123422320a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e6573121a0a1463616e5f656469745f636f6e74726f6c6c65727312020a00121d0a1763616e5f656469745f636f6e66696775726174696f6e7312020a00123e0a1763616e5f766965775f636f6e66696775726174696f6e73122322210a020a000a1b1219121763616e5f656469745f636f6e66696775726174696f6e731aee060a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f766965775f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a290a1463616e5f656469745f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f636f6e74726f6c6c65727312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f656469745f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2d0a1863616e5f656469745f676c6f62616c5f656e74697469657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f656469745f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f626f6f745f656e74697469657312110a0f0a0567726f757012066d656d6265720a2a0a1563616e5f766965775f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a290a1463616e5f766965775f697061646472657373657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a2c0a1763616e5f766965775f636f6e66696775726174696f6e7312110a0f0a0567726f757012066d656d6265720a2b0a1663616e5f766965775f6e6f74696669636174696f6e7312110a0f0a0567726f757012066d656d6265720a280a1363616e5f656469745f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f766965775f6964656e74697469657312110a0f0a0567726f757012066d656d6265720a2a0a1563616e5f656469745f6c6963656e73655f6b65797312110a0f0a0567726f757012066d656d6265720a250a1063616e5f766965775f6465766963657312110a0f0a0567726f757012066d656d6265721acc040a04706f6f6c120c0a06706172656e7412020a00123e0a1163616e5f656469745f6d616368696e6573122922270a020a000a211a1f0a081206706172656e741213121163616e5f656469745f6d616368696e657312590a1363616e5f6465706c6f795f6d616368696e6573124222400a020a000a151213121163616e5f656469745f6d616368696e65730a231a210a081206706172656e741215121363616e5f6465706c6f795f6d616368696e657312550a1163616e5f766965775f6d616368696e65731240223e0a020a000a151213121163616e5f656469745f6d616368696e65730a211a1f0a081206706172656e741213121163616e5f766965775f6d616368696e65731280010a1b63616e5f766965775f617661696c61626c655f6d616368696e65731261225f0a020a000a151213121163616e5f656469745f6d616368696e65730a151213121163616e5f766965775f6d616368696e65730a2b1a290a081206706172656e74121d121b63616e5f766965775f617661696c61626c655f6d616368696e65731ac0010a120a06706172656e7412080a060a046d6161730a260a1163616e5f656469745f6d616368696e657312110a0f0a0567726f757012066d656d6265720a280a1363616e5f6465706c6f795f6d616368696e657312110a0f0a0567726f757012066d656d6265720a260a1163616e5f766965775f6d616368696e657312110a0f0a0567726f757012066d656d6265720a300a1b63616e5f766965775f617661696c61626c655f6d616368696e657312110a0f0a0567726f757012066d656d626572 \. @@ -8497,8 +8497,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-12 13:46:03.354671 -2 1 t 2026-02-12 13:46:03.35626 +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 \. @@ -8507,13 +8508,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-12 13:46:03.334831 -2 1 t 2026-02-12 13:46:03.337048 -3 2 t 2026-02-12 13:46:03.340647 -4 3 t 2026-02-12 13:46:03.340924 -5 4 t 2026-02-12 13:46:03.341186 -6 5 t 2026-02-12 13:46:03.341337 -7 6 t 2026-02-12 13:46:03.342237 +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 \. @@ -8522,7 +8523,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-12 13:46:03.35626+00 2026-02-12 13:46:03.35626+00 \N +00000000000000000000000000 MAAS 2026-02-25 12:51:32.117442+00 2026-02-25 12:51:32.117442+00 \N \. @@ -8531,6 +8532,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 \. @@ -10335,7 +10350,7 @@ COPY temporal_visibility.schema_version (version_partition, db_name, creation_ti -- Name: goose_app_db_version_id_seq; Type: SEQUENCE SET; Schema: openfga; Owner: - -- -SELECT pg_catalog.setval('openfga.goose_app_db_version_id_seq', 2, true); +SELECT pg_catalog.setval('openfga.goose_app_db_version_id_seq', 3, true); -- diff --git a/src/maasserver/testing/openfga.py b/src/maasserver/testing/openfga.py new file mode 100644 index 000000000..885b3e86b --- /dev/null +++ b/src/maasserver/testing/openfga.py @@ -0,0 +1,85 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from django.db import connection + +from maascommon.openfga.sync_client import SyncOpenFGAClient + + +class OpenFGAClientMock(SyncOpenFGAClient): + # Methods allowing access ONLY to superusers + SUPERUSER_ONLY = [ + "can_edit_machines", + "can_edit_machines_in_pool", + "can_view_machines_in_pool", + "can_edit_global_entities", + "can_view_controllers", + "can_edit_controllers", + "can_view_identities", + "can_edit_identities", + "can_view_configurations", + "can_edit_configurations", + "can_edit_notifications", + "can_view_notifications", + "can_view_boot_entities", + "can_edit_boot_entities", + "can_view_license_keys", + "can_edit_license_keys", + "can_view_devices", + "can_view_ipaddresses", + ] + + # Methods allowing access to EVERYONE + ALWAYS_ALLOWED = [ + "can_deploy_machines_in_pool", + "can_view_available_machines_in_pool", + "can_view_global_entities", + ] + + # Methods returning pools ONLY for superusers + LIST_SUPERUSER_ONLY = [ + "list_pools_with_view_machines_access", + "list_pools_with_edit_machines_access", + ] + + # Methods returning pools for EVERYONE + LIST_ALWAYS_ALLOWED = [ + "list_pools_with_view_deployable_machines_access", + "list_pool_with_deploy_machines_access", + ] + + def __init__(self, *args, **kwargs): + self.client = None + self._bind_methods() + + def clear_cache(self): + # No caching in this mock, so nothing to clear + pass + + def _bind_methods(self): + # Permission checks + for method in self.SUPERUSER_ONLY: + setattr(self, method, lambda user, *args: user.is_superuser) + + for method in self.ALWAYS_ALLOWED: + setattr(self, method, lambda user, *args: True) + + # Listing methods + for method in self.LIST_SUPERUSER_ONLY: + setattr( + self, + method, + lambda user: self._get_resource_pools() + if user.is_superuser + else [], + ) + + for method in self.LIST_ALWAYS_ALLOWED: + setattr(self, method, lambda user: self._get_resource_pools()) + + def _get_resource_pools(self) -> list[int]: + with connection.cursor() as cursor: + cursor.execute( + "SELECT id FROM maasserver_resourcepool; /* COUNTQUERIES-IGNOREME */" + ) + return [row[0] for row in cursor.fetchall()] diff --git a/src/maasserver/testing/sampledata/machine.py b/src/maasserver/testing/sampledata/machine.py index b338da787..87beb8853 100644 --- a/src/maasserver/testing/sampledata/machine.py +++ b/src/maasserver/testing/sampledata/machine.py @@ -6,7 +6,7 @@ from django.contrib.auth.models import User from maasserver.enum import NODE_STATUS -from maasserver.models import BMC, Machine, Pod, Tag +from maasserver.models import BMC, Machine, Pod, ResourcePool, Tag from maasserver.testing.commissioning import FakeCommissioningData from metadataserver.builtin_scripts.hooks import ( process_lxd_results, @@ -39,6 +39,7 @@ def make_machines( tags: List[Tag], users: List[User], redfish_address: str, + resourcepools: List[ResourcePool], ): bmcs = cycle(vmhosts) owners = cycle(users) @@ -88,6 +89,7 @@ def make_machines( instance_power_parameters=instance_power_parameters, status=status, owner=owner, + pool=random.choice(resourcepools), ) machine.tags.add(*random.choices(tags, k=10)) lxd_info = json.dumps(machine_info.render()).encode() diff --git a/src/maasserver/testing/sampledata/main.py b/src/maasserver/testing/sampledata/main.py index 33416d113..3cc570988 100644 --- a/src/maasserver/testing/sampledata/main.py +++ b/src/maasserver/testing/sampledata/main.py @@ -60,6 +60,12 @@ def parse_args() -> Namespace: type=int, default=1000, ) + parser.add_argument( + "--resourcepools", + help="number of resource pools to create", + type=int, + default=100, + ) parser.add_argument( "--log-queries", help="log SQL queries", @@ -170,6 +176,7 @@ def main(): args.ownerdata_prefix, args.tag_prefix, args.redfish_address, + args.resourcepools, ) enable_triggers() end_time = time.monotonic() diff --git a/src/maasserver/testing/sampledata/resourcepool.py b/src/maasserver/testing/sampledata/resourcepool.py new file mode 100644 index 000000000..e54a9bf18 --- /dev/null +++ b/src/maasserver/testing/sampledata/resourcepool.py @@ -0,0 +1,11 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from maasserver.models import ResourcePool + + +def make_resourcepools(num: int): + return [ + ResourcePool.objects.create(name=f"ResourcePool-{i}") + for i in range(num) + ] diff --git a/src/maasserver/testing/sampledata/sampledata.py b/src/maasserver/testing/sampledata/sampledata.py index 43376f9d2..edcd76b6c 100644 --- a/src/maasserver/testing/sampledata/sampledata.py +++ b/src/maasserver/testing/sampledata/sampledata.py @@ -1,3 +1,6 @@ +# Copyright 2022-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + from django.db import transaction from ...sqlalchemy import service_layer @@ -24,6 +27,7 @@ def generate( ownerdata_prefix: str, tag_prefix: str, redfish_address: str, + resourcepool_count: int, ): service_layer.init() from metadataserver.builtin_scripts import load_builtin_scripts @@ -38,6 +42,7 @@ def generate( make_rackcontrollers, make_rackcontrollers_primary_or_secondary, ) + from .resourcepool import make_resourcepools from .storage import make_storage_setup from .tag import make_tags from .user import make_users @@ -92,10 +97,13 @@ def generate( rackcontrollers = make_rackcontrollers(rackcontroller_infos, tags) make_rackcontrollers_primary_or_secondary(rackcontrollers, vlans) + LOGGER.info(f"creating {resourcepool_count} resource pools") + resourcepools = make_resourcepools(resourcepool_count) + LOGGER.info(f"creating {machine_count} machines") make_storage_setup(machine_infos) machines = make_machines( - machine_infos, vmhosts, tags, users, redfish_address + machine_infos, vmhosts, tags, users, redfish_address, resourcepools ) LOGGER.info("creating 5 pci devices per machine") diff --git a/src/maasserver/testing/testcase.py b/src/maasserver/testing/testcase.py index b32fb75d7..7d4bfe401 100644 --- a/src/maasserver/testing/testcase.py +++ b/src/maasserver/testing/testcase.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Custom test-case classes.""" @@ -38,9 +38,11 @@ from maasserver.sqlalchemy import service_layer from maasserver.testing.fixtures import ( IntroCompletedFixture, + OpenFGAMock, PackageRepositoryFixture, RBACClearFixture, ) +from maasserver.testing.openfga import OpenFGAClientMock from maasserver.testing.orm import PostCommitHooksTestMixin from maasserver.testing.resources import DjangoDatabasesManager from maasserver.testing.testclient import MAASSensibleClient @@ -68,6 +70,9 @@ class MAASRegionTestCaseBase(PostCommitHooksTestMixin): mock_cache_boot_source = True mock_delete_large_object_content_later = True + # Mock openfga automatically for the test. Set to False to disable this behavior and mock it manually in the test. + auto_mock_openfga = True + @property def client(self): """Create a client on demand, and cache it. @@ -104,6 +109,9 @@ def setUp(self): if self.mock_cache_boot_source: self.patch(bootsources_module, "post_commit_do") + if self.auto_mock_openfga: + self.useFixture(OpenFGAMock(client=OpenFGAClientMock())) + def setUpFixtures(self): """This should be called by a subclass once other set-up is done.""" # Avoid circular imports. diff --git a/src/maasserver/tests/test_middleware.py b/src/maasserver/tests/test_middleware.py index 6356c88ec..3f3e1655e 100644 --- a/src/maasserver/tests/test_middleware.py +++ b/src/maasserver/tests/test_middleware.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). import http.client @@ -18,12 +18,12 @@ from maasserver.middleware import ( AccessMiddleware, APIRPCErrorsMiddleware, + AuthorizationCacheMiddleware, CSRFHelperMiddleware, DebuggingLoggerMiddleware, ExceptionMiddleware, ExternalAuthInfoMiddleware, is_public_path, - RBACMiddleware, RPCErrorsMiddleware, TracingMiddleware, ) @@ -632,12 +632,12 @@ def test_with_external_auth_strip_trailing_slash(self): self.assertEqual(request.external_auth_info.url, "https://example.com") -class TestRBACMiddleware(MAASServerTestCase): +class TestAuthorizationCacheMiddleware(MAASServerTestCase): def process_request(self, request): def get_response(request): return None - middleware = RBACMiddleware(get_response) + middleware = AuthorizationCacheMiddleware(get_response) return middleware(request) def test_calls_rbac_clear(self): diff --git a/src/maasserver/urls_api.py b/src/maasserver/urls_api.py index e1328163b..5daa59ba5 100644 --- a/src/maasserver/urls_api.py +++ b/src/maasserver/urls_api.py @@ -1,4 +1,4 @@ -# Copyright 2012-2020 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """URL API routing configuration.""" @@ -317,33 +317,33 @@ node_devices_handler = RestrictedResource( NodeDevicesHandler, authentication=api_auth ) - -# Admin handlers. -commissioning_script_handler = AdminRestrictedResource( - CommissioningScriptHandler, authentication=api_auth -) -commissioning_scripts_handler = AdminRestrictedResource( - CommissioningScriptsHandler, authentication=api_auth -) -boot_source_handler = AdminRestrictedResource( +boot_source_handler = RestrictedResource( BootSourceHandler, authentication=api_auth ) -boot_sources_handler = AdminRestrictedResource( +boot_sources_handler = RestrictedResource( BootSourcesHandler, authentication=api_auth ) -boot_source_selection_handler = AdminRestrictedResource( +boot_source_selection_handler = RestrictedResource( BootSourceSelectionHandler, authentication=api_auth ) -boot_source_selections_handler = AdminRestrictedResource( +boot_source_selections_handler = RestrictedResource( BootSourceSelectionsHandler, authentication=api_auth ) -license_key_handler = AdminRestrictedResource( +license_key_handler = RestrictedResource( LicenseKeyHandler, authentication=api_auth ) -license_keys_handler = AdminRestrictedResource( +license_keys_handler = RestrictedResource( LicenseKeysHandler, authentication=api_auth ) +# Admin handlers. +commissioning_script_handler = AdminRestrictedResource( + CommissioningScriptHandler, authentication=api_auth +) +commissioning_scripts_handler = AdminRestrictedResource( + CommissioningScriptsHandler, authentication=api_auth +) + # Internal Handlers image_sync_progress_handler = OperationsResource( diff --git a/src/maasserver/websockets/base.py b/src/maasserver/websockets/base.py index 234ae89dc..486a4a3c8 100644 --- a/src/maasserver/websockets/base.py +++ b/src/maasserver/websockets/base.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The base class that all handlers must extend.""" @@ -25,9 +25,9 @@ from maascommon.tracing import regenerate_trace_id from maasserver import concurrency +from maasserver.authorization import clear_caches from maasserver.permissions import NodePermission from maasserver.prometheus.middleware import wrap_query_counter_cursor -from maasserver.rbac import rbac from maasserver.utils.forms import get_QueryDict from maasserver.utils.orm import transactional from maasserver.utils.threads import deferToDatabase @@ -423,8 +423,8 @@ def execute(self, method_name, params): if IAsynchronous.providedBy( method ) or asyncio.iscoroutinefunction(method): - # Running in the io thread so clear RBAC now. - rbac.clear() + # Running in the io thread so clear RBAC/openfga now. + clear_caches() # Reload the user from the database. d = concurrency.webapp.run( @@ -438,10 +438,11 @@ def execute(self, method_name, params): @wraps(method) @transactional def prep_user_execute(params): - # Clear RBAC and reload the user to ensure that - # its up to date. `rbac.clear` must be done inside + # Clear RBAC/openfga and reload the user to ensure that + # its up to date. This must be done inside # the thread because it uses thread locals internally. - rbac.clear() + clear_caches() + self.user.refresh_from_db() # Perform the work in the database. diff --git a/src/maasserver/websockets/handlers/config.py b/src/maasserver/websockets/handlers/config.py index 644213a4d..7b02dafef 100644 --- a/src/maasserver/websockets/handlers/config.py +++ b/src/maasserver/websockets/handlers/config.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The config handler for the WebSocket connection.""" @@ -6,6 +6,10 @@ from django.core.exceptions import ValidationError from django.http import HttpRequest +from maasserver.authorization import ( + can_edit_configurations, + can_view_configurations, +) from maasserver.enum import ENDPOINT from maasserver.forms import ConfigForm from maasserver.forms.settings import ( @@ -26,7 +30,7 @@ def get_config_keys(user): config_keys = list(CONFIG_ITEMS) + ["uuid", "maas_url"] - if user.is_superuser: + if can_view_configurations(user): config_keys.append("rpc_shared_secret") return config_keys @@ -87,7 +91,7 @@ def _fix_validation_error(self, name, errors): def bulk_update(self, params): """Update config values in bulk.""" - if not self.user.is_superuser: + if not can_edit_configurations(self.user): raise HandlerPermissionError() if "items" not in params: raise HandlerPKError("Missing map of items in params") @@ -119,7 +123,7 @@ def bulk_update(self, params): def update(self, params): """Update a config value.""" - if not self.user.is_superuser: + if not can_edit_configurations(self.user): raise HandlerPermissionError() if "name" not in params: raise HandlerPKError("Missing name in params") diff --git a/src/maasserver/websockets/handlers/controller.py b/src/maasserver/websockets/handlers/controller.py index 0b3debaa3..2e7e8c4c4 100644 --- a/src/maasserver/websockets/handlers/controller.py +++ b/src/maasserver/websockets/handlers/controller.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The controller handler for the WebSocket connection.""" @@ -16,6 +16,7 @@ Subquery, ) +from maasserver.authorization import can_edit_controllers from maasserver.config import RegionConfiguration from maasserver.exceptions import NodeActionError from maasserver.forms import ControllerForm @@ -258,7 +259,7 @@ def register_info(self, params): User must be a superuser to perform this action. """ - if not self.user.is_superuser: + if not can_edit_controllers(self.user): raise HandlerPermissionError() secret = SecretManager().get_simple_secret("rpc-shared") diff --git a/src/maasserver/websockets/handlers/dhcpsnippet.py b/src/maasserver/websockets/handlers/dhcpsnippet.py index 7531eb265..a2e718c76 100644 --- a/src/maasserver/websockets/handlers/dhcpsnippet.py +++ b/src/maasserver/websockets/handlers/dhcpsnippet.py @@ -1,4 +1,4 @@ -# Copyright 2016-2018 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The DHCPSnippet handler for the WebSocket connection.""" @@ -9,6 +9,7 @@ from django.http import HttpRequest from maasserver.audit import create_audit_event +from maasserver.authorization import can_edit_global_entities from maasserver.enum import ENDPOINT from maasserver.forms.dhcpsnippet import DHCPSnippetForm from maasserver.models import DHCPSnippet @@ -62,7 +63,7 @@ def dehydrate(self, obj, data, for_list=False): def create(self, params): """Create the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() request = HttpRequest() @@ -83,7 +84,7 @@ def create(self, params): def update(self, params): """Update the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() obj = self.get_object(params) @@ -102,13 +103,13 @@ def update(self, params): def delete(self, params): """Delete the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() return super().delete(params) def revert(self, params): """Revert a value to a previous state.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() dhcp_snippet = self.get_object(params) diff --git a/src/maasserver/websockets/handlers/general.py b/src/maasserver/websockets/handlers/general.py index d4a929b2b..721c23fa9 100644 --- a/src/maasserver/websockets/handlers/general.py +++ b/src/maasserver/websockets/handlers/general.py @@ -1,4 +1,4 @@ -# Copyright 2015-2021 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The general handler for the WebSocket connection.""" @@ -7,6 +7,7 @@ import petname +from maasserver.authorization import can_edit_controllers, can_edit_machines from maasserver.certificates import get_maas_certificate from maasserver.clusterrpc.driver_parameters import get_all_power_types from maasserver.enum import ( @@ -136,20 +137,21 @@ def dehydrate_actions(self, actions): def _node_actions(self, params, node_type): # Only admins can perform controller actions - if not self.user.is_superuser and node_type in [ + if node_type in [ NODE_TYPE.RACK_CONTROLLER, NODE_TYPE.REGION_CONTROLLER, NODE_TYPE.REGION_AND_RACK_CONTROLLER, - ]: + ] and not can_edit_controllers(self.user): return [] actions = OrderedDict() + user_can_edit_machines = can_edit_machines(self.user) for name, action in ACTIONS_DICT.items(): if node_type not in action.for_type: continue if ( action.get_permission(node_type) == NodePermission.admin - and not self.user.is_superuser + and not user_can_edit_machines ): continue actions[name] = action @@ -183,7 +185,6 @@ def random_hostname(self, params): Node.objects.get(hostname=new_hostname) except Node.DoesNotExist: return new_hostname - return "" def bond_options(self, params): """Return all the possible bond options.""" diff --git a/src/maasserver/websockets/handlers/machine.py b/src/maasserver/websockets/handlers/machine.py index 0f844518c..44afeec46 100644 --- a/src/maasserver/websockets/handlers/machine.py +++ b/src/maasserver/websockets/handlers/machine.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The machine handler for the WebSocket connection.""" diff --git a/src/maasserver/websockets/handlers/packagerepository.py b/src/maasserver/websockets/handlers/packagerepository.py index 26ad2f4e0..f63f7897e 100644 --- a/src/maasserver/websockets/handlers/packagerepository.py +++ b/src/maasserver/websockets/handlers/packagerepository.py @@ -1,4 +1,4 @@ -# Copyright 2016-2018 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The PackageRepository handler for the WebSocket connection.""" @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from django.http import HttpRequest +from maasserver.authorization import can_edit_global_entities from maasserver.enum import ENDPOINT from maasserver.forms.packagerepository import PackageRepositoryForm from maasserver.models import PackageRepository @@ -28,7 +29,7 @@ class Meta: def create(self, params): """Create the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() request = HttpRequest() @@ -49,7 +50,7 @@ def create(self, params): def update(self, params): """Update the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() obj = self.get_object(params) @@ -70,6 +71,6 @@ def update(self, params): def delete(self, params): """Delete the object from params iff admin.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() return super().delete(params) diff --git a/src/maasserver/websockets/handlers/pod.py b/src/maasserver/websockets/handlers/pod.py index 7a113aabe..af27e9e35 100644 --- a/src/maasserver/websockets/handlers/pod.py +++ b/src/maasserver/websockets/handlers/pod.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020 Canonical Ltd. This software is licensed under the +# Copyright 2017-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The Pod handler for the WebSocket connection.""" @@ -8,6 +8,7 @@ import attr from django.http import HttpRequest +from maasserver.authorization import can_view_configurations, clear_caches from maasserver.clusterrpc.pods import ( discover_pod_projects, get_best_discovered_result, @@ -19,7 +20,6 @@ from maasserver.models.virtualmachine import get_vm_host_resources from maasserver.models.zone import Zone from maasserver.permissions import PodPermission -from maasserver.rbac import rbac from maasserver.utils.orm import reload_object, transactional from maasserver.utils.threads import deferToDatabase from maasserver.vmhost import ( @@ -117,7 +117,7 @@ def dehydrate(self, obj, data, for_list=False): "resources": self.dehydrate_resources(obj, for_list=for_list), } ) - if self.user.is_superuser: + if can_view_configurations(self.user): data["power_parameters"] = obj.get_power_parameters() if not for_list: if obj.host: @@ -184,8 +184,8 @@ async def create(self, params): @transactional def create_obj(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() if not self.user.has_perm(self._meta.create_permission): raise HandlerPermissionError() @@ -213,8 +213,8 @@ async def update(self, params): @transactional def update_obj(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() obj = self.get_object(params) if not self.user.has_perm(self._meta.edit_permission, obj): @@ -246,8 +246,8 @@ async def delete(self, params): @transactional def get_object(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() obj = self.get_object(params) if not self.user.has_perm(self._meta.delete_permission, obj): @@ -267,8 +267,8 @@ async def refresh(self, params): @transactional def get_object(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() obj = self.get_object(params) if not self.user.has_perm(self._meta.edit_permission, obj): @@ -291,7 +291,8 @@ async def compose(self, params): def get_object(params): # Running inside new database thread, be sure the rbac cache is # cleared so accessing information will not be already cached. - rbac.clear() + clear_caches() + obj = self.get_object(params) if not self.user.has_perm(PodPermission.compose, obj): raise HandlerPermissionError() diff --git a/src/maasserver/websockets/handlers/reservedip.py b/src/maasserver/websockets/handlers/reservedip.py index 596aa6a1b..51124b297 100644 --- a/src/maasserver/websockets/handlers/reservedip.py +++ b/src/maasserver/websockets/handlers/reservedip.py @@ -5,6 +5,7 @@ from django.db.models.query import QuerySet +from maasserver.authorization import can_edit_global_entities from maasserver.dhcp import configure_dhcp_on_agents from maasserver.forms.reservedip import ReservedIPForm from maasserver.models import Interface @@ -63,7 +64,7 @@ def dehydrate(self, obj, data: dict, for_list: bool = False) -> dict: return data def create(self, params: dict) -> dict: - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() reserved_ip = super().create(params) @@ -73,7 +74,7 @@ def create(self, params: dict) -> dict: return reserved_ip def update(self, params: dict): - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() entry_id = params.get("id") @@ -87,7 +88,7 @@ def update(self, params: dict): return updated_reserved_ip def delete(self, params: dict) -> None: - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() reserved_ip = self.get_object(params) diff --git a/src/maasserver/websockets/handlers/staticroute.py b/src/maasserver/websockets/handlers/staticroute.py index 34d091b6a..bb289f6c3 100644 --- a/src/maasserver/websockets/handlers/staticroute.py +++ b/src/maasserver/websockets/handlers/staticroute.py @@ -1,8 +1,9 @@ -# Copyright 2016 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """The StaticRoute handler for the WebSocket connection.""" +from maasserver.authorization import can_edit_global_entities from maasserver.forms.staticroute import StaticRouteForm from maasserver.models import StaticRoute from maasserver.websockets.base import HandlerPermissionError @@ -22,18 +23,18 @@ class Meta: def create(self, params): """Create a static route.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() return super().create(params) def update(self, params): """Update this static route.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() return super().update(params) def delete(self, params): """Delete this static route.""" - if not self.user.is_superuser: + if not can_edit_global_entities(self.user): raise HandlerPermissionError() return super().delete(params) diff --git a/src/maasserver/websockets/handlers/tests/test_config.py b/src/maasserver/websockets/handlers/tests/test_config.py index 7b61eb8ee..ebdd2a454 100644 --- a/src/maasserver/websockets/handlers/tests/test_config.py +++ b/src/maasserver/websockets/handlers/tests/test_config.py @@ -1,10 +1,11 @@ -# Copyright 2016-2019 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for `maasserver.websockets.handlers.config`""" import random +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.forms.settings import CONFIG_ITEMS, get_config_field from maasserver.models.config import Config from maasserver.secrets import SecretManager @@ -316,3 +317,31 @@ def test_on_listen_returns_update_for_loaded_delete(self): ("config", "update", {"name": "curtin_verbose", "value": True}), updated, ) + + +class TestConfigHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_bulk_update_requires_can_edit_configurations(self): + self.openfga_client.can_edit_configurations.return_value = True + admin = factory.make_admin() + handler = ConfigHandler(admin, {}, None) + updated = handler.bulk_update( + {"items": {"curtin_verbose": True, "enable_analytics": False}} + ) + self.assertEqual( + {"curtin_verbose": True, "enable_analytics": False}, updated + ) + self.openfga_client.can_edit_configurations.assert_called_once_with( + admin + ) + + def test_update_requires_can_edit_configurations(self): + self.openfga_client.can_edit_configurations.return_value = True + admin = factory.make_admin() + handler = ConfigHandler(admin, {}, None) + updated = handler.update({"name": "curtin_verbose", "value": True}) + self.assertEqual({"name": "curtin_verbose", "value": True}, updated) + self.openfga_client.can_edit_configurations.assert_called_once_with( + admin + ) diff --git a/src/maasserver/websockets/handlers/tests/test_controller.py b/src/maasserver/websockets/handlers/tests/test_controller.py index dc353d4ed..81cc43ebd 100644 --- a/src/maasserver/websockets/handlers/tests/test_controller.py +++ b/src/maasserver/websockets/handlers/tests/test_controller.py @@ -3,6 +3,7 @@ from maasserver import bootresources +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.config import RegionConfiguration from maasserver.enum import NODE_TYPE from maasserver.forms import ControllerForm @@ -589,3 +590,15 @@ def test_vault_flag_returned_false(self): list_results = handler.list({}) self.assertFalse(list_results[0]["vault_configured"]) + + +class TestControllerHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_register_info_requires_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + handler = ControllerHandler(self.user, {}, None) + handler.register_info({}) + self.openfga_client.can_edit_controllers.assert_called_once_with( + self.user + ) diff --git a/src/maasserver/websockets/handlers/tests/test_device.py b/src/maasserver/websockets/handlers/tests/test_device.py index 308e4a558..0f72ef325 100644 --- a/src/maasserver/websockets/handlers/tests/test_device.py +++ b/src/maasserver/websockets/handlers/tests/test_device.py @@ -1,4 +1,4 @@ -# Copyright 2015-2019 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). @@ -287,7 +287,7 @@ def test_get_num_queries_is_the_expected_number(self): def test_get_no_numa_nodes_for_device(self): user = factory.make_User() - device = factory.make_Device() + device = factory.make_Device(owner=user) handler = DeviceHandler(user, {}, None) result = handler.get({"system_id": device.system_id}) self.assertNotIn("numa_nodes", result) diff --git a/src/maasserver/websockets/handlers/tests/test_dhcpsnippet.py b/src/maasserver/websockets/handlers/tests/test_dhcpsnippet.py index 1fd319505..b3348d85f 100644 --- a/src/maasserver/websockets/handlers/tests/test_dhcpsnippet.py +++ b/src/maasserver/websockets/handlers/tests/test_dhcpsnippet.py @@ -7,6 +7,7 @@ import random from maascommon.events import AUDIT +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import DHCPSnippet, Event, VersionedTextFile from maasserver.testing.factory import factory from maasserver.testing.testcase import MAASServerTestCase @@ -212,3 +213,51 @@ def test_revert_errors_on_invalid_id(self): handler.revert, {"id": dhcp_snippet.id, "to": textfile.id}, ) + + +class TestDHCPSnippetHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + handler = DHCPSnippetHandler(user, {}, None) + dhcp_snippet_name = factory.make_name("dhcp_snippet_name") + with post_commit_hooks: + handler.create( + {"name": dhcp_snippet_name, "value": factory.make_string()} + ) + self.assertIsNotNone(DHCPSnippet.objects.get(name=dhcp_snippet_name)) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + handler = DHCPSnippetHandler(user, {}, None) + dhcp_snippet = factory.make_DHCPSnippet() + node = factory.make_Node() + with post_commit_hooks: + handler.update({"id": dhcp_snippet.id, "node": node.system_id}) + dhcp_snippet = reload_object(dhcp_snippet) + self.assertEqual(node, dhcp_snippet.node) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + handler = DHCPSnippetHandler(user, {}, None) + with post_commit_hooks: + dhcp_snippet = factory.make_DHCPSnippet() + handler.delete({"id": dhcp_snippet.id}) + self.assertRaises( + DHCPSnippet.DoesNotExist, + DHCPSnippet.objects.get, + id=dhcp_snippet.id, + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) diff --git a/src/maasserver/websockets/handlers/tests/test_general.py b/src/maasserver/websockets/handlers/tests/test_general.py index 48502bcfd..2125e873d 100644 --- a/src/maasserver/websockets/handlers/tests/test_general.py +++ b/src/maasserver/websockets/handlers/tests/test_general.py @@ -7,6 +7,7 @@ from distro_info import UbuntuDistroInfo import petname +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.enum import ( BOND_LACP_RATE_CHOICES, BOND_MODE_CHOICES, @@ -461,3 +462,79 @@ def test_install_type(self): handler = GeneralHandler(factory.make_User(), {}, None) result = handler.install_type({}) self.assertEqual("snap", result) + + +class TestGeneralHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_machine_actions_for_users_that_can_edit_machines(self): + self.openfga_client.can_edit_machines.return_value = True + user = factory.make_User() + handler = GeneralHandler(user, {}, None) + actions_expected = handler.machine_actions({}) + self.assertCountEqual( + { + "commission", + "acquire", + "deploy", + "on", + "off", + "release", + "abort", + "test", + "rescue-mode", + "exit-rescue-mode", + "mark-broken", + "mark-fixed", + "override-failed-testing", + "lock", + "unlock", + "tag", + "untag", + "clone", + "set-zone", + "set-pool", + "delete", + }, + [action["name"] for action in actions_expected], + ) + self.openfga_client.can_edit_machines.assert_called_once_with(user) + + def test_machine_actions_for_users_with_no_edit_permissions(self): + self.openfga_client.can_edit_machines.return_value = False + user = factory.make_User() + handler = GeneralHandler(user, {}, None) + actions_expected = handler.machine_actions({}) + self.assertCountEqual( + { + "acquire", + "deploy", + "on", + "off", + "release", + "mark-broken", + "lock", + "unlock", + }, + [action["name"] for action in actions_expected], + ) + self.openfga_client.can_edit_machines.assert_called_once_with(user) + + def test_controller_actions_for_users_that_can_edit_controllers(self): + self.openfga_client.can_edit_controllers.return_value = True + user = factory.make_User() + handler = GeneralHandler(user, {}, None) + actions_expected = handler.region_controller_actions({}) + self.assertCountEqual( + {"set-zone", "delete"}, + [action["name"] for action in actions_expected], + ) + self.openfga_client.can_edit_controllers.assert_called_once_with(user) + + def test_controller_actions_for_users_with_no_permissions(self): + self.openfga_client.can_edit_controllers.return_value = False + user = factory.make_User() + handler = GeneralHandler(user, {}, None) + actions_expected = handler.region_controller_actions({}) + self.assertEqual([], actions_expected) + self.openfga_client.can_edit_controllers.assert_called_once_with(user) diff --git a/src/maasserver/websockets/handlers/tests/test_machine.py b/src/maasserver/websockets/handlers/tests/test_machine.py index fb5dee4af..9f437f980 100644 --- a/src/maasserver/websockets/handlers/tests/test_machine.py +++ b/src/maasserver/websockets/handlers/tests/test_machine.py @@ -1,4 +1,4 @@ -# Copyright 2016-2025 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from datetime import timedelta diff --git a/src/maasserver/websockets/handlers/tests/test_packagerepository.py b/src/maasserver/websockets/handlers/tests/test_packagerepository.py index 8ea576c97..895460517 100644 --- a/src/maasserver/websockets/handlers/tests/test_packagerepository.py +++ b/src/maasserver/websockets/handlers/tests/test_packagerepository.py @@ -4,6 +4,7 @@ """Tests for `maasserver.websockets.handlers.packagerepository`""" from maascommon.events import AUDIT +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models import Event, PackageRepository from maasserver.testing.factory import factory from maasserver.testing.testcase import MAASServerTestCase @@ -108,3 +109,42 @@ def test_delete(self): PackageRepository.objects.get, id=package_repository.id, ) + + +class TestPackageRepositoryHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + handler = PackageRepositoryHandler(user, {}, None) + package_repository_name = factory.make_name("package_repository_name") + handler.create( + { + "name": package_repository_name, + "url": factory.make_url(scheme="http"), + } + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + package_repository = factory.make_PackageRepository() + user = factory.make_User() + handler = PackageRepositoryHandler(user, {}, None) + handler.update({"id": package_repository.id}) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + package_repository = factory.make_PackageRepository() + user = factory.make_User() + handler = PackageRepositoryHandler(user, {}, None) + handler.delete({"id": package_repository.id}) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) diff --git a/src/maasserver/websockets/handlers/tests/test_reservedip.py b/src/maasserver/websockets/handlers/tests/test_reservedip.py index 5a2df6f28..04d2cae79 100644 --- a/src/maasserver/websockets/handlers/tests/test_reservedip.py +++ b/src/maasserver/websockets/handlers/tests/test_reservedip.py @@ -6,6 +6,7 @@ import pytest from twisted.internet import defer +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.dhcp import configure_dhcp_on_agents from maasserver.models.reservedip import ReservedIP from maasserver.testing.factory import factory @@ -312,3 +313,55 @@ def test_list_with_node_summary(self): ], reserved_ips, ) + + +class TestReservedIPHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def setUp(self): + super().setUp() + d = defer.succeed(None) + self.patch(reservedip_module, "post_commit_do").return_value = d + + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + subnet = factory.make_Subnet(cidr="10.0.0.0/24") + handler = ReservedIPHandler(user, {}, None) + handler.create( + { + "ip": "10.0.0.55", + "subnet": subnet.id, + "mac_address": "00:11:22:33:44:55", + "comment": "this is a comment", + } + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + reservedip = factory.make_ReservedIP() + user = factory.make_User() + handler = ReservedIPHandler(user, {}, None) + handler.update( + { + "id": reservedip.id, + "mac_address": reservedip.mac_address, + "comment": "test update", + } + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + reservedip = factory.make_ReservedIP() + user = factory.make_User() + handler = ReservedIPHandler(user, {}, None) + handler.delete({"id": reservedip.id}) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) diff --git a/src/maasserver/websockets/handlers/tests/test_staticroute.py b/src/maasserver/websockets/handlers/tests/test_staticroute.py index ade2dc90b..cfcae5d8f 100644 --- a/src/maasserver/websockets/handlers/tests/test_staticroute.py +++ b/src/maasserver/websockets/handlers/tests/test_staticroute.py @@ -5,6 +5,7 @@ import random +from maasserver.auth.tests.test_auth import OpenFGAMockMixin from maasserver.models.staticroute import StaticRoute from maasserver.testing.factory import factory from maasserver.testing.testcase import MAASServerTestCase @@ -130,3 +131,49 @@ def test_delete_admin_only(self): self.assertRaises( HandlerPermissionError, handler.delete, {"id": staticroute.id} ) + + +class TestStaticRouteHandlerOpenFGAIntegration( + OpenFGAMockMixin, MAASServerTestCase +): + def test_create_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + source = factory.make_Subnet() + destination = factory.make_Subnet( + version=source.get_ipnetwork().version + ) + gateway_ip = factory.pick_ip_in_Subnet(source) + metric = random.randint(0, 500) + handler = StaticRouteHandler(user, {}, None) + handler.create( + { + "source": source.id, + "destination": destination.id, + "gateway_ip": gateway_ip, + "metric": metric, + } + ) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_update_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + staticroute = factory.make_StaticRoute() + handler = StaticRouteHandler(user, {}, None) + handler.update({"id": staticroute.id}) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) + + def test_delete_requires_can_edit_global_entities(self): + self.openfga_client.can_edit_global_entities.return_value = True + user = factory.make_User() + staticroute = factory.make_StaticRoute() + handler = StaticRouteHandler(user, {}, None) + handler.delete({"id": staticroute.id}) + self.openfga_client.can_edit_global_entities.assert_called_once_with( + user + ) diff --git a/src/maasserver/websockets/handlers/vmcluster.py b/src/maasserver/websockets/handlers/vmcluster.py index fdeaaa607..d02bfbfef 100644 --- a/src/maasserver/websockets/handlers/vmcluster.py +++ b/src/maasserver/websockets/handlers/vmcluster.py @@ -1,12 +1,12 @@ -# Copyright 2021 Canonical Ltd. This software is licensed under the +# Copyright 2021-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from django.http import HttpRequest +from maasserver.authorization import clear_caches from maasserver.forms.vmcluster import UpdateVMClusterForm from maasserver.models import VMCluster from maasserver.permissions import VMClusterPermission -from maasserver.rbac import rbac from maasserver.utils.orm import transactional from maasserver.utils.threads import deferToDatabase from maasserver.websockets.base import ( @@ -126,8 +126,9 @@ def dehydrate(self, cluster, vmhosts, resources, vms): async def list(self, params): @transactional def get_objects(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() + return VMCluster.objects.get_clusters( self.user, self._meta.view_permission ) @@ -198,8 +199,8 @@ async def delete(self, params): @transactional def get_vmcluster(params): - # Clear rbac cache before check (this is in its own thread). - rbac.clear() + # Clear rbac/openfga cache before check (this is in its own thread). + clear_caches() return VMCluster.objects.get_cluster_or_404( user=self.user, perm=self._meta.delete_permission, **params @@ -215,7 +216,7 @@ async def update(self, params): @transactional def update_obj(params): # Clear rbac cache before check (this is in its own thread). - rbac.clear() + clear_caches() obj = self.get_object(params) if not self.user.has_perm(self._meta.edit_permission, obj): diff --git a/src/maasserver/websockets/tests/test_protocol.py b/src/maasserver/websockets/tests/test_protocol.py index eeebe83b6..5975394d0 100644 --- a/src/maasserver/websockets/tests/test_protocol.py +++ b/src/maasserver/websockets/tests/test_protocol.py @@ -1,4 +1,4 @@ -# Copyright 2015-2025 Canonical Ltd. This software is licensed under the +# Copyright 2015-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). @@ -18,6 +18,7 @@ from apiclient.utils import ascii_url from maascommon.utils.url import splithost from maasserver.eventloop import services +from maasserver.testing.factory import factory from maasserver.testing.factory import factory as maas_factory from maasserver.testing.listener import FakePostgresListenerService from maasserver.testing.testcase import MAASTransactionServerTestCase @@ -551,12 +552,13 @@ def test_handleRequest_builds_handler(self): @wait_for_reactor @inlineCallbacks def test_handleRequest_sends_response(self): + user = yield deferToDatabase(factory.make_User) node = yield deferToDatabase(self.make_node) # Need to delete the node as the transaction is committed self.addCleanup(self.clean_node, node) protocol, _ = self.make_protocol() - protocol.user = MagicMock() + protocol.user = user message = { "type": MSG_TYPE.REQUEST, "request_id": 1, diff --git a/src/maasservicelayer/builders/openfga_tuple.py b/src/maasservicelayer/builders/openfga_tuple.py index 688725870..4a96f52dc 100644 --- a/src/maasservicelayer/builders/openfga_tuple.py +++ b/src/maasservicelayer/builders/openfga_tuple.py @@ -34,43 +34,43 @@ def build_user_member_group( ) @classmethod - def build_group_can_edit_pool( + def build_group_can_edit_machines_in_pool( cls, group_id: str, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_edit", + relation="can_edit_machines", object_id=pool_id, object_type="pool", ) @classmethod - def build_group_can_view_pool( + def build_group_can_view_machines_in_pool( cls, group_id: str, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_view", + relation="can_view_machines", object_id=pool_id, object_type="pool", ) @classmethod - def build_group_can_edit_machines( + def build_group_can_view_available_machines_in_pool( cls, group_id: str, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_edit_machines", + relation="can_view_available_machines", object_id=pool_id, object_type="pool", ) @classmethod - def build_group_can_deploy_machines( + def build_group_can_deploy_machines_in_pool( cls, group_id: str, pool_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( @@ -82,43 +82,43 @@ def build_group_can_deploy_machines( ) @classmethod - def build_group_can_edit_pools( + def build_group_can_edit_machines( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_edit_pools", + relation="can_edit_machines", object_id="0", object_type="maas", ) @classmethod - def build_group_can_view_pools( + def build_group_can_view_machines( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_view_pools", + relation="can_view_machines", object_id="0", object_type="maas", ) @classmethod - def build_group_can_edit_machines_in_pools( + def build_group_can_view_available_machines( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_edit_machines", + relation="can_view_available_machines", object_id="0", object_type="maas", ) @classmethod - def build_group_can_deploy_machines_in_pools( + def build_group_can_deploy_machines( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( @@ -154,25 +154,73 @@ def build_group_can_edit_global_entities( ) @classmethod - def build_group_can_view_permissions( + def build_group_can_edit_controllers( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_edit_controllers", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_controllers( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_controllers", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_identities( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_identities", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_edit_identities( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_view_permissions", + relation="can_edit_identities", object_id="0", object_type="maas", ) @classmethod - def build_group_can_edit_permissions( + def build_group_can_view_boot_entities( cls, group_id: str ) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( user=f"group:{group_id}#member", user_type="userset", - relation="can_edit_permissions", + relation="can_view_boot_entities", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_edit_boot_entities( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_edit_boot_entities", object_id="0", object_type="maas", ) @@ -201,6 +249,78 @@ def build_group_can_edit_configurations( object_type="maas", ) + @classmethod + def build_group_can_edit_notifications( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_edit_notifications", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_notifications( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_notifications", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_edit_license_keys( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_edit_license_keys", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_license_keys( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_license_keys", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_devices( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_devices", + object_id="0", + object_type="maas", + ) + + @classmethod + def build_group_can_view_ipaddresses( + cls, group_id: str + ) -> "OpenFGATupleBuilder": + return OpenFGATupleBuilder( + user=f"group:{group_id}#member", + user_type="userset", + relation="can_view_ipaddresses", + object_id="0", + object_type="maas", + ) + @classmethod def build_pool(cls, pool_id: str) -> "OpenFGATupleBuilder": return OpenFGATupleBuilder( diff --git a/src/maasservicelayer/db/repositories/openfga_tuples.py b/src/maasservicelayer/db/repositories/openfga_tuples.py index 9129b5518..d05901505 100644 --- a/src/maasservicelayer/db/repositories/openfga_tuples.py +++ b/src/maasservicelayer/db/repositories/openfga_tuples.py @@ -3,11 +3,12 @@ from sqlalchemy import delete, insert from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql.operators import eq from maascommon.enums.openfga import OPENFGA_STORE_ID from maascommon.utils.ulid import generate_ulid from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder -from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.filters import Clause, ClauseFactory, QuerySpec from maasservicelayer.db.mappers.base import ( BaseDomainDataMapper, CreateOrUpdateResource, @@ -26,6 +27,26 @@ from maasservicelayer.utils.date import utcnow +class OpenFGATuplesClauseFactory(ClauseFactory): + @classmethod + def with_object_type(cls, object_type: str) -> Clause: + return Clause( + condition=eq(OpenFGATupleTable.c.object_type, object_type) + ) + + @classmethod + def with_object_id(cls, object_id: str) -> Clause: + return Clause(condition=eq(OpenFGATupleTable.c.object_id, object_id)) + + @classmethod + def with_relation(cls, relation: str) -> Clause: + return Clause(condition=eq(OpenFGATupleTable.c.relation, relation)) + + @classmethod + def with_user(cls, user: str) -> Clause: + return Clause(condition=eq(OpenFGATupleTable.c._user, user)) + + class OpenFGATuplesDataMapper(BaseDomainDataMapper): def __init__(self): super().__init__(OpenFGATupleTable) diff --git a/src/maasservicelayer/services/__init__.py b/src/maasservicelayer/services/__init__.py index 154e26008..78fd0c1da 100644 --- a/src/maasservicelayer/services/__init__.py +++ b/src/maasservicelayer/services/__init__.py @@ -475,9 +475,18 @@ async def produce( ZonesService.__name__, ZonesService.build_cache_object ), # type: ignore ) + services.openfga_tuples = OpenFGATupleService( + context=context, + openfga_tuple_repository=OpenFGATuplesRepository(context), + cache=cache.get( + OpenFGATupleService.__name__, + OpenFGATupleService.build_cache_object, + ), # type: ignore + ) services.resource_pools = ResourcePoolsService( context=context, resource_pools_repository=ResourcePoolRepository(context), + openfga_tuples_service=services.openfga_tuples, ) services.machines = MachinesService( context=context, @@ -545,6 +554,7 @@ async def produce( filestorage_service=services.filestorage, consumers_service=services.consumers, tokens_service=services.tokens, + openfga_tuple_service=services.openfga_tuples, ) services.domains = DomainsService( context=context, @@ -725,8 +735,4 @@ async def produce( vlans_service=services.vlans, v3dnsrrsets_service=services.v3dnsrrsets, ) - services.openfga_tuples = OpenFGATupleService( - context=context, - openfga_tuple_repository=OpenFGATuplesRepository(context), - ) return services diff --git a/src/maasservicelayer/services/openfga_tuples.py b/src/maasservicelayer/services/openfga_tuples.py index 62ba85803..79b4507f8 100644 --- a/src/maasservicelayer/services/openfga_tuples.py +++ b/src/maasservicelayer/services/openfga_tuples.py @@ -1,14 +1,27 @@ # Copyright 2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). +from dataclasses import dataclass + +from maascommon.openfga.async_client import OpenFGAClient from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder from maasservicelayer.context import Context from maasservicelayer.db.filters import QuerySpec from maasservicelayer.db.repositories.openfga_tuples import ( + OpenFGATuplesClauseFactory, OpenFGATuplesRepository, ) from maasservicelayer.models.openfga_tuple import OpenFGATuple -from maasservicelayer.services.base import Service +from maasservicelayer.services.base import Service, ServiceCache + + +@dataclass(slots=True) +class OpenFGAServiceCache(ServiceCache): + client: OpenFGAClient | None = None + + async def close(self) -> None: + if self.client: + await self.client.close() class OpenFGATupleService(Service): @@ -16,12 +29,44 @@ def __init__( self, context: Context, openfga_tuple_repository: OpenFGATuplesRepository, + cache: ServiceCache, ): - super().__init__(context) + super().__init__(context, cache) self.openfga_tuple_repository = openfga_tuple_repository + @staticmethod + def build_cache_object() -> OpenFGAServiceCache: + return OpenFGAServiceCache() + + @Service.from_cache_or_execute(attr="client") + async def get_client(self) -> OpenFGAClient: + return OpenFGAClient() + async def create(self, builder: OpenFGATupleBuilder) -> OpenFGATuple: return await self.openfga_tuple_repository.create(builder) async def delete_many(self, query: QuerySpec) -> None: return await self.openfga_tuple_repository.delete_many(query) + + async def delete_pool(self, pool_id: int) -> None: + query = QuerySpec( + where=OpenFGATuplesClauseFactory.and_clauses( + [ + OpenFGATuplesClauseFactory.with_object_id(str(pool_id)), + OpenFGATuplesClauseFactory.with_object_type("pool"), + OpenFGATuplesClauseFactory.with_relation("parent"), + ] + ) + ) + await self.delete_many(query) + + async def delete_user(self, user_id: int) -> None: + query = QuerySpec( + where=OpenFGATuplesClauseFactory.and_clauses( + [ + OpenFGATuplesClauseFactory.with_user(f"user:{user_id}"), + OpenFGATuplesClauseFactory.with_relation("member"), + ] + ) + ) + await self.delete_many(query) diff --git a/src/maasservicelayer/services/resource_pools.py b/src/maasservicelayer/services/resource_pools.py index 5833590d6..4968ffddd 100644 --- a/src/maasservicelayer/services/resource_pools.py +++ b/src/maasservicelayer/services/resource_pools.py @@ -1,6 +1,9 @@ -# 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 typing import List + +from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder from maasservicelayer.builders.resource_pools import ResourcePoolBuilder from maasservicelayer.context import Context from maasservicelayer.db.filters import QuerySpec @@ -20,6 +23,7 @@ ResourcePoolWithSummary, ) from maasservicelayer.services.base import BaseService +from maasservicelayer.services.openfga_tuples import OpenFGATupleService class ResourcePoolsService( @@ -31,8 +35,10 @@ def __init__( self, context: Context, resource_pools_repository: ResourcePoolRepository, + openfga_tuples_service: OpenFGATupleService, ): super().__init__(context, resource_pools_repository) + self.openfga_tuples_service = openfga_tuples_service async def list_ids(self) -> set[int]: """Returns all the ids of the resource pools in the db.""" @@ -57,3 +63,25 @@ async def pre_delete_hook( ) ] ) + + async def post_create_hook(self, resource: ResourcePool) -> None: + await self.openfga_tuples_service.create( + OpenFGATupleBuilder.build_pool(str(resource.id)) + ) + + async def post_create_many_hook( + self, resources: List[ResourcePool] + ) -> None: + for resource in resources: + await self.openfga_tuples_service.create( + OpenFGATupleBuilder.build_pool(str(resource.id)) + ) + + async def post_delete_hook(self, resource: ResourcePool) -> None: + await self.openfga_tuples_service.delete_pool(resource.id) + + async def post_delete_many_hook( + self, resources: List[ResourcePool] + ) -> None: + for resource in resources: + await self.openfga_tuples_service.delete_pool(resource.id) diff --git a/src/maasservicelayer/services/users.py b/src/maasservicelayer/services/users.py index 02c338ae3..e3ec704f6 100644 --- a/src/maasservicelayer/services/users.py +++ b/src/maasservicelayer/services/users.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). import hashlib @@ -70,6 +70,7 @@ from maasservicelayer.services.ipranges import IPRangesService from maasservicelayer.services.nodes import NodesService from maasservicelayer.services.notifications import NotificationsService +from maasservicelayer.services.openfga_tuples import OpenFGATupleService from maasservicelayer.services.sshkeys import SshKeysService from maasservicelayer.services.sslkey import SSLKeysService from maasservicelayer.services.staticipaddress import StaticIPAddressService @@ -96,6 +97,7 @@ def __init__( filestorage_service: FileStorageService, consumers_service: ConsumersService, tokens_service: TokensService, + openfga_tuple_service: OpenFGATupleService, ): super().__init__(context, users_repository) self.staticipaddress_service = staticipaddress_service @@ -107,6 +109,7 @@ def __init__( self.filestorage_service = filestorage_service self.consumers_service = consumers_service self.tokens_service = tokens_service + self.openfga_tuple_service = openfga_tuple_service async def get_or_create_MAAS_user(self) -> User: # DO NOT create a profile for the MAAS technical users. @@ -292,6 +295,7 @@ async def post_delete_hook(self, resource: User) -> None: where=FileStorageClauseFactory.with_owner_id(resource.id) ) ) + await self.openfga_tuple_service.delete_user(resource.id) logger.info( f"{USER_DELETED}:{resource.username}", type=SECURITY, diff --git a/src/maastesting/djangotestcase.py b/src/maastesting/djangotestcase.py index 94d1f52c5..8407c9452 100644 --- a/src/maastesting/djangotestcase.py +++ b/src/maastesting/djangotestcase.py @@ -1,4 +1,4 @@ -# Copyright 2012-2025 Canonical Ltd. This software is licensed under the +# Copyright 2012-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Django-enabled test cases.""" @@ -82,10 +82,26 @@ def __exit__(self, exc_type, exc_value, traceback): self._end_count += self._sqlalchemy_counter.count self._sqlalchemy_counter.remove() + # @property + # def count(self): + # """Number of queries.""" + # return self._end_count - self._start_count + @property def count(self): - """Number of queries.""" - return self._end_count - self._start_count + total = self._end_count - self._start_count + if total <= 0: + return self._sqlalchemy_counter.count + + recent = self.connection.queries[-total:] + + # Do not count queries that contain COUNTQUERIES-IGNOREME in their SQL, as these are used to prevent counting of queries in some cases. + filtered = [ + q + for q in recent + if "COUNTQUERIES-IGNOREME" not in q.get("sql", "") + ] + return len(filtered) + self._sqlalchemy_counter.count @property def queries(self): diff --git a/src/perftests/maasserver/api/test_machines.py b/src/perftests/maasserver/api/test_machines.py index 157acc784..7231190be 100644 --- a/src/perftests/maasserver/api/test_machines.py +++ b/src/perftests/maasserver/api/test_machines.py @@ -1,11 +1,14 @@ -# Copyright 2022 Canonical Ltd. This software is licensed under the +# Copyright 2022-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). - +from django.contrib.auth.models import User from django.urls import reverse from piston3.emitters import Emitter from piston3.handler import typemapper from maasserver.api.machines import MachinesHandler +from maasserver.models import Machine +from maasserver.models.user import get_auth_tokens +from maasserver.testing.testclient import MAASSensibleOAuthClient from maastesting.http import make_HttpRequest @@ -15,13 +18,29 @@ def render(self, request): def test_perf_list_machines_MachineHandler_api_endpoint( - perf, admin_api_client + perf, maasdb, mock_maas_env, openfga_server ): + admin = User.objects.filter(is_superuser=True).first() + if admin is None: + raise Exception("No superuser found in the database.") + client = MAASSensibleOAuthClient( + user=admin, token=get_auth_tokens(admin)[0] + ) + + machine_count = Machine.objects.all().count() + with perf.record("test_perf_list_machines_MachineHandler_api_endpoint"): - admin_api_client.get(reverse("machines_handler")) + retrieved_machines = client.get(reverse("machines_handler")) + assert machine_count == len(retrieved_machines.json()) + +def test_perf_list_machines_MachinesHander_direct_call( + perf, maasdb, mock_maas_env, openfga_server +): + admin = User.objects.filter(is_superuser=True).first() + if admin is None: + raise Exception("No superuser found in the database.") -def test_perf_list_machines_MachinesHander_direct_call(perf, admin): handler = MachinesHandler() request = make_HttpRequest() request.user = admin @@ -37,10 +56,19 @@ def test_perf_list_machines_MachinesHander_direct_call(perf, admin): emitter.render(request) -def test_perf_list_machines_MachinesHander_only_objects(perf, admin): +def test_perf_list_machines_MachinesHander_only_objects( + perf, maasdb, mock_maas_env, openfga_server +): + admin = User.objects.filter(is_superuser=True).first() + if admin is None: + raise Exception("No superuser found in the database.") + + machine_count = Machine.objects.all().count() + handler = MachinesHandler() request = make_HttpRequest() request.user = admin with perf.record("test_perf_list_machines_MachinesHander_only_objects"): - list(handler.read(request)) + retrieved_machines = list(handler.read(request)) + assert machine_count == len(retrieved_machines) diff --git a/src/perftests/maasserver/api_v3/test_machines.py b/src/perftests/maasserver/api_v3/test_machines.py index 6b2030726..2de6265f3 100644 --- a/src/perftests/maasserver/api_v3/test_machines.py +++ b/src/perftests/maasserver/api_v3/test_machines.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). import math @@ -58,7 +58,6 @@ async def test_perf_list_machines_APIv3_endpoint_all( # APIv3 without any pagination. machine_count = await get_machine_count(db_connection) machine_pages = math.ceil(machine_count / MAX_PAGE_SIZE) - print(machine_pages) responses = [None] * machine_pages with perf.record("test_perf_list_machines_APIv3_endpoint_all"): # Extracted from a clean load of labmaas with empty local diff --git a/src/perftests/maasserver/conftest.py b/src/perftests/maasserver/conftest.py new file mode 100644 index 000000000..bcbcf5b15 --- /dev/null +++ b/src/perftests/maasserver/conftest.py @@ -0,0 +1,28 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import pytest + +from maasserver.openfga import get_openfga_client +from tests.e2e.conftest import ( + mock_maas_env, + openfga_server, + openfga_socket_path, + project_root_path, +) +from tests.maasapiserver.fixtures.db import db, test_config + +__all__ = [ + "db", + "test_config", + "openfga_socket_path", + "openfga_server", + "mock_maas_env", + "project_root_path", +] + + +@pytest.fixture(autouse=True) +def clear_openfga_client_cache(): + # Clear the cache of the get_openfga_client function before each test to ensure a fresh client instance is used. + get_openfga_client.cache_clear() diff --git a/src/perftests/maasserver/websockets/test_machines.py b/src/perftests/maasserver/websockets/test_machines.py index cf4c285f9..8bd075fa9 100644 --- a/src/perftests/maasserver/websockets/test_machines.py +++ b/src/perftests/maasserver/websockets/test_machines.py @@ -1,14 +1,24 @@ -# Copyright 2022-2025 Canonical Ltd. This software is licensed under the +# Copyright 2022-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). + import math +from django.contrib.auth.models import User + from maasserver.models import Machine from maasserver.websockets.handlers.machine import MachineHandler -def test_perf_list_machines_Websocket_endpoint(perf, admin, maasdb): +def test_perf_list_machines_Websocket_endpoint( + perf, maasdb, mock_maas_env, openfga_server +): # This should test the websocket calls that are used to load # the machine listing page on the initial page load. + + admin = User.objects.filter(is_superuser=True).first() + if admin is None: + raise Exception("No superuser found in the database.") + machine_count = Machine.objects.all().count() expected_pages = math.ceil(machine_count / 50) num_pages = 0 @@ -30,9 +40,16 @@ def test_perf_list_machines_Websocket_endpoint(perf, admin, maasdb): assert num_pages == expected_pages -def test_perf_list_machines_Websocket_endpoint_all(perf, admin, maasdb): +def test_perf_list_machines_Websocket_endpoint_all( + perf, maasdb, mock_maas_env, openfga_server +): # How long would it take to list all the machines using the # websocket without any pagination. + + admin = User.objects.filter(is_superuser=True).first() + if admin is None: + raise Exception("No superuser found in the database.") + machine_count = Machine.objects.all().count() with perf.record("test_perf_list_machines_Websocket_endpoint_all"): ws_handler = MachineHandler(admin, {}, None) diff --git a/src/tests/e2e/conftest.py b/src/tests/e2e/conftest.py index 76c9819d9..a0fccaad4 100644 --- a/src/tests/e2e/conftest.py +++ b/src/tests/e2e/conftest.py @@ -1,6 +1,79 @@ # Copyright 2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). +from datetime import timedelta +import os +import subprocess +import time + +import pytest +import yaml + from tests.maasapiserver.fixtures.db import db, db_connection, test_config -__all__ = ["db_connection", "db", "test_config"] +__all__ = [ + "db_connection", + "db", + "test_config", + "openfga_socket_path", + "openfga_server", + "mock_maas_env", + "project_root_path", +] + + +@pytest.fixture +def project_root_path(request): + return request.config.rootpath + + +@pytest.fixture +def openfga_socket_path(tmpdir): + return tmpdir / "openfga-http.sock" + + +@pytest.fixture +def mock_maas_env(monkeypatch, openfga_socket_path): + """Mocks the MAAS_OPENFGA_HTTP_SOCKET_PATH environment variable.""" + monkeypatch.setenv( + "MAAS_OPENFGA_HTTP_SOCKET_PATH", str(openfga_socket_path) + ) + + +@pytest.fixture +def openfga_server(tmpdir, project_root_path, openfga_socket_path, db): + """Fixture to start the OpenFGA server as a subprocess for testing. After the test is done, it ensures that the server process is terminated.""" + binary_path = project_root_path / "src/maasopenfga/build/maas-openfga" + + # Set the environment variable for the OpenFGA server to use the socket path in the temporary directory + env = os.environ.copy() + env["MAAS_OPENFGA_HTTP_SOCKET_PATH"] = str(openfga_socket_path) + + regiond_conf = { + "database_host": db.config.host, + "database_name": db.config.name, + "database_user": "ubuntu", + } + + # Write the regiond configuration to a file in the temporary directory + with open(tmpdir / "regiond.conf", "w") as f: + f.write(yaml.dump(regiond_conf)) + + env["SNAP_DATA"] = str(tmpdir) + + pid = subprocess.Popen(binary_path, env=env) + + timeout = timedelta(seconds=30) + start_time = time.monotonic() + while True: + if time.monotonic() - start_time > timeout.total_seconds(): + pid.terminate() + raise TimeoutError( + "OpenFGA server did not start within the expected time." + ) + if not openfga_socket_path.exists(): + time.sleep(0.5) + else: + break + yield pid + pid.terminate() diff --git a/src/tests/e2e/test_openfga_integration.py b/src/tests/e2e/test_openfga_integration.py index 384cd2f29..fda914918 100644 --- a/src/tests/e2e/test_openfga_integration.py +++ b/src/tests/e2e/test_openfga_integration.py @@ -1,70 +1,15 @@ # Copyright 2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). -from datetime import timedelta -import os -import subprocess -import time - import pytest -import yaml -from maascommon.openfga.client.client import OpenFGAClient +from maascommon.openfga.async_client import OpenFGAClient from maasservicelayer.builders.openfga_tuple import OpenFGATupleBuilder from maasservicelayer.context import Context from maasservicelayer.services import CacheForServices, ServiceCollectionV3 from tests.e2e.env import skip_if_integration_disabled -@pytest.fixture -def project_root_path(request): - return request.config.rootpath - - -@pytest.fixture -def openfga_socket_path(tmpdir): - return tmpdir / "openfga-http.sock" - - -@pytest.fixture -def openfga_server(tmpdir, project_root_path, openfga_socket_path, db): - """Fixture to start the OpenFGA server as a subprocess for testing. After the test is done, it ensures that the server process is terminated.""" - binary_path = project_root_path / "src/maasopenfga/build/maas-openfga" - - # Set the environment variable for the OpenFGA server to use the socket path in the temporary directory - env = os.environ.copy() - env["MAAS_OPENFGA_HTTP_SOCKET_PATH"] = str(openfga_socket_path) - - regiond_conf = { - "database_host": db.config.host, - "database_name": db.config.name, - "database_user": "ubuntu", - } - - # Write the regiond configuration to a file in the temporary directory - with open(tmpdir / "regiond.conf", "w") as f: - f.write(yaml.dump(regiond_conf)) - - env["SNAP_DATA"] = str(tmpdir) - - pid = subprocess.Popen(binary_path, env=env) - - timeout = timedelta(seconds=30) - start_time = time.monotonic() - while True: - if time.monotonic() - start_time > timeout.total_seconds(): - pid.terminate() - raise TimeoutError( - "OpenFGA server did not start within the expected time." - ) - if not openfga_socket_path.exists(): - time.sleep(0.5) - else: - break - yield pid - pid.terminate() - - @pytest.mark.asyncio @skip_if_integration_disabled() class TestIntegrationConfigurationsService: @@ -77,15 +22,15 @@ async def test_get( Context(connection=db_connection), cache=CacheForServices() ) - # Create pool:0, pool:1 and pool:2 - for i in range(0, 3): + # Create pool:1, pool:2 and pool:3. pool:1 is the default and already exists + for i in range(1, 4): await services.openfga_tuples.create( OpenFGATupleBuilder.build_pool(str(i)) ) # team A can edit and view everything await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_pools(group_id="teamA") + OpenFGATupleBuilder.build_group_can_edit_machines(group_id="teamA") ) await services.openfga_tuples.create( OpenFGATupleBuilder.build_group_can_edit_global_entities( @@ -93,7 +38,12 @@ async def test_get( ) ) await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_permissions( + OpenFGATupleBuilder.build_group_can_edit_controllers( + group_id="teamA" + ) + ) + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_group_can_edit_identities( group_id="teamA" ) ) @@ -102,6 +52,24 @@ async def test_get( group_id="teamA" ) ) + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_group_can_edit_boot_entities( + group_id="teamA" + ) + ) + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_group_can_edit_notifications( + group_id="teamA" + ) + ) + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_group_can_edit_license_keys( + group_id="teamA" + ) + ) + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_group_can_view_devices(group_id="teamA") + ) # alice belongs to group team A await services.openfga_tuples.create( @@ -112,7 +80,7 @@ async def test_get( # team B can_edit_machines and can_view_machines in pool:0 await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_edit_machines( + OpenFGATupleBuilder.build_group_can_edit_machines_in_pool( group_id="teamB", pool_id="0" ) ) @@ -125,83 +93,164 @@ async def test_get( # team C can_view_machines in pool:0 await services.openfga_tuples.create( - OpenFGATupleBuilder.build_group_can_deploy_machines( + OpenFGATupleBuilder.build_group_can_deploy_machines_in_pool( group_id="teamC", pool_id="0" ) ) - # bob belongs to group team B + # carl belongs to group team C await services.openfga_tuples.create( OpenFGATupleBuilder.build_user_member_group( user_id="carl", group_id="teamC" ) ) + # team D 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" + ) + ) + # carl belongs to group team C + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_user_member_group( + user_id="dingo", group_id="teamD" + ) + ) + await db_connection.commit() client = OpenFGAClient(str(openfga_socket_path)) # alice should have all permissions on pool1 because of teamA's system rights - assert (await client.can_edit_pools(user_id="alice")) is True - assert (await client.can_view_pools(user_id="alice")) is True - for i in range(0, 3): assert ( - await client.can_edit_machines(user_id="alice", pool_id=str(i)) + await client.can_edit_machines_in_pool( + user_id="alice", pool_id=str(i) + ) ) is True assert ( - await client.can_view_machines(user_id="alice", pool_id=str(i)) + await client.can_view_machines_in_pool( + user_id="alice", pool_id=str(i) + ) ) is True assert ( - await client.can_deploy_machines( + await client.can_view_available_machines_in_pool( + user_id="alice", pool_id=str(i) + ) + ) is True + assert ( + await client.can_deploy_machines_in_pool( user_id="alice", pool_id=str(i) ) ) is True - assert (await client.can_view_global_entities(user_id="alice")) is True + assert (await client.can_edit_machines(user_id="alice")) is True assert (await client.can_edit_global_entities(user_id="alice")) is True - assert (await client.can_view_permissions(user_id="alice")) is True - assert (await client.can_edit_permissions(user_id="alice")) is True - assert (await client.can_view_configurations(user_id="alice")) is True + assert (await client.can_view_global_entities(user_id="alice")) is True + assert (await client.can_edit_controllers(user_id="alice")) is True + assert (await client.can_view_controllers(user_id="alice")) is True + assert (await client.can_edit_identities(user_id="alice")) is True + assert (await client.can_view_identities(user_id="alice")) is True assert (await client.can_edit_configurations(user_id="alice")) is True + assert (await client.can_view_configurations(user_id="alice")) is True + assert (await client.can_edit_notifications(user_id="alice")) is True + assert (await client.can_view_notifications(user_id="alice")) is True + assert (await client.can_edit_boot_entities(user_id="alice")) is True + assert (await client.can_view_boot_entities(user_id="alice")) is True + assert (await client.can_view_license_keys(user_id="alice")) is True + 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 - assert (await client.can_edit_pools(user_id="bob")) is False - assert (await client.can_view_pools(user_id="bob")) is False - assert ( - await client.can_edit_machines(user_id="bob", pool_id="0") + await client.can_edit_machines_in_pool(user_id="bob", pool_id="0") ) is True assert ( - await client.can_view_machines(user_id="bob", pool_id="0") + await client.can_view_machines_in_pool(user_id="bob", pool_id="0") + ) is True + assert ( + await client.can_view_available_machines_in_pool( + user_id="bob", pool_id="0" + ) ) is True assert ( - await client.can_deploy_machines(user_id="bob", pool_id="0") + await client.can_deploy_machines_in_pool( + user_id="bob", pool_id="0" + ) ) is True for i in range(1, 3): assert ( - await client.can_edit_machines(user_id="bob", pool_id=str(i)) + await client.can_edit_machines_in_pool( + user_id="bob", pool_id=str(i) + ) + ) is False + assert ( + await client.can_view_machines_in_pool( + user_id="bob", pool_id=str(i) + ) ) is False assert ( - await client.can_view_machines(user_id="bob", pool_id=str(i)) + await client.can_view_available_machines_in_pool( + user_id="bob", pool_id=str(i) + ) ) is False assert ( - await client.can_deploy_machines(user_id="bob", pool_id=str(i)) + await client.can_deploy_machines_in_pool( + user_id="bob", 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_view_permissions(user_id="bob")) is False - assert (await client.can_edit_permissions(user_id="bob")) is False - assert (await client.can_view_configurations(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 and view permissions on pool0 because of teamC's rights + # carl should just have deploy permissions on pool0 because of teamC's rights + assert ( + await client.can_edit_machines_in_pool(user_id="carl", pool_id="0") + ) is False assert ( - await client.can_edit_machines(user_id="carl", pool_id="0") + await client.can_view_machines_in_pool(user_id="carl", pool_id="0") ) is False assert ( - await client.can_view_machines(user_id="carl", pool_id="0") + await client.can_view_available_machines_in_pool( + user_id="carl", pool_id="0" + ) + ) is False + assert ( + await client.can_deploy_machines_in_pool( + user_id="carl", pool_id="0" + ) ) is True + + # dingo should just view permissions on pool0 because of teamD's rights assert ( - await client.can_deploy_machines(user_id="carl", pool_id="0") + await client.can_edit_machines_in_pool( + user_id="dingo", pool_id="0" + ) + ) is False + assert ( + await client.can_view_machines_in_pool( + user_id="dingo", pool_id="0" + ) + ) is False + assert ( + await client.can_view_available_machines_in_pool( + user_id="dingo", pool_id="0" + ) ) is True + assert ( + await client.can_deploy_machines_in_pool( + user_id="dingo", pool_id="0" + ) + ) is False diff --git a/src/tests/fixtures/__init__.py b/src/tests/fixtures/__init__.py index efa2cc9aa..9e4bfba2d 100644 --- a/src/tests/fixtures/__init__.py +++ b/src/tests/fixtures/__init__.py @@ -1,11 +1,12 @@ -# Copyright 2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). +# Copyright 2025-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). from pathlib import Path from unittest.mock import Mock import pytest +from maasserver.testing.openfga import OpenFGAClientMock from maasservicelayer.services import ServiceCollectionV3 @@ -65,3 +66,10 @@ async def __anext__(self): @pytest.fixture def services_mock(): yield Mock(ServiceCollectionV3) + + +@pytest.fixture +def mock_openfga(mocker): + openfga_mock = OpenFGAClientMock() + mocker.patch("maasserver.openfga._get_client", return_value=openfga_mock) + yield diff --git a/src/tests/maascommon/conftest.py b/src/tests/maascommon/conftest.py index 6471dc08c..f32ffbf05 100644 --- a/src/tests/maascommon/conftest.py +++ b/src/tests/maascommon/conftest.py @@ -1,7 +1,12 @@ -# Copyright 2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). +# Copyright 2025-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +from pathlib import Path + +from aiohttp import web import pytest +from maascommon.enums.openfga import OPENFGA_STORE_ID from maascommon.osystem import ( BOOT_IMAGE_PURPOSE, OperatingSystem, @@ -61,3 +66,48 @@ def osystem_registry(): yield _registry.clear() _registry.update(registry_copy) + + +class StubOpenFGAServer: + def __init__(self): + self.allowed = True + self.last_payload = None + self.status_code = 200 + self.list_objects_response = {"objects": []} + + async def check_handler(self, request): + self.last_payload = await request.json() + if self.status_code != 200: + return web.Response(status=self.status_code) + return web.json_response({"allowed": self.allowed, "resolution": ""}) + + async def list_objects_handler(self, request): + self.last_payload = await request.json() + if self.status_code != 200: + return web.Response(status=self.status_code) + return web.json_response(self.list_objects_response) + + +@pytest.fixture +async def stub_openfga_server(tmp_path: Path): + socket_path = str(tmp_path / "test-openfga.sock") + handler_store = StubOpenFGAServer() + + app = web.Application() + app.router.add_post( + f"/stores/{OPENFGA_STORE_ID}/check", handler_store.check_handler + ) + app.router.add_post( + f"/stores/{OPENFGA_STORE_ID}/list-objects", + handler_store.list_objects_handler, + ) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.UnixSite(runner, socket_path) + await site.start() + + yield handler_store, socket_path + + await runner.cleanup() diff --git a/src/tests/maascommon/openfga/base.py b/src/tests/maascommon/openfga/base.py new file mode 100644 index 000000000..859f8e054 --- /dev/null +++ b/src/tests/maascommon/openfga/base.py @@ -0,0 +1,66 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +PERMISSION_METHODS = [ + ("can_edit_machines", ("u1",), "can_edit_machines", "maas:0"), + ( + "can_edit_machines_in_pool", + ("u1", "p1"), + "can_edit_machines", + "pool:p1", + ), + ( + "can_deploy_machines_in_pool", + ("u1", "p1"), + "can_deploy_machines", + "pool:p1", + ), + ( + "can_view_machines_in_pool", + ("u1", "p1"), + "can_view_machines", + "pool:p1", + ), + ( + "can_view_available_machines_in_pool", + ("u1", "p1"), + "can_view_available_machines", + "pool:p1", + ), + ( + "can_edit_global_entities", + ("u1",), + "can_edit_global_entities", + "maas:0", + ), + ( + "can_view_global_entities", + ("u1",), + "can_view_global_entities", + "maas:0", + ), + ("can_edit_controllers", ("u1",), "can_edit_controllers", "maas:0"), + ("can_view_controllers", ("u1",), "can_view_controllers", "maas:0"), + ("can_edit_identities", ("u1",), "can_edit_identities", "maas:0"), + ("can_view_identities", ("u1",), "can_view_identities", "maas:0"), + ("can_edit_configurations", ("u1",), "can_edit_configurations", "maas:0"), + ("can_view_configurations", ("u1",), "can_view_configurations", "maas:0"), + ("can_edit_notifications", ("u1",), "can_edit_notifications", "maas:0"), + ("can_view_notifications", ("u1",), "can_view_notifications", "maas:0"), + ("can_edit_boot_entities", ("u1",), "can_edit_boot_entities", "maas:0"), + ("can_view_boot_entities", ("u1",), "can_view_boot_entities", "maas:0"), + ("can_edit_license_keys", ("u1",), "can_edit_license_keys", "maas:0"), + ("can_view_license_keys", ("u1",), "can_view_license_keys", "maas:0"), + ("can_view_devices", ("u1",), "can_view_devices", "maas:0"), + ("can_view_ipaddresses", ("u1",), "can_view_ipaddresses", "maas:0"), +] + +LIST_METHODS = [ + ("list_pools_with_view_machines_access", "can_view_machines"), + ( + "list_pools_with_view_deployable_machines_access", + "can_view_available_machines", + ), + ("list_pool_with_deploy_machines_access", "can_deploy_machines"), + ("list_pools_with_edit_machines_access", "can_edit_machines"), +] diff --git a/src/tests/maascommon/openfga/client/__init__.py b/src/tests/maascommon/openfga/client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/tests/maascommon/openfga/client/test_client.py b/src/tests/maascommon/openfga/client/test_client.py deleted file mode 100644 index d76c63edb..000000000 --- a/src/tests/maascommon/openfga/client/test_client.py +++ /dev/null @@ -1,176 +0,0 @@ -# Copyright 2026 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). - -from pathlib import Path - -from aiohttp import web -import httpx -import pytest - -from maascommon.enums.openfga import ( - OPENFGA_AUTHORIZATION_MODEL_ID, - OPENFGA_STORE_ID, -) -from maascommon.openfga.client.client import OpenFGAClient - - -class MockFGAServer: - def __init__(self): - self.allowed = True - self.last_payload = None - self.status_code = 200 - - async def check_handler(self, request): - self.last_payload = await request.json() - if self.status_code != 200: - return web.Response(status=self.status_code) - - return web.json_response({"allowed": self.allowed, "resolution": ""}) - - -@pytest.fixture -async def fga_server(tmp_path: Path): - """Spin up a mock OpenFGA server using aiohttp that listens on a Unix socket. The server will record the last payload it received and return a configurable allowed value.""" - socket_path = str(tmp_path / "test-openfga.sock") - handler_store = MockFGAServer() - - app = web.Application() - app.router.add_post( - f"/stores/{OPENFGA_STORE_ID}/check", handler_store.check_handler - ) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.UnixSite(runner, socket_path) - await site.start() - - yield handler_store, socket_path - - await runner.cleanup() - - -@pytest.fixture -async def fga_client(fga_server): - """Fixture to create an instance of OpenFGAClient that connects to the mock server.""" - _, socket_path = fga_server - return OpenFGAClient(unix_socket=socket_path) - - -@pytest.mark.asyncio -class TestOpenFGAClient: - async def test_check_returns_allowed_true(self, fga_client, fga_server): - server_state, _ = fga_server - server_state.allowed = True - - tuple_key = { - "user": "user:1", - "relation": "can_edit_pools", - "object": "maas:0", - } - result = await fga_client._check(tuple_key) - - assert result is True - assert server_state.last_payload["tuple_key"] == tuple_key - assert ( - server_state.last_payload["authorization_model_id"] - == OPENFGA_AUTHORIZATION_MODEL_ID - ) - - async def test_check_returns_allowed_false(self, fga_client, fga_server): - server_state, _ = fga_server - server_state.allowed = False - - result = await fga_client.can_edit_pools("user-1") - assert result is False - - @pytest.mark.parametrize( - "method_name, args, expected_relation, expected_object", - [ - ("can_edit_pools", ("u1",), "can_edit_pools", "maas:0"), - ("can_view_pools", ("u1",), "can_view_pools", "maas:0"), - ( - "can_edit_machines", - ("u1", "p1"), - "can_edit_machines", - "pool:p1", - ), - ( - "can_deploy_machines", - ("u1", "p1"), - "can_deploy_machines", - "pool:p1", - ), - ( - "can_view_machines", - ("u1", "p1"), - "can_view_machines", - "pool:p1", - ), - ( - "can_view_global_entities", - ("u1",), - "can_view_global_entities", - "maas:0", - ), - ( - "can_edit_global_entities", - ("u1",), - "can_edit_global_entities", - "maas:0", - ), - ( - "can_view_permissions", - ("u1",), - "can_view_permissions", - "maas:0", - ), - ( - "can_edit_permissions", - ("u1",), - "can_edit_permissions", - "maas:0", - ), - ( - "can_view_configurations", - ("u1",), - "can_view_configurations", - "maas:0", - ), - ( - "can_edit_configurations", - ("u1",), - "can_edit_configurations", - "maas:0", - ), - ], - ) - async def test_permission_methods( - self, - fga_client, - fga_server, - method_name, - args, - expected_relation, - expected_object, - ): - server_state, _ = fga_server - server_state.allowed = True - - method = getattr(fga_client, method_name) - await method(*args) - - payload = server_state.last_payload - assert payload["tuple_key"]["user"] == f"user:{args[0]}" - assert payload["tuple_key"]["relation"] == expected_relation - assert payload["tuple_key"]["object"] == expected_object - assert ( - payload["authorization_model_id"] == OPENFGA_AUTHORIZATION_MODEL_ID - ) - - async def test_check_raises_for_status(self, fga_client, fga_server): - server_state, _ = fga_server - server_state.status_code = 500 - - with pytest.raises(httpx.HTTPStatusError): - await fga_client.can_edit_pools("u1") diff --git a/src/tests/maascommon/openfga/test_async_client.py b/src/tests/maascommon/openfga/test_async_client.py new file mode 100644 index 000000000..a1776ff2d --- /dev/null +++ b/src/tests/maascommon/openfga/test_async_client.py @@ -0,0 +1,70 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import httpx +import pytest + +from maascommon.openfga.async_client import OpenFGAClient +from tests.maascommon.openfga.base import LIST_METHODS, PERMISSION_METHODS + + +@pytest.mark.asyncio +class TestOpenFGAClient: + """Tests for the Asynchronous Client.""" + + @pytest.fixture + async def client(self, stub_openfga_server): + _, socket_path = stub_openfga_server + client = OpenFGAClient(unix_socket=socket_path) + yield client + await client.close() + + @pytest.mark.parametrize("method, args, rel, obj", PERMISSION_METHODS) + async def test_all_permissions( + self, client, stub_openfga_server, method, args, rel, obj + ): + server, _ = stub_openfga_server + await getattr(client, method)(*args) + assert server.last_payload["tuple_key"] == { + "user": f"user:{args[0]}", + "relation": rel, + "object": obj, + } + + @pytest.mark.parametrize("method, rel", LIST_METHODS) + async def test_all_listings( + self, client, stub_openfga_server, method, rel + ): + server, _ = stub_openfga_server + server.list_objects_response = {"objects": ["pool:p1", "pool:p2"]} + + result = await getattr(client, method)("u1") + + assert result == ["p1", "p2"] + assert server.last_payload["relation"] == rel + assert server.last_payload["type"] == "pool" + + @pytest.mark.parametrize("status", [403, 500]) + async def test_async_raises_for_status( + self, client, stub_openfga_server, status + ): + server, _ = stub_openfga_server + server.status_code = status + + with pytest.raises(httpx.HTTPStatusError): + await client.can_edit_machines("u1") + + with pytest.raises(httpx.HTTPStatusError): + await client.list_pools_with_view_machines_access("u1") + + async def test_async_client_closes_properly(self): + client = OpenFGAClient() + await client.close() + assert client.client.is_closed + + async def test_socket_path_is_set_from_env(self, monkeypatch): + test_socket_path = "/tmp/test_socket" + monkeypatch.setenv("MAAS_OPENFGA_HTTP_SOCKET_PATH", test_socket_path) + client = OpenFGAClient() + assert client.socket_path == test_socket_path + await client.close() diff --git a/src/tests/maascommon/openfga/test_sync_client.py b/src/tests/maascommon/openfga/test_sync_client.py new file mode 100644 index 000000000..3a63b4ef4 --- /dev/null +++ b/src/tests/maascommon/openfga/test_sync_client.py @@ -0,0 +1,85 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import asyncio + +import httpx +import pytest + +from maascommon.openfga.sync_client import SyncOpenFGAClient +from tests.maascommon.openfga.base import LIST_METHODS, PERMISSION_METHODS + + +@pytest.mark.asyncio +class TestSyncOpenFGAClient: + """Tests for the Synchronous Client.""" + + class MockUser: + def __init__(self, user_id): + self.id = user_id + + @pytest.fixture + async def client(self, stub_openfga_server): + _, socket_path = stub_openfga_server + client = SyncOpenFGAClient(unix_socket=socket_path) + yield client + client.close() + + @pytest.mark.parametrize("method, args, rel, obj", PERMISSION_METHODS) + async def test_all_permissions_sync( + self, client, stub_openfga_server, method, args, rel, obj + ): + server, _ = stub_openfga_server + user = self.MockUser(args[0]) + method = getattr(client, method) + + # Replace the first argument with the MockUser (django User) instance + await asyncio.to_thread(method, user, *args[1:]) + + assert server.last_payload["tuple_key"] == { + "user": f"user:{args[0]}", + "relation": rel, + "object": obj, + } + + @pytest.mark.parametrize("method, rel", LIST_METHODS) + async def test_all_listings_sync( + self, client, stub_openfga_server, method, rel + ): + server, _ = stub_openfga_server + server.list_objects_response = {"objects": ["pool:id-123"]} + method = getattr(client, method) + + result = await asyncio.to_thread(method, self.MockUser("admin")) + + assert result == ["id-123"] + assert server.last_payload["relation"] == rel + assert server.last_payload["user"] == "user:admin" + + @pytest.mark.parametrize("status", [401, 404, 503]) + async def test_sync_raises_for_status( + self, client, stub_openfga_server, status + ): + server, _ = stub_openfga_server + server.status_code = status + user = self.MockUser("tester") + + # The exception bubbles up through the anyio thread worker + with pytest.raises(httpx.HTTPStatusError) as excinfo: + await asyncio.to_thread(client.can_edit_machines, user) + + assert excinfo.value.response.status_code == status + + async def test_client_closes_properly(self): + client = SyncOpenFGAClient() + client.close() + assert client.client.is_closed + + async def test_socket_path_is_set_from_env( + self, stub_openfga_server, monkeypatch + ): + _, socket_path = stub_openfga_server + monkeypatch.setenv("MAAS_OPENFGA_HTTP_SOCKET_PATH", socket_path) + client = SyncOpenFGAClient() + assert client.socket_path == socket_path + client.close() diff --git a/src/tests/maasserver/conftest.py b/src/tests/maasserver/conftest.py index 72303cf74..591cb681b 100644 --- a/src/tests/maasserver/conftest.py +++ b/src/tests/maasserver/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2022 Canonical Ltd. This software is licensed under the +# Copyright 2022-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). from contextlib import contextmanager @@ -18,6 +18,10 @@ get_region_vault_client_if_enabled, ) +__all__ = ["mock_openfga"] + +from tests.fixtures import mock_openfga + @pytest.fixture(autouse=True) def clean_globals(clean_globals): diff --git a/src/tests/maasserver/test_openfga.py b/src/tests/maasserver/test_openfga.py new file mode 100644 index 000000000..6a1f2ec27 --- /dev/null +++ b/src/tests/maasserver/test_openfga.py @@ -0,0 +1,49 @@ +# Copyright 2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import threading +from unittest.mock import MagicMock + +import pytest + +from maasserver.openfga import ThreadLocalFGACache + + +class TestFGACache: + @pytest.fixture + def client_mock(self): + return MagicMock() + + @pytest.fixture + def wrapped_client(self, client_mock): + return ThreadLocalFGACache(client_mock) + + def test_calls_are_cached(self, wrapped_client, client_mock): + client_mock.check.return_value = True + + wrapped_client.can_edit_machines("u1") + wrapped_client.can_edit_machines("u1") + + assert client_mock.can_edit_machines.call_count == 1 + + def test_cache_is_cleared(self, wrapped_client, client_mock): + wrapped_client.can_edit_machines("u1") + wrapped_client.clear_cache() + wrapped_client.can_edit_machines("u1") + + assert client_mock.can_edit_machines.call_count == 2 + + def test_thread_safety(self, wrapped_client, client_mock): + """Ensures threads don't share their cache buckets.""" + + def call_fga(): + wrapped_client.can_edit_machines("same_id") + + threads = [threading.Thread(target=call_fga) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Each thread had an empty local cache, so 3 calls to the mock + assert client_mock.can_edit_machines.call_count == 3 diff --git a/src/tests/maasserver/test_sessiontimeout.py b/src/tests/maasserver/test_sessiontimeout.py index 8f41f12e1..7346d29e7 100644 --- a/src/tests/maasserver/test_sessiontimeout.py +++ b/src/tests/maasserver/test_sessiontimeout.py @@ -1,10 +1,14 @@ -# Tests for custom sessionbase to configure timeout +# Copyright 2023-2026 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +import pytest from maasserver.models import Config from maasserver.sessiontimeout import SessionStore from maasserver.websockets.handlers.config import ConfigHandler +@pytest.mark.usefixtures("mock_openfga") class TestSessionTimeout: def test_default_config(self, factory): admin = factory.make_admin() diff --git a/src/tests/maasserver/websockets/handlers/test_machine.py b/src/tests/maasserver/websockets/handlers/test_machine.py index bc332f568..982d99b78 100644 --- a/src/tests/maasserver/websockets/handlers/test_machine.py +++ b/src/tests/maasserver/websockets/handlers/test_machine.py @@ -1,5 +1,6 @@ -# Copyright 2016-2020 Canonical Ltd. This software is licensed under the +# Copyright 2016-2026 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). + from operator import attrgetter from django.db import transaction @@ -35,6 +36,7 @@ def force_rbac_off(): @pytest.mark.usefixtures("maasdb") +@pytest.mark.usefixtures("mock_openfga") class TestMachineHandler: maxDiff = None @@ -303,6 +305,7 @@ def test_secret_power_params_only_viewable_with_admin_read_permission_rbac( assert node_data["power_parameters"] == sanitised_power_params +@pytest.mark.usefixtures("mock_openfga") @pytest.mark.usefixtures("maasdb") class TestMachineHandlerNewSchema: def test_filter_simple(self, mocker): @@ -876,6 +879,7 @@ def test_list_an_unsubscribed_object_subscribes(self, mocker): @pytest.mark.allow_transactions +@pytest.mark.usefixtures("mock_openfga") @pytest.mark.usefixtures("maasdb") class TestMachineHandlerWithServiceLayer: def test_list_no_power_params_certificate(self): diff --git a/src/tests/maasservicelayer/builders/test_openfga_tuple.py b/src/tests/maasservicelayer/builders/test_openfga_tuple.py index fa8dcd71f..649ca1b10 100644 --- a/src/tests/maasservicelayer/builders/test_openfga_tuple.py +++ b/src/tests/maasservicelayer/builders/test_openfga_tuple.py @@ -28,10 +28,13 @@ def test_build_user_member_group(self): @pytest.mark.parametrize( "method_name, relation", [ - ("build_group_can_edit_pool", "can_edit"), - ("build_group_can_view_pool", "can_view"), - ("build_group_can_edit_machines", "can_edit_machines"), - ("build_group_can_deploy_machines", "can_deploy_machines"), + ("build_group_can_edit_machines_in_pool", "can_edit_machines"), + ("build_group_can_view_machines_in_pool", "can_view_machines"), + ( + "build_group_can_view_available_machines_in_pool", + "can_view_available_machines", + ), + ("build_group_can_deploy_machines_in_pool", "can_deploy_machines"), ], ) def test_group_pool_scoped_builders(self, method_name, relation): @@ -50,16 +53,13 @@ def test_group_pool_scoped_builders(self, method_name, relation): @pytest.mark.parametrize( "method_name, relation", [ - ("build_group_can_edit_pools", "can_edit_pools"), - ("build_group_can_view_pools", "can_view_pools"), - ( - "build_group_can_edit_machines_in_pools", - "can_edit_machines", - ), + ("build_group_can_edit_machines", "can_edit_machines"), + ("build_group_can_view_machines", "can_view_machines"), ( - "build_group_can_deploy_machines_in_pools", - "can_deploy_machines", + "build_group_can_view_available_machines", + "can_view_available_machines", ), + ("build_group_can_deploy_machines", "can_deploy_machines"), ( "build_group_can_view_global_entities", "can_view_global_entities", @@ -68,22 +68,24 @@ def test_group_pool_scoped_builders(self, method_name, relation): "build_group_can_edit_global_entities", "can_edit_global_entities", ), + ("build_group_can_edit_controllers", "can_edit_controllers"), + ("build_group_can_view_controllers", "can_view_controllers"), + ("build_group_can_view_identities", "can_view_identities"), + ("build_group_can_edit_identities", "can_edit_identities"), + ("build_group_can_view_configurations", "can_view_configurations"), + ("build_group_can_edit_configurations", "can_edit_configurations"), + ("build_group_can_edit_notifications", "can_edit_notifications"), + ("build_group_can_view_notifications", "can_view_notifications"), ( - "build_group_can_view_permissions", - "can_view_permissions", - ), - ( - "build_group_can_edit_permissions", - "can_edit_permissions", - ), - ( - "build_group_can_view_configurations", - "can_view_configurations", + "build_group_can_edit_boot_entities", + "can_edit_boot_entities", ), ( - "build_group_can_edit_configurations", - "can_edit_configurations", + "build_group_can_view_boot_entities", + "can_view_boot_entities", ), + ("build_group_can_view_devices", "can_view_devices"), + ("build_group_can_view_ipaddresses", "can_view_ipaddresses"), ], ) def test_group_global_scoped_builders(self, method_name, relation): diff --git a/src/tests/maasservicelayer/db/repositories/test_openfga_tuples.py b/src/tests/maasservicelayer/db/repositories/test_openfga_tuples.py index 1ae1c8d4a..5485ff030 100644 --- a/src/tests/maasservicelayer/db/repositories/test_openfga_tuples.py +++ b/src/tests/maasservicelayer/db/repositories/test_openfga_tuples.py @@ -11,6 +11,7 @@ from maasservicelayer.context import Context from maasservicelayer.db.filters import QuerySpec from maasservicelayer.db.repositories.openfga_tuples import ( + OpenFGATuplesClauseFactory, OpenFGATuplesRepository, ) from maasservicelayer.db.tables import OpenFGATupleTable @@ -19,6 +20,32 @@ from tests.utils.ulid import is_ulid +class TestOpenFGATuplesClauseFactory: + def test_with_object_type(self) -> None: + clause = OpenFGATuplesClauseFactory.with_object_type("type") + assert str( + clause.condition.compile(compile_kwargs={"literal_binds": True}) + ) == ("openfga.tuple.object_type = 'type'") + + def test_with_object_id(self) -> None: + clause = OpenFGATuplesClauseFactory.with_object_id("id") + assert str( + clause.condition.compile(compile_kwargs={"literal_binds": True}) + ) == ("openfga.tuple.object_id = 'id'") + + def test_with_relation(self): + clause = OpenFGATuplesClauseFactory.with_relation("relation") + assert str( + clause.condition.compile(compile_kwargs={"literal_binds": True}) + ) == ("openfga.tuple.relation = 'relation'") + + def test_with_user(self): + clause = OpenFGATuplesClauseFactory.with_user("user:user") + assert str( + clause.condition.compile(compile_kwargs={"literal_binds": True}) + ) == ("openfga.tuple._user = 'user:user'") + + @pytest.mark.usefixtures("ensuremaasdb") @pytest.mark.asyncio class TestOpenFGATuplesRepository: diff --git a/src/tests/maasservicelayer/services/test_openfga_tuples.py b/src/tests/maasservicelayer/services/test_openfga_tuples.py new file mode 100644 index 000000000..762a98881 --- /dev/null +++ b/src/tests/maasservicelayer/services/test_openfga_tuples.py @@ -0,0 +1,127 @@ +# 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.context import Context +from maasservicelayer.db.filters import QuerySpec +from maasservicelayer.db.repositories.openfga_tuples import ( + OpenFGATuplesClauseFactory, + OpenFGATuplesRepository, +) +from maasservicelayer.db.tables import OpenFGATupleTable +from maasservicelayer.services import OpenFGATupleService, ServiceCollectionV3 +from maasservicelayer.services.openfga_tuples import OpenFGAServiceCache +from tests.fixtures.factories.openfga_tuples import create_openfga_tuple +from tests.maasapiserver.fixtures.db import Fixture + + +@pytest.mark.asyncio +class TestIntegrationOpenFGAService: + async def test_create( + self, + fixture: Fixture, + services: ServiceCollectionV3, + ): + await services.openfga_tuples.create( + OpenFGATupleBuilder.build_pool("1000") + ) + retrieved_pool = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "pool"), + eq(OpenFGATupleTable.c.object_id, "1000"), + ), + ) + assert len(retrieved_pool) == 1 + assert retrieved_pool[0]["_user"] == "maas:0" + assert retrieved_pool[0]["object_type"] == "pool" + assert retrieved_pool[0]["object_id"] == "1000" + assert retrieved_pool[0]["relation"] == "parent" + + async def test_delete_many( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + await create_openfga_tuple( + fixture, "user:1", "user", "member", "group", "2000" + ) + await services.openfga_tuples.delete_many( + QuerySpec(where=OpenFGATuplesClauseFactory.with_user("user:1")) + ) + retrieved_tuple = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "group"), + eq(OpenFGATupleTable.c.object_id, "2000"), + eq(OpenFGATupleTable.c._user, "user:1"), + ), + ) + assert len(retrieved_tuple) == 0 + + async def test_delete_pool( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + await create_openfga_tuple( + fixture, "maas:0", "user", "parent", "pool", "100" + ) + await services.openfga_tuples.delete_pool(100) + retrieved_tuple = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "pool"), + eq(OpenFGATupleTable.c.object_id, "100"), + eq(OpenFGATupleTable.c._user, "maas:0"), + ), + ) + assert len(retrieved_tuple) == 0 + + async def test_delete_user( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + await create_openfga_tuple( + fixture, "user:1", "user", "member", "group", "2000" + ) + await services.openfga_tuples.delete_user(1) + retrieved_tuple = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "group"), + eq(OpenFGATupleTable.c.object_id, "2000"), + eq(OpenFGATupleTable.c._user, "user:1"), + ), + ) + assert len(retrieved_tuple) == 0 + + +@pytest.mark.asyncio +class TestOpenFGAService: + async def test_get_client_is_cached(self) -> None: + cache = OpenFGAServiceCache() + agents_service = OpenFGATupleService( + context=Context(), + openfga_tuple_repository=Mock(OpenFGATuplesRepository), + cache=cache, + ) + + agents_service2 = OpenFGATupleService( + context=Context(), + openfga_tuple_repository=Mock(OpenFGATuplesRepository), + cache=cache, + ) + + apiclient = await agents_service.get_client() + apiclient_again = await agents_service.get_client() + + apiclient2 = await agents_service2.get_client() + apiclient2_again = await agents_service2.get_client() + + assert ( + id(apiclient) + == id(apiclient2) + == id(apiclient_again) + == id(apiclient2_again) + ) diff --git a/src/tests/maasservicelayer/services/test_resource_pools.py b/src/tests/maasservicelayer/services/test_resource_pools.py index c952c3777..fbefccced 100644 --- a/src/tests/maasservicelayer/services/test_resource_pools.py +++ b/src/tests/maasservicelayer/services/test_resource_pools.py @@ -1,20 +1,32 @@ -# Copyright 2024 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 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.resource_pools import ResourcePoolBuilder from maasservicelayer.context import Context +from maasservicelayer.db.filters import QuerySpec from maasservicelayer.db.repositories.resource_pools import ( + ResourcePoolClauseFactory, ResourcePoolRepository, ) +from maasservicelayer.db.tables import OpenFGATupleTable from maasservicelayer.exceptions.catalog import BadRequestException from maasservicelayer.models.base import MaasBaseModel from maasservicelayer.models.resource_pools import ResourcePool -from maasservicelayer.services import ResourcePoolsService +from maasservicelayer.services import ( + OpenFGATupleService, + ResourcePoolsService, + ServiceCollectionV3, +) from maasservicelayer.services.base import BaseService from maasservicelayer.utils.date import utcnow +from tests.maasapiserver.fixtures.db import Fixture from tests.maasservicelayer.services.base import ServiceCommonTests @@ -25,6 +37,7 @@ def service_instance(self) -> BaseService: return ResourcePoolsService( context=Context(), resource_pools_repository=Mock(ResourcePoolRepository), + openfga_tuples_service=Mock(OpenFGATupleService), ) @pytest.fixture @@ -38,6 +51,74 @@ def test_instance(self) -> MaasBaseModel: ) +@pytest.mark.asyncio +class TestIntegrationResourcePoolsService: + async def test_create_stores_openfga_tuple( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + resource_pool = await services.resource_pools.create( + ResourcePoolBuilder(name="test", description="") + ) + retrieved_pool = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "pool"), + eq(OpenFGATupleTable.c.object_id, str(resource_pool.id)), + eq(OpenFGATupleTable.c.relation, "parent"), + ), + ) + assert len(retrieved_pool) == 1 + assert retrieved_pool[0]["_user"] == "maas:0" + assert retrieved_pool[0]["object_type"] == "pool" + assert retrieved_pool[0]["object_id"] == str(resource_pool.id) + assert retrieved_pool[0]["relation"] == "parent" + + async def test_delete_removes_openfga_tuple( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + resource_pool = await services.resource_pools.create( + ResourcePoolBuilder(name="test", description="") + ) + await services.resource_pools.delete_by_id(resource_pool.id) + retrieved_pool = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "pool"), + eq(OpenFGATupleTable.c.object_id, str(resource_pool.id)), + eq(OpenFGATupleTable.c.relation, "parent"), + ), + ) + assert len(retrieved_pool) == 0 + + async def test_delete_many_removes_openfga_tuples( + self, fixture: Fixture, services: ServiceCollectionV3 + ): + resource_pool_1 = await services.resource_pools.create( + ResourcePoolBuilder(name="test1", description="") + ) + resource_pool_2 = await services.resource_pools.create( + ResourcePoolBuilder(name="test2", description="") + ) + await services.resource_pools.delete_many( + QuerySpec( + where=ResourcePoolClauseFactory.with_ids( + [resource_pool_1.id, resource_pool_2.id] + ) + ) + ) + retrieved_pools = await fixture.get( + OpenFGATupleTable.fullname, + and_( + eq(OpenFGATupleTable.c.object_type, "pool"), + eq(OpenFGATupleTable.c.relation, "parent"), + OpenFGATupleTable.c.object_id.in_( + [str(resource_pool_1.id), str(resource_pool_2.id)] + ), + ), + ) + assert len(retrieved_pools) == 0 + + @pytest.mark.asyncio class TestResourcePoolsService: async def test_list_ids(self) -> None: @@ -46,6 +127,7 @@ async def test_list_ids(self) -> None: resource_pools_service = ResourcePoolsService( context=Context(), resource_pools_repository=resource_pool_repository_mock, + openfga_tuples_service=Mock(OpenFGATupleService), ) ids_list = await resource_pools_service.list_ids() resource_pool_repository_mock.list_ids.assert_called_once() @@ -56,7 +138,52 @@ async def test_cannot_delete_default_resourcepool(self) -> None: resource_pools_service = ResourcePoolsService( context=Context(), resource_pools_repository=resource_pools_repository, + openfga_tuples_service=Mock(OpenFGATupleService), ) resource_pool = ResourcePool(id=0, name="default", description="") with pytest.raises(BadRequestException): await resource_pools_service.pre_delete_hook(resource_pool) + + async def test_create_calls_post_create_hook(self) -> None: + resource_pools_repository = Mock(ResourcePoolRepository) + openfga_tuples_service = Mock(OpenFGATupleService) + resource_pools_service = ResourcePoolsService( + context=Context(), + resource_pools_repository=resource_pools_repository, + openfga_tuples_service=openfga_tuples_service, + ) + resource_pool = ResourcePool(id=1, name="test", description="") + await resource_pools_service.post_create_hook(resource_pool) + openfga_tuples_service.create.assert_called_once() + + async def test_delete_calls_post_delete_hook(self) -> None: + resource_pools_repository = Mock(ResourcePoolRepository) + openfga_tuples_service = Mock(OpenFGATupleService) + resource_pools_service = ResourcePoolsService( + context=Context(), + resource_pools_repository=resource_pools_repository, + openfga_tuples_service=openfga_tuples_service, + ) + resource_pool = ResourcePool(id=1, name="test", description="") + await resource_pools_service.post_delete_hook(resource_pool) + openfga_tuples_service.delete_pool.assert_called_once_with(1) + + async def test_create_many_calls_post_create_many_hook(self) -> None: + resource_pools_repository = Mock(ResourcePoolRepository) + openfga_tuples_service = Mock(OpenFGATupleService) + resource_pools_service = ResourcePoolsService( + context=Context(), + resource_pools_repository=resource_pools_repository, + openfga_tuples_service=openfga_tuples_service, + ) + resource_pool_1 = ResourcePool(id=1, name="test1", description="") + resource_pool_2 = ResourcePool(id=2, name="test2", description="") + await resource_pools_service.post_create_many_hook( + [resource_pool_1, resource_pool_2] + ) + openfga_tuples_service.create.assert_any_call( + OpenFGATupleBuilder.build_pool(str(resource_pool_1.id)) + ) + openfga_tuples_service.create.assert_any_call( + OpenFGATupleBuilder.build_pool(str(resource_pool_2.id)) + ) diff --git a/src/tests/maasservicelayer/services/test_users.py b/src/tests/maasservicelayer/services/test_users.py index 39f419364..aef037df7 100644 --- a/src/tests/maasservicelayer/services/test_users.py +++ b/src/tests/maasservicelayer/services/test_users.py @@ -1,9 +1,10 @@ -# 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). import time from unittest.mock import AsyncMock, Mock, patch import pytest +from sqlalchemy.sql.operators import eq from maascommon.enums.consumer import ConsumerState from maascommon.enums.token import TokenType @@ -43,6 +44,7 @@ UserClauseFactory, UsersRepository, ) +from maasservicelayer.db.tables import OpenFGATupleTable from maasservicelayer.exceptions.catalog import ( BadRequestException, NotFoundException, @@ -54,6 +56,7 @@ from maasservicelayer.models.users import User, UserProfile from maasservicelayer.services import ( ConsumersService, + OpenFGATupleService, ServiceCollectionV3, TokensService, UsersService, @@ -73,6 +76,7 @@ create_test_notification_dismissal_entry, create_test_notification_entry, ) +from tests.fixtures.factories.openfga_tuples import create_openfga_tuple from tests.fixtures.factories.sslkey import create_test_sslkey from tests.fixtures.factories.token import create_test_user_token from tests.fixtures.factories.user import ( @@ -148,6 +152,14 @@ async def test_delete_cascade_entities( self, fixture: Fixture, services: ServiceCollectionV3 ): user = await create_test_user(fixture, username="foo") + await create_openfga_tuple( + fixture, + user=f"user:{user.id}", + user_type="user", + relation="member", + object_type="group", + object_id="testgroup", + ) await create_test_user_profile(fixture, user_id=user.id) consumer = await create_test_user_consumer(fixture, user_id=user.id) await create_test_user_token( @@ -178,6 +190,12 @@ async def test_delete_cascade_entities( ) assert notifications == [] + retrieved_openfga_tuples = await fixture.get( + OpenFGATupleTable.fullname, + eq(OpenFGATupleTable.c._user, f"user:{user.id}"), + ) + assert retrieved_openfga_tuples == [] + @pytest.mark.asyncio class TestCommonUsersService(ServiceCommonTests): @@ -195,6 +213,7 @@ def service_instance(self) -> BaseService: filestorage_service=Mock(FileStorageService), consumers_service=Mock(ConsumersService), tokens_service=Mock(TokensService), + openfga_tuple_service=Mock(OpenFGATupleService), ) # we test the pre delete hook in the next tests service.pre_delete_hook = AsyncMock() @@ -231,6 +250,7 @@ def users_service(self, users_repository: Mock): filestorage_service=Mock(FileStorageService), consumers_service=Mock(ConsumersService), tokens_service=Mock(TokensService), + openfga_tuple_service=Mock(OpenFGATupleService), ) async def test_get_by_session_id( @@ -487,6 +507,9 @@ async def test_post_delete_hook( where=FileStorageClauseFactory.with_owner_id(TEST_USER.id) ) ) + users_service.openfga_tuple_service.delete_user.assert_called_once_with( + TEST_USER.id + ) async def test_post_delete_hook_creates_log( self, users_service: UsersService, users_repository: Mock