diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c7275bf35..e31272158 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6682,15 +6682,21 @@ cleared rather than read as already-consumed, so a fresh token minted after removal always re-enrolls the host instead of rejecting its reports indefinitely with 400s that no reinstall can escape (#1772). -### Agent token revocation remains continuous across persistence failure +### Agent token issuance and revocation remain continuous across persistence failure + +An operator-created API token carrying agent authority becomes live and is +returned only after its expanded inventory commits. A failed creation write +restores every prior token and the primary-token projection, so the runtime +does not retain an undisclosed agent credential or evict an older working +credential from its sorted inventory. The shared API-token deletion boundary may mark an agent credential revoked only after the reduced token inventory is durably committed. If persistence fails, the complete prior inventory and primary-token projection are restored and the request fails, so connected agents do not become unauthenticated only in the live process or regain a supposedly revoked credential after restart. -The lifecycle proof deletes one exact token from a three-token inventory and -forces a failed persistence commit in +The lifecycle proofs cover failed creation, delete one exact token from a +three-token inventory, and force failed persistence commits in `internal/api/security_tokens_lifecycle_test.go`. ### External watchdog wiring does not widen agent lifecycle authority diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9881e5633..b8e3bf6af 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10003,7 +10003,17 @@ facet on the wire. `internal/models/metrics_types_test.go`, `frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts` pin the wire shape and both client projections. -### API token deletion commits durable state before reporting success +### API token creation and deletion commit durable state before reporting success + +`POST /api/security/tokens` returns a newly generated credential only after +the expanded token inventory is durably persisted. If that write fails, the +runtime restores the complete prior inventory and its legacy primary-token +projection, returns `500`, and does not expose the generated secret. The +rollback must remove the new newest-first record rather than truncating the +sorted inventory and accidentally removing an older valid token. +`TestSecurityTokensCreateRollsBackCompleteInventoryWhenPersistenceFails` in +`internal/api/security_tokens_lifecycle_test.go` pins this failed-commit +boundary. `DELETE /api/security/tokens/{id}` removes exactly the requested token and returns `204` only after the resulting token inventory is durably persisted. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index f6ec8452a..9a02f96c9 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2365,15 +2365,22 @@ sudo outright; that relaxation is part of the grant's declared cost. The agent-r informational: the fleet doctor presents it descriptively and must not treat a non-root agent as unhealthy on that evidence alone. -### API token revocation is a durable credential transition +### API token creation and revocation are durable credential transitions + +Creation may not admit an unreturned secret or evict an older valid token when +persistence fails. The token-management API retains the complete pre-creation +inventory until the expanded inventory commits, then restores that inventory +and its primary-token projection before returning an error on failed writes. +The generated secret is returned only after a successful commit. Revocation may not create different live and restart-time credential sets. The token-management API therefore retains a complete pre-mutation inventory until the reduced inventory is persisted. A failed write restores the prior tokens and primary-token projection, emits only a failed `token_deleted` audit event, and returns an error; a successful response identifies a deletion that -will survive restart. Exact multi-token removal and persistence-failure -rollback are exercised in `internal/api/security_tokens_lifecycle_test.go`. +will survive restart. Creation rollback, exact multi-token removal, and +revocation persistence-failure rollback are exercised in +`internal/api/security_tokens_lifecycle_test.go`. ### Deploy enrollment never exposes an uncommitted credential diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b6f50c656..a4fd01a3d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5637,15 +5637,21 @@ no backup, snapshot, restore, retention, cleanup, or verification authority. The storage/recovery resource query and its admission, freshness, and persistence contracts are unchanged. -### Token deletion preserves restart-time persistence truth +### Token creation and deletion preserve restart-time persistence truth + +The shared `internal/api` token creation path treats its persisted inventory as +the commit boundary. If expansion cannot be written, it restores the complete +prior inventory and legacy primary-token projection and returns no generated +credential. Because token records sort newest-first, rollback restores the +snapshot rather than truncating the sorted list and evicting an older token. The shared `internal/api` token revocation path treats its persisted inventory as the commit boundary. It snapshots the complete in-memory inventory before removal, persists only the intended reduced set, and restores the snapshot and legacy primary-token projection if that write fails. This adds no backup, restore, or recovery authority; it prevents a successful API response from -describing credential state that the next restart would undo. The exact-token -and forced-write-failure proofs live in +describing credential state that the next restart would undo. The creation, +exact-token deletion, and forced-write-failure proofs live in `internal/api/security_tokens_lifecycle_test.go`. ### Dead-man persistence is availability evidence, not recovery authority diff --git a/internal/api/security_tokens.go b/internal/api/security_tokens.go index e03c92847..1a55523c4 100644 --- a/internal/api/security_tokens.go +++ b/internal/api/security_tokens.go @@ -223,12 +223,17 @@ func (r *Router) createAPITokenRecord( config.Mu.Lock() defer config.Mu.Unlock() + previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...) r.config.APITokens = append(r.config.APITokens, *record) r.config.SortAPITokens() if r.persistence != nil { if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { - r.config.APITokens = r.config.APITokens[:len(r.config.APITokens)-1] + // The new record sorts newest-first, so truncating the sorted slice + // would remove an older valid token and leave the unreturned token + // active. Restore the complete pre-mutation inventory instead. + r.config.APITokens = previousTokens + r.config.SortAPITokens() return "", nil, fmt.Errorf("persist token: %w", err) } } diff --git a/internal/api/security_tokens_lifecycle_test.go b/internal/api/security_tokens_lifecycle_test.go index 4a2fb9590..3e8f4e073 100644 --- a/internal/api/security_tokens_lifecycle_test.go +++ b/internal/api/security_tokens_lifecycle_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "net/http" "net/http/httptest" "os" @@ -12,6 +13,43 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" ) +func TestSecurityTokensCreateRollsBackCompleteInventoryWhenPersistenceFails(t *testing.T) { + now := time.Now().UTC() + tokens := []config.APITokenRecord{ + {ID: "newest", Name: "newest", Hash: "hash-newest", CreatedAt: now, Scopes: []string{config.ScopeWildcard}}, + {ID: "oldest", Name: "oldest", Hash: "hash-oldest", CreatedAt: now.Add(-time.Minute), Scopes: []string{config.ScopeWildcard}}, + } + cfg := &config.Config{APITokens: append([]config.APITokenRecord(nil), tokens...)} + cfg.SortAPITokens() + + stateDir := filepath.Join(t.TempDir(), "state") + persistence := config.NewConfigPersistence(stateDir) + if err := os.RemoveAll(stateDir); err != nil { + t.Fatalf("remove persistence directory: %v", err) + } + if err := os.WriteFile(stateDir, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create persistence blocker: %v", err) + } + router := &Router{config: cfg, persistence: persistence} + + req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", bytes.NewBufferString(`{"name":"must-not-survive"}`)) + rec := httptest.NewRecorder() + router.handleCreateAPIToken(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + assertLifecycleAPITokenIDs(t, cfg.APITokens, "newest", "oldest") + if cfg.APIToken != "hash-newest" { + t.Fatalf("legacy primary token = %q, want rollback to %q", cfg.APIToken, "hash-newest") + } + for _, token := range cfg.APITokens { + if token.Name == "must-not-survive" { + t.Fatalf("failed creation left generated token active: %+v", token) + } + } +} + func TestSecurityTokensDeletePersistsOnlyRequestedRemoval(t *testing.T) { now := time.Now().UTC() tokens := []config.APITokenRecord{