From a4d123eaa51cc87fe4e5c3dec5dfe53ae5645968 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 12:54:19 -0400 Subject: [PATCH 1/3] docs(api): state what the API does today for logout, anonymous reads, tokens, pagination and errors Eight factual corrections to the API guide, each checked against the contract, the code or an annotated test, none changing a product promise (CP bugs/OW-065). - Anonymous reads: the guide named five credential-free operations and two anonymous reads; the handlers answer eight. The three it omitted (permissions:registry, sso/providers/enabled, the SSO redirect pair) are listed with what each returns and what the registry never returns. - Credential scope: scope_id is required for host scope and forbidden for system scope (credential.validate, api-credentials AC-03). The matching description on the contract's create schema lands with the OW-011 contract change, which regenerates code under annotated tests. - Pagination: the five cursor-paginated lists are named once, with the rule that next_cursor becomes the next request's cursor. - Environment overrides: the loader is a fixed allowlist, not a generic form; the guide now says so and links the reference. - Audit export: documented beside the list, with its filters, the 10,000-row cap and the truncation header, and the fact that it drops correlation_id and ignores unknown parameters (bugs/OW-064). - Errors: the three plain-text responses the service generates today are named (bugs/OW-063), and clients are told to expect non-JSON bodies from intermediaries regardless. - Authentication: API tokens are the automation credential and password login is interactive; the example reads the password from a file, uses --fail-with-body so a 401 cannot become TOKEN=null, sets expires_at, and names the revoke route. One sentence on the CSRF header for cookie callers. - Logout: the row says it revokes the cookies it is given and does nothing for a Bearer caller today (bugs/OW-062). Unresolved behavior is described as unresolved and named by request; nothing here presents it as fixed. The detect-secrets baseline is the hook's own line-number refresh. --- .secrets.baseline | 11 +--- docs/guides/API_GUIDE.md | 116 +++++++++++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 33 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index ebd9373f..62e955c3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -168,15 +168,6 @@ "line_number": 290 } ], - "docs/guides/API_GUIDE.md": [ - { - "type": "Secret Keyword", - "filename": "docs/guides/API_GUIDE.md", - "hashed_secret": "b48cf0140bea12734db05ebcdb012f1d265bed84", - "is_verified": false, - "line_number": 76 - } - ], "docs/guides/ENVIRONMENT_REFERENCE.md": [ { "type": "Basic Auth Credentials", @@ -818,5 +809,5 @@ } ] }, - "generated_at": "2026-09-19T22:42:43Z" + "generated_at": "2026-09-21T16:54:05Z" } diff --git a/docs/guides/API_GUIDE.md b/docs/guides/API_GUIDE.md index 51dacddf..edc02e17 100644 --- a/docs/guides/API_GUIDE.md +++ b/docs/guides/API_GUIDE.md @@ -39,6 +39,11 @@ When the OpenAPI document and this guide disagree, the OpenAPI document wins. - An optional `X-Correlation-Id` header is propagated through logs and audit events. If you omit it, the server generates one and returns it in the response. +- Paginated lists (`/api/v1/audit/events`, `/api/v1/scans`, `/api/v1/alerts`, + `/api/v1/intelligence/events`, `/api/v1/activity`) take `limit` and an + opaque `cursor`. Each page carries `next_cursor`; pass its value as the + next request's `cursor`, and stop when it is absent or null. Other lists + take `limit` only, and `GET /api/v1/hosts` returns the whole fleet. --- @@ -47,19 +52,34 @@ When the OpenAPI document and this guide disagree, the OpenAPI document wins. The API accepts two credential types. Both resolve to the same identity and permission set: -- A `Bearer` access token in the `Authorization` header. This is the path for - scripts and CI. +- A `Bearer` value in the `Authorization` header. Two kinds exist. An + **API token** (`owk_` prefix) is the credential for scripts and CI: it is + bound to one role, can carry an expiry, is stored only as a hash, and is + revoked at once by `DELETE /api/v1/tokens/{id}`. An **access token** is the + short-lived JWT that `POST /api/v1/auth/login` returns for interactive use + and first-time setup; it expires 30 minutes after issue. - The browser session cookie (`openwatch_session`), used by the web UI. Cookie rotation and the on-401 refresh flow are UI concerns and are not covered here. + A request that presents the session cookie must also echo the `XSRF-TOKEN` + cookie in an `X-CSRF-Token` header on every mutation or it gets `403`; + Bearer requests are exempt from that check. The contract declares five operations credential-free: `GET /api/v1/health`, `GET /api/v1/version`, `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh` -and `POST /api/v1/auth/refresh-cookie`. Two more answer an anonymous caller by -design: `GET /api/v1/capabilities`, and `GET /api/v1/license`, which returns -only `tier`, `status` and `features` until the caller is authenticated. -Everything else requires a valid identity. An anonymous caller gets `401` -`auth.required`; an authenticated caller without the permission gets `403` -`authz.permission_denied`. +and `POST /api/v1/auth/refresh-cookie`. Three more answer an anonymous caller +by design and return only what a login page or a client needs before it has +an identity: `GET /api/v1/capabilities` (which capabilities this deployment +has, with no license detail), `GET /api/v1/license` (only `tier`, `status` +and `features` until the caller is authenticated), and +`GET /api/v1/sso/providers/enabled` (provider `id` and `name` only), together +with the SSO redirect pair `GET /api/v1/auth/sso/{id}/login` and +`/callback`. `GET /api/v1/auth/permissions:registry` is also anonymous: it +returns the static permission registry (permission ids, descriptions, +categories and the built-in role bundles), which this repository publishes +in [User roles](USER_ROLES.md); it never returns users, role assignments, +custom roles or deployment settings. Everything else requires a valid +identity. An anonymous caller gets `401` `auth.required`; an authenticated +caller without the permission gets `403` `authz.permission_denied`. `GET /api/v1/capabilities` reports every capability this deployment has, with whether it is available here, so a client can present a locked control rather @@ -67,17 +87,41 @@ than discovering the gate from a `402`. It exposes no customer identity or license detail; `GET /api/v1/license` returns those to an authenticated caller and only `tier`, `status` and `features` to an anonymous one. -### Log in +### Create an API token for automation + +Do this once, interactively, with an identity that holds `token:write`. Pick +the narrowest built-in or custom role the job needs and set an expiry; the +secret is returned once and never again. ```bash -TOKEN=$(curl -s --cacert /etc/openwatch/tls/cert.pem \ +curl --fail-with-body -s --cacert /etc/openwatch/tls/cert.pem \ -X POST https://localhost:8443/api/v1/auth/login \ -H "Content-Type: application/json" \ - -d '{"username":"admin","password":"yourpassword"}' | jq -r '.access_token') + -d @login.json | jq -r '.access_token' > /tmp/ow-access +# login.json holds {"username": "...", "password": "..."} and is deleted after use; +# a password on the command line lands in shell history and in `ps`. + +curl --fail-with-body -s --cacert /etc/openwatch/tls/cert.pem \ + -X POST https://localhost:8443/api/v1/tokens \ + -H "Authorization: Bearer $(cat /tmp/ow-access)" \ + -H "Content-Type: application/json" \ + -d '{"name":"ci-scanner","role_id":"viewer","expires_at":"2027-01-01T00:00:00Z"}' \ + | jq -r '.token' > ci-token # store it in your secret manager, not in the repo ``` -The request body is `{username, password}` with an optional `otp` (6 digits) -when the account has TOTP MFA enrolled. The response is: +`--fail-with-body` (curl 7.76 and later) makes a `401` or `403` exit +non-zero with the error envelope printed, instead of writing the string +`null` into your token file and failing later with a misleading `401`. On +older curl, check `$?` and the envelope yourself. `expires_at` is optional +in the contract; set it for every automation credential and rotate it before +it lapses. Revoke a token with `DELETE /api/v1/tokens/{id}` (`token:delete`); +`GET /api/v1/tokens` lists metadata only. + +### Log in with a password + +The password login is for interactive use and for minting the first API +token. The request body is `{username, password}` with an optional `otp` +(6 digits) when the account has TOTP MFA enrolled. The response is: ```json { @@ -87,7 +131,8 @@ when the account has TOTP MFA enrolled. The response is: } ``` -All later examples assume `-H "Authorization: Bearer $TOKEN"`. +All later examples assume `-H "Authorization: Bearer $TOKEN"`, where +`$TOKEN` is an API token or an access token. ### Refresh, identity, and log out @@ -96,7 +141,7 @@ All later examples assume `-H "Authorization: Bearer $TOKEN"`. | `POST` | `/api/v1/auth/refresh` | Rotate the refresh token; returns a new access + refresh pair. Body: `{refresh_token}`. | | `GET` | `/api/v1/auth/me` | Return the calling identity (`id`, `username`, `email`, `role`). | | `GET` | `/api/v1/auth/me/permissions` | Return the caller's effective permission strings. | -| `POST` | `/api/v1/auth/logout` | Revoke the calling session (`204`). | +| `POST` | `/api/v1/auth/logout` | Revoke the session and refresh token presented as cookies (`204`). A caller that presents only a Bearer value has nothing this route revokes today: an access token stays valid until it expires (30 minutes), and a refresh token returned in the login body has no revoke route until CP `bugs/OW-062` is resolved. Revoke an API token with `DELETE /api/v1/tokens/{id}`. | | `POST` | `/api/v1/auth/password:change` | Change the caller's password. Body: `{current_password, new_password}`. | | `POST` | `/api/v1/auth/mfa:enroll` | Begin TOTP enrollment; returns a `provisioning_uri`. | | `POST` | `/api/v1/auth/mfa:verify` | Confirm an enrolled secret. Body: `{otp}`. | @@ -179,8 +224,12 @@ encrypted at rest and never returned in responses. | `POST` | `/api/v1/credentials/{id}:clone` | Clone to a new scope (secret inherited; no plaintext on the wire). | A create body requires `scope`, `name`, `username`, and `auth_method` (one of -`ssh_key`, `password`, `both`). Provide `private_key` (and optional -`private_key_passphrase`) and/or `password` to match the chosen method. +`ssh_key`, `password`, `both`). `scope_id` is the host's UUID and is required +when `scope` is `host`; it must be absent when `scope` is `system`. Either +mismatch returns `400` `credentials.invalid_scope`, and a `scope_id` that +names no active host returns `400` `credentials.host_not_found`. Provide +`private_key` (and optional `private_key_passphrase`) and/or `password` to +match the chosen method. --- @@ -309,9 +358,20 @@ cursor-paginated, newest first. |--------|------|---------| | `GET` | `/api/v1/audit/events` | List audit events. | -Query parameters: `action`, `correlation_id`, `actor_type`, `resource_type`, -`resource_id`, `since`, `until` (both RFC 3339), `cursor`, and `limit` (1–200, -default 50). Follow the `cursor` field in each page to paginate. +| `GET` | `/api/v1/audit/events/export` | Download the filtered trail as CSV (default) or JSON (`format=json`). Requires `audit:export`. | + +List query parameters: `action`, `correlation_id`, `actor_type`, +`resource_type`, `resource_id`, `since`, `until` (both RFC 3339), `cursor`, +and `limit` (1 to 200, default 50). Each page carries `next_cursor`; pass it +as the next request's `cursor`. + +The export takes `action`, `actor_type`, `resource_type`, `resource_id`, +`since` and `until`, returns the whole filtered set newest first, and stops +at 10,000 rows; a capped export carries an `X-OpenWatch-Export-Truncated` +header. Today the export does not accept `correlation_id`, and a query +parameter it does not recognize is ignored rather than rejected, so a +misspelled or unsupported filter widens the export to everything the caller +may see. CP `bugs/OW-064` tracks both. --- @@ -373,6 +433,14 @@ rate-limited per client IP and return `429` with a `Retry-After` header over the limit. There is no `422` validation status: validation failures return `400` with the envelope above. +Three responses the service generates today are plain text rather than the +envelope: `404` for an `/api/` path that does not exist, `405`, and `400` for +a query parameter that is missing or fails to parse. CP `bugs/OW-063` tracks +making them use the envelope. Independently of that, a proxy or load balancer +in front of OpenWatch produces its own `502`, `503` or `504` bodies, so a +client should treat any non-2xx whose body is not JSON as an infrastructure +error rather than fail on the parse. + --- ## Operations: the CLI and systemd @@ -389,9 +457,11 @@ systemctl restart openwatch journalctl -u openwatch -f ``` -Configuration lives in `/etc/openwatch/openwatch.toml`, with environment -overrides of the form `OPENWATCH_
_` and the database DSN in -`/etc/openwatch/secrets.env` (`OPENWATCH_DATABASE_DSN`). For full install and +Configuration lives in `/etc/openwatch/openwatch.toml`, with a fixed set of +environment overrides named `OPENWATCH_
_` (the loader +recognizes only the variables listed in the +[environment reference](ENVIRONMENT_REFERENCE.md) and ignores any other) and +the database DSN in `/etc/openwatch/secrets.env` (`OPENWATCH_DATABASE_DSN`). For full install and configuration steps, see [`docs/guides/INSTALLATION.md`](INSTALLATION.md). From 83dcda82292cc6306460a161cb40e868f50547ec Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 13:15:34 -0400 Subject: [PATCH 2/3] docs(api): name the ninth anonymous read, the caller's own permission introspection GET /api/v1/auth/me/permissions answers an anonymous caller with is_anonymous true and an empty list, found while classifying every operation for OW-011 (#873). It exposes nothing the caller does not already know; the guide's inventory of anonymous reads must still name it. --- docs/guides/API_GUIDE.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/guides/API_GUIDE.md b/docs/guides/API_GUIDE.md index edc02e17..fd3c2f2a 100644 --- a/docs/guides/API_GUIDE.md +++ b/docs/guides/API_GUIDE.md @@ -73,12 +73,14 @@ has, with no license detail), `GET /api/v1/license` (only `tier`, `status` and `features` until the caller is authenticated), and `GET /api/v1/sso/providers/enabled` (provider `id` and `name` only), together with the SSO redirect pair `GET /api/v1/auth/sso/{id}/login` and -`/callback`. `GET /api/v1/auth/permissions:registry` is also anonymous: it -returns the static permission registry (permission ids, descriptions, -categories and the built-in role bundles), which this repository publishes -in [User roles](USER_ROLES.md); it never returns users, role assignments, -custom roles or deployment settings. Everything else requires a valid -identity. An anonymous caller gets `401` `auth.required`; an authenticated +`/callback`. Two introspection reads are anonymous as well. +`GET /api/v1/auth/permissions:registry` returns the static permission +registry (permission ids, descriptions, categories and the built-in role +bundles), which this repository publishes in [User roles](USER_ROLES.md); +it never returns users, role assignments, custom roles or deployment +settings. `GET /api/v1/auth/me/permissions` tells a caller what it is: an +anonymous caller gets `is_anonymous: true` and an empty permission list. +Everything else requires a valid identity. An anonymous caller gets `401` `auth.required`; an authenticated caller without the permission gets `403` `authz.permission_denied`. `GET /api/v1/capabilities` reports every capability this deployment has, with From 27cf96a8a55b217a1d5506c967a47f8385927119 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 20:17:07 -0400 Subject: [PATCH 3/3] docs(api): keep the export row inside the audit endpoints table A blank line between the two rows split the table, so the export row would have rendered as loose text. --- docs/guides/API_GUIDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guides/API_GUIDE.md b/docs/guides/API_GUIDE.md index fae267a0..1703cef5 100644 --- a/docs/guides/API_GUIDE.md +++ b/docs/guides/API_GUIDE.md @@ -359,7 +359,6 @@ cursor-paginated, newest first. | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/v1/audit/events` | List audit events. | - | `GET` | `/api/v1/audit/events/export` | Download the filtered trail as CSV (default) or JSON (`format=json`). Requires `audit:export`. | List query parameters: `action`, `correlation_id`, `actor_type`,