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
This commit is contained in:
pulse-triage[bot]
2026-10-09 16:51:14 +01:00
parent ad583f6d5f
commit 8a4f31b504
6 changed files with 171 additions and 66 deletions
+11 -2
View File
@@ -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
+30 -28
View File
@@ -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
+11 -2
View File
@@ -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
+30 -28
View File
@@ -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
+56 -6
View File
@@ -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()
+33
View File
@@ -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)