carlos / platform Public

console: webhook key delivery and rotation

Task 15. Written by codex (gpt-5.6-sol) across two runs; the spec
correction below, the mutation testing and this message are mine.

WHAT IT DOES. `store scan grant` derives the app's callback secret and
seals it alongside the store credential in ONE appconfig.Apply — two
calls would be two generation bumps and so two unit restarts for one
operator action. `revoke` unsets it, because a key outliving the consent
it was issued under is the same mistake as a result object outliving it.
`system antivirus rotate-webhook-key` re-seals every scanning-enabled app
a bounded batch at a time, skipping any app already carrying the current
key id, and reports how many remain so the CLI can loop — the shape
`scan sweep` already uses, so an interrupted rotation resumes rather than
restarting. `store scan rotate-webhook-key --app` rotates one app alone.

The skip is not an optimisation. appconfig.Seal draws a fresh ephemeral
key and nonce on every call, so the ciphertext differs even for identical
plaintext and the console — holding only the public key — cannot compare
by opening the old value. Without a recorded key id every rotation
re-seals every app, and because the box's Sync restarts a unit whenever
the rendered bundle changes, that restarts every scanning app on the
deployment every time.

THE SPEC WAS WRONG, AND CODEX FOUND IT. I had added `webhook_gen` to
ScanBlock and claimed it stopped a reclaimed app name inheriting the
deleted app's callback key. It did not. `PurgeApp` lists the whole app
prefix and deletes every key in it (trash.go:229-241), `store.json`
included — so a counter starting at zero starts at zero again for
whoever claims the name next, and derives the identical key. The claimed
property was simply false.

The generation is therefore RANDOM, not a counter: a fresh value below
2^53 minted at grant and again on each per-app rotation. That makes the
property true BECAUSE the record is destroyed rather than in spite of it.
Zero stays legal — records written before this have no field and their
apps hold keys derived with zero, and rotate-webhook-key is the backfill.
`TestReclaimedAppNameGetsADifferentKey` is new and would have caught it.

The app-side variable is `<PREFIX>_SCAN_CALLBACK_SECRET`, never the
master's name: one name for both is how a deployment master ends up in a
tenant's environment. The delivered value is hex, because EnvironmentFile
values are text and a raw NUL or newline in a key would break the app's
whole config render.

WHAT THE MUTATION TESTING SAID. The random generation and the key-id
stamping are both pinned. The rotation SKIP is enforced in two places —
target selection and delivery — so removing either alone changes nothing
and only removing both fails a test. That is redundancy rather than a
gap: the property holds under either check, which is the thing worth
guaranteeing. Recorded here so the next reader knows the duplication is
load-bearing in aggregate and not an oversight.

TWO PROCESS NOTES. Codex's first run delivered 320 lines with no tests at
all, because I handed it a sandbox that forbids binding a socket and the
console tests need an httptest listener. It said so plainly rather than
claiming success, which is the behaviour worth having; the fault was
mine, and the second run had the tests.

And my own mutation harness was wrong AGAIN in the same way as earlier
tonight: the first attempt mutated the redundant copy of the skip and
reported a survivor that meant nothing. Both harnesses now assert up
front that the tests they claim to run actually ran.

The CLI documentation codex drafted is deliberately NOT in this commit.
It is user-facing copy, this project reviews that with a person before it
ships, and the plan holds docs until task 17 anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a0be6f4bb41f643b44f3088cfdcab9a75b83c56d parent 90ea50b
18 files changed, +637 −21
  • .gitignore +1 −0
  • apimode.go +24 −0
  • cmd/console/main.go +11 −0
  • docs/superpowers/plans/2026-09-02-scan-on-demand.md +9 −2
  • docs/superpowers/specs/2026-09-02-scan-on-demand-design.md +17 −4
  • internal/console/apiscan.go +148 −4
  • internal/console/apiscan_keys_test.go +244 −0
  • internal/console/apiscan_test.go +1 −1
  • internal/console/apistore_test.go +1 −0
  • internal/console/apisystem.go +90 −0
  • internal/console/authcli.go +2 −0
  • internal/console/scansign.go +8 −0
  • internal/console/server.go +4 −0
  • internal/console/svcroutes_test.go +3 −2
  • internal/objstore/objstore.go +5 −3
  • internal/objstore/walk.go +40 −3
  • scan.go +8 −1
  • system.go +21 −1

CI passed — run details

diff --git a/.gitignore b/.gitignore
index 42d71f76e..e1f835413 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ platform
*.tfstate*
.terraform/
.claude/worktrees/
+writing/
# Pinned MinIO/mc binaries cached by .amadan/ci.d/30-minio-contract.
# amadan reuses one workspace per repo across jobs, so an untracked
diff --git a/apimode.go b/apimode.go
index fbb91f813..e937fb90e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1680,6 +1680,26 @@ func (s *apiSession) systemAntivirusSet(ctx context.Context, on bool) (apiSystem
return out, nil
}
+type apiWebhookRotateReply struct {
+ Rotated int `json:"rotated"`
+ Remaining int `json:"remaining"`
+}
+
+func (s *apiSession) systemWebhookRotate(ctx context.Context) (apiWebhookRotateReply, error) {
+ status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus/rotate-webhook-key", struct{}{})
+ if err != nil {
+ return apiWebhookRotateReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiWebhookRotateReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiWebhookRotateReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return out, fmt.Errorf("rotate webhook key: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// ---- store scanning (scan.go) ----------------------------------------
// The scan reply's blocks, mirrored here rather than imported from
@@ -1809,6 +1829,10 @@ func (s *apiSession) scanEnforce(ctx context.Context, a apiAcct, app string, on
return s.scanCall(ctx, "store scan enforce", apiPath(a, app, "/store/scan/enforce"), in)
}
+func (s *apiSession) scanRotateCallbackKey(ctx context.Context, a apiAcct, app string) (apiScanReply, error) {
+ return s.scanCall(ctx, "store scan rotate-webhook-key", apiPath(a, app, "/store/scan/callback-key/rotate"), struct{}{})
+}
+
// storeCreate asks the console to declare (or re-declare) a store for app,
// under envPrefix ("" takes the console's own default — objstore.
// DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 57f73f4e5..eeb1e988f 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -260,6 +260,16 @@ func serve() {
st := openConsoleDB()
storeProvisioner, storeDeployment := storeBackend()
storeScanner, seedAntivirus := storeScannerFor(storeDeployment)
+ var callbackSigner *console.CallbackSigner
+ if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") == "" && os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS") != "" {
+ log.Fatal("console: CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS is set without CARLOS_SCAN_WEBHOOK_KEY")
+ }
+ if os.Getenv("CARLOS_SCAN_WEBHOOK_KEY") != "" {
+ callbackSigner, err = console.NewCallbackSigner(os.Getenv("CARLOS_SCAN_WEBHOOK_KEY"), os.Getenv("CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
mailDep := mailDeployment()
mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer)
srv, err := console.NewServer(console.Config{
@@ -287,6 +297,7 @@ func serve() {
ScanBudget: envDuration("CARLOS_STORE_SCAN_BUDGET", time.Minute),
ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour),
ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour),
+ CallbackSigner: callbackSigner,
StoreDeployment: storeDeployment,
StoreOperators: storeOperators(),
// CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads
diff --git a/docs/superpowers/plans/2026-09-02-scan-on-demand.md b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
index 2a9200008..a883f2976 100644
--- a/docs/superpowers/plans/2026-09-02-scan-on-demand.md
+++ b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
@@ -577,8 +577,15 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
A 202 would promise a delivery that cannot happen. (The door already accepts
the field, so this is a fix, not only an addition.)
- [ ] **`TestPerAppKeyGenerationRotatesOneApp`** — `webhook_gen` in `ScanBlock`;
- a tenant that leaks its key must not need a deployment-wide rotation, and a
- reclaimed app name must not inherit the deleted app's key.
+ a tenant that leaks its key must not need a deployment-wide rotation.
+- [ ] **`TestReclaimedAppNameGetsADifferentKey`** — and this one needs
+ `webhook_gen` to be RANDOM rather than a counter. `PurgeApp` deletes
+ `store.json` with the rest of the app prefix (`trash.go:229-241`), so a
+ counter from zero restarts at zero for whoever claims the name next and
+ derives the identical key. Mint a random positive int below 2^53 at grant
+ and again on each per-app rotation. Zero stays legal: records predating this
+ have no field, their apps hold keys derived with zero, and
+ `rotate-webhook-key` is the backfill.
**The app-side variable is NOT `CARLOS_SCAN_WEBHOOK_KEY`.** Same name for the
master and the derived child is a copy-paste footgun that ends with a master in
diff --git a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
index 98a390fe4..614d03418 100644
--- a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
+++ b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
@@ -721,10 +721,23 @@ unit** on a bundle change (`sync.go:33,42,331`). So:
- **Per-app rotation.** `ScanBlock` carries a non-secret `webhook_gen`, and the
derivation is over `sqid + "/" + app + "/" + gen`. Without it a tenant that
leaks its callback key has no remedy short of an operator rotating the
- deployment master and restarting every scanning app — and a reclaimed app
- name in the same account would inherit the deleted app's key. The tag key
- needs no such thing because it never leaves the console; this one is handed
- to tenants, so it needs an owner-side rotation.
+ deployment master and restarting every scanning app. The tag key needs no
+ such thing because it never leaves the console; this one is handed to
+ tenants, so it needs an owner-side rotation.
+- **`webhook_gen` IS RANDOM, NOT A COUNTER FROM ZERO**, and the reclaimed-name
+ property depends entirely on that. Draft 5 said a generation stopped a
+ reclaimed app name inheriting the deleted app's key. **It did not.** The
+ implementer pointed out that `PurgeApp` lists the whole app prefix and
+ deletes every key in it (`trash.go:229-241`), `store.json` included — so a
+ counter starting at zero starts at zero again for the next app to claim that
+ name, and derives the identical key. The claim was false as written.
+ A fresh random value at grant makes it true, and makes it true *because* the
+ record is destroyed rather than in spite of it: mint a random positive
+ integer below 2^53 (JSON-number safe, since the console serves this to
+ browsers) at the first grant, and mint a new one on every per-app rotation.
+ Zero remains a legal derivation input, because records written before this
+ ships have no field and their apps already hold a key derived with zero;
+ `rotate-webhook-key` is the backfill that moves them onto a random one.
- **With no master configured**, the enqueue door refuses a `callback_path`
with 503 `callbacks_not_configured` rather than accepting it. A 202 would
promise a delivery that cannot happen. Delivery refuses to build a request
diff --git a/internal/console/apiscan.go b/internal/console/apiscan.go
index c3d1e2f6a..afd44fc2e 100644
--- a/internal/console/apiscan.go
+++ b/internal/console/apiscan.go
@@ -23,12 +23,16 @@ package console
import (
"context"
"crypto/ecdh"
+ "crypto/rand"
+ "encoding/binary"
"errors"
+ "fmt"
"log"
"net/http"
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -311,6 +315,15 @@ func (s *Server) handleAPIScanGrant(w http.ResponseWriter, r *http.Request) {
scanNoEngine(w)
return
}
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ if s.cfg.CallbackSigner == nil {
+ scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured",
+ "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
defer cancel()
@@ -366,6 +379,13 @@ func (s *Server) handleAPIScanGrant(w http.ResponseWriter, r *http.Request) {
rec.Scanning.Enabled = &objstore.ScanEnabled{
Engine: engine, At: time.Now().UTC(), By: addr,
}
+ if rec.Scanning.WebhookGen == 0 {
+ gen, err := randomWebhookGeneration()
+ if err != nil {
+ return err
+ }
+ rec.Scanning.WebhookGen = gen
+ }
return nil
})
if err != nil {
@@ -373,6 +393,22 @@ func (s *Server) handleAPIScanGrant(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not record the engine — try again")
return
}
+ if err := s.regrantForScanning(ctx, pub, acct, app, addr, true); err != nil {
+ log.Printf("console: api scan grant %s/%s: deliver callback key: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ "scanning was enabled but its callback secret was not delivered — re-run the grant to resume")
+ return
+ }
+ rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ rec.Scanning.WebhookKeyID = s.cfg.CallbackSigner.CurrentKeyID()
+ return nil
+ })
+ if err != nil {
+ log.Printf("console: api scan grant %s/%s: record callback key id: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ "the callback secret was delivered but its key id was not recorded — re-run the grant to converge")
+ return
+ }
s.audit(acct, app, addr, "store-scan-grant", "enabled "+engine+" scanning on the store (cli)")
s.replyScan(w, r, acct, app, rec)
}
@@ -432,12 +468,20 @@ func (s *Server) handleAPIScanRevoke(w http.ResponseWriter, r *http.Request) {
// this the record would say enforcement is off while the live policy still
// denied reads — the exact drift the whole enforcement design exists to
// avoid.
- if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil {
+ if err := s.regrantForScanning(ctx, pub, acct, app, addr, false); err != nil {
log.Printf("console: api scan revoke %s/%s: regrant: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway,
"the record was cleared but the store policy was not rewritten — re-run `carlos store grant` to converge it")
return
}
+ if rec.Declared != nil {
+ callbackName := rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET"
+ if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{callbackName}); err != nil {
+ log.Printf("console: api scan revoke %s/%s: unset callback secret: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "scanning was revoked but its callback secret could not be removed — re-run to resume")
+ return
+ }
+ }
s.audit(acct, app, addr, "store-scan-revoke", "disabled object scanning on the store (cli)")
s.replyScan(w, r, acct, app, rec)
}
@@ -525,7 +569,7 @@ func (s *Server) handleAPIScanEnforce(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil {
+ if err := s.regrantForScanning(ctx, pub, acct, app, addr, false); err != nil {
log.Printf("console: api scan enforce %s/%s: regrant: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway,
"the record was updated but the store policy was not rewritten — re-run `carlos store grant` to converge it")
@@ -550,7 +594,7 @@ var (
//
// A store that was never granted has no policy to converge, so this is a
// no-op for it rather than an error.
-func (s *Server) regrantForScanning(ctx context.Context, pub *ecdh.PublicKey, acct *Account, app, addr string) error {
+func (s *Server) regrantForScanning(ctx context.Context, pub *ecdh.PublicKey, acct *Account, app, addr string, deliverCallback bool) error {
rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
if err != nil {
return err
@@ -558,11 +602,106 @@ func (s *Server) regrantForScanning(ctx context.Context, pub *ecdh.PublicKey, ac
if rec == nil || rec.Provisioned == nil {
return nil
}
+ var extras []objstore.DeliverySecret
+ if deliverCallback {
+ if s.cfg.CallbackSigner == nil || rec.Scanning == nil {
+ return ErrNoCallbackKey
+ }
+ name := rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET"
+ extras = append(extras, objstore.DeliverySecret{
+ Name: name, Value: s.cfg.CallbackSigner.DerivedKey(acct.Sqid, app, rec.Scanning.WebhookGen),
+ })
+ }
_, err = objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
- pub, acct.Sqid, app, addr, "", time.Now())
+ pub, acct.Sqid, app, addr, "", time.Now(), extras...)
+ return err
+}
+
+func (s *Server) deliverCallbackSecret(ctx context.Context, pub *ecdh.PublicKey, acct, app string, rec *objstore.Record, gen int) error {
+ if s.cfg.CallbackSigner == nil || rec.Declared == nil {
+ return ErrNoCallbackKey
+ }
+ name := rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET"
+ envelope, err := appconfig.Seal(pub, s.cfg.CallbackSigner.DerivedKey(acct, app, gen))
+ if err != nil {
+ return err
+ }
+ _, err = appconfig.Apply(ctx, s.cfg.Releases, acct, app, map[string]appconfig.Var{
+ name: {Kind: "secret", Envelope: envelope},
+ }, nil)
return err
}
+// randomWebhookGeneration deliberately does not survive app deletion. A
+// reclaimed name starts from a fresh random value rather than repeating a
+// counter whose record was swept with the old app. Keep it below 2^53 so the
+// value round-trips through JSON implementations that store numbers as
+// IEEE-754 doubles; zero remains reserved for records written before this
+// field existed.
+func randomWebhookGeneration() (int, error) {
+ var raw [8]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return 0, fmt.Errorf("mint callback generation: %w", err)
+ }
+ return int(binary.BigEndian.Uint64(raw[:])%((1<<53)-1)) + 1, nil
+}
+
+// handleAPIScanCallbackRotate changes only this app's derived key. The
+// generation is recorded before it is used again for signing; a deployment
+// master rotation leaves it untouched.
+func (s *Server) handleAPIScanCallbackRotate(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.storeOperator(addr) {
+ jsonError(w, http.StatusForbidden, "rotating a scan callback key needs a deployment operator (CARLOS_STORE_OPERATORS)")
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ if s.cfg.CallbackSigner == nil {
+ scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured", "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
+ defer cancel()
+ rec, _, ok := s.scanRecord(ctx, w, acct, app)
+ if !ok {
+ return
+ }
+ if rec.Scanning == nil || rec.Scanning.Enabled == nil {
+ jsonError(w, http.StatusConflict, "scanning is not enabled on this store — run `carlos store scan grant` first")
+ return
+ }
+ gen, err := randomWebhookGeneration()
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not mint a new callback key — try again")
+ return
+ }
+ if err := s.deliverCallbackSecret(ctx, pub, acct.Sqid, app, rec, gen); err != nil {
+ jsonError(w, http.StatusBadGateway, "could not deliver the rotated callback secret — re-run to resume")
+ return
+ }
+ rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ rec.Scanning.WebhookGen = gen
+ rec.Scanning.WebhookKeyID = s.cfg.CallbackSigner.CurrentKeyID()
+ return nil
+ })
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, "the callback secret was delivered but its generation was not recorded — re-run to converge")
+ return
+ }
+ s.bumpSerial(ctx)
+ s.replyScan(w, r, acct, app, rec)
+}
+
// handleAPIScanSweep answers `carlos store scan sweep`: run one budget of
// scanning now rather than waiting for the tick.
func (s *Server) handleAPIScanSweep(w http.ResponseWriter, r *http.Request) {
@@ -771,6 +910,11 @@ func (s *Server) handleAPIScanEnqueue(w http.ResponseWriter, r *http.Request) {
" — callback_path is a path on your app's platform host, not a URL")
return
}
+ if s.cfg.CallbackSigner == nil {
+ scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured",
+ "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
+ return
+ }
}
if s.cfg.Releases == nil {
scanNoEngineState(w)
diff --git a/internal/console/apiscan_keys_test.go b/internal/console/apiscan_keys_test.go
new file mode 100644
index 000000000..840ce59a5
--- /dev/null
+++ b/internal/console/apiscan_keys_test.go
@@ -0,0 +1,244 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const testWebhookMaster = "test-current-webhook-master"
+
+func grantScanning(t *testing.T, fx *consoleFixture, token, app string) {
+ t.Helper()
+ if s, o := postJSON(t, scanURL(fx, app), map[string]any{"encrypted": false}, token); s != http.StatusOK {
+ t.Fatalf("scan request %s = %d %v", app, s, o)
+ }
+ if s, o := postJSON(t, scanURL(fx, app)+"/grant", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("scan grant %s = %d %v", app, s, o)
+ }
+}
+
+func callbackSecret(t *testing.T, fx *consoleFixture, priv, app string) string {
+ t.Helper()
+ v, ok := appVars(t, fx, app)[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]
+ if !ok {
+ t.Fatal("callback secret was not delivered")
+ }
+ return openSecret(t, priv, v)
+}
+
+func TestGrantDeliversWebhookKey(t *testing.T) {
+ fx, _, token, priv := storeArmed(t, "code-webhook-grant")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Scanning.WebhookGen <= 0 || rec.Scanning.WebhookGen >= 1<<53 {
+ t.Fatalf("webhook_gen = %d, want a positive JSON-safe generation", rec.Scanning.WebhookGen)
+ }
+ want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen))
+ if got := callbackSecret(t, fx, priv, "titogo"); got != want {
+ t.Fatalf("callback secret = %q, want derived app key %q", got, want)
+ }
+ if rec.Scanning.WebhookKeyID != fx.srv.cfg.CallbackSigner.CurrentKeyID() {
+ t.Fatalf("webhook_key_id = %q, want current key id", rec.Scanning.WebhookKeyID)
+ }
+}
+
+type callbackPutCounter struct {
+ release.Store
+ configPuts atomic.Int64
+ cancel context.CancelFunc
+}
+
+func (s *callbackPutCounter) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
+ got, err := s.Store.Put(ctx, key, body, etag)
+ if err == nil && strings.HasSuffix(key, "/config.json") {
+ s.configPuts.Add(1)
+ if s.cancel != nil {
+ s.cancel()
+ }
+ }
+ return got, err
+}
+
+func TestRotateWebhookKeySkipsUnchangedApps(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-webhook-skip")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+
+ counter := &callbackPutCounter{Store: fx.rel}
+ fx.srv.cfg.Releases = counter
+ pub, err := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if remaining != 0 || changed != 0 || counter.configPuts.Load() != 0 {
+ t.Fatalf("unchanged rotation = remaining %d, changed %d, config puts %d; want all zero", remaining, changed, counter.configPuts.Load())
+ }
+}
+
+func TestRotateWebhookKeyDoesNotDropPrevious(t *testing.T) {
+ fx, _, token, priv := storeArmed(t, "code-webhook-previous")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+ fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "new-master", testWebhookMaster)
+ pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
+ if _, _, err := fx.srv.rotateWebhookBudget(context.Background(), pub); err != nil {
+ t.Fatal(err)
+ }
+ if fx.srv.cfg.CallbackSigner.previous == nil || fx.srv.cfg.CallbackSigner.previous.id == "" {
+ t.Fatal("rotation dropped the previous callback master")
+ }
+ rec := storedRecord(t, fx, "titogo")
+ want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen))
+ if got := callbackSecret(t, fx, priv, "titogo"); got != want {
+ t.Fatal("rotation did not deliver the current master")
+ }
+}
+
+func TestGrantFoldsBothSecretsIntoOneApply(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-webhook-one-apply")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ requestScanning(t, fx, token, false)
+ counter := &callbackPutCounter{Store: fx.rel}
+ fx.srv.cfg.Releases = counter
+
+ if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", s, o)
+ }
+ if got := counter.configPuts.Load(); got != 1 {
+ t.Fatalf("scan grant wrote config %d times, want one Apply", got)
+ }
+}
+
+func TestRevokeUnsetsTheCallbackSecret(t *testing.T) {
+ fx, _, token, priv := storeArmed(t, "code-webhook-revoke")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+ _ = callbackSecret(t, fx, priv, "titogo")
+ if s, o := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("scan revoke = %d %v", s, o)
+ }
+ if _, ok := appVars(t, fx, "titogo")[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]; ok {
+ t.Fatal("revoke left the callback secret in app config")
+ }
+}
+
+func TestRotateIsBudgetedAndResumable(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-webhook-budget")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if s, o := postJSON(t, storeURL(fx, "second"), map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("declare second = %d %v", s, o)
+ }
+ if s, o := postJSON(t, storeURL(fx, "second")+"/grant", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("store grant second = %d %v", s, o)
+ }
+ grantScanning(t, fx, token, "second")
+ fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "replacement-master", testWebhookMaster)
+ pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test")
+ ctx, cancel := context.WithCancel(context.Background())
+ counter := &callbackPutCounter{Store: fx.rel, cancel: cancel}
+ fx.srv.cfg.Releases = counter
+ remaining, changed, err := fx.srv.rotateWebhookBudget(ctx, pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changed != 1 || remaining != 1 {
+ t.Fatalf("bounded rotation = changed %d remaining %d, want 1/1", changed, remaining)
+ }
+ counter.cancel = nil
+ remaining, changed, err = fx.srv.rotateWebhookBudget(context.Background(), pub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changed != 1 || remaining != 0 {
+ t.Fatalf("resumed rotation = changed %d remaining %d, want 1/0", changed, remaining)
+ }
+}
+
+func TestNoMasterRefusesACallbackPath(t *testing.T) {
+ fx, token := enqueueArmed(t, "code-webhook-no-master")
+ fx.srv.cfg.CallbackSigner = nil
+ status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x", "callback_path": "/scan-result"}, token)
+ if status != http.StatusServiceUnavailable || out["reason"] != "callbacks_not_configured" {
+ t.Fatalf("enqueue callback without master = %d %v, want 503 callbacks_not_configured", status, out)
+ }
+ if got := queuedKeys(t, fx, "titogo"); len(got) != 0 {
+ t.Fatalf("refused callback still queued work: %v", got)
+ }
+}
+
+func TestPerAppKeyGenerationRotatesOneApp(t *testing.T) {
+ fx, _, token, priv := storeArmed(t, "code-webhook-one-app")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+ before := storedRecord(t, fx, "titogo").Scanning.WebhookGen
+ secretBefore := callbackSecret(t, fx, priv, "titogo")
+ if s, o := postJSON(t, scanURL(fx, "titogo")+"/callback-key/rotate", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("per-app rotate = %d %v", s, o)
+ }
+ after := storedRecord(t, fx, "titogo").Scanning.WebhookGen
+ if after == before || after <= 0 || after >= 1<<53 {
+ t.Fatalf("webhook_gen did not rotate safely: %d -> %d", before, after)
+ }
+ if got := callbackSecret(t, fx, priv, "titogo"); got == secretBefore {
+ t.Fatal("per-app rotation left the delivered callback secret unchanged")
+ }
+}
+
+func TestReclaimedAppNameGetsADifferentKey(t *testing.T) {
+ fx, _, token, priv := storeArmed(t, "code-webhook-reclaim")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ grantScanning(t, fx, token, "titogo")
+ old := callbackSecret(t, fx, priv, "titogo")
+
+ keys, err := fx.rel.List(context.Background(), release.AppPrefix(fx.sqid, "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range keys {
+ if err := fx.rel.Delete(context.Background(), key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ declareStore(t, fx, token)
+ if s, o := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("reclaimed store grant = %d %v", s, o)
+ }
+ grantScanning(t, fx, token, "titogo")
+ if got := callbackSecret(t, fx, priv, "titogo"); got == old {
+ t.Fatal("reclaimed app name received the deleted app's callback key")
+ }
+}
diff --git a/internal/console/apiscan_test.go b/internal/console/apiscan_test.go
index 0dffa612f..76eeba6c5 100644
--- a/internal/console/apiscan_test.go
+++ b/internal/console/apiscan_test.go
@@ -52,7 +52,7 @@ func TestScanDoorsGateOrder(t *testing.T) {
fx.srv.cfg.StoreOperators = []string{"eve@keymail.dev", "alice@keymail.dev"}
eve := cliToken(t, fx, "eve@keymail.dev", "code-scan-gate-eve")
- for _, door := range []string{"", "/grant", "/revoke", "/sweep", "/enforce"} {
+ for _, door := range []string{"", "/grant", "/revoke", "/sweep", "/enforce", "/callback-key/rotate"} {
t.Run("scan"+door, func(t *testing.T) {
// (a) an operator who is a member of nothing, against a real app.
statusA, bodyA := postRaw(t, scanURL(fx, "titogo")+door, map[string]any{}, eve)
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
index bcca27b2f..d97a97fa5 100644
--- a/internal/console/apistore_test.go
+++ b/internal/console/apistore_test.go
@@ -510,6 +510,7 @@ func storeArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemProvis
fx.srv.cfg.SecretsPub = pub
fx.srv.cfg.StoreProvisioner = fake
fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
+ fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "test-current-webhook-master", "")
// Spelled with different capitals on purpose: an operator list is typed by
// a human into a unit file, and addresses are case-insensitive.
fx.srv.cfg.StoreOperators = []string{"Alice@Keymail.dev"}
diff --git a/internal/console/apisystem.go b/internal/console/apisystem.go
index 7abbe9d3a..faf97b79b 100644
--- a/internal/console/apisystem.go
+++ b/internal/console/apisystem.go
@@ -21,12 +21,15 @@ package console
import (
"context"
+ "crypto/ecdh"
"errors"
"log"
"net/http"
"os"
+ "strings"
"time"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -54,6 +57,93 @@ func (s *Server) apiSystemOperator(w http.ResponseWriter, r *http.Request) (stri
return p.Address, true
}
+// handleAPISystemWebhookRotate spends one sweep-sized request budget sealing
+// the current master for scanning-enabled apps. Progress needs no cursor: the
+// non-secret key id is the cursor, so a retry skips everything already done.
+func (s *Server) handleAPISystemWebhookRotate(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.apiSystemOperator(w, r); !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ if s.cfg.CallbackSigner == nil {
+ scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured", "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), scanSweepTimeout)
+ defer cancel()
+ remaining, changed, err := s.rotateWebhookBudget(ctx, pub)
+ if err != nil {
+ log.Printf("console: rotate scan webhook key: %v", err)
+ jsonError(w, http.StatusBadGateway, "callback-key rotation stopped — re-run to resume")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]int{"rotated": changed, "remaining": remaining})
+}
+
+func (s *Server) rotateWebhookBudget(ctx context.Context, pub *ecdh.PublicKey) (remaining, changed int, err error) {
+ accounts, err := s.cfg.Releases.ListDirs(ctx, "apps/")
+ if err != nil {
+ return 0, 0, err
+ }
+ type target struct{ acct, app string }
+ var targets []target
+ for _, accountPrefix := range accounts {
+ acct := strings.TrimSuffix(strings.TrimPrefix(accountPrefix, "apps/"), "/")
+ apps, lerr := s.cfg.Releases.ListDirs(ctx, accountPrefix)
+ if lerr != nil {
+ return 0, changed, lerr
+ }
+ for _, appPrefix := range apps {
+ app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, accountPrefix), "/")
+ rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app)
+ if gerr != nil {
+ return 0, changed, gerr
+ }
+ if rec != nil && rec.Scanning != nil && rec.Scanning.Enabled != nil && rec.Scanning.WebhookKeyID != s.cfg.CallbackSigner.CurrentKeyID() {
+ targets = append(targets, target{acct, app})
+ }
+ }
+ }
+ remaining = len(targets)
+ for _, target := range targets {
+ if ctx.Err() != nil {
+ return remaining, changed, nil
+ }
+ rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, target.acct, target.app)
+ if gerr != nil {
+ return remaining, changed, gerr
+ }
+ if rec == nil || rec.Scanning == nil || rec.Scanning.Enabled == nil || rec.Scanning.WebhookKeyID == s.cfg.CallbackSigner.CurrentKeyID() {
+ remaining--
+ continue
+ }
+ if derr := s.deliverCallbackSecret(ctx, pub, target.acct, target.app, rec, rec.Scanning.WebhookGen); derr != nil {
+ if ctx.Err() != nil {
+ return remaining, changed, nil
+ }
+ return remaining, changed, derr
+ }
+ account := &Account{Sqid: target.acct}
+ if _, werr := s.writeScanRecord(ctx, account, target.app, func(fresh *objstore.Record) error {
+ fresh.Scanning.WebhookKeyID = s.cfg.CallbackSigner.CurrentKeyID()
+ return nil
+ }); werr != nil {
+ return remaining, changed, werr
+ }
+ remaining--
+ changed++
+ }
+ return remaining, changed, nil
+}
+
// systemAntivirusReply is the one shape all three doors answer with, so
// status and the mutating doors cannot describe the deployment differently.
//
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 93f8179ad..c4acb96ee 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -756,6 +756,7 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/callback-key/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIScanCallbackRotate)
// The ONE scan door an app's own credential reaches: "scan this object
// now" (spec §5.1). Service, not Admin, and the difference is the point —
// every verb above decides POLICY (who may scan, whether enforcement is
@@ -774,6 +775,7 @@ func (s *Server) registerCLIRoutes() {
// Authority is the CARLOS_STORE_OPERATORS address list, checked inside.
s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet)
s.cliRoute("POST /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusSet)
+ s.cliRoute("POST /api/cli/system/antivirus/rotate-webhook-key", svcrole.Set{}, s.handleAPISystemWebhookRotate)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/console/scansign.go b/internal/console/scansign.go
index 1038bc233..7a1c192e7 100644
--- a/internal/console/scansign.go
+++ b/internal/console/scansign.go
@@ -58,6 +58,14 @@ func newCallbackMaster(secret string) callbackMaster {
return callbackMaster{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]}
}
+func (s *CallbackSigner) CurrentKeyID() string { return s.current.id }
+
+func (s *CallbackSigner) DerivedKey(sqid, app string, gen int) []byte {
+ // EnvironmentFile values are text. Hex keeps every derived byte intact
+ // without allowing a random newline or NUL to break the app's whole render.
+ return []byte(hex.EncodeToString(DeriveCallbackKey(string(s.current.secret), sqid, app, gen)))
+}
+
// DeriveCallbackKey returns the one key delivered to an app. The domain label
// prevents another future HMAC(master, value) use from producing the same key;
// gen lets one tenant rotate a leaked key without rotating the deployment.
diff --git a/internal/console/server.go b/internal/console/server.go
index 6982631b1..99c54f4cc 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -557,6 +557,10 @@ type Config struct {
// a fresh one, and an audit answer that cannot tell them apart is worth
// less than it looks.
ScanMaxSigAge time.Duration
+ // CallbackSigner retains the deployment master because callbacks are signed
+ // after grant. nil is a supported deployment shape; callback enrollment and
+ // key delivery refuse rather than ever falling back to unsigned delivery.
+ CallbackSigner *CallbackSigner
// StoreDeployment is the deployment-wide half of every store: which
// backend, which region and endpoint buckets are made in, and the prefix
// minted bucket names carry. Threaded into every walk so this config is
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 66697ebb0..9602b264d 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -87,6 +87,7 @@ var closedDoors = []string{
// credential's.
"GET /api/cli/system/antivirus",
"POST /api/cli/system/antivirus",
+ "POST /api/cli/system/antivirus/rotate-webhook-key",
// A credential may never mint a credential, including itself. This is
// the escalation cut of spec §3.
"POST /api/cli/apps/{account}/{app}/instance-token",
@@ -708,8 +709,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 21 {
- t.Errorf("walked %d closed doors, want all %d (and 21 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, two deployment-wide antivirus)",
+ if seen != len(closedDoors) || seen != 22 {
+ t.Errorf("walked %d closed doors, want all %d (and 22 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, three deployment-wide antivirus)",
seen, len(closedDoors))
}
}
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go
index a60e2646b..f50d8f8ef 100644
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
@@ -85,9 +85,11 @@ type ScanEnabled struct {
// off` clear a deny on a deployment with no engine built. A bool here would
// mean unsetting CARLOS_STORE_SCANNER left every enforcing store unrepairable.
type ScanBlock struct {
- Requested *ScanRequested `json:"requested,omitempty"`
- Enabled *ScanEnabled `json:"enabled,omitempty"`
- Enforce *DenyTag `json:"enforce,omitempty"`
+ Requested *ScanRequested `json:"requested,omitempty"`
+ Enabled *ScanEnabled `json:"enabled,omitempty"`
+ Enforce *DenyTag `json:"enforce,omitempty"`
+ WebhookKeyID string `json:"webhook_key_id,omitempty"`
+ WebhookGen int `json:"webhook_gen,omitempty"`
}
// Record is apps/<acct>/<app>/store.json — three blocks for three actors
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index 663b8b9e8..66231955e 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -130,8 +130,13 @@ func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) }
// in the sequence — is repaired by walking it again. adoptBucket names an
// existing bucket to adopt instead of minting one; it is honoured only on the
// FIRST grant, since the recorded bucket is what the app's data is in.
+type DeliverySecret struct {
+ Name string
+ Value []byte
+}
+
func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
- sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error) {
+ sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time, extras ...DeliverySecret) (*Record, error) {
// 1. resolve + write the grant block
rec, etag, err := Get(ctx, s, acct, app)
if err != nil {
@@ -197,7 +202,32 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
if err != nil {
return nil, err
}
+ manifest, merr := appconfig.Get(ctx, s, acct, app)
+ if merr != nil {
+ return nil, merr
+ }
+ extrasPresent := true
+ for _, extra := range extras {
+ if v, ok := manifest.Vars[extra.Name]; !ok || v.Kind != "secret" {
+ extrasPresent = false
+ break
+ }
+ }
+ if len(missing) == 0 && extrasPresent {
+ return rec, nil
+ }
if len(missing) == 0 {
+ sets := make(map[string]appconfig.Var, len(extras))
+ for _, extra := range extras {
+ envelope, serr := appconfig.Seal(sealPub, extra.Value)
+ if serr != nil {
+ return nil, fmt.Errorf("objstore: seal %s: %w", extra.Name, serr)
+ }
+ sets[extra.Name] = appconfig.Var{Kind: "secret", Envelope: envelope}
+ }
+ if _, aerr := appconfig.Apply(ctx, s, acct, app, sets, nil); aerr != nil {
+ return nil, fmt.Errorf("objstore: deliver supplemental config for %s/%s: %w", acct, app, aerr)
+ }
return rec, nil
}
}
@@ -223,7 +253,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err)
}
// 5. deliver — the secret's ONLY landing place (§7)
- if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil {
+ if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret, extras...); err != nil {
return nil, err
}
// 6. record success — after the secret is recorded, never before (§5).
@@ -600,7 +630,7 @@ func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, st
// longer uses. The set half is always non-empty, so this never trips
// applyAt's refusal to create a bundle on an unset-only call.
func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
- acct, app, prefix, bucket, region, endpoint, keyID, secret string) error {
+ acct, app, prefix, bucket, region, endpoint, keyID, secret string, extras ...DeliverySecret) error {
bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix)
sets := map[string]appconfig.Var{
bucketName: {Kind: "env", Value: bucket},
@@ -631,6 +661,13 @@ func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
}
sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env}
}
+ for _, extra := range extras {
+ env, err := appconfig.Seal(sealPub, extra.Value)
+ if err != nil {
+ return fmt.Errorf("objstore: seal %s: %w", extra.Name, err)
+ }
+ sets[extra.Name] = appconfig.Var{Kind: "secret", Envelope: env}
+ }
if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil {
return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err)
}
diff --git a/scan.go b/scan.go
index 61bfd65e6..eef6c9585 100644
--- a/scan.go
+++ b/scan.go
@@ -34,7 +34,7 @@ import (
"time"
)
-const storeScanUsage = `usage: carlos store scan <request|status|grant|revoke|sweep|enforce> [flags]
+const storeScanUsage = `usage: carlos store scan <request|status|grant|revoke|sweep|enforce|rotate-webhook-key> [flags]
request --app <a> (--plaintext | --client-encrypted)
ask for this store's objects to be scanned. One of
@@ -51,6 +51,9 @@ const storeScanUsage = `usage: carlos store scan <request|status|grant|revoke|sw
enforce --app <a> on|off
operator-only: deny the app's own credentials read
access to any object the engine tagged infected.
+ rotate-webhook-key --app <a>
+ operator-only: replace this app's callback secret
+ and restart it, without touching any other app.
Flags: --account/--account-id to disambiguate, --console to pick a console.
`
@@ -73,6 +76,8 @@ func runStoreScan(args []string) {
runStoreScanSimple(args[1:], "sweep")
case "enforce":
runStoreScanEnforce(args[1:])
+ case "rotate-webhook-key":
+ runStoreScanSimple(args[1:], "rotate-webhook-key")
default:
fmt.Fprint(os.Stderr, storeScanUsage)
os.Exit(1)
@@ -152,6 +157,8 @@ func runStoreScanSimple(args []string, verb string) {
res, err = sess.scanRevoke(ctx, acct, name)
case "sweep":
res, err = sess.scanSweep(ctx, acct, name)
+ case "rotate-webhook-key":
+ res, err = sess.scanRotateCallbackKey(ctx, acct, name)
}
if err != nil {
log.Fatalf("store scan %s: %v", verb, err)
diff --git a/system.go b/system.go
index 6b7e567d3..a06fa5039 100644
--- a/system.go
+++ b/system.go
@@ -17,7 +17,7 @@ import (
"time"
)
-const systemUsage = `usage: carlos system antivirus <status|enable|disable> [flags]
+const systemUsage = `usage: carlos system antivirus <status|enable|disable|rotate-webhook-key> [flags]
antivirus status is object scanning wired, switched on, and answering?
Reports wiring and policy separately, because they fail
@@ -29,6 +29,11 @@ const systemUsage = `usage: carlos system antivirus <status|enable|disable> [fla
antivirus disable turn it off. Takes effect on the next tick — no restart,
which is the point of the switch living in a record
rather than in the unit file.
+ antivirus rotate-webhook-key
+ deliver the current master-derived callback secret to
+ every scanning-enabled app, one bounded batch at a
+ time. Skips apps already on that key, restarts the
+ rest, and is safe to run again.
Deployment operators only (CARLOS_STORE_OPERATORS). This does not scan
anything by itself: a member still requests scanning for their app, and an
@@ -52,6 +57,8 @@ func runSystem(args []string) {
runSystemAntivirus(rest[1:], "enable")
case "disable":
runSystemAntivirus(rest[1:], "disable")
+ case "rotate-webhook-key":
+ runSystemAntivirus(rest[1:], "rotate-webhook-key")
default:
fmt.Fprint(os.Stderr, systemUsage)
os.Exit(1)
@@ -77,6 +84,19 @@ func runSystemAntivirus(args []string, verb string) {
res, err = sess.systemAntivirusSet(ctx, true)
case "disable":
res, err = sess.systemAntivirusSet(ctx, false)
+ case "rotate-webhook-key":
+ warnf(stdout, "this rotation restarts every scanning-enabled app whose callback secret changes")
+ for {
+ rotated, rotateErr := sess.systemWebhookRotate(ctx)
+ if rotateErr != nil {
+ log.Fatalf("system antivirus %s: %v", verb, rotateErr)
+ }
+ fmt.Fprintf(stdout, "rotated callback secrets for %d apps; %d remain\n", rotated.Rotated, rotated.Remaining)
+ if rotated.Remaining == 0 {
+ break
+ }
+ }
+ return
}
if err != nil {
log.Fatalf("system antivirus %s: %v", verb, err)