From 8a4f31b504e260ca1d648136e986dfbc3338cbb7 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:14 +0100 Subject: [PATCH] Keep RBAC administrative recipes bounded and private Reuse the existing private-response API helper for all role and user operations, preserving exact methods, bodies, privileges and targets. Document independent decisions and uncertain mutation readback; exercise copied commands against synthetic private success, denial, redirect and incomplete responses without exposing credentials or retrying. Contract-Neutral: Documentation, mirrored guides and copied-recipe regression tests only; no runtime authentication, RBAC, API or frontend behaviour changes. Change-source: pulse-maintainer --- docs/API.md | 13 +++++- docs/RBAC.md | 58 ++++++++++++++------------- frontend-modern/public/docs/API.md | 13 +++++- frontend-modern/public/docs/RBAC.md | 58 ++++++++++++++------------- scripts/tests/test_admin_docs.py | 62 ++++++++++++++++++++++++++--- scripts/tests/test_api_auth_docs.py | 33 +++++++++++++++ 6 files changed, 171 insertions(+), 66 deletions(-) diff --git a/docs/API.md b/docs/API.md index bb37d00963..12ee36475f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -58,7 +58,7 @@ pulse_api() ( set -eu umask 077 if [ "$#" -ne 2 ]; then - printf 'Usage: pulse_api GET|POST /api/path\n' >&2 + printf 'Usage: pulse_api GET|POST|PUT|DELETE /api/path\n' >&2 exit 2 fi method=$1 @@ -70,7 +70,9 @@ pulse_api() ( case "$method" in GET) set -- ;; POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;; - *) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;; + PUT) set -- --header 'Content-Type: application/json' --request PUT --data-binary @- ;; + DELETE) set -- --request DELETE ;; + *) printf 'This example helper accepts only GET, POST, PUT and DELETE.\n' >&2; exit 2 ;; esac auth_dir="$HOME/.config/pulse" auth_file="$auth_dir/api-header" @@ -118,6 +120,13 @@ No request is retried automatically, especially a POST whose outcome is uncertain: inspect the existing action and target before deciding what to do. A 2xx response is only HTTP success, not proof that an action completed. +The helper also supports the existing [RBAC administration examples](RBAC.md#managing-roles). +POST and PUT read their JSON body from standard input; GET and DELETE send no +body. A method being supported is not permission to change a resource. Choose +each administrative change separately and check its current state afterwards. +A timeout or incomplete response can occur **after a change was applied**; +do not repeat a POST, PUT or DELETE merely because the response was lost. + Keep `--disable` first: it ignores local curl configuration that could enable trace output, bypass TLS or follow redirects. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public diff --git a/docs/RBAC.md b/docs/RBAC.md index ca9b5d1ccd..0d41868368 100644 --- a/docs/RBAC.md +++ b/docs/RBAC.md @@ -55,15 +55,29 @@ full-access (`*`) API token bound to an authorised administrator; a monitoring token is not enough. Do not widen an agent's token for this job. If you already use an administration token, prepare its private header file -as described in [API authentication](API.md#-authentication). The examples read -`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into -a command. Keep this file outside shared repositories and diagnostics. +and define `pulse_api` **in the same Bash session** using +[API authentication](API.md#-authentication). The helper is local example code, +not an installed Pulse command. Never paste the token or a session cookie into +a command, URL or report, or use browser **Copy as cURL**. -The loopback URLs work on the Pulse host. For remote use, substitute your -Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or -later; `--disable` must remain first to ignore local trace/verbose defaults, -and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each -change separately and check its response before continuing. +On the Pulse host, the helper uses loopback; for remote use, follow that guide's +HTTPS-origin instructions and keep certificate verification enabled. It makes +one request with a five-second connection limit and a twenty-second total limit, +prints only the HTTP status and saves each response in a new owner-only file. +Inspect the file privately, including on failure: role lists, user identities +and errors can contain private information. Share only a relevant redacted +excerpt, not full responses, header files or tokens. HTTP 401/402/403, redirects +and transport errors are not an empty role or user list. + +**These are separate operations, not a script to run from top to bottom.** +Choose the intended target and change before making a request; preserve an +existing administrator recovery path. Deleting a role or user and sending an +empty role list remove access. After a change, check that target's saved roles +and intended access through the normal UI or a separate read-only request. +The helper does not follow redirects or retry. A timeout or partial response +can occur **after the change was applied**; inspect the current state before +deciding what to do, rather than repeating the mutation blindly. HTTP success +alone does not establish the intended access or disable an upstream IdP account. ### Creating a Role @@ -74,9 +88,7 @@ Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`, **API:** ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST \ - --data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON' +pulse_api POST /api/admin/roles <<'JSON' { "id": "alert-manager", "name": "Alert Manager", @@ -93,16 +105,13 @@ JSON ### Listing Roles ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/admin/roles +pulse_api GET /api/admin/roles ``` ### Updating a Role ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON' +pulse_api PUT /api/admin/roles/alert-manager <<'JSON' { "name": "Alert Manager", "description": "Updated description", @@ -119,8 +128,7 @@ JSON ### Deleting a Role ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager +pulse_api DELETE /api/admin/roles/alert-manager ``` --- @@ -130,8 +138,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ ### Listing Users and Their Roles ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/admin/users +pulse_api GET /api/admin/users ``` SSO users are displayed using the latest configured username claim and email @@ -148,9 +155,7 @@ SSO principal). `jane` below is an example local username. Create the custom role before assigning it; do not run the deletion example first. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +pulse_api PUT /api/admin/users/jane/roles <<'JSON' {"roleIds": ["alert-manager", "viewer"]} JSON ``` @@ -158,9 +163,7 @@ JSON To clear a user's entire role assignment, including built-in roles, send an empty list: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +pulse_api PUT /api/admin/users/jane/roles <<'JSON' {"roleIds": []} JSON ``` @@ -170,8 +173,7 @@ Note: Users cannot modify their own role assignments (self-escalation prevention ### Removing User Access ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --request DELETE http://127.0.0.1:7655/api/admin/users/jane +pulse_api DELETE /api/admin/users/jane ``` This removes the Pulse identity and all role assignments and revokes its active diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index bb37d00963..12ee36475f 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -58,7 +58,7 @@ pulse_api() ( set -eu umask 077 if [ "$#" -ne 2 ]; then - printf 'Usage: pulse_api GET|POST /api/path\n' >&2 + printf 'Usage: pulse_api GET|POST|PUT|DELETE /api/path\n' >&2 exit 2 fi method=$1 @@ -70,7 +70,9 @@ pulse_api() ( case "$method" in GET) set -- ;; POST) set -- --header 'Content-Type: application/json' --request POST --data-binary @- ;; - *) printf 'This example helper accepts only GET and POST.\n' >&2; exit 2 ;; + PUT) set -- --header 'Content-Type: application/json' --request PUT --data-binary @- ;; + DELETE) set -- --request DELETE ;; + *) printf 'This example helper accepts only GET, POST, PUT and DELETE.\n' >&2; exit 2 ;; esac auth_dir="$HOME/.config/pulse" auth_file="$auth_dir/api-header" @@ -118,6 +120,13 @@ No request is retried automatically, especially a POST whose outcome is uncertain: inspect the existing action and target before deciding what to do. A 2xx response is only HTTP success, not proof that an action completed. +The helper also supports the existing [RBAC administration examples](RBAC.md#managing-roles). +POST and PUT read their JSON body from standard input; GET and DELETE send no +body. A method being supported is not permission to change a resource. Choose +each administrative change separately and check its current state afterwards. +A timeout or incomplete response can occur **after a change was applied**; +do not repeat a POST, PUT or DELETE merely because the response was lost. + Keep `--disable` first: it ignores local curl configuration that could enable trace output, bypass TLS or follow redirects. On an authentication-enabled instance, the protected summary checks token access; `/api/health` is public diff --git a/frontend-modern/public/docs/RBAC.md b/frontend-modern/public/docs/RBAC.md index ca9b5d1ccd..0d41868368 100644 --- a/frontend-modern/public/docs/RBAC.md +++ b/frontend-modern/public/docs/RBAC.md @@ -55,15 +55,29 @@ full-access (`*`) API token bound to an authorised administrator; a monitoring token is not enough. Do not widen an agent's token for this job. If you already use an administration token, prepare its private header file -as described in [API authentication](API.md#-authentication). The examples read -`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into -a command. Keep this file outside shared repositories and diagnostics. +and define `pulse_api` **in the same Bash session** using +[API authentication](API.md#-authentication). The helper is local example code, +not an installed Pulse command. Never paste the token or a session cookie into +a command, URL or report, or use browser **Copy as cURL**. -The loopback URLs work on the Pulse host. For remote use, substitute your -Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or -later; `--disable` must remain first to ignore local trace/verbose defaults, -and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each -change separately and check its response before continuing. +On the Pulse host, the helper uses loopback; for remote use, follow that guide's +HTTPS-origin instructions and keep certificate verification enabled. It makes +one request with a five-second connection limit and a twenty-second total limit, +prints only the HTTP status and saves each response in a new owner-only file. +Inspect the file privately, including on failure: role lists, user identities +and errors can contain private information. Share only a relevant redacted +excerpt, not full responses, header files or tokens. HTTP 401/402/403, redirects +and transport errors are not an empty role or user list. + +**These are separate operations, not a script to run from top to bottom.** +Choose the intended target and change before making a request; preserve an +existing administrator recovery path. Deleting a role or user and sending an +empty role list remove access. After a change, check that target's saved roles +and intended access through the normal UI or a separate read-only request. +The helper does not follow redirects or retry. A timeout or partial response +can occur **after the change was applied**; inspect the current state before +deciding what to do, rather than repeating the mutation blindly. HTTP success +alone does not establish the intended access or disable an upstream IdP account. ### Creating a Role @@ -74,9 +88,7 @@ Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`, **API:** ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request POST \ - --data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON' +pulse_api POST /api/admin/roles <<'JSON' { "id": "alert-manager", "name": "Alert Manager", @@ -93,16 +105,13 @@ JSON ### Listing Roles ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/admin/roles +pulse_api GET /api/admin/roles ``` ### Updating a Role ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON' +pulse_api PUT /api/admin/roles/alert-manager <<'JSON' { "name": "Alert Manager", "description": "Updated description", @@ -119,8 +128,7 @@ JSON ### Deleting a Role ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager +pulse_api DELETE /api/admin/roles/alert-manager ``` --- @@ -130,8 +138,7 @@ curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ ### Listing Users and Their Roles ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - http://127.0.0.1:7655/api/admin/users +pulse_api GET /api/admin/users ``` SSO users are displayed using the latest configured username claim and email @@ -148,9 +155,7 @@ SSO principal). `jane` below is an example local username. Create the custom role before assigning it; do not run the deletion example first. ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +pulse_api PUT /api/admin/users/jane/roles <<'JSON' {"roleIds": ["alert-manager", "viewer"]} JSON ``` @@ -158,9 +163,7 @@ JSON To clear a user's entire role assignment, including built-in roles, send an empty list: ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' --request PUT \ - --data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON' +pulse_api PUT /api/admin/users/jane/roles <<'JSON' {"roleIds": []} JSON ``` @@ -170,8 +173,7 @@ Note: Users cannot modify their own role assignments (self-escalation prevention ### Removing User Access ```bash -curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ - --request DELETE http://127.0.0.1:7655/api/admin/users/jane +pulse_api DELETE /api/admin/users/jane ``` This removes the Pulse identity and all role assignments and revokes its active diff --git a/scripts/tests/test_admin_docs.py b/scripts/tests/test_admin_docs.py index 530fb1780d..268aef8201 100644 --- a/scripts/tests/test_admin_docs.py +++ b/scripts/tests/test_admin_docs.py @@ -69,7 +69,7 @@ def recipes(name: str) -> list[str]: def executable_recipe(name: str, step: str) -> str: # Use the actual shared helper, not a test-only safe client. Legacy curl # snippets remain executable so the parent control can expose their leak. - if name == "AUDIT_LOGGING" and "pulse_api " in step: + if "pulse_api " in step: return request_helper() + "\n" + step return step @@ -83,13 +83,13 @@ class AdminDocsTest(unittest.TestCase): for step in steps: self.assertNotRegex(step, r"(?:\b\w*TOKEN=|Authorization:|X-API-Token:|Bearer\s|--cookie\b)") self.assertNotRegex(step, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") - if name == "AUDIT_LOGGING": - self.assertIn("pulse_api GET ", step) + if name in ("AUDIT_LOGGING", "RBAC"): + self.assertIn("pulse_api ", step) self.assertNotIn("curl ", step) else: self.assertIn('curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header"', step) - if re.search(r"--request (POST|PUT)", step): - self.assertIn("--data-binary @-", step) + if re.search(r"(?:--request|pulse_api) (POST|PUT)", step): + self.assertIn("--data-binary @-", request_helper() if "pulse_api " in step else step) self.assertIn("<<'JSON'", step) def test_guides_explain_authority_and_sensitive_output(self): @@ -154,7 +154,7 @@ class AdminDocsTest(unittest.TestCase): self.assertNotIn(b"Saved private export", result.stdout) # curl preserves an error body, in stdout or the requested file. exports = list(Path(temporary).glob("*/audit-export.json")) - if name == "AUDIT_LOGGING": + if name in ("AUDIT_LOGGING", "RBAC"): responses = list((Path(temporary) / ".config/pulse").glob("api-response.*")) self.assertEqual(len(responses), 1) output = responses[0].read_bytes() @@ -205,6 +205,56 @@ class AdminDocsTest(unittest.TestCase): self.assertEqual(result.returncode, expected_exit, result.stderr.decode()) self.assertEqual(len(requests), before + 1, "must not follow redirects or retry partial reads") + def test_rbac_guidance_preserves_private_and_uncertain_change_boundaries(self): + guide = (DOCS / "RBAC.md").read_text() + for boundary in ("in the same Bash session", "not an installed Pulse command", + "owner-only file", "redacted", "five-second", "twenty-second", + "not a script to run from top to bottom", "administrator recovery path", + "empty role list remove access", "does not follow redirects or retry", + "after the change was applied", "inspect the current state", + "HTTP success", "upstream IdP account"): + self.assertIn(boundary, guide) + + def test_rbac_operations_keep_private_success_bodies_in_new_owner_only_files(self): + steps = recipes("RBAC") + self.assertEqual(len(steps), len(EXPECTED["RBAC"])) + with private_recording_server() as (port, requests), tempfile.TemporaryDirectory(prefix="rbac docs ") as temporary: + home = Path(temporary) + for header in (f"X-API-Token: {TEST_TOKEN}", f"Authorization: Bearer {TEST_TOKEN}"): + for step, (method, path, _) in zip(steps, EXPECTED["RBAC"]): + with self.subTest(method=method, path=path, header=header.split(":")[0]): + before = len(requests) + result = exercise_curl(self, home, header, port, + executable_recipe("RBAC", step), private_response=True) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(len(requests), before + 1) + self.assertEqual((requests[-1][2], requests[-1][0]), (method, path)) + self.assertIn(b"HTTP 200", result.stdout) + self.assertEqual(len(list((home / ".config/pulse").glob("api-response.*"))), 17) + + def test_rbac_operations_keep_private_http_errors_and_fail_without_retry(self): + for status in (400, 401, 402, 403, 500, 503): + with private_recording_server(status) as (port, requests): + for step in recipes("RBAC"): + with self.subTest(status=status, step=step), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, + executable_recipe("RBAC", step), private_response=True) + self.assertEqual(result.returncode, 22, result.stderr.decode()) + self.assertEqual(len(requests), before + 1) + self.assertIn(f"HTTP {status}".encode(), result.stdout) + + def test_rbac_operations_refuse_redirect_success_and_uncertain_transfer_retry(self): + for status, partial, expected_exit in ((302, False, 1), (200, True, 18)): + with private_recording_server(status, partial) as (port, requests): + for step in recipes("RBAC"): + with self.subTest(status=status, partial=partial, step=step), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, + executable_recipe("RBAC", step), private_response=True) + self.assertEqual(result.returncode, expected_exit, result.stderr.decode()) + self.assertEqual(len(requests), before + 1, "an uncertain mutation must not be repeated or redirected") + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py index 5ebda96876..93ace3d22f 100644 --- a/scripts/tests/test_api_auth_docs.py +++ b/scripts/tests/test_api_auth_docs.py @@ -366,6 +366,39 @@ class APIAuthDocsTest(unittest.TestCase): self.assertEqual(result.returncode, exitcode, result.stderr.decode()) self.assertEqual(len(requests), 1, "uncertain POST must not be repeated or redirected") + def test_shared_helper_preserves_method_and_body_for_existing_admin_operations(self): + with private_recording_server() as (port, requests), tempfile.TemporaryDirectory() as temporary: + for method in ("GET", "POST", "PUT", "DELETE"): + with self.subTest(method=method): + call = f"pulse_api {method} /api/admin/roles <<'JSON'\n{{\"fixture\":true}}\nJSON\n" + result = self.run_documented_request(Path(temporary), "X-API-Token: " + TEST_TOKEN, + port, request_helper() + "\n" + call) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + path, headers, sent_method, body = requests[-1] + self.assertEqual((sent_method, path), (method, "/api/admin/roles")) + if method in ("POST", "PUT"): + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(json.loads(body), {"fixture": True}) + else: + self.assertEqual(body, b"") + self.assertNotIn("Content-Type", headers) + self.assertEqual(len(requests), 4) + + def test_shared_helper_refuses_unsupported_methods_urls_and_missing_auth_before_transport(self): + with recording_server() as (port, requests), tempfile.TemporaryDirectory() as temporary: + env = dict(os.environ, HOME=temporary) + for call, expected_exit in (("pulse_api PATCH /api/admin/roles", 2), + ("pulse_api GET https://example.invalid/api/admin/roles", 2), + ("pulse_api DELETE", 2), + ("pulse_api DELETE /api/admin/roles/unused", 1)): + with self.subTest(call=call): + command = (request_helper() + "\n" + call).replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + result = subprocess.run(["bash", "-eu", "-c", command], env=env, + capture_output=True, timeout=10) + self.assertEqual(result.returncode, expected_exit, result.stderr.decode()) + self.assertEqual(requests, []) + self.assertFalse((Path(temporary) / ".config").exists()) + def test_private_response_assertion_rejects_console_output_regression(self): request = commands()[1] self.assertEqual(request.count('--output "$result_file" '), 1)