Keep API token creation atomic

Restore the complete pre-creation token inventory when persistence fails so sorting cannot evict an older valid token or leave an unreturned credential active. Govern and test the durable creation boundary across API, security, agent lifecycle, and storage.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-28 07:08:08 +01:00
parent b9d69d029a
commit 73be3df3c5
6 changed files with 83 additions and 11 deletions
@@ -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
@@ -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.
@@ -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
@@ -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
+6 -1
View File
@@ -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)
}
}
@@ -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{