Skip to content

Commit ae80dc9

Browse files
committed
19886 FIX Fix service discovery REST API permissions
Previously, the service discovery REST API endpoints (getting the discovery result, waiting for a discovery run to complete, and updating a service's discovery phase) required the "Read access to all hosts and folders" permission unconditionally. This permission is normally reserved for administrators. As a result, users who could only access a host through their contact group's folder permissions - without holding the blanket "Read access to all hosts and folders" permission - were rejected with: _We are sorry, but you lack the permission for this operation. If you do not like this then please ask your administrator to provide you with the following permission: 'Read access to all hosts and folders'._ even though they were otherwise allowed to see and manage that host. These endpoints now use the same per-host, contact-group-aware permission check already used by the host configuration REST API endpoints. Users with folder-scoped access can now run and inspect service discoveries via the REST API without needing the global "Read access to all hosts and folders" permission. SUP-29084 Change-Id: I6f68f8c659fb11bb3eb7f1f89bd9cee7897fd99a
1 parent 7db08dd commit ae80dc9

4 files changed

Lines changed: 188 additions & 9 deletions

File tree

.werks/19886.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[//]: # (werk v2)
2+
# Fix service discovery REST API permissions
3+
4+
key | value
5+
---------- | ---
6+
date | 2026-08-11T08:09:37.723492+00:00
7+
version | 2.4.0p36
8+
class | fix
9+
edition | cre
10+
component | rest-api
11+
level | 1
12+
compatible | yes
13+
14+
Previously, the service discovery REST API endpoints (getting the discovery
15+
result, waiting for a discovery run to complete, and updating a service's
16+
discovery phase) required the "Read access to all hosts and folders"
17+
permission unconditionally. This permission is normally reserved for
18+
administrators.
19+
20+
As a result, users who could only access a host through their contact
21+
group's folder permissions - without holding the blanket "Read access to
22+
all hosts and folders" permission - were rejected with:
23+
24+
_We are sorry, but you lack the permission for this operation. If you do
25+
not like this then please ask your administrator to provide you with the
26+
following permission: 'Read access to all hosts and folders'._
27+
28+
even though they were otherwise allowed to see and manage that host.
29+
30+
These endpoints now use the same per-host, contact-group-aware permission
31+
check already used by the host configuration REST API endpoints. Users
32+
with folder-scoped access can now run and inspect service discoveries via
33+
the REST API without needing the global "Read access to all hosts and
34+
folders" permission.

cmk/gui/openapi/endpoints/service_discovery/__init__.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
from cmk.gui.openapi.endpoints.host_config.request_schemas import EXISTING_HOST_NAME
3131
from cmk.gui.openapi.restful_objects import constructors, Endpoint, response_schemas
3232
from cmk.gui.openapi.restful_objects.constructors import domain_object, link_rel, object_property
33-
from cmk.gui.openapi.restful_objects.parameters import HOST_NAME
3433
from cmk.gui.openapi.restful_objects.registry import EndpointRegistry
3534
from cmk.gui.openapi.restful_objects.type_defs import DomainObject, LinkType
3635
from cmk.gui.openapi.utils import EXT, problem, ProblemException, serve_json
@@ -81,7 +80,8 @@
8180
[
8281
permissions.Perm("wato.edit"),
8382
permissions.Perm("wato.services"),
84-
permissions.Perm("wato.see_all_folders"),
83+
# Only used as a shortcut to see hosts without being a contact of their folder.
84+
permissions.Optional(permissions.Perm("wato.see_all_folders")),
8585
# The permissions below are required to manage BackgroundJobs
8686
permissions.Optional(permissions.Perm("background_jobs.stop_jobs")),
8787
permissions.Optional(permissions.Perm("background_jobs.stop_foreign_jobs")),
@@ -90,6 +90,14 @@
9090
]
9191
)
9292

93+
HOST_NAME_SETUP_READ = {
94+
"host_name": gui_fields.HostField(
95+
description="A host name.",
96+
should_exist=True,
97+
permission_type="setup_read",
98+
)
99+
}
100+
93101
SERVICE_DISCOVERY_PHASES = {
94102
"undecided": DiscoveryState.UNDECIDED,
95103
"vanished": DiscoveryState.VANISHED,
@@ -172,6 +180,7 @@ def _discovery_mode(default_mode: str) -> fields.String:
172180
description="The host of the service discovery result",
173181
example="example.com",
174182
required=True,
183+
permission_type="setup_read",
175184
)
176185
}
177186
],
@@ -182,7 +191,6 @@ def show_service_discovery_result(params: Mapping[str, Any]) -> Response:
182191
"""Show the current service discovery result"""
183192
user.need_permission("wato.edit")
184193
user.need_permission("wato.services")
185-
user.need_permission("wato.see_all_folders")
186194

187195
host = Host.load_host(params["host_name"])
188196

@@ -251,6 +259,7 @@ class UpdateDiscoveryPhase(BaseSchema):
251259
"host_name": gui_fields.HostField(
252260
description="The host of the service which shall be updated.",
253261
example="example.com",
262+
permission_type="setup_read",
254263
),
255264
}
256265
],
@@ -265,7 +274,8 @@ class UpdateDiscoveryPhase(BaseSchema):
265274
permissions.Perm("wato.service_discovery_to_ignored"),
266275
permissions.Perm("wato.service_discovery_to_undecided"),
267276
permissions.Perm("wato.service_discovery_to_removed"),
268-
permissions.Perm("wato.see_all_folders"),
277+
# Only used as a shortcut to see hosts without being a contact of their folder.
278+
permissions.Optional(permissions.Perm("wato.see_all_folders")),
269279
]
270280
),
271281
)
@@ -275,7 +285,6 @@ def update_service_phase(params: Mapping[str, Any]) -> Response:
275285
user.need_permission("wato.service_discovery_to_ignored")
276286
user.need_permission("wato.service_discovery_to_undecided")
277287
user.need_permission("wato.service_discovery_to_removed")
278-
user.need_permission("wato.see_all_folders")
279288

280289
body = params["body"]
281290
host = Host.load_host(params["host_name"])
@@ -312,15 +321,14 @@ def _update_single_service_phase(
312321
"cmk/show",
313322
method="get",
314323
tag_group="Setup",
315-
path_params=[HOST_NAME],
324+
path_params=[HOST_NAME_SETUP_READ],
316325
response_schema=response_schemas.DomainObject,
317326
permissions_required=RO_PERMISSIONS,
318327
)
319328
def show_service_discovery_run(params: Mapping[str, Any]) -> Response:
320329
"""Show the last service discovery background job on a host"""
321330
user.need_permission("wato.edit")
322331
user.need_permission("wato.services")
323-
user.need_permission("wato.see_all_folders")
324332
host = Host.load_host(params["host_name"])
325333
snapshot = _job_snapshot(host)
326334
job_id = snapshot.job_id
@@ -354,7 +362,7 @@ def show_service_discovery_run(params: Mapping[str, Any]) -> Response:
354362
"'Wait for completion' endpoint.",
355363
404: "There is no running service discovery",
356364
},
357-
path_params=[HOST_NAME],
365+
path_params=[HOST_NAME_SETUP_READ],
358366
additional_status_codes=[302],
359367
output_empty=True,
360368
permissions_required=RO_PERMISSIONS,
@@ -366,7 +374,6 @@ def service_discovery_run_wait_for_completion(params: Mapping[str, Any]) -> Resp
366374
"""
367375
user.need_permission("wato.edit")
368376
user.need_permission("wato.services")
369-
user.need_permission("wato.see_all_folders")
370377

371378
host = Host.load_host(params["host_name"])
372379
snapshot = _job_snapshot(host)

tests/testlib/unit/rest_api_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"service",
6767
"service_discovery",
6868
"discovery_run",
69+
"service_discovery_run",
6970
"ldap_connection",
7071
"saml_connection",
7172
"parent_scan",
@@ -2807,6 +2808,7 @@ def get_all(
28072808
class ServiceDiscoveryClient(RestApiClient):
28082809
service_discovery_domain: API_DOMAIN = "service_discovery"
28092810
discovery_run_domain: API_DOMAIN = "discovery_run"
2811+
service_discovery_run_domain: API_DOMAIN = "service_discovery_run"
28102812

28112813
def bulk_discovery(
28122814
self,
@@ -2853,6 +2855,36 @@ def discovery_run_status(self, id_: str, expect_ok: bool = True) -> Response:
28532855
expect_ok=expect_ok,
28542856
)
28552857

2858+
def start_service_discovery(
2859+
self, host_name: str, mode: str = "refresh", expect_ok: bool = True
2860+
) -> Response:
2861+
return self.request(
2862+
"post",
2863+
url=f"/domain-types/{self.service_discovery_run_domain}/actions/start/invoke",
2864+
body={"host_name": host_name, "mode": mode},
2865+
expect_ok=expect_ok,
2866+
follow_redirects=False,
2867+
)
2868+
2869+
def wait_for_service_discovery_completion(
2870+
self, host_name: str, expect_ok: bool = True
2871+
) -> Response:
2872+
return self.request(
2873+
"get",
2874+
url=(
2875+
f"/objects/{self.service_discovery_run_domain}/{host_name}"
2876+
"/actions/wait-for-completion/invoke"
2877+
),
2878+
expect_ok=expect_ok,
2879+
)
2880+
2881+
def get_service_discovery_status(self, host_name: str, expect_ok: bool = True) -> Response:
2882+
return self.request(
2883+
"get",
2884+
url=f"/objects/{self.service_discovery_run_domain}/{host_name}",
2885+
expect_ok=expect_ok,
2886+
)
2887+
28562888

28572889
class LDAPConnectionClient(RestApiClient):
28582890
domain: API_DOMAIN = "ldap_connection"

tests/unit/cmk/gui/openapi/test_openapi_service_discovery.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,3 +1264,109 @@ def test_openapi_refresh_job_status(
12641264
assert "state" in resp.json["extensions"]
12651265
assert "result" in resp.json["extensions"]["logs"]
12661266
assert "progress" in resp.json["extensions"]["logs"]
1267+
1268+
1269+
@pytest.mark.usefixtures("inline_background_jobs")
1270+
def test_openapi_service_discovery_accessible_to_folder_contact(
1271+
clients: ClientRegistry,
1272+
mock_discovery_preview: MagicMock,
1273+
) -> None:
1274+
"""Regression test for SUP-29084.
1275+
1276+
A user who can see a host only through their contact group's folder permissions (not
1277+
through the blanket 'wato.see_all_folders' permission, which is only held by admins) must
1278+
still be able to run a service discovery and read back its result/status via the REST API.
1279+
"""
1280+
host_name = "restricted_host"
1281+
clients.ContactGroup.create("folder_cg", alias="folder_cg")
1282+
clients.User.create(
1283+
username="folder_member",
1284+
fullname="folder_member",
1285+
customer=None,
1286+
roles=["user"],
1287+
contactgroups=["folder_cg"],
1288+
auth_option={"auth_type": "password", "password": "supersecretish"},
1289+
)
1290+
clients.Folder.create(
1291+
title="restricted_folder",
1292+
parent="/",
1293+
folder_name="restricted_folder",
1294+
attributes={"contactgroups": {"groups": ["folder_cg"], "recurse_perms": True}},
1295+
)
1296+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1297+
1298+
clients.ServiceDiscovery.set_credentials("folder_member", "supersecretish")
1299+
1300+
clients.ServiceDiscovery.start_service_discovery(host_name, "refresh").assert_status_code(303)
1301+
1302+
clients.ServiceDiscovery.wait_for_service_discovery_completion(host_name).assert_status_code(
1303+
204
1304+
)
1305+
1306+
run_resp = clients.ServiceDiscovery.get_service_discovery_status(host_name)
1307+
1308+
run_resp.assert_status_code(200)
1309+
assert run_resp.json["extensions"]["state"] == "finished"
1310+
1311+
1312+
def test_openapi_service_discovery_inaccessible_to_non_folder_contact(
1313+
clients: ClientRegistry,
1314+
) -> None:
1315+
"""A user who is NOT a member of the host folder's contact group gets 404, indistinguishable
1316+
from a host that does not exist, so that host existence is not leaked."""
1317+
host_name = "restricted_host"
1318+
clients.ContactGroup.create("correct_cg", alias="correct_cg")
1319+
clients.ContactGroup.create("wrong_cg", alias="wrong_cg")
1320+
clients.User.create(
1321+
username="wrong_member",
1322+
fullname="wrong_member",
1323+
customer=None,
1324+
roles=["user"],
1325+
contactgroups=["wrong_cg"],
1326+
auth_option={"auth_type": "password", "password": "supersecretish"},
1327+
)
1328+
clients.Folder.create(
1329+
title="restricted_folder",
1330+
parent="/",
1331+
folder_name="restricted_folder",
1332+
attributes={"contactgroups": {"groups": ["correct_cg"], "recurse_perms": True}},
1333+
)
1334+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1335+
1336+
clients.ServiceDiscovery.set_credentials("wrong_member", "supersecretish")
1337+
1338+
clients.ServiceDiscovery.wait_for_service_discovery_completion(
1339+
host_name, expect_ok=False
1340+
).assert_status_code(404)
1341+
1342+
1343+
@pytest.mark.usefixtures("inline_background_jobs")
1344+
def test_openapi_service_discovery_accessible_to_admin_not_in_folder_contact_group(
1345+
clients: ClientRegistry,
1346+
mock_discovery_preview: MagicMock,
1347+
) -> None:
1348+
"""Regression test for SUP-29084.
1349+
1350+
Admins can see every host via the blanket 'wato.see_all_folders' permission, regardless of
1351+
contact group membership.
1352+
"""
1353+
host_name = "restricted_host"
1354+
clients.ContactGroup.create("folder_cg", alias="folder_cg")
1355+
clients.Folder.create(
1356+
title="restricted_folder",
1357+
parent="/",
1358+
folder_name="restricted_folder",
1359+
attributes={"contactgroups": {"groups": ["folder_cg"], "recurse_perms": True}},
1360+
)
1361+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1362+
1363+
clients.ServiceDiscovery.start_service_discovery(host_name, "refresh").assert_status_code(303)
1364+
1365+
clients.ServiceDiscovery.wait_for_service_discovery_completion(host_name).assert_status_code(
1366+
204
1367+
)
1368+
1369+
run_resp = clients.ServiceDiscovery.get_service_discovery_status(host_name)
1370+
1371+
run_resp.assert_status_code(200)
1372+
assert run_resp.json["extensions"]["state"] == "finished"

0 commit comments

Comments
 (0)