Make administration guides safe and executable

Keep RBAC, audit and organisation tokens out of process arguments. Use signed-in organisation mutations, actual schemas and acceptance rules, a custom role ID, and private audit exports. Exercise copied commands and handler lifecycles without changing runtime authority.

Contract-Neutral: Documentation and documentation tests only; authentication, RBAC, tenant isolation, licensing and API runtime contracts are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 08:23:23 +01:00
parent 704f4eeb4e
commit 0e06f2b38b
11 changed files with 731 additions and 214 deletions

View file

@ -1204,7 +1204,7 @@ Returns all members with their roles. User must be a member of the org.
```json
{ "userId": "jane", "role": "editor" }
```
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
### Remove Member
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
@ -1229,7 +1229,7 @@ Returns resources shared inbound to this organization from other organizations.
"accessRole": "viewer"
}
```
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg` (not `host`). Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
### Delete Share
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)

View file

@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
### API
Use a token with `audit:read`, bound to a user permitted to read audit logs,
and the licensed `audit_logging` capability. Prepare the private header file
in [API authentication](API.md#-authentication); never paste the token or a
session cookie into a command. For a one-off read, you can instead open the
API path in your signed-in Pulse browser.
The loopback URLs below apply on the Pulse host. For remote access, use your
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
keeping `--disable` first to ignore local trace/verbose defaults.
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
402 and 403. Share only the relevant redacted error, not the header file or
whole audit response: events can contain usernames, client addresses and paths.
```bash
# List recent events
curl http://localhost:7655/api/audit?limit=50 \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?limit=50'
# Filter by event type and date range
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
# Get audit summary
curl http://localhost:7655/api/audit/summary \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/summary
```
### Query Parameters
@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
Export the audit log for external analysis or compliance archival:
```bash
curl http://localhost:7655/api/audit/export \
-H "Authorization: Bearer $TOKEN" \
-o audit-export.json
umask 077
export_dir="$(mktemp -d)" &&
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--output "$export_dir/audit-export.json" \
http://127.0.0.1:7655/api/audit/export &&
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
```
The export includes all events matching the current filter criteria.
This creates a new private directory and prints the file's location only on
success. It does not reuse filters selected in the UI. Treat the export as
sensitive data; keep it outside shared repositories and issue attachments.
---
@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
```bash
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
```
Response:

View file

@ -51,9 +51,30 @@ Each member has a role within an organization:
Organizations can share specific resources with other organizations:
- Share a VM, container, host, or storage resource with another org.
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
- The receiving org sees shared resources alongside their own, with a share badge.
- The receiving org must accept the share before it sees the resource alongside its own.
## Before Using the API
Create organizations and manage members and shares in the signed-in Pulse UI.
These changes require **session-based user authentication** and the relevant
organization role; API tokens are rejected with `403 session_required`, even
when they have `settings:write`. The UI handles the session and CSRF protection.
Do not extract a session cookie or CSRF token into a shell command.
The read-only curl examples use an org-bound token with `settings:read` and
the private header file from [API authentication](API.md#-authentication).
Keep the token out of command lines, URLs and reports. A token's organization
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
not grant it access to another organization.
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
keep certificate verification enabled. Replace the example organization ID
`production-datacenter` with your own. Run requests separately, and share only
the relevant redacted error, not whole member or infrastructure responses.
## Managing Organizations
@ -61,14 +82,15 @@ Organizations can share specific resources with other organizations:
**UI:** Settings → Organization → Create Organization
**API:**
```bash
curl -X POST http://localhost:7655/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
**API contract:** `POST /api/orgs`, session authentication only:
```json
{"id": "production-datacenter", "displayName": "Production Datacenter"}
```
The creator becomes the owner. `id` is a lowercase alphanumeric/hyphen ID
(3–64 characters); `displayName` is the name shown in Pulse. `name` and
`description` are not the creation fields.
### Switching Organizations
Use the **Org Switcher** dropdown in the header. When you switch:
@ -81,45 +103,57 @@ Use the **Org Switcher** dropdown in the header. When you switch:
**UI:** Settings → Organization → Access
**API:**
**Read-only API:**
```bash
# List members
curl http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN"
# Add a member
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id", "role": "editor"}'
# Update role
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/members
```
**Invite or update a member:** use the Access panel as an owner or admin.
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
```json
{"userId": "user-id", "role": "editor"}
```
For a new member, the response is `202` with a pending invitation; the user
must accept it in Pulse before gaining access. An existing member's role is
updated by posting their `userId` and new role to the same endpoint, not by
PATCHing a member URL:
```json
{"userId": "user-id", "role": "admin"}
```
Only the current owner can transfer ownership, and only to an existing member
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
member update. Default-organization members cannot be managed here.
### Sharing Resources
**UI:** Settings → Organization → Sharing
**API:**
```bash
# Create a share
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetOrgId": "other-org-id",
"resourceType": "host",
"resourceId": "resource-id",
"role": "viewer"
}'
Create the share as an owner or admin of the source organization. An owner or
admin of the target organization then accepts it in **Sharing → Incoming**.
Until acceptance, the share is pending and does not grant access.
# View incoming shares
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
```json
{
"targetOrgId": "other-org-id",
"resourceType": "vm",
"resourceId": "vm:101",
"accessRole": "viewer"
}
```
Use `accessRole`, not `role`. Supported resource types are `vm`, `container`,
`agent`, `storage`, `pbs` and `pmg`; `host` is not supported. Use the resource ID
returned by Pulse, not a display name. Changing a share's access role makes it
pending again, so the target must accept the new grant.
**Read-only incoming shares:**
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
```
## Monitoring Multiple Internal Estates
@ -158,15 +192,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
| `GET` | `/api/orgs` | List organizations the current user can access |
| `POST` | `/api/orgs` | Create a new organization |
| `GET` | `/api/orgs/{id}` | Get organization details |
| `PATCH` | `/api/orgs/{id}` | Update organization |
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
| `DELETE` | `/api/orgs/{id}` | Delete organization |
| `GET` | `/api/orgs/{id}/members` | List members |
| `POST` | `/api/orgs/{id}/members` | Add a member |
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
| `POST` | `/api/orgs/{id}/shares` | Create a share |
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
### Tenant Context
@ -177,6 +212,9 @@ All data-fetching endpoints respect the active organization context. The active
2. Session cookie (browser)
3. Falls back to the `default` organization
Neither the active context nor a token scope overrides organization membership
or a token's organization binding.
## Storage
- The **default** org uses the root data directory (backward compatible).
@ -202,7 +240,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
### Shared resources not appearing
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
2. Confirm the share role grants sufficient access.
3. Check that the source org's resources are online.

View file

@ -46,42 +46,65 @@ user merely because no local administrator is configured.
## Managing Roles
### Before Using the API
Prefer **Settings → Security → Access Control** for one-off administration.
The API examples require the licensed `rbac` capability and permission to
administer users. With RBAC enabled, role and user administration requires a
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.
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.
### Creating a Role
**UI:** Settings → Security → Access Control → Create Role
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
**API:**
```bash
curl -X POST http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "operator",
"name": "Operator",
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'
{
"id": "alert-manager",
"name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}
]
}'
}
JSON
```
### Listing Roles
```bash
curl http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/roles
```
### Updating a Role
```bash
curl -X PUT http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Operator",
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'
{
"name": "Alert Manager",
"description": "Updated description",
"permissions": [
{"action": "read", "resource": "alerts"},
@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}
]
}'
}
JSON
```
### Deleting a Role
```bash
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
```
---
@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
### Listing Users and Their Roles
```bash
curl http://localhost:7655/api/admin/users \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/users
```
SSO users are displayed using the latest configured username claim and email
@ -118,20 +142,27 @@ principal used for authorization.
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
Use the stable `username` returned by the user list, not the display name or
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
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 -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": ["operator", "viewer"]}'
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'
{"roleIds": ["alert-manager", "viewer"]}
JSON
```
To remove all custom roles from a user, send an empty list:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": []}'
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'
{"roleIds": []}
JSON
```
Note: Users cannot modify their own role assignments (self-escalation prevention).
@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
### Removing User Access
```bash
curl -X DELETE http://localhost:7655/api/admin/users/jane \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
```
This removes the Pulse identity and all role assignments and revokes its active

View file

@ -1204,7 +1204,7 @@ Returns all members with their roles. User must be a member of the org.
```json
{ "userId": "jane", "role": "editor" }
```
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
### Remove Member
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
@ -1229,7 +1229,7 @@ Returns resources shared inbound to this organization from other organizations.
"accessRole": "viewer"
}
```
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg` (not `host`). Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
### Delete Share
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)

View file

@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
### API
Use a token with `audit:read`, bound to a user permitted to read audit logs,
and the licensed `audit_logging` capability. Prepare the private header file
in [API authentication](API.md#-authentication); never paste the token or a
session cookie into a command. For a one-off read, you can instead open the
API path in your signed-in Pulse browser.
The loopback URLs below apply on the Pulse host. For remote access, use your
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
keeping `--disable` first to ignore local trace/verbose defaults.
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
402 and 403. Share only the relevant redacted error, not the header file or
whole audit response: events can contain usernames, client addresses and paths.
```bash
# List recent events
curl http://localhost:7655/api/audit?limit=50 \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?limit=50'
# Filter by event type and date range
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
# Get audit summary
curl http://localhost:7655/api/audit/summary \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/summary
```
### Query Parameters
@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
Export the audit log for external analysis or compliance archival:
```bash
curl http://localhost:7655/api/audit/export \
-H "Authorization: Bearer $TOKEN" \
-o audit-export.json
umask 077
export_dir="$(mktemp -d)" &&
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--output "$export_dir/audit-export.json" \
http://127.0.0.1:7655/api/audit/export &&
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
```
The export includes all events matching the current filter criteria.
This creates a new private directory and prints the file's location only on
success. It does not reuse filters selected in the UI. Treat the export as
sensitive data; keep it outside shared repositories and issue attachments.
---
@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
```bash
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
```
Response:

View file

@ -51,9 +51,30 @@ Each member has a role within an organization:
Organizations can share specific resources with other organizations:
- Share a VM, container, host, or storage resource with another org.
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
- The receiving org sees shared resources alongside their own, with a share badge.
- The receiving org must accept the share before it sees the resource alongside its own.
## Before Using the API
Create organizations and manage members and shares in the signed-in Pulse UI.
These changes require **session-based user authentication** and the relevant
organization role; API tokens are rejected with `403 session_required`, even
when they have `settings:write`. The UI handles the session and CSRF protection.
Do not extract a session cookie or CSRF token into a shell command.
The read-only curl examples use an org-bound token with `settings:read` and
the private header file from [API authentication](API.md#-authentication).
Keep the token out of command lines, URLs and reports. A token's organization
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
not grant it access to another organization.
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
keep certificate verification enabled. Replace the example organization ID
`production-datacenter` with your own. Run requests separately, and share only
the relevant redacted error, not whole member or infrastructure responses.
## Managing Organizations
@ -61,14 +82,15 @@ Organizations can share specific resources with other organizations:
**UI:** Settings → Organization → Create Organization
**API:**
```bash
curl -X POST http://localhost:7655/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
**API contract:** `POST /api/orgs`, session authentication only:
```json
{"id": "production-datacenter", "displayName": "Production Datacenter"}
```
The creator becomes the owner. `id` is a lowercase alphanumeric/hyphen ID
(3–64 characters); `displayName` is the name shown in Pulse. `name` and
`description` are not the creation fields.
### Switching Organizations
Use the **Org Switcher** dropdown in the header. When you switch:
@ -81,45 +103,57 @@ Use the **Org Switcher** dropdown in the header. When you switch:
**UI:** Settings → Organization → Access
**API:**
**Read-only API:**
```bash
# List members
curl http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN"
# Add a member
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id", "role": "editor"}'
# Update role
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/members
```
**Invite or update a member:** use the Access panel as an owner or admin.
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
```json
{"userId": "user-id", "role": "editor"}
```
For a new member, the response is `202` with a pending invitation; the user
must accept it in Pulse before gaining access. An existing member's role is
updated by posting their `userId` and new role to the same endpoint, not by
PATCHing a member URL:
```json
{"userId": "user-id", "role": "admin"}
```
Only the current owner can transfer ownership, and only to an existing member
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
member update. Default-organization members cannot be managed here.
### Sharing Resources
**UI:** Settings → Organization → Sharing
**API:**
```bash
# Create a share
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetOrgId": "other-org-id",
"resourceType": "host",
"resourceId": "resource-id",
"role": "viewer"
}'
Create the share as an owner or admin of the source organization. An owner or
admin of the target organization then accepts it in **Sharing → Incoming**.
Until acceptance, the share is pending and does not grant access.
# View incoming shares
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
```json
{
"targetOrgId": "other-org-id",
"resourceType": "vm",
"resourceId": "vm:101",
"accessRole": "viewer"
}
```
Use `accessRole`, not `role`. Supported resource types are `vm`, `container`,
`agent`, `storage`, `pbs` and `pmg`; `host` is not supported. Use the resource ID
returned by Pulse, not a display name. Changing a share's access role makes it
pending again, so the target must accept the new grant.
**Read-only incoming shares:**
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
```
## Monitoring Multiple Internal Estates
@ -158,15 +192,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
| `GET` | `/api/orgs` | List organizations the current user can access |
| `POST` | `/api/orgs` | Create a new organization |
| `GET` | `/api/orgs/{id}` | Get organization details |
| `PATCH` | `/api/orgs/{id}` | Update organization |
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
| `DELETE` | `/api/orgs/{id}` | Delete organization |
| `GET` | `/api/orgs/{id}/members` | List members |
| `POST` | `/api/orgs/{id}/members` | Add a member |
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
| `POST` | `/api/orgs/{id}/shares` | Create a share |
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
### Tenant Context
@ -177,6 +212,9 @@ All data-fetching endpoints respect the active organization context. The active
2. Session cookie (browser)
3. Falls back to the `default` organization
Neither the active context nor a token scope overrides organization membership
or a token's organization binding.
## Storage
- The **default** org uses the root data directory (backward compatible).
@ -202,7 +240,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
### Shared resources not appearing
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
2. Confirm the share role grants sufficient access.
3. Check that the source org's resources are online.

View file

@ -46,42 +46,65 @@ user merely because no local administrator is configured.
## Managing Roles
### Before Using the API
Prefer **Settings → Security → Access Control** for one-off administration.
The API examples require the licensed `rbac` capability and permission to
administer users. With RBAC enabled, role and user administration requires a
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.
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.
### Creating a Role
**UI:** Settings → Security → Access Control → Create Role
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
**API:**
```bash
curl -X POST http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "operator",
"name": "Operator",
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'
{
"id": "alert-manager",
"name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}
]
}'
}
JSON
```
### Listing Roles
```bash
curl http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/roles
```
### Updating a Role
```bash
curl -X PUT http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Operator",
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'
{
"name": "Alert Manager",
"description": "Updated description",
"permissions": [
{"action": "read", "resource": "alerts"},
@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}
]
}'
}
JSON
```
### Deleting a Role
```bash
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
```
---
@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
### Listing Users and Their Roles
```bash
curl http://localhost:7655/api/admin/users \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/users
```
SSO users are displayed using the latest configured username claim and email
@ -118,20 +142,27 @@ principal used for authorization.
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
Use the stable `username` returned by the user list, not the display name or
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
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 -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": ["operator", "viewer"]}'
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'
{"roleIds": ["alert-manager", "viewer"]}
JSON
```
To remove all custom roles from a user, send an empty list:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": []}'
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'
{"roleIds": []}
JSON
```
Note: Users cannot modify their own role assignments (self-escalation prevention).
@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
### Removing User Access
```bash
curl -X DELETE http://localhost:7655/api/admin/users/jane \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
```
This removes the Pulse identity and all role assignments and revokes its active

View file

@ -0,0 +1,175 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// Read what the guide actually sends, rather than maintaining a second client.
// Authentication, licensing and CSRF are tested by the existing route tests;
// these documentation tests exercise the real handlers and persistent values.
func adminDocBodies(t *testing.T, name, heading string) [][]byte {
t.Helper()
contents, err := os.ReadFile(filepath.Join("..", "..", "docs", name+".md"))
if err != nil {
t.Fatal(err)
}
_, section, found := strings.Cut(string(contents), "### "+heading+"\n")
if !found {
t.Fatalf("missing %s heading %q", name, heading)
}
if next := strings.Index(section, "\n##"); next >= 0 {
section = section[:next]
}
// Both shell heredocs and API schema blocks contain standalone JSON.
re := regexp.MustCompile("(?s)(?:<<'JSON'\\n|```json\\n)(.*?)(?:\\nJSON|\\n```)")
var bodies [][]byte
for _, match := range re.FindAllStringSubmatch(section, -1) {
if !json.Valid([]byte(match[1])) {
t.Fatalf("invalid JSON in %s/%s", name, heading)
}
bodies = append(bodies, []byte(match[1]))
}
if len(bodies) == 0 {
t.Fatalf("missing request JSON in %s/%s", name, heading)
}
return bodies
}
func TestAdminDocsCustomRoleLifecycle(t *testing.T) {
manager, err := auth.NewFileManager(t.TempDir())
if err != nil {
t.Fatal(err)
}
previous := auth.GetManager()
auth.SetManager(manager)
t.Cleanup(func() { auth.SetManager(previous) })
h := NewRBACHandlers(&config.Config{AuthUser: "alice"})
// Exact-base guide control: creating/updating the built-in operator fails.
legacy := httptest.NewRecorder()
h.HandleRoles(legacy, withUser(httptest.NewRequest(http.MethodPost, "/api/admin/roles",
strings.NewReader(`{"id":"operator","name":"Operator"}`)), "alice"))
if legacy.Code != http.StatusInternalServerError {
t.Fatalf("legacy built-in mutation was not rejected: %d", legacy.Code)
}
assignments := adminDocBodies(t, "RBAC", "Setting Roles for a User")
if len(assignments) != 2 {
t.Fatalf("want assign and clear examples, got %d", len(assignments))
}
steps := []struct {
method, path string
body []byte
handler http.HandlerFunc
status int
}{
{http.MethodPost, "/api/admin/roles", adminDocBodies(t, "RBAC", "Creating a Role")[0], h.HandleRoles, http.StatusOK},
{http.MethodPut, "/api/admin/roles/alert-manager", adminDocBodies(t, "RBAC", "Updating a Role")[0], h.HandleRoles, http.StatusOK},
{http.MethodPut, "/api/admin/users/jane/roles", assignments[0], h.HandleUserRoleActions, http.StatusOK},
{http.MethodPut, "/api/admin/users/jane/roles", assignments[1], h.HandleUserRoleActions, http.StatusOK},
{http.MethodDelete, "/api/admin/users/jane", nil, h.HandleUserRoleActions, http.StatusNoContent},
{http.MethodDelete, "/api/admin/roles/alert-manager", nil, h.HandleRoles, http.StatusNoContent},
}
for _, step := range steps {
rec := httptest.NewRecorder()
step.handler(rec, withUser(httptest.NewRequest(step.method, step.path, bytes.NewReader(step.body)), "alice"))
if rec.Code != step.status {
t.Fatalf("documented %s %s: got %d: %s", step.method, step.path, rec.Code, rec.Body.String())
}
if step.method == http.MethodPut && step.path == "/api/admin/roles/alert-manager" {
role, exists := manager.GetRole("alert-manager")
if !exists || role.IsBuiltIn || len(role.Permissions) != 4 {
t.Fatalf("custom role update did not persist its permissions: %+v", role)
}
}
if step.method == http.MethodPut && strings.HasSuffix(step.path, "/jane/roles") {
assignment, exists := manager.GetUserAssignment("jane")
var want struct {
RoleIDs []string `json:"roleIds"`
}
if err := json.Unmarshal(step.body, &want); err != nil {
t.Fatal(err)
}
if !exists || strings.Join(assignment.RoleIDs, ",") != strings.Join(want.RoleIDs, ",") {
t.Fatalf("assignment did not persist the complete role list: %+v", assignment)
}
}
}
if _, exists := manager.GetRole("alert-manager"); exists {
t.Fatal("custom role deletion did not persist")
}
if role, exists := manager.GetRole("operator"); !exists || !role.IsBuiltIn {
t.Fatal("examples altered the built-in role")
}
}
func TestAdminDocsOrganizationSchemasAndAcceptance(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
wasEnabled := IsMultiTenantEnabled()
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(wasEnabled) })
persistence := config.NewMultiTenantPersistence(t.TempDir())
h := NewOrgHandlers(persistence, nil)
call := func(method, path, user, orgID string, body []byte, handler http.HandlerFunc) *httptest.ResponseRecorder {
req := withUser(httptest.NewRequest(method, path, bytes.NewReader(body)), user)
req.SetPathValue("id", orgID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
check := func(rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Fatalf("documented request: got %d, want %d: %s", rec.Code, status, rec.Body.String())
}
}
// Exact-base guide controls: wrong creation fields and member PATCH fail.
check(call(http.MethodPost, "/api/orgs", "alice", "", []byte(`{"name":"Production Datacenter","description":"EU production infrastructure"}`), h.HandleCreateOrg), http.StatusBadRequest)
check(call(http.MethodPatch, "/api/orgs/production-datacenter/members/user-id", "alice", "production-datacenter", []byte(`{"role":"admin"}`), h.HandleInviteMember), http.StatusMethodNotAllowed)
creation := adminDocBodies(t, "MULTI_TENANT", "Creating an Organization")[0]
check(call(http.MethodPost, "/api/orgs", "alice", "", creation, h.HandleCreateOrg), http.StatusCreated)
check(call(http.MethodPost, "/api/orgs", "bob", "", []byte(`{"id":"other-org-id","displayName":"Other estate"}`), h.HandleCreateOrg), http.StatusCreated)
members := adminDocBodies(t, "MULTI_TENANT", "Managing Members")
if len(members) != 2 {
t.Fatalf("want invitation and existing-member update, got %d", len(members))
}
invitation := call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[0], h.HandleInviteMember)
check(invitation, http.StatusAccepted)
var invited organizationAccessMutationResponse
if err := json.Unmarshal(invitation.Body.Bytes(), &invited); err != nil || invited.Kind != "invitation" || invited.Member != nil {
t.Fatalf("new member was not pending: %s", invitation.Body.String())
}
acceptInvitationForTest(t, h, "production-datacenter", "user-id")
check(call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[1], h.HandleInviteMember), http.StatusOK)
org, err := persistence.LoadOrganization("production-datacenter")
if err != nil || organizationRoleForUser(org, "user-id") != models.OrgRoleAdmin {
t.Fatalf("documented role update did not persist: %v", err)
}
check(call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", []byte(`{"targetOrgId":"other-org-id","resourceType":"host","resourceId":"resource-id","role":"viewer"}`), h.HandleCreateShare), http.StatusBadRequest)
shareBody := adminDocBodies(t, "MULTI_TENANT", "Sharing Resources")[0]
created := call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", shareBody, h.HandleCreateShare)
check(created, http.StatusCreated)
var share models.OrganizationShare
if err := json.Unmarshal(created.Body.Bytes(), &share); err != nil || share.Status != models.OrganizationShareStatusPending || share.AccessRole != models.OrgRoleViewer {
t.Fatalf("documented share grant was not pending/viewer: %s", created.Body.String())
}
accepted := acceptIncomingShareForTest(t, h, "other-org-id", "bob", share.ID)
if accepted.Status != models.OrganizationShareStatusAccepted {
t.Fatal("share acceptance did not persist")
}
}

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Execute the administration guides with synthetic credentials and loopback.
Run with pulse-worker-source-proof; these fixtures do not perform real role,
tenant or audit operations. The Go documentation tests check the actual handlers.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import stat
import tempfile
import unittest
from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server
DOCS = ROOT / "docs"
GUIDES = ("RBAC", "AUDIT_LOGGING", "MULTI_TENANT")
EXPECTED = {
"RBAC": (
("POST", "/api/admin/roles", {
"id": "alert-manager", "name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}],
}),
("GET", "/api/admin/roles", None),
("PUT", "/api/admin/roles/alert-manager", {
"name": "Alert Manager", "description": "Updated description",
"permissions": [{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}],
}),
("DELETE", "/api/admin/roles/alert-manager", None),
("GET", "/api/admin/users", None),
("PUT", "/api/admin/users/jane/roles", {"roleIds": ["alert-manager", "viewer"]}),
("PUT", "/api/admin/users/jane/roles", {"roleIds": []}),
("DELETE", "/api/admin/users/jane", None),
),
"AUDIT_LOGGING": (
("GET", "/api/audit?limit=50", None),
("GET", "/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false", None),
("GET", "/api/audit/summary", None),
("GET", "/api/audit/export", None),
("GET", "/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify", None),
),
"MULTI_TENANT": (
("GET", "/api/orgs/production-datacenter/members", None),
("GET", "/api/orgs/production-datacenter/shares/incoming", None),
),
}
def recipes(name: str) -> list[str]:
requests = []
for block in re.findall(r"```bash\n(.*?)```", (DOCS / f"{name}.md").read_text(), re.DOTALL):
# The audit read examples share a fence but are independent operations.
for step in re.split(r"\n(?=# )", block):
if "curl " in step:
requests.append(step)
return requests
class AdminDocsTest(unittest.TestCase):
def test_shell_recipes_never_expose_credentials_or_override_tls(self):
for name in GUIDES:
with self.subTest(guide=name):
steps = recipes(name)
self.assertEqual(len(steps), len(EXPECTED[name]))
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)")
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)
self.assertIn("<<'JSON'", step)
def test_guides_explain_authority_and_sensitive_output(self):
for name in GUIDES:
guide = (DOCS / f"{name}.md").read_text()
with self.subTest(guide=name):
self.assertIn("API.md#-authentication", guide)
self.assertIn("HTTPS", guide)
self.assertIn("certificate verification", guide)
rbac = (DOCS / "RBAC.md").read_text()
for boundary in ("full-access (`*`)", "bound to an authorised administrator", "cannot be modified or deleted", "self-escalation", "complete list", "URL-encode"):
self.assertIn(boundary, rbac)
audit = (DOCS / "AUDIT_LOGGING.md").read_text()
for boundary in ("audit:read", "audit_logging", "private directory", "redacted error", "does not reuse filters"):
self.assertIn(boundary, audit)
org = (DOCS / "MULTI_TENANT.md").read_text()
for boundary in ("session-based user authentication", "403 session_required", "CSRF", "settings:read", "organization binding", "pending invitation", "accept the share", "accessRole"):
self.assertIn(boundary, org)
self.assertNotRegex(org, r"\| `PATCH` \|")
def test_shipped_copies_are_identical(self):
for name in (*GUIDES, "API"):
with self.subTest(guide=name):
self.assertEqual((DOCS / f"{name}.md").read_bytes(),
(ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes())
def test_all_recipes_send_exact_methods_paths_and_bodies_with_each_header(self):
with recording_server() as (port, requests):
for name, operations in EXPECTED.items():
steps = recipes(name)
self.assertEqual(len(steps), len(operations))
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
for step, (method, path, body) in zip(steps, operations):
with self.subTest(guide=name, method=method, path=path, header=key), tempfile.TemporaryDirectory(prefix="admin docs ") as temporary:
before = len(requests)
result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, step)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(len(requests), before + 1, "a copied step sends exactly one request")
sent_path, headers, sent_method, sent_body = requests[-1]
self.assertEqual((sent_method, sent_path), (method, path))
self.assertEqual(headers[key], value)
self.assertNotIn("X-Curlrc-Injected", headers)
self.assertNotIn("Authorization" if key == "X-API-Token" else "X-API-Token", headers)
if body is None:
self.assertEqual(sent_body, b"")
else:
self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(json.loads(sent_body), body)
def test_every_recipe_surfaces_auth_and_license_errors_without_next_steps(self):
for status in (401, 402, 403):
with recording_server(status) as (port, requests):
for name in GUIDES:
for step in recipes(name):
with self.subTest(status=status, guide=name), tempfile.TemporaryDirectory() as temporary:
before = len(requests)
result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, step)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertEqual(len(requests), before + 1)
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"))
output = exports[0].read_bytes() if exports else result.stdout
self.assertIn(b'{"fixture":true}', output)
def test_audit_export_creates_a_private_new_directory_and_file(self):
step = next(step for step in recipes("AUDIT_LOGGING") if "/api/audit/export" in step)
with recording_server() as (port, _), tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
for _ in range(2):
result = exercise_curl(self, home, f"X-API-Token: {TEST_TOKEN}", port, step)
self.assertEqual(result.returncode, 0, result.stderr.decode())
output = Path(result.stdout.decode().strip().removeprefix("Saved private export to "))
self.assertEqual(output.parent.parent, home)
self.assertEqual(stat.S_IMODE(output.parent.stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
self.assertEqual(json.loads(output.read_bytes()), {"fixture": True})
self.assertEqual(len(list(home.glob("*/audit-export.json"))), 2)
if __name__ == "__main__":
unittest.main()

View file

@ -80,6 +80,9 @@ def recording_server(status: int = 200):
do_GET = record_request
do_POST = record_request
do_PUT = record_request
do_PATCH = record_request
do_DELETE = record_request
def log_message(self, *_args):
pass
@ -95,6 +98,48 @@ def recording_server(status: int = 200):
thread.join(timeout=5)
def exercise_curl(case: unittest.TestCase, home: Path, header_text: str, port: int, request: str):
"""Execute the exact copied recipe; capture argv and hostile curl defaults."""
header = home / ".config/pulse/api-header"
header.parent.mkdir(parents=True, exist_ok=True)
header.write_text(header_text + "\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
case.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"from pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
trace = home / "curl-trace.txt"
# An existing curl configuration must not turn safe argv into a trace
# containing the header file's credential, or inject another header.
(home / ".curlrc").write_text(
f'header = "X-Curlrc-Injected: yes"\nverbose\ntrace-ascii = "{trace}"\n'
)
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
CURL_HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), TMPDIR=str(home),
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
for key in list(env):
if key.lower().endswith("_proxy"):
del env[key]
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
argv = json.loads(receipt.read_text())
case.assertEqual(argv[0], "--disable", "curl defaults must be disabled by the first option")
case.assertNotIn(TEST_TOKEN, " ".join(argv))
case.assertIn("@" + str(header), argv)
case.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
case.assertFalse(trace.exists(), "local curl configuration must not create a credential trace")
return result
class APIAuthDocsTest(unittest.TestCase):
def test_credentials_are_not_documented_as_command_arguments(self):
section = auth_section()
@ -155,46 +200,7 @@ class APIAuthDocsTest(unittest.TestCase):
self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "")
def run_documented_request(self, home: Path, header_text: str, port: int, request=None):
if request is None:
_, request = commands()
header = home / ".config/pulse/api-header"
header.parent.mkdir(parents=True, exist_ok=True)
header.write_text(header_text + "\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
self.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"from pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
trace = home / "curl-trace.txt"
# An existing curl configuration must not turn safe argv into a trace
# containing the header file's credential, or inject another header.
(home / ".curlrc").write_text(
f'header = "X-Curlrc-Injected: yes"\nverbose\ntrace-ascii = "{trace}"\n'
)
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
CURL_HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"),
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
for key in list(env):
if key.lower().endswith("_proxy"):
del env[key]
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
argv = json.loads(receipt.read_text())
self.assertEqual(argv[0], "--disable", "curl defaults must be disabled by the first option")
self.assertNotIn(TEST_TOKEN, " ".join(argv))
self.assertIn("@" + str(header), argv)
self.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
self.assertFalse(trace.exists(), "local curl configuration must not create a credential trace")
return result
return exercise_curl(self, home, header_text, port, request if request is not None else commands()[1])
def test_header_file_sends_each_supported_header_without_exposing_argv(self):
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):