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