carlos / platform Public

objstore: the record's scanning block, additive claim asserted

Three actors again: the member requests, an operator enables, and
Enforce carries the deny in force.

ScanRequested.Encrypted is an attestation rather than a detection
because nothing can tell client-side ciphertext from plaintext by
looking, and a scanner that cannot read the bytes reports every object
clean forever. Server-side encryption is explicitly not this — it is
transparent to a credentialled reader and scans fine.

Enforce holds the DenyTag itself rather than a bool: Grant takes a
Provisioner and has no Scanner to ask, and a bool would mean unsetting
CARLOS_STORE_SCANNER left every enforcing store unrepairable.

objstore.go's PARSER LIST comment has always claimed additive keys are
safe, and TestPurgeGuardSeesOurRecord pins the two protected names
without ever writing an unknown one. The new subtests assert both
directions: a scanning block beside grant still blocks auto-purge, and
scanning alone names no backend estate so it must not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c0c4bf96d5764b7235899aea268282985c1a73d2 parent 1a68147
2 files changed, +201 −0
  • internal/objstore/objstore.go +48 −0
  • internal/objstore/scanblock_test.go +153 −0
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go
index 71d913ea8..a60e2646b 100644
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
@@ -47,6 +47,49 @@ type Provisioned struct {
At time.Time `json:"at"`
}
+// ScanRequested is the MEMBER's half of scanning: they asked for it, and they
+// attested what kind of objects the store holds.
+//
+// Encrypted means CLIENT-SIDE encrypted — the objects reach the store as
+// ciphertext, so a scanner cannot read them, every object would scan clean
+// forever, and no coverage is claimed (spec §2.2). Server-side encryption
+// (SSE-S3, SSE-KMS, on by default for every AWS bucket) is NOT this: it is
+// transparent to a credentialled reader and scans perfectly well. The platform
+// cannot tell the two apart by looking, which is why this is an attestation
+// and not a detection.
+type ScanRequested struct {
+ At time.Time `json:"at"`
+ By string `json:"by"`
+ Encrypted bool `json:"encrypted"`
+}
+
+// ScanEnabled is the OPERATOR's half. Enabling calls out to the deployment's
+// own cloud account and, on a metered engine, starts a bill — the same test
+// that puts the store grant behind CARLOS_STORE_OPERATORS.
+type ScanEnabled struct {
+ Engine string `json:"engine"` // "clamav" | "guardduty"
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+// ScanBlock is the scanning half of the record — the same three-author shape
+// the store itself has.
+//
+// It holds NO sweep rollup. Counts live in the scan.json sidecar, whose single
+// writer is the sweep, so a walk writing once per page over minutes never
+// contends with the doors that write this object.
+//
+// Enforce holds the DenyTag ITSELF rather than a bool. Grant takes a
+// Provisioner and has no Scanner to ask for the tag, so the record must carry
+// everything the policy writer needs — which is also what lets `scan enforce
+// 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"`
+}
+
// Record is apps/<acct>/<app>/store.json — three blocks for three actors
// (member declares, operator grants, console provisions). The secret key is
// never here; it exists only sealed inside config.json.
@@ -59,12 +102,17 @@ type Provisioned struct {
// silently stops seeing backend estate; the mirror test in walk_test.go
// (TestPurgeGuardSeesOurRecord) exists to fail loudly if that ever happens.
// Additive changes are safe; these two names are not free to move.
+//
+// `scanning` is such an additive change: release's local struct ignores keys
+// it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts
+// directly. That freedom does NOT extend to `grant` and `provisioned`.
type Record struct {
Schema int `json:"schema"`
App string `json:"app"`
Declared *Declared `json:"declared,omitempty"`
Grant *GrantBlock `json:"grant,omitempty"`
Provisioned *Provisioned `json:"provisioned,omitempty"`
+ Scanning *ScanBlock `json:"scanning,omitempty"`
}
func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" }
diff --git a/internal/objstore/scanblock_test.go b/internal/objstore/scanblock_test.go
new file mode 100644
index 000000000..a0ed7b0b5
--- /dev/null
+++ b/internal/objstore/scanblock_test.go
@@ -0,0 +1,153 @@
+package objstore
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The scanning block round-trips, and its three sub-blocks are null-or-present
+// so a client can tell "requested, waiting for an operator" from "enabled"
+// without inspecting fields — the same tri-state the store's own blocks give.
+func TestScanBlockRoundTrip(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ rec := &Record{
+ App: "titogo",
+ Scanning: &ScanBlock{
+ Requested: &ScanRequested{At: now, By: "m@example.com"},
+ Enabled: &ScanEnabled{Engine: "clamav", At: now, By: "op@example.com"},
+ Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)},
+ },
+ }
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back Record
+ if err := json.Unmarshal(body, &back); err != nil {
+ t.Fatal(err)
+ }
+ if back.Scanning == nil || back.Scanning.Enabled == nil {
+ t.Fatalf("scanning.enabled did not round-trip: %s", body)
+ }
+ if back.Scanning.Enabled.Engine != "clamav" {
+ t.Fatalf("engine = %q, want clamav", back.Scanning.Enabled.Engine)
+ }
+ if back.Scanning.Enforce == nil || back.Scanning.Enforce.Value != string(VerdictInfected) {
+ t.Fatalf("enforce did not round-trip: %s", body)
+ }
+}
+
+// An absent scanning block stays absent. Every deployment that never touches
+// scanning must not grow a null key in every store record it owns.
+func TestScanBlockOmittedWhenAbsent(t *testing.T) {
+ body, err := json.Marshal(&Record{App: "titogo"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(body, []byte("scanning")) {
+ t.Fatalf("an absent scanning block leaked into the JSON: %s", body)
+ }
+}
+
+// The attestation is a real field, not an inference: a store whose objects
+// arrive already encrypted cannot be scanned, and the record has to say so
+// because nothing can tell by looking (spec §2.2).
+func TestScanRequestedCarriesEncryptedAttestation(t *testing.T) {
+ body, err := json.Marshal(&ScanRequested{
+ At: time.Now().UTC(), By: "m@example.com", Encrypted: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back ScanRequested
+ if err := json.Unmarshal(body, &back); err != nil {
+ t.Fatal(err)
+ }
+ if !back.Encrypted {
+ t.Fatalf("the encrypted attestation did not round-trip: %s", body)
+ }
+}
+
+// The scanning block is ADDITIVE. release.PurgeApp parses this shape with its
+// own local struct — it cannot import objstore, because objstore imports
+// release — and must keep refusing on grant/provisioned while ignoring keys it
+// has never heard of. objstore.go's PARSER LIST comment claims exactly this,
+// and TestPurgeGuardSeesOurRecord pins the two names without ever writing a
+// scanning key, so without this test the additive half of the claim is
+// unasserted.
+//
+// Driven through release.PurgeApp itself rather than an extracted predicate:
+// the guard is inline in PurgeApp, and pulling it out to make it callable
+// would refactor the fleet-wide purge path for the sake of a test.
+func TestPurgeGuardIgnoresScanningBlock(t *testing.T) {
+ ctx := context.Background()
+ _, pub := testSealKeypair(t)
+ relPub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ backdated := time.Now().Add(-2 * release.TrashCooloff)
+
+ // A record naming backend estate still blocks the purge with a scanning
+ // block sitting beside it — the new key must not shadow the guard.
+ t.Run("provisioned-plus-scanning-still-blocks", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ p := NewMemProvisioner("eu-west-1")
+ rotateFixture(t, s, pub, p)
+
+ stored, etag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || stored == nil {
+ t.Fatalf("re-read: %+v, %v", stored, err)
+ }
+ stored.Scanning = &ScanBlock{
+ Enabled: &ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"},
+ }
+ if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil {
+ t.Fatal(err)
+ }
+ err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now())
+ if !errors.Is(err, release.ErrStoreProvisioned) {
+ t.Fatalf("PurgeApp over provisioned+scanning = %v, want release.ErrStoreProvisioned", err)
+ }
+ })
+
+ // The other direction, and the one that proves the key is genuinely
+ // ignored rather than accidentally load-bearing: scanning alone names no
+ // backend estate, so it must not block a purge.
+ t.Run("scanning-alone-names-no-estate", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ stored, etag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || stored == nil {
+ t.Fatalf("re-read: %+v, %v", stored, err)
+ }
+ stored.Scanning = &ScanBlock{
+ Requested: &ScanRequested{At: time.Now().UTC(), By: "m@example.com"},
+ }
+ if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()); err != nil {
+ t.Fatalf("PurgeApp over a declared+scanning record: %v", err)
+ }
+ if _, _, err := s.Get(ctx, Key("bab", "titogo")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("store.json survived the purge: %v", err)
+ }
+ })
+}