Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 91 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 8 additions & 3 deletions docs/guides/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,14 @@ PostgreSQL. Replace `<dsn>` 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`.
Expand Down
41 changes: 22 additions & 19 deletions docs/runbooks/SECURITY_INCIDENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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"
Expand Down Expand Up @@ -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);
"
```

Expand Down
Loading