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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
until it expires and names the remedy that works: an administrator can
end it by resetting the user's password.

### Security

- **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`.
- **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;`

## [0.8.0-rc.5] Eyrie (2026-09-19)

`v0.8.0-rc.4` built, passed every machine gate, and its assets were
Expand Down
8 changes: 6 additions & 2 deletions audit/events.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ events:
binders record the session, access-token and account-state reasons.
SSO records the sso_ reasons, and sso_account_disabled or
sso_account_deleted when the local account may not sign in; the
sign-in page stays generic. account_state_unavailable,
sign-in page stays generic. A service-account token whose owner is
disabled or deleted, or that has no owner, records
api_token_owner_disabled, api_token_owner_deleted or
api_token_ownerless. account_state_unavailable,
session_lookup_failed and role_lookup_unavailable mean the server
could not tell, and the request answered 503, not 401. invalid_credentials, account_locked,
mfa_failed and sso_failed are legacy values that nothing records
Expand All @@ -93,7 +96,8 @@ events:
password_changed_during_login,
invalid_session_token, session_expired, session_revoked,
session_lookup_failed, session_user_lookup_failed,
invalid_api_token, invalid_jwt, jwt_expired, jwt_verify_failed,
invalid_api_token, api_token_owner_disabled,
api_token_owner_deleted, api_token_ownerless, invalid_jwt, jwt_expired, jwt_verify_failed,
invalid_jwt_subject, invalid_jwt_session,
role_lookup_unavailable,
sid_absent, session_absent, session_owner_mismatch,
Expand Down
5 changes: 4 additions & 1 deletion docs/guides/API_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ permission set:
- 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
revoked at once by `DELETE /api/v1/tokens/{id}`. It works only while the
user who created it may sign in: it is refused while that user is disabled
or deleted, and works again if they are re-enabled. A token with no owner
is refused. 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
Expand Down
2 changes: 1 addition & 1 deletion docs/runbooks/SECURITY_INCIDENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ different places. Rotating the JWT signing key handles only the first.
| Access token (bearer JWT, 30 minutes) | Signed with `jwt_private.pem`, not stored | Rotating the signing key and restarting |
| 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}` |
| 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
Expand Down
41 changes: 32 additions & 9 deletions internal/apitoken/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"time"

"github.com/Hanalyx/openwatch/internal/auth"
"github.com/Hanalyx/openwatch/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
Expand Down Expand Up @@ -153,19 +154,33 @@ func (s *Service) Revoke(ctx context.Context, id uuid.UUID) error {
}

// AuthenticateToken resolves a raw token to an auth.Identity. Rejects
// unknown, revoked, and expired tokens. Updates last_used_at best-effort.
// Implements the identity binder's TokenAuthenticator.
// unknown, revoked, and expired tokens, and a token whose owner may not
// authenticate: disabled, deleted, or absent. Updates last_used_at
// best-effort, and only on success. Implements the identity binder's
// TokenAuthenticator.
//
// The owner check is a check, not a revocation: the token row is never
// written on a refusal, so re-enabling the owner restores the token, and
// a token revoked through Revoke stays revoked. An ownerless token fails
// closed, because there is no account whose state could permit it.
// Spec system-api-tokens C-02, C-04; bugs/OW-071.
func (s *Service) AuthenticateToken(ctx context.Context, raw string) (auth.Identity, error) {
const stmt = `
SELECT id, role_id, expires_at
FROM api_tokens
WHERE token_hash = $1 AND revoked_at IS NULL`
SELECT t.id, t.role_id, t.expires_at,
u.id IS NULL,
u.disabled_at IS NOT NULL,
u.deleted_at IS NOT NULL
FROM api_tokens t
LEFT JOIN users u ON u.id = t.created_by
WHERE t.token_hash = $1 AND t.revoked_at IS NULL`
var (
id uuid.UUID
roleID string
expiresAt *time.Time
id uuid.UUID
roleID string
expiresAt *time.Time
ownerAbsent, disabled, deleted bool
)
err := s.pool.QueryRow(ctx, stmt, hashToken(raw)).Scan(&id, &roleID, &expiresAt)
err := s.pool.QueryRow(ctx, stmt, hashToken(raw)).Scan(
&id, &roleID, &expiresAt, &ownerAbsent, &disabled, &deleted)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return auth.Identity{}, ErrInvalidToken
Expand All @@ -175,6 +190,14 @@ func (s *Service) AuthenticateToken(ctx context.Context, raw string) (auth.Ident
if expiresAt != nil && time.Now().After(*expiresAt) {
return auth.Identity{}, ErrInvalidToken
}
switch {
case ownerAbsent:
return auth.Identity{}, fmt.Errorf("%w: %w", ErrInvalidToken, identity.ErrTokenOwnerAbsent)
case deleted:
return auth.Identity{}, fmt.Errorf("%w: %w", ErrInvalidToken, identity.ErrTokenOwnerDeleted)
case disabled:
return auth.Identity{}, fmt.Errorf("%w: %w", ErrInvalidToken, identity.ErrTokenOwnerDisabled)
}
// Best-effort usage stamp; never blocks auth on failure.
_, _ = s.pool.Exec(ctx, `UPDATE api_tokens SET last_used_at = now() WHERE id = $1`, id)
return auth.Identity{ID: id.String(), RoleID: auth.RoleID(roleID)}, nil
Expand Down
16 changes: 15 additions & 1 deletion internal/apitoken/apitoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ func freshService(t *testing.T) (*Service, *pgxpool.Pool) {
return NewService(pool), pool
}

// activeOwner inserts an active user to own a token. Since C-04 a token
// authenticates only while its owner may, so a token meant to succeed
// needs one.
func activeOwner(t *testing.T, pool *pgxpool.Pool) *uuid.UUID {
t.Helper()
id := uuid.New()
if _, err := pool.Exec(context.Background(),
`INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, 'x')`,
id, "owner-"+id.String()[:8], id.String()[:8]+"@example.test"); err != nil {
t.Fatalf("insert owner: %v", err)
}
return &id
}

// @ac AC-01
func TestCreate_HashedNotPlaintext(t *testing.T) {
t.Run("system-api-tokens/AC-01", func(t *testing.T) {
Expand Down Expand Up @@ -59,7 +73,7 @@ func TestAuthenticate_Succeeds(t *testing.T) {
t.Run("system-api-tokens/AC-02", func(t *testing.T) {
svc, pool := freshService(t)
ctx := context.Background()
raw, tok, _ := svc.Create(ctx, CreateParams{Name: "ci", RoleID: auth.RoleOpsLead})
raw, tok, _ := svc.Create(ctx, CreateParams{Name: "ci", RoleID: auth.RoleOpsLead, CreatedBy: activeOwner(t, pool)})
id, err := svc.AuthenticateToken(ctx, raw)
if err != nil {
t.Fatalf("AuthenticateToken: %v", err)
Expand Down
4 changes: 2 additions & 2 deletions internal/audit/events.gen.go

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

19 changes: 18 additions & 1 deletion internal/identity/binder.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ type TokenAuthenticator interface {
AuthenticateToken(ctx context.Context, raw string) (auth.Identity, error)
}

// Owner-state refusals for a service-account token. An authenticator
// wraps one of these when a token is otherwise valid but its owner may not
// authenticate, so the audit record can say why. Spec system-api-tokens
// C-04.
var (
ErrTokenOwnerDisabled = errors.New("identity: api token owner is disabled")
ErrTokenOwnerDeleted = errors.New("identity: api token owner is deleted")
ErrTokenOwnerAbsent = errors.New("identity: api token has no owner")
)

// BinderOption configures optional binder behavior.
type BinderOption func(*binderConfig)

Expand Down Expand Up @@ -352,7 +362,14 @@ func resolveIdentity(ctx context.Context, pool *pgxpool.Pool, lookups Lookups, c
// authenticator; everything else is a session JWT.
if cfg.tokenAuth != nil && strings.HasPrefix(token, auth.APITokenPrefix) {
id, err := cfg.tokenAuth.AuthenticateToken(ctx, token)
if err != nil {
switch {
case errors.Is(err, ErrTokenOwnerDisabled):
return anon(), "api_token_owner_disabled"
case errors.Is(err, ErrTokenOwnerDeleted):
return anon(), "api_token_owner_deleted"
case errors.Is(err, ErrTokenOwnerAbsent):
return anon(), "api_token_ownerless"
case err != nil:
return anon(), "invalid_api_token"
}
return withGrants(ctx, lookups, id), ""
Expand Down
151 changes: 151 additions & 0 deletions internal/server/api_token_owner_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// @spec system-api-tokens
//
// A service-account token is refused while its owner may not authenticate
// (C-02, C-04; bugs/OW-071).

package server

import (
"context"
"net/http"
"strings"
"testing"

"github.com/Hanalyx/openwatch/internal/apitoken"
"github.com/Hanalyx/openwatch/internal/auth"
"github.com/Hanalyx/openwatch/internal/users"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)

// tokenRow is the part of an api_tokens row the gate must never write.
type tokenRow struct {
revoked, used bool
}

func readTokenRow(t *testing.T, pool *pgxpool.Pool, id uuid.UUID) tokenRow {
t.Helper()
var r tokenRow
if err := pool.QueryRow(context.Background(),
`SELECT revoked_at IS NOT NULL, last_used_at IS NOT NULL FROM api_tokens WHERE id = $1`, id).
Scan(&r.revoked, &r.used); err != nil {
t.Fatalf("read token row: %v", err)
}
return r
}

// presentToken sends the token as a Bearer credential to a route the
// token's role may read, with its own correlation id.
func presentToken(t *testing.T, url, raw string) (int, string) {
t.Helper()
cid := "tok-" + strings.ReplaceAll(uuid.NewString(), "-", "")
req, _ := http.NewRequest("GET", url+"/api/v1/hosts", nil)
req.Header.Set("Authorization", "Bearer "+raw)
req.Header.Set("X-Correlation-Id", cid)
resp := doReq(t, req)
resp.Body.Close()
return resp.StatusCode, cid
}

// @ac AC-06
// AC-06: an otherwise-valid token does not authenticate while its owner is
// disabled or deleted, nor at all without an owner; the refusal writes
// nothing to the token; re-enabling the owner restores it, and a revoked
// token stays revoked.
func TestAPIToken_RefusedWhileOwnerMayNotAuthenticate(t *testing.T) {
t.Run("system-api-tokens/AC-06", func(t *testing.T) {
url, pool := freshAPIServer(t)
ctx := context.Background()
svc := users.NewService(pool, nil)
tokens := apitoken.NewService(pool)

newOwnerToken := func(t *testing.T, name string) (uuid.UUID, uuid.UUID, string) {
t.Helper()
u := seedAuthUser(t, svc, name, false)
_ = svc.AssignRole(ctx, u.ID, "admin", nil)
raw, tok, err := tokens.Create(ctx, apitoken.CreateParams{Name: name, RoleID: auth.RoleID("viewer"), CreatedBy: &u.ID})
if err != nil {
t.Fatalf("create token: %v", err)
}
return u.ID, tok.ID, raw
}

for _, tc := range []struct {
name, stmt, reason string
}{
{"owner disabled", `UPDATE users SET disabled_at = now() WHERE id = $1`, "api_token_owner_disabled"},
{"owner soft-deleted", `UPDATE users SET deleted_at = now() WHERE id = $1`, "api_token_owner_deleted"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
owner, tokID, raw := newOwnerToken(t, "ac06"+strings.ReplaceAll(tc.name, " ", ""))
if _, err := pool.Exec(ctx, tc.stmt, owner); err != nil {
t.Fatalf("set owner state: %v", err)
}
code, cid := presentToken(t, url, raw)
if code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", code)
}
if got := loginFailureReasonFor(t, pool, cid); got != tc.reason {
t.Errorf("audit reason = %q, want %q", got, tc.reason)
}
if row := readTokenRow(t, pool, tokID); row.revoked || row.used {
t.Errorf("the refusal wrote to the token: revoked=%v last_used=%v", row.revoked, row.used)
}
})
}

t.Run("ownerless", func(t *testing.T) {
_, tokID, raw := newOwnerToken(t, "ac06ownerless")
if _, err := pool.Exec(ctx, `UPDATE api_tokens SET created_by = NULL WHERE id = $1`, tokID); err != nil {
t.Fatalf("clear owner: %v", err)
}
code, cid := presentToken(t, url, raw)
if code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401: an ownerless token fails closed", code)
}
if got := loginFailureReasonFor(t, pool, cid); got != "api_token_ownerless" {
t.Errorf("audit reason = %q, want api_token_ownerless", got)
}
if row := readTokenRow(t, pool, tokID); row.revoked || row.used {
t.Errorf("the refusal wrote to the token: revoked=%v last_used=%v", row.revoked, row.used)
}
})

t.Run("active control", func(t *testing.T) {
_, tokID, raw := newOwnerToken(t, "ac06active")
if code, _ := presentToken(t, url, raw); code != http.StatusOK {
t.Errorf("status = %d, want 200 for an active owner", code)
}
if row := readTokenRow(t, pool, tokID); !row.used {
t.Error("a successful authentication did not record last_used_at")
}
})

t.Run("re-enable restores, revoked stays revoked", func(t *testing.T) {
owner, _, raw := newOwnerToken(t, "ac06reenable")
revokedRaw, revoked, err := tokens.Create(ctx, apitoken.CreateParams{Name: "ac06revoked", RoleID: auth.RoleID("viewer"), CreatedBy: &owner})
if err != nil {
t.Fatalf("create second token: %v", err)
}
if err := tokens.Revoke(ctx, revoked.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if err := svc.Disable(ctx, owner); err != nil {
t.Fatalf("disable: %v", err)
}
if code, _ := presentToken(t, url, raw); code != http.StatusUnauthorized {
t.Fatalf("precondition: token while owner disabled = %d, want 401", code)
}
if _, err := svc.Enable(ctx, owner); err != nil {
t.Fatalf("enable: %v", err)
}
if code, _ := presentToken(t, url, raw); code != http.StatusOK {
t.Errorf("token after re-enable = %d, want 200: the gate is a check, not a revocation", code)
}
if code, _ := presentToken(t, url, revokedRaw); code != http.StatusUnauthorized {
t.Errorf("revoked token after re-enable = %d, want 401", code)
}
})
})
}
Loading
Loading