Skip to content

Commit f8c8de8

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 2208d51 commit f8c8de8

4 files changed

Lines changed: 187 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 v3)
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.5.0p12
8+
class | fix
9+
edition | community
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
@@ -28,7 +28,6 @@
2828
from cmk.gui.openapi.endpoints.host_config.request_schemas import EXISTING_HOST_NAME
2929
from cmk.gui.openapi.restful_objects import constructors, Endpoint, response_schemas
3030
from cmk.gui.openapi.restful_objects.constructors import domain_object, link_rel, object_property
31-
from cmk.gui.openapi.restful_objects.parameters import HOST_NAME
3231
from cmk.gui.openapi.restful_objects.registry import EndpointRegistry
3332
from cmk.gui.openapi.restful_objects.type_defs import DomainObject, LinkType
3433
from cmk.gui.openapi.utils import EXT, problem, ProblemException, serve_json
@@ -84,7 +83,8 @@
8483
[
8584
permissions.Perm("wato.edit"),
8685
permissions.Perm("wato.services"),
87-
permissions.Perm("wato.see_all_folders"),
86+
# Only used as a shortcut to see hosts without being a contact of their folder.
87+
permissions.Optional(permissions.Perm("wato.see_all_folders")),
8888
# The permissions below are required to manage BackgroundJobs
8989
permissions.Optional(permissions.Perm("background_jobs.stop_jobs")),
9090
permissions.Optional(permissions.Perm("background_jobs.stop_foreign_jobs")),
@@ -93,6 +93,14 @@
9393
]
9494
)
9595

96+
HOST_NAME_SETUP_READ = {
97+
"host_name": gui_fields.HostField(
98+
description="A host name.",
99+
should_exist=True,
100+
permission_type="setup_read",
101+
)
102+
}
103+
96104
SERVICE_DISCOVERY_PHASES = {
97105
"undecided": DiscoveryState.UNDECIDED,
98106
"vanished": DiscoveryState.VANISHED,
@@ -175,6 +183,7 @@ def _discovery_mode(default_mode: str) -> fields.String:
175183
description="The host of the service discovery result",
176184
example="example.com",
177185
required=True,
186+
permission_type="setup_read",
178187
)
179188
}
180189
],
@@ -185,7 +194,6 @@ def show_service_discovery_result(params: Mapping[str, Any]) -> Response:
185194
"""Show the current service discovery result"""
186195
user.need_permission("wato.edit")
187196
user.need_permission("wato.services")
188-
user.need_permission("wato.see_all_folders")
189197

190198
host = Host.load_host(params["host_name"])
191199

@@ -264,6 +272,7 @@ class UpdateDiscoveryPhase(BaseSchema):
264272
"host_name": gui_fields.HostField(
265273
description="The host of the service which shall be updated.",
266274
example="example.com",
275+
permission_type="setup_read",
267276
),
268277
}
269278
],
@@ -278,7 +287,8 @@ class UpdateDiscoveryPhase(BaseSchema):
278287
permissions.Perm("wato.service_discovery_to_ignored"),
279288
permissions.Perm("wato.service_discovery_to_undecided"),
280289
permissions.Perm("wato.service_discovery_to_removed"),
281-
permissions.Perm("wato.see_all_folders"),
290+
# Only used as a shortcut to see hosts without being a contact of their folder.
291+
permissions.Optional(permissions.Perm("wato.see_all_folders")),
282292
]
283293
),
284294
)
@@ -288,7 +298,6 @@ def update_service_phase(params: Mapping[str, Any]) -> Response:
288298
user.need_permission("wato.service_discovery_to_ignored")
289299
user.need_permission("wato.service_discovery_to_undecided")
290300
user.need_permission("wato.service_discovery_to_removed")
291-
user.need_permission("wato.see_all_folders")
292301

293302
body = params["body"]
294303
host = Host.load_host(params["host_name"])
@@ -351,15 +360,14 @@ def _update_single_service_phase(
351360
"cmk/show",
352361
method="get",
353362
tag_group="Setup",
354-
path_params=[HOST_NAME],
363+
path_params=[HOST_NAME_SETUP_READ],
355364
response_schema=ServiceDiscoveryRunSchema,
356365
permissions_required=RO_PERMISSIONS,
357366
)
358367
def show_service_discovery_run(params: Mapping[str, Any]) -> Response:
359368
"""Show the last service discovery background job on a host"""
360369
user.need_permission("wato.edit")
361370
user.need_permission("wato.services")
362-
user.need_permission("wato.see_all_folders")
363371
host = Host.load_host(params["host_name"])
364372
snapshot = _job_snapshot(host)
365373
job_id = snapshot.job_id
@@ -393,7 +401,7 @@ def show_service_discovery_run(params: Mapping[str, Any]) -> Response:
393401
"'Wait for completion' endpoint.",
394402
404: "There is no running service discovery",
395403
},
396-
path_params=[HOST_NAME],
404+
path_params=[HOST_NAME_SETUP_READ],
397405
additional_status_codes=[302],
398406
output_empty=True,
399407
permissions_required=RO_PERMISSIONS,
@@ -405,7 +413,6 @@ def service_discovery_run_wait_for_completion(params: Mapping[str, Any]) -> Resp
405413
"""
406414
user.need_permission("wato.edit")
407415
user.need_permission("wato.services")
408-
user.need_permission("wato.see_all_folders")
409416

410417
host = Host.load_host(params["host_name"])
411418
snapshot = _job_snapshot(host)

tests/testlib/unit/rest_api_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2994,6 +2994,7 @@ def get_all(
29942994
class ServiceDiscoveryClient(RestApiClient):
29952995
service_discovery_domain: DomainType = "service_discovery"
29962996
discovery_run_domain: DomainType = "discovery_run"
2997+
service_discovery_run_domain: DomainType = "service_discovery_run"
29972998

29982999
def bulk_discovery(
29993000
self,
@@ -3040,6 +3041,36 @@ def discovery_run_status(self, id_: str, expect_ok: bool = True) -> Response:
30403041
expect_ok=expect_ok,
30413042
)
30423043

3044+
def start_service_discovery(
3045+
self, host_name: str, mode: str = "refresh", expect_ok: bool = True
3046+
) -> Response:
3047+
return self.request(
3048+
"post",
3049+
url=f"/domain-types/{self.service_discovery_run_domain}/actions/start/invoke",
3050+
body={"host_name": host_name, "mode": mode},
3051+
expect_ok=expect_ok,
3052+
follow_redirects=False,
3053+
)
3054+
3055+
def wait_for_service_discovery_completion(
3056+
self, host_name: str, expect_ok: bool = True
3057+
) -> Response:
3058+
return self.request(
3059+
"get",
3060+
url=(
3061+
f"/objects/{self.service_discovery_run_domain}/{host_name}"
3062+
"/actions/wait-for-completion/invoke"
3063+
),
3064+
expect_ok=expect_ok,
3065+
)
3066+
3067+
def get_service_discovery_status(self, host_name: str, expect_ok: bool = True) -> Response:
3068+
return self.request(
3069+
"get",
3070+
url=f"/objects/{self.service_discovery_run_domain}/{host_name}",
3071+
expect_ok=expect_ok,
3072+
)
3073+
30433074

30443075
class LDAPConnectionClient(RestApiClient):
30453076
domain: DomainType = "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
@@ -1290,3 +1290,109 @@ def test_openapi_refresh_job_status(
12901290
assert "state" in resp.json["extensions"]
12911291
assert "result" in resp.json["extensions"]["logs"]
12921292
assert "progress" in resp.json["extensions"]["logs"]
1293+
1294+
1295+
@pytest.mark.usefixtures("inline_background_jobs")
1296+
def test_openapi_service_discovery_accessible_to_folder_contact(
1297+
clients: ClientRegistry,
1298+
mock_discovery_preview: MagicMock,
1299+
) -> None:
1300+
"""Regression test for SUP-29084.
1301+
1302+
A user who can see a host only through their contact group's folder permissions (not
1303+
through the blanket 'wato.see_all_folders' permission, which is only held by admins) must
1304+
still be able to run a service discovery and read back its result/status via the REST API.
1305+
"""
1306+
host_name = "restricted_host"
1307+
clients.ContactGroup.create("folder_cg", alias="folder_cg")
1308+
clients.User.create(
1309+
username="folder_member",
1310+
fullname="folder_member",
1311+
customer=None,
1312+
roles=["user"],
1313+
contactgroups=["folder_cg"],
1314+
auth_option={"auth_type": "password", "password": "supersecretish"},
1315+
)
1316+
clients.Folder.create(
1317+
title="restricted_folder",
1318+
parent="/",
1319+
folder_name="restricted_folder",
1320+
attributes={"contactgroups": {"groups": ["folder_cg"], "recurse_perms": True}},
1321+
)
1322+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1323+
1324+
clients.ServiceDiscovery.set_credentials("folder_member", "supersecretish")
1325+
1326+
clients.ServiceDiscovery.start_service_discovery(host_name, "refresh").assert_status_code(303)
1327+
1328+
clients.ServiceDiscovery.wait_for_service_discovery_completion(host_name).assert_status_code(
1329+
204
1330+
)
1331+
1332+
run_resp = clients.ServiceDiscovery.get_service_discovery_status(host_name)
1333+
1334+
run_resp.assert_status_code(200)
1335+
assert run_resp.json["extensions"]["state"] == "finished"
1336+
1337+
1338+
def test_openapi_service_discovery_inaccessible_to_non_folder_contact(
1339+
clients: ClientRegistry,
1340+
) -> None:
1341+
"""A user who is NOT a member of the host folder's contact group gets 404, indistinguishable
1342+
from a host that does not exist, so that host existence is not leaked."""
1343+
host_name = "restricted_host"
1344+
clients.ContactGroup.create("correct_cg", alias="correct_cg")
1345+
clients.ContactGroup.create("wrong_cg", alias="wrong_cg")
1346+
clients.User.create(
1347+
username="wrong_member",
1348+
fullname="wrong_member",
1349+
customer=None,
1350+
roles=["user"],
1351+
contactgroups=["wrong_cg"],
1352+
auth_option={"auth_type": "password", "password": "supersecretish"},
1353+
)
1354+
clients.Folder.create(
1355+
title="restricted_folder",
1356+
parent="/",
1357+
folder_name="restricted_folder",
1358+
attributes={"contactgroups": {"groups": ["correct_cg"], "recurse_perms": True}},
1359+
)
1360+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1361+
1362+
clients.ServiceDiscovery.set_credentials("wrong_member", "supersecretish")
1363+
1364+
clients.ServiceDiscovery.wait_for_service_discovery_completion(
1365+
host_name, expect_ok=False
1366+
).assert_status_code(404)
1367+
1368+
1369+
@pytest.mark.usefixtures("inline_background_jobs")
1370+
def test_openapi_service_discovery_accessible_to_admin_not_in_folder_contact_group(
1371+
clients: ClientRegistry,
1372+
mock_discovery_preview: MagicMock,
1373+
) -> None:
1374+
"""Regression test for SUP-29084.
1375+
1376+
Admins can see every host via the blanket 'wato.see_all_folders' permission, regardless of
1377+
contact group membership.
1378+
"""
1379+
host_name = "restricted_host"
1380+
clients.ContactGroup.create("folder_cg", alias="folder_cg")
1381+
clients.Folder.create(
1382+
title="restricted_folder",
1383+
parent="/",
1384+
folder_name="restricted_folder",
1385+
attributes={"contactgroups": {"groups": ["folder_cg"], "recurse_perms": True}},
1386+
)
1387+
clients.HostConfig.create(host_name=host_name, folder="/restricted_folder")
1388+
1389+
clients.ServiceDiscovery.start_service_discovery(host_name, "refresh").assert_status_code(303)
1390+
1391+
clients.ServiceDiscovery.wait_for_service_discovery_completion(host_name).assert_status_code(
1392+
204
1393+
)
1394+
1395+
run_resp = clients.ServiceDiscovery.get_service_discovery_status(host_name)
1396+
1397+
run_resp.assert_status_code(200)
1398+
assert run_resp.json["extensions"]["state"] == "finished"

0 commit comments

Comments
 (0)