mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user