From e1406a0d4b654a63e70ee0f9ee1b91e36910498c Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Fri, 25 Sep 2026 21:16:53 -0400 Subject: [PATCH] fix(auth): refuse an API token while its owner is disabled or deleted A disabled user's owk_ token kept authenticating with its full role, so disabling a departing administrator did not stop their automation (bugs/OW-071). Only admin and security_admin hold token:write, so the exposure was the highest-privilege accounts. The workaround was partial: the token list does not show a token's owner. AuthenticateToken now joins the token's owner and refuses the token when the owner is disabled or soft-deleted, and always when the token has no owner (fail closed). It is a check at authentication time, not a revocation: a refusal writes nothing to the token row, so re-enabling the owner restores the token, and a token revoked through Revoke stays revoked. The binder records each refusal with its own declared reason: api_token_owner_disabled, api_token_owner_deleted, api_token_ownerless. This is P8 AC-95 only. P8's audit fields (service_token_effect) are not included. system-api-tokens 1.1.0: C-02 amended, C-04 and AC-06 added. AC-02's fixture now gives its token an active owner, because an ownerless token is refused by design. Mutations on each owner state, on ownerless tokens, on stamping last_used_at on a refusal, on the binder reason, and on implementing the gate as a revocation each turned an AC-06 case red. Upgrade note in CHANGELOG: tokens with no owner stop authenticating; the query to find them is included. Approved as a v0.8 scope-freeze exception on 2026-09-25 (projects/openwatch/V0_8_0_READINESS). Adjacent finding filed, not fixed: bugs/OW-079. --- CHANGELOG.md | 16 ++ audit/events.yaml | 8 +- docs/guides/API_GUIDE.md | 5 +- docs/runbooks/SECURITY_INCIDENT.md | 2 +- internal/apitoken/apitoken.go | 41 +++-- internal/apitoken/apitoken_test.go | 16 +- internal/audit/events.gen.go | 4 +- internal/identity/binder.go | 19 ++- internal/server/api_token_owner_state_test.go | 151 ++++++++++++++++++ specs/system/api-tokens.spec.yaml | 52 +++++- 10 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 internal/server/api_token_owner_state_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a973d0bd2..5b5b4dfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/audit/events.yaml b/audit/events.yaml index 8016acf98..13e5f2e7d 100644 --- a/audit/events.yaml +++ b/audit/events.yaml @@ -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 @@ -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, diff --git a/docs/guides/API_GUIDE.md b/docs/guides/API_GUIDE.md index c23d4522d..bebef9479 100644 --- a/docs/guides/API_GUIDE.md +++ b/docs/guides/API_GUIDE.md @@ -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 diff --git a/docs/runbooks/SECURITY_INCIDENT.md b/docs/runbooks/SECURITY_INCIDENT.md index b11da36e3..91f29c165 100644 --- a/docs/runbooks/SECURITY_INCIDENT.md +++ b/docs/runbooks/SECURITY_INCIDENT.md @@ -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 diff --git a/internal/apitoken/apitoken.go b/internal/apitoken/apitoken.go index ded268210..446a0a056 100644 --- a/internal/apitoken/apitoken.go +++ b/internal/apitoken/apitoken.go @@ -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" @@ -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 @@ -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 diff --git a/internal/apitoken/apitoken_test.go b/internal/apitoken/apitoken_test.go index e68381d4f..8e585f39c 100644 --- a/internal/apitoken/apitoken_test.go +++ b/internal/apitoken/apitoken_test.go @@ -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) { @@ -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) diff --git a/internal/audit/events.gen.go b/internal/audit/events.gen.go index 76800e072..085b1e3b3 100644 --- a/internal/audit/events.gen.go +++ b/internal/audit/events.gen.go @@ -25,7 +25,7 @@ const ( const ( // User authenticated successfully AuthLoginSuccess Code = "auth.login.success" - // Authentication attempt failed, or a presented credential was refused. reason names why. Password login records wrong_password, unknown_user, account_disabled, mfa_required, mfa_invalid, mfa_required_during_login or password_changed_during_login. The 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, 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 now; older rows may carry them. username is the submitted name, clipped. remote_addr and user_agent are recorded by the binders, the user agent clipped. + // Authentication attempt failed, or a presented credential was refused. reason names why. Password login records wrong_password, unknown_user, account_disabled, mfa_required, mfa_invalid, mfa_required_during_login or password_changed_during_login. The 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. 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 now; older rows may carry them. username is the submitted name, clipped. remote_addr and user_agent are recorded by the binders, the user agent clipped. AuthLoginFailure Code = "auth.login.failure" // User explicitly logged out. One login family is ended, located by the request's cookies. anchor names which cookie selected it. target_conflict is true when both cookies resolved to different families; only the session cookie's family was revoked. No credential values are recorded. AuthLogout Code = "auth.logout" @@ -350,7 +350,7 @@ var Metadata = map[Code]EventMeta{ Code: AuthLoginFailure, Category: "auth", Severity: SeverityWarning, - Description: `Authentication attempt failed, or a presented credential was refused. reason names why. Password login records wrong_password, unknown_user, account_disabled, mfa_required, mfa_invalid, mfa_required_during_login or password_changed_during_login. The 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, 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 now; older rows may carry them. username is the submitted name, clipped. remote_addr and user_agent are recorded by the binders, the user agent clipped.`, + Description: `Authentication attempt failed, or a presented credential was refused. reason names why. Password login records wrong_password, unknown_user, account_disabled, mfa_required, mfa_invalid, mfa_required_during_login or password_changed_during_login. The 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. 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 now; older rows may carry them. username is the submitted name, clipped. remote_addr and user_agent are recorded by the binders, the user agent clipped.`, ActorTypes: []string{"user"}, DetailKeys: []string{"auth_method", "reason", "remote_addr", "user_agent", "username"}, }, diff --git a/internal/identity/binder.go b/internal/identity/binder.go index 4312c2d7f..c4bbcead2 100644 --- a/internal/identity/binder.go +++ b/internal/identity/binder.go @@ -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) @@ -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), "" diff --git a/internal/server/api_token_owner_state_test.go b/internal/server/api_token_owner_state_test.go new file mode 100644 index 000000000..7d635572f --- /dev/null +++ b/internal/server/api_token_owner_state_test.go @@ -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) + } + }) + }) +} diff --git a/specs/system/api-tokens.spec.yaml b/specs/system/api-tokens.spec.yaml index 635972de5..c67f0c42f 100644 --- a/specs/system/api-tokens.spec.yaml +++ b/specs/system/api-tokens.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-api-tokens title: API service-account tokens — hashed store + bearer authentication - version: "1.0.0" + version: "1.1.0" status: approved tier: 1 @@ -53,11 +53,26 @@ spec: description: >- AuthenticateToken MUST reject (ErrInvalidToken, anonymous identity) an unknown, revoked (revoked_at set), or expired (expires_at in the - past) token. A successful authentication returns an auth.Identity + past) token, and a token whose owner may not authenticate (C-04). + A successful authentication returns an auth.Identity carrying the token's role; permissions are NOT embedded (the RBAC middleware derives them from the role per request). type: security enforcement: error + - id: C-04 + description: >- + A service-account token MUST NOT authenticate while its owner is + disabled or soft-deleted, and a token with no owner (created_by + NULL) MUST NOT authenticate at all. The check runs at + authentication time and is a check, not a revocation: a refusal + writes nothing to the token row, including last_used_at, so + re-enabling the owner restores the token, while a token revoked + through Revoke stays revoked. Each refusal is recorded on + auth.login.failure with its own reason, api_token_owner_disabled, + api_token_owner_deleted or api_token_ownerless. This is P8 AC-95 + from bugs/doing/OW-062, and bugs/OW-071. + type: security + enforcement: error - id: C-03 description: >- The identity binder MUST route a Bearer value carrying @@ -73,7 +88,7 @@ spec: priority: critical references_constraints: [C-01] - id: AC-02 - description: AuthenticateToken on the freshly created raw token returns an Identity with the token's role and a non-empty ID; the token's last_used_at is stamped after authentication. + description: AuthenticateToken on the freshly created raw token, owned by an active user, returns an Identity with the token's role and a non-empty ID; the token's last_used_at is stamped after authentication. priority: critical references_constraints: [C-02] - id: AC-03 @@ -88,3 +103,34 @@ spec: description: List returns tokens newest-first with no secret material (no token_hash); Create with an empty name or unknown role_id returns ErrInvalidParams. priority: high references_constraints: [C-01] + - id: AC-06 + description: >- + An otherwise-valid token is refused while its owner is disabled or + soft-deleted, and always when it has no owner; the refusal writes + nothing to the token; re-enabling the owner restores the token; a + revoked token stays revoked. + priority: critical + references_constraints: [C-02, C-04] + inputs: + initial_state: "An unexpired, non-revoked owk_ token created while its owner is active" + operation: "Present the token as a Bearer credential to GET /api/v1/hosts, with its own correlation id" + variants: + - name: "Owner disabled" + setup: "disabled_at set on the owner by direct update" + - name: "Owner soft-deleted" + setup: "deleted_at set on the owner by direct update" + - name: "Ownerless" + setup: "created_by set to NULL on the token by direct update" + - name: "Active control" + setup: "The owner is left active" + - name: "Re-enable" + setup: "Owner disabled through the users service, then enabled; a second token revoked through Revoke before the disable" + expected_output: + per_variant: + Owner disabled: {status: 401, audit_reason: api_token_owner_disabled, token_row_written: false} + Owner soft-deleted: {status: 401, audit_reason: api_token_owner_deleted, token_row_written: false} + Ownerless: {status: 401, audit_reason: api_token_ownerless, token_row_written: false} + Active control: {status: 200, last_used_at: "stamped"} + Re-enable: {while_disabled: 401, after_enable: 200, revoked_token_after_enable: 401} + audit_read_by: "The request's correlation id" + cookies: "Not applicable; Bearer credential"