diff --git a/.secrets.baseline b/.secrets.baseline index 6d949a9e..9e31806c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -254,14 +254,14 @@ "filename": "docs/runbooks/SECURITY_INCIDENT.md", "hashed_secret": "45065ce1e8e982145327f46a77a380fb89cb529d", "is_verified": false, - "line_number": 328 + "line_number": 331 }, { "type": "Basic Auth Credentials", "filename": "docs/runbooks/SECURITY_INCIDENT.md", "hashed_secret": "45065ce1e8e982145327f46a77a380fb89cb529d", "is_verified": false, - "line_number": 331 + "line_number": 334 } ], "docs/runbooks/examples/report-verification/counterexample/report.meta.json": [ @@ -809,5 +809,5 @@ } ] }, - "generated_at": "2026-09-25T00:33:45Z" + "generated_at": "2026-09-26T01:45:11Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5b4dfb..839d65ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,31 +10,111 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Fixed - -- **Logout failure messages no longer send users to a Settings feature that - does not exist.** When logout could not revoke a session, or could not - confirm that it did, the message told the user to check or revoke their - sessions in Settings. Settings shows only the current session and cannot - list or end others. Each message now says the session may remain valid - until it expires and names the remedy that works: an administrator can - end it by resetting the user's password. +### Upgrade notes + +- **Upgrading signs everyone out.** Migration 0065 revokes every live session + and refresh token. Access tokens issued before it carry no session binding + and are refused afterwards. Every user signs in again. API tokens (`owk_`) + are not affected. Plan the upgrade window for it. +- **API clients that log out with cookies must send the CSRF token.** + `POST /api/v1/auth/logout` now requires the double-submit check whenever a + session or refresh cookie selects what to revoke. A missing or mismatched + token answers 403 `authz.csrf_invalid` and changes nothing. A client that + holds a refresh cookie but no XSRF cookie, for example after a browser + restart, cannot complete that request. +- **An export with an unknown query parameter is refused.** + `GET /api/v1/audit/events/export` answers 400 `request.unknown_parameter` + naming the parameter, where it used to ignore it and export the whole trail. + The list endpoint is unchanged. +- **API tokens with no owner stop working.** List and replace them before + upgrading; the query is under Security below. (#881) ### Security +- **Disabling, deleting, or resetting the password of a user ends every + interactive credential they hold**: sessions, refresh tokens and access + tokens. Before this, a disabled user's refresh token could mint a working + session, and an access token outlived a password reset. (#875, #876) +- **Access tokens are bound to the session that issued them.** Revoking the + session stops them at once, and logout ends the whole login family it + names. A token whose session belongs to a different user is refused. (#876) +- **SSO refuses disabled and deleted accounts**, when it resolves the identity + and again when it issues the session. The sign-in page shows the generic + error; the audit record names the state (`sso_account_disabled`, + `sso_account_deleted`). (#877) +- **Re-enabling an account signs it out.** A disabled-to-enabled change + revokes every interactive credential, so the user signs in fresh. Enabling + an account that is already enabled changes nothing. (#878) - **An API token stops working while the user who created it is disabled or deleted.** Previously a disabled user's `owk_` token kept authenticating with its full role, so disabling a departing administrator did not stop their automation. The token is refused at sign-in, and works again if the user is re-enabled; a token revoked through `DELETE /api/v1/tokens/{id}` stays revoked. The refusal is recorded on `auth.login.failure` as - `api_token_owner_disabled` or `api_token_owner_deleted`. + `api_token_owner_disabled` or `api_token_owner_deleted`. (#881) - **A token with no owner no longer authenticates.** A token whose `created_by` is empty, for example because its creator's user row was removed, is refused and recorded as `api_token_ownerless`. **Before upgrading, list any such tokens and replace them with tokens created by an active user:** `SELECT id, name, prefix FROM api_tokens WHERE created_by - IS NULL AND revoked_at IS NULL;` + IS NULL AND revoked_at IS NULL;` (#881) + +### Changed + +- **When an outcome cannot be confirmed, OpenWatch says so.** A sign-in, + refresh, logout or administrative change whose commit result is unknown + answers 503 `server.error`, not retryable, and claims neither success nor + failure. SSO shows `sso_error=unconfirmed`; reload before signing in again. + An administrative change reports that it may or may not have been applied; + check the account before repeating it. (#879) +- **Credential operations are time-bounded.** A lock wait is limited to 5 + seconds and an operation to 15, plus up to 2 seconds of cleanup. When a + limit is reached before anything changed, the request answers 503 + `server.error`, retryable. A cookie request whose session row stays locked + gets the same answer instead of waiting without limit. A failed session or + role lookup answers 503 rather than signing the user out. (#879) +- **Every error the service generates carries the JSON error envelope.** An + unmatched `/api/` path (404), an unsupported method (405), and a parameter + that fails to parse used to answer in plain text. A parameter message names + the parameter and no longer echoes the rejected value. (#871) +- **The contract declares how every operation is authorized.** Each of the + 159 operations in `api/openapi.yaml` carries exactly one of + `x-required-permission`, `x-requires-identity`, or an anonymous entry with + its reason. No operation was found open. (#873) +- **The audit export accepts `correlation_id`**, the one list filter it + lacked. (#872) +- **Audit vocabulary.** `admin.user.enabled` records `transition` and + `revocation_scope`. `auth.login.failure` declares its full reason set; new + reasons include `sid_absent`, `session_owner_mismatch`, + `session_absolute_expired`, `account_disabled`, `account_deleted`, + `role_lookup_unavailable`, `sso_account_disabled` and + `sso_account_deleted`. The legacy reasons `invalid_credentials`, + `account_locked`, `mfa_failed` and `sso_failed` are no longer recorded. + +### Fixed + +- **The API guide states what the API does today** for logout, anonymous + reads, token scope, pagination, environment overrides, the audit export and + plain-text errors. (#870) +- **Logout failure messages no longer send users to a Settings feature that + does not exist.** When logout could not revoke a session, or could not + confirm that it did, the message told the user to check or revoke their + sessions in Settings. Settings shows only the current session and cannot + list or end others. Each message now says the session may remain valid + until it expires and names the remedy that works: an administrator can + end it by resetting the user's password. (#880) + +### Known limitations + +- **Changing your own password does not sign out your other sessions.** They + stay valid until their absolute limit, 12 hours by default. To end them, ask + an administrator to reset your password. (CP `bugs/OW-072`) +- **Settings shows only the current session.** It cannot list or revoke other + sessions. +- **A Bearer-only logout revokes nothing.** An access token presented alone + stays valid until it expires, 30 minutes after issue, and a refresh token + returned in the login body has no revoke route. (CP `bugs/OW-062`) + ## [0.8.0-rc.5] Eyrie (2026-09-19) diff --git a/docs/guides/QUICKSTART.md b/docs/guides/QUICKSTART.md index 78ee5528..f44c0681 100644 --- a/docs/guides/QUICKSTART.md +++ b/docs/guides/QUICKSTART.md @@ -380,9 +380,14 @@ PostgreSQL. Replace `` with the value from `GET /api/v1/audit/events` and stored in PostgreSQL; export the relevant window for analysis. 3. If credentials may be exposed, rotate them: revoke or replace the affected - SSH credentials (`/api/v1/credentials`) and rotate any user passwords. - Active sessions can be ended via logout; force re-authentication for - affected users. + SSH credentials (`/api/v1/credentials`). To end a user's access at once, + disable the account (`POST /api/v1/users/{id}:disable`) or reset its + password as an administrator (`POST /api/v1/users/{id}:reset-password`). + Either ends every session, refresh token and access token the user holds. + A user who changes their own password does not sign out their other + sessions. Logout ends only the login it is sent from. The + [security incident runbook](../runbooks/SECURITY_INCIDENT.md) covers + revoking every session at once. 4. If the host itself is compromised, isolate it at the network layer and stop the service to halt outbound SSH: `systemctl stop openwatch`. diff --git a/docs/runbooks/SECURITY_INCIDENT.md b/docs/runbooks/SECURITY_INCIDENT.md index 91f29c16..1d3c051f 100644 --- a/docs/runbooks/SECURITY_INCIDENT.md +++ b/docs/runbooks/SECURITY_INCIDENT.md @@ -251,16 +251,18 @@ WHERE revoked_at IS NULL ### Disable a compromised account -There is no `is_active` flag; disabling an account means soft-deleting it. Prefer the API so the action is itself audited (`account.user.deleted`): +Disable the account through the API. Disabling ends every interactive credential the user holds (sessions, refresh tokens and access tokens), is audited as `admin.user.disabled`, and can be reversed with `:enable`: ```bash # Authenticated as an admin; replace TOKEN and USER_ID -curl -sk -X DELETE \ +curl -sk -X POST \ -H "Authorization: Bearer TOKEN" \ - https://localhost:8443/api/v1/users/USER_ID + https://localhost:8443/api/v1/users/USER_ID:disable ``` -If the API is unavailable, soft-delete directly. This also removes the account from the active-uniqueness indexes: +Deleting the account (`DELETE /api/v1/users/USER_ID`, audited as `admin.user.deleted`) also ends its interactive credentials, but it removes the account from the active-uniqueness indexes and cannot be undone through the API. Prefer disable while the investigation is open. + +If the API is unavailable, soft-delete directly. The binders refuse a deleted account's interactive credentials on every request, but this path revokes no rows and writes no audit event: ```bash psql -U openwatch -d openwatch -c " @@ -271,34 +273,35 @@ WHERE username = 'USERNAME' AND deleted_at IS NULL; ### Revoke every session (full re-authentication) -Four kinds of credential keep a user signed in, and they are revoked in -different places. Rotating the JWT signing key handles only the first. +Four kinds of credential keep a user signed in. An access token names the +session that issued it, so revoking the session ends both. | Credential | Where it lives | Ended by | |---|---|---| -| Access token (bearer JWT, 30 minutes) | Signed with `jwt_private.pem`, not stored | Rotating the signing key and restarting | +| Access token (bearer JWT, 30 minutes) | Signed with `jwt_private.pem`, not stored; carries its session id | Revoking its session row | | Browser session (`openwatch_session` cookie) | `sessions` table, hashed | Setting `revoked_at` on the row | | Refresh token (cookie or body) | `refresh_tokens` table, hashed | Setting `revoked_at` on the row | | API token (`/api/v1/tokens`) | `api_tokens` table, hashed | Deleting it through `/api/v1/tokens/{id}`. Disabling or deleting the user who created it also stops it, until that user is re-enabled | -Verified on 0.8.0-rc.3: after a signing-key rotation and restart, a bearer -token issued before it returned 401, while the same browser's session cookie -and refresh cookie still returned 200. An open tab stays signed in until its -row is revoked. +Rotating the signing key does not end a browser session or a refresh token. +An open tab stays signed in until its row is revoked. -To sign everyone out now, revoke the rows first (immediate, no restart), then -rotate the key so that any access token still in flight dies within its -30-minute lifetime rather than living out the rest of it: +To sign everyone out now, revoke the rows. This takes effect at once, on every +node, with no restart, and it ends the access tokens those sessions issued. +Rotate the signing key as well only if the key itself may be exposed: anyone +holding it can sign a token that names a live session and claims any role. ```bash -# 1. Sessions and refresh tokens: immediate, fleet-wide, no restart. +# 1. Sessions, their refresh tokens and their access tokens: immediate, +# fleet-wide, no restart. sudo -u openwatch sh -c 'set -a; . /etc/openwatch/secrets.env; set +a; psql "$OPENWATCH_DATABASE_DSN" \ -c "UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL;" \ -c "UPDATE refresh_tokens SET revoked_at = now() WHERE revoked_at IS NULL;"' -# 2. Access tokens: rotate the signing key at a NEW path (never overwrite the -# old one, so you can roll back), point the service at it, restart. +# 2. Only if the signing key may be exposed: rotate it at a NEW path (never +# overwrite the old one, so you can roll back), point the service at it, +# restart. NEW_JWT="/etc/openwatch/keys/jwt_private-$(date -u +%Y%m%d)-incident.pem" sudo sh -c 'umask 077; openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /root/jwt_private.new.pem' sudo install -m 0640 -o root -g openwatch /root/jwt_private.new.pem "$NEW_JWT" @@ -388,11 +391,11 @@ Expect `"status": "healthy"`. ```bash psql -U openwatch -d openwatch -c " -SELECT count(*) AS live_sessions_for_deleted_users +SELECT count(*) AS live_sessions_for_disabled_or_deleted_users FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.revoked_at IS NULL AND s.expires_at > now() - AND u.deleted_at IS NOT NULL; + AND (u.disabled_at IS NOT NULL OR u.deleted_at IS NOT NULL); " ```