carlos / platform Public

console: the scan doors, member gate first

Six doors under the existing /api/cli/apps/{account}/{app}/store prefix
— there is no second convention here, and inventing one was the obvious
way to get this wrong.

Which doors need an engine is the design, not an oversight. grant and
sweep need one: there is nothing to enable or run without it. request,
revoke and enforce-off must work with NO engine, because otherwise
unsetting CARLOS_STORE_SCANNER on a deployment with an enforcing record
strands it — the only door that could clear the Deny refusing for
exactly the reason it needs to be used. The test drives that sequence
honestly: enable, enforce, take the engine away, then clear it.

Enforcement is a separate switch from enabling, and the test asserts on
the live policy rather than the record: a record that said enforcing
while the app's IAM policy still allowed reads would be worse than
never having offered the switch.

Enforcement on a client-encrypted store is refused. Nothing will ever
be tagged infected there, so the Deny could never fire, and the record
would claim a control that cannot act.

The attestation has no default. A scanner cannot read ciphertext and
server-blindness is this family's default posture, so guessing
plaintext hands a client-encrypted store a coverage claim that means
nothing, and guessing encrypted silently disables a control someone
asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
Paul Campbell pushed by paul@keymail.dev 83f6824eb5cd565c815455422d874d10faa6b8c6 parent 10dc492
3 files changed, +791 −0
  • internal/console/apiscan.go +533 −0
  • internal/console/apiscan_test.go +248 −0
  • internal/console/authcli.go +10 −0
diff --git a/internal/console/apiscan.go b/internal/console/apiscan.go
new file mode 100644
index 000000000..f81146372
--- /dev/null
+++ b/internal/console/apiscan.go
@@ -0,0 +1,533 @@
+// The CLI's object-storage scanning API (spec §9): `carlos store scan`.
+//
+// Same gate order as apistore.go, for the same reason: the apiAccountApp 404
+// runs FIRST, so an unknown account, someone else's account and an app that
+// isn't there are one byte-identical answer, and the operator check that
+// follows can never confirm which apps exist.
+//
+// TWO AUTHORITIES AGAIN, drawn in the same place. A member REQUESTS scanning
+// and attests whether their objects are readable; a deployment operator
+// ENABLES an engine, sweeps on demand, and turns enforcement on. Enabling
+// spends money and CPU in the deployment's own cloud account, so it needs an
+// address on CARLOS_STORE_OPERATORS.
+//
+// WHICH DOORS NEED AN ENGINE, and why it is not simply "all of them". grant
+// and sweep need one: there is nothing to enable or run without it. request,
+// revoke and enforce-off must work with NO engine configured — otherwise
+// unsetting CARLOS_STORE_SCANNER on a deployment with an enforcing record
+// would leave the Deny unclearable and the store unrepairable, with the only
+// door that could fix it refusing for the very reason it needs fixing. That
+// deadlock is the whole point of the split.
+package console
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// scanSweepTimeout bounds an on-demand sweep. Longer than storeTimeout
+// because a sweep is a budgeted walk over a bucket, not a single round trip,
+// and shorter than the budget could theoretically run so a wedged engine
+// still costs one request rather than a console goroutine.
+const scanSweepTimeout = 2 * time.Minute
+
+// scanNoEngine is the 503 the doors that need an engine give. It names the
+// variable, because that is the one thing the person reading it can act on.
+func scanNoEngine(w http.ResponseWriter) {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no virus-scanning engine is configured for this deployment (CARLOS_STORE_SCANNER)")
+}
+
+// scanReply is the single shape every door here answers with, so status and
+// the mutating doors can never describe the same record differently.
+//
+// The engine's signature version is fetched live. That is deliberate: an
+// unreachable clamd is exactly what an operator needs to see, and it surfaces
+// here rather than failing the door — a status call that 502'd because the
+// engine was down would hide the state of the record, which is the thing
+// being asked about.
+func (s *Server) scanReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) {
+ side, _, err := objstore.GetSidecar(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ // A sidecar that cannot be read is a real answer — a schema refusal or
+ // an ownership mismatch an operator has to look at — not a reason to
+ // fail the whole door.
+ log.Printf("console: api scan status %s/%s: sidecar: %v", acct.Sqid, app, err)
+ }
+ out := map[string]any{
+ "app": app,
+ "backend": s.cfg.StoreDeployment.Backend,
+ "scanning": rec.Scanning,
+ "coverage": side,
+ "max_signature_age_seconds": int64(s.scanMaxSigAge() / time.Second),
+ }
+ if s.cfg.StoreScanner == nil {
+ out["engine"] = ""
+ return out, nil
+ }
+ engine, version, perr := s.cfg.StoreScanner.Provenance(ctx)
+ out["engine"] = engine
+ if perr != nil {
+ out["engine_error"] = perr.Error()
+ return out, nil
+ }
+ out["signature"] = version
+ return out, nil
+}
+
+func (s *Server) scanMaxSigAge() time.Duration {
+ if s.cfg.ScanMaxSigAge > 0 {
+ return s.cfg.ScanMaxSigAge
+ }
+ return 168 * time.Hour
+}
+
+// scanRecord resolves the store record behind a scan door, answering 404-shaped
+// conflicts the way the store doors do.
+func (s *Server) scanRecord(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*objstore.Record, string, bool) {
+ rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api scan %s/%s: read record: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
+ return nil, "", false
+ }
+ if rec == nil {
+ jsonError(w, http.StatusConflict,
+ "no store is declared for this app — run `carlos store create` first")
+ return nil, "", false
+ }
+ return rec, etag, true
+}
+
+// writeScanRecord is one read-modify-write against store.json, retried once on
+// a lost CAS. Every scan door changes one small block of a record other doors
+// also write, and losing that race to a concurrent grant is ordinary, not
+// exceptional.
+func (s *Server) writeScanRecord(ctx context.Context, acct *Account, app string,
+ mutate func(rec *objstore.Record) error) (*objstore.Record, error) {
+
+ var lastErr error
+ for attempt := 0; attempt < 3; attempt++ {
+ rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, objstore.ErrNotDeclared
+ }
+ if err := mutate(rec); err != nil {
+ return nil, err
+ }
+ if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ lastErr = err
+ continue
+ }
+ return nil, err
+ }
+ return rec, nil
+ }
+ return nil, lastErr
+}
+
+// handleAPIScanStatus answers `carlos store scan status`. A MEMBER verb: the
+// person whose data it is may always ask what is being claimed about it.
+func (s *Server) handleAPIScanStatus(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Releases == nil {
+ storeNoBucket(w)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+
+ rec, _, ok := s.scanRecord(ctx, w, acct, app)
+ if !ok {
+ return
+ }
+ reply, err := s.scanReply(ctx, acct, app, rec)
+ if err != nil {
+ log.Printf("console: api scan status %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the scan state — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, reply)
+}
+
+// handleAPIScanRequest answers `carlos store scan request`. A MEMBER verb, and
+// it needs no engine: a member cannot see whether their deployment has one,
+// and a request is what an operator needs in order to act.
+//
+// THE ATTESTATION IS REQUIRED, with no default. A scanner cannot read
+// ciphertext, and server-blindness is this platform family's default posture,
+// so "are these objects readable" has no defensible default answer — guessing
+// plaintext would let a client-encrypted store collect a coverage claim that
+// means nothing, and guessing encrypted would silently disable a control
+// someone asked for.
+func (s *Server) handleAPIScanRequest(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !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
+ }
+ if s.cfg.Releases == nil {
+ storeNoBucket(w)
+ return
+ }
+ var req struct {
+ Encrypted *bool `json:"encrypted"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if req.Encrypted == nil {
+ jsonError(w, http.StatusBadRequest,
+ "say whether this store holds readable objects: pass --plaintext or --client-encrypted (a scanner cannot read ciphertext)")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+
+ rec, err := s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ if rec.Scanning == nil {
+ rec.Scanning = &objstore.ScanBlock{}
+ }
+ rec.Scanning.Requested = &objstore.ScanRequested{
+ At: time.Now().UTC(), By: addr, Encrypted: *req.Encrypted,
+ }
+ return nil
+ })
+ if errors.Is(err, objstore.ErrNotDeclared) {
+ jsonError(w, http.StatusConflict,
+ "no store is declared for this app — run `carlos store create` first")
+ return
+ }
+ if err != nil {
+ log.Printf("console: api scan request %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not record the request — try again")
+ return
+ }
+ s.audit(acct, app, addr, "store-scan-request", "requested object scanning (cli)")
+ s.replyScan(w, r, acct, app, rec)
+}
+
+// handleAPIScanGrant answers `carlos store scan grant`: an OPERATOR turning
+// the engine on for one store.
+func (s *Server) handleAPIScanGrant(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,
+ "enabling object scanning needs a deployment operator (CARLOS_STORE_OPERATORS)")
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
+ return
+ }
+ if s.cfg.StoreScanner == nil {
+ scanNoEngine(w)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+
+ rec, _, ok := s.scanRecord(ctx, w, acct, app)
+ if !ok {
+ return
+ }
+ if rec.Provisioned == nil {
+ jsonError(w, http.StatusConflict,
+ "this store has not been granted yet — run `carlos store grant` first")
+ return
+ }
+ engine, _, err := s.cfg.StoreScanner.Provenance(ctx)
+ if err != nil {
+ log.Printf("console: api scan grant %s/%s: engine: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusServiceUnavailable,
+ "the scanning engine is not answering — check clamd and try again")
+ return
+ }
+ // Enable on the backend BEFORE recording it. If the record said enabled
+ // and the backend was not, the sweep would run against a store nothing had
+ // prepared; the other order merely repeats an idempotent enable.
+ if err := s.cfg.StoreScanner.EnsureEnabled(ctx, rec.Provisioned.Bucket); err != nil {
+ log.Printf("console: api scan grant %s/%s: enable: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not enable scanning on the store — try again")
+ return
+ }
+ rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ if rec.Scanning == nil {
+ rec.Scanning = &objstore.ScanBlock{}
+ }
+ rec.Scanning.Enabled = &objstore.ScanEnabled{
+ Engine: engine, At: time.Now().UTC(), By: addr,
+ }
+ return nil
+ })
+ if err != nil {
+ log.Printf("console: api scan grant %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not record the engine — try again")
+ return
+ }
+ s.audit(acct, app, addr, "store-scan-grant", "enabled "+engine+" scanning on the store (cli)")
+ s.replyScan(w, r, acct, app, rec)
+}
+
+// handleAPIScanRevoke answers `carlos store scan revoke`: an OPERATOR turning
+// the engine off.
+//
+// It does NOT require an engine. This is the door that gets an operator out of
+// trouble after CARLOS_STORE_SCANNER is unset, and a version of it that
+// refused without one would strand every enforcing record it was meant to
+// clear.
+func (s *Server) handleAPIScanRevoke(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,
+ "disabling object scanning 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
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
+ defer cancel()
+
+ // Best effort on the backend, and only when there IS one. A revoke whose
+ // EnsureDisabled failed must still clear the record, or the operator has
+ // no way to stop enforcement.
+ if rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app); err == nil &&
+ rec != nil && rec.Provisioned != nil && s.cfg.StoreScanner != nil {
+ if derr := s.cfg.StoreScanner.EnsureDisabled(ctx, rec.Provisioned.Bucket); derr != nil {
+ log.Printf("console: api scan revoke %s/%s: disable: %v", acct.Sqid, app, derr)
+ }
+ }
+
+ rec, err := s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ if rec.Scanning == nil {
+ return nil
+ }
+ rec.Scanning.Enabled = nil
+ rec.Scanning.Enforce = nil
+ return nil
+ })
+ if err != nil {
+ log.Printf("console: api scan revoke %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not clear the engine — try again")
+ return
+ }
+ // Re-walk so the Deny actually comes back out of the app's policy. Without
+ // 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 {
+ 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
+ }
+ s.audit(acct, app, addr, "store-scan-revoke", "disabled object scanning on the store (cli)")
+ s.replyScan(w, r, acct, app, rec)
+}
+
+// handleAPIScanEnforce answers `carlos store scan enforce on|off`: the Deny in
+// the app's own IAM policy.
+//
+// on needs an engine, because the tag denied on comes from it. off does not,
+// for the deadlock reason at the top of this file.
+func (s *Server) handleAPIScanEnforce(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,
+ "changing scan enforcement needs a deployment operator (CARLOS_STORE_OPERATORS)")
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
+ return
+ }
+ var req struct {
+ On *bool `json:"on"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if req.On == nil {
+ jsonError(w, http.StatusBadRequest, "say which: `carlos store scan enforce on` or `... off`")
+ return
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return
+ }
+ var deny *objstore.DenyTag
+ if *req.On {
+ if s.cfg.StoreScanner == nil {
+ scanNoEngine(w)
+ return
+ }
+ d := s.cfg.StoreScanner.Enforcement()
+ deny = &d
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
+ defer cancel()
+
+ rec, err := s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
+ if rec.Scanning == nil {
+ rec.Scanning = &objstore.ScanBlock{}
+ }
+ if *req.On {
+ if rec.Scanning.Enabled == nil {
+ return errEnforceWithoutEngine
+ }
+ // Enforcement on a store nobody can scan is theatre: no object
+ // will ever carry the infected tag, so the Deny can never fire,
+ // and the record would claim a control that cannot act.
+ if rec.Scanning.Requested != nil && rec.Scanning.Requested.Encrypted {
+ return errEnforceOnEncrypted
+ }
+ }
+ rec.Scanning.Enforce = deny
+ return nil
+ })
+ switch {
+ case errors.Is(err, errEnforceWithoutEngine):
+ jsonError(w, http.StatusConflict,
+ "scanning is not enabled on this store — run `carlos store scan grant` first")
+ return
+ case errors.Is(err, errEnforceOnEncrypted):
+ jsonError(w, http.StatusConflict,
+ "this store is attested client-encrypted — nothing can ever be tagged infected, so enforcement would deny nothing")
+ return
+ case errors.Is(err, objstore.ErrNotDeclared):
+ jsonError(w, http.StatusConflict,
+ "no store is declared for this app — run `carlos store create` first")
+ return
+ case err != nil:
+ log.Printf("console: api scan enforce %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not record enforcement — try again")
+ return
+ }
+
+ if err := s.regrantForScanning(ctx, pub, acct, app, addr); 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")
+ return
+ }
+ what := "off"
+ if *req.On {
+ what = "on"
+ }
+ s.audit(acct, app, addr, "store-scan-enforce", "turned scan enforcement "+what+" (cli)")
+ s.replyScan(w, r, acct, app, rec)
+}
+
+var (
+ errEnforceWithoutEngine = errors.New("console: enforcement without an enabled engine")
+ errEnforceOnEncrypted = errors.New("console: enforcement on a client-encrypted store")
+)
+
+// regrantForScanning re-runs the store walk so the app's inline policy catches
+// up with the record. Grant is the only writer of that policy, and it is
+// idempotent on a healthy store: everything but the policy short-circuits.
+//
+// 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 {
+ rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return err
+ }
+ if rec == nil || rec.Provisioned == nil {
+ return nil
+ }
+ _, err = objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
+ pub, acct.Sqid, app, addr, "", time.Now())
+ return err
+}
+
+// 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) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.storeOperator(addr) {
+ jsonError(w, http.StatusForbidden,
+ "running a scan sweep needs a deployment operator (CARLOS_STORE_OPERATORS)")
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
+ return
+ }
+ if s.cfg.StoreScanner == nil {
+ scanNoEngine(w)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), scanSweepTimeout)
+ defer cancel()
+
+ if _, err := s.sweepNow(ctx, acct.Sqid, app); err != nil {
+ if errors.Is(err, errNoStore) {
+ jsonError(w, http.StatusConflict,
+ "this store has not been granted yet — run `carlos store grant` first")
+ return
+ }
+ log.Printf("console: api scan sweep %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway,
+ fmt.Sprintf("the sweep did not finish: %s", "see the console log; re-run to resume"))
+ return
+ }
+ rec, _, ok := s.scanRecord(ctx, w, acct, app)
+ if !ok {
+ return
+ }
+ s.audit(acct, app, addr, "store-scan-sweep", "ran a scan sweep (cli)")
+ s.replyScan(w, r, acct, app, rec)
+}
+
+// replyScan writes the shared reply, or a 502 if it cannot be assembled.
+func (s *Server) replyScan(w http.ResponseWriter, r *http.Request, acct *Account, app string, rec *objstore.Record) {
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ reply, err := s.scanReply(ctx, acct, app, rec)
+ if err != nil {
+ log.Printf("console: api scan reply %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "the change was made but could not be read back — run `carlos store scan status`")
+ return
+ }
+ writeJSON(w, http.StatusOK, reply)
+}
diff --git a/internal/console/apiscan_test.go b/internal/console/apiscan_test.go
new file mode 100644
index 000000000..a787ceede
--- /dev/null
+++ b/internal/console/apiscan_test.go
@@ -0,0 +1,248 @@
+package console
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/objstore"
+)
+
+func scanURL(fx *consoleFixture, app string) string { return storeURL(fx, app) + "/scan" }
+
+// grantStore drives the real operator grant so the store is provisioned — the
+// state every scan verb but `request` needs to act on.
+func grantStore(t *testing.T, fx *consoleFixture, token string) {
+ t.Helper()
+ declareStore(t, fx, token)
+ if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("grant = %d %v", status, out)
+ }
+}
+
+// scanArmed is storeArmed plus a granted store and a working engine.
+func scanArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemScanner, string) {
+ t.Helper()
+ fx, _, token, _ := storeArmed(t, code)
+ grantStore(t, fx, token)
+ sc := objstore.NewMemScanner()
+ fx.srv.cfg.StoreScanner = sc
+ return fx, sc, token
+}
+
+// The member gate runs FIRST: a stranger gets the same byte-identical 404 the
+// store doors give, never a 403 that would confirm the app exists.
+func TestScanDoorsGateOrder(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-scan-gate")
+ 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"} {
+ 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)
+ // (b) a member, against an account that does not exist.
+ statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/store/scan"+door, map[string]any{}, token)
+ // (c) a member of the account, against an app it never claimed.
+ statusC, bodyC := postRaw(t, scanURL(fx, "ghost-app")+door, map[string]any{}, token)
+ if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
+ t.Fatalf("scan%s gate = %d / %d / %d, want 404 for all three", door, statusA, statusB, statusC)
+ }
+ if bodyA != bodyB || bodyB != bodyC {
+ t.Fatalf("scan%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", door, bodyA, bodyB, bodyC)
+ }
+ if bodyA != "404 page not found\n" {
+ t.Fatalf("scan%s refusal body = %q, want the stdlib http.NotFound output", door, bodyA)
+ }
+ })
+ }
+ if status, _ := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, ""); status != http.StatusUnauthorized {
+ t.Fatalf("scan request with no token = %d, want 401", status)
+ }
+}
+
+// grant and sweep 503 naming CARLOS_STORE_SCANNER when no engine is built.
+func TestScanGrantRequires503WithoutScanner(t *testing.T) {
+ fx, _, token, _ := storeArmed(t, "code-scan-noengine")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = nil
+
+ for _, door := range []string{"/grant", "/sweep"} {
+ status, out := postJSON(t, scanURL(fx, "titogo")+door, map[string]any{}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("scan%s with no engine = %d %v, want 503", door, status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_SCANNER") {
+ t.Fatalf("scan%s error = %q, want the scanner variable named", door, msg)
+ }
+ }
+ // enforce ON also needs one — the tag denied on comes from the engine.
+ status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("enforce on with no engine = %d %v, want 503", status, out)
+ }
+}
+
+// request, revoke and enforce off work with NO engine built. Otherwise
+// unsetting the scanner on a deployment with an enforcing record would leave
+// the deny unclearable and the store unrepairable — the door that fixes it
+// refusing for the very reason it needs fixing.
+func TestScanRevokeAndEnforceOffWorkWithoutScanner(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-nodeadlock")
+
+ // Get into the bad state honestly: enable and enforce with an engine.
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", status, out)
+ }
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token); status != http.StatusOK {
+ t.Fatalf("enforce on = %d %v", status, out)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Scanning == nil || rec.Scanning.Enforce == nil {
+ t.Fatal("setup: enforcement was never recorded")
+ }
+
+ // Now the engine goes away, exactly as unsetting the variable would do.
+ fx.srv.cfg.StoreScanner = nil
+
+ if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); status != http.StatusOK {
+ t.Fatalf("scan request with no engine = %d %v, want 200", status, out)
+ }
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": false}, token); status != http.StatusOK {
+ t.Fatalf("enforce off with no engine = %d %v, want 200", status, out)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enforce != nil {
+ t.Fatal("enforce off left the deny standing on the record")
+ }
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("revoke with no engine = %d %v, want 200", status, out)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enabled != nil {
+ t.Fatal("revoke left the engine recorded")
+ }
+}
+
+// Non-operators cannot grant, and the check runs after the member gate.
+func TestScanGrantRequiresOperator(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-operator")
+ fx.srv.cfg.StoreOperators = []string{"someone-else@keymail.dev"}
+
+ for _, door := range []string{"/grant", "/revoke", "/sweep", "/enforce"} {
+ status, out := postJSON(t, scanURL(fx, "titogo")+door, map[string]any{"on": false}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("scan%s as a non-operator = %d %v, want 403", door, status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") {
+ t.Fatalf("scan%s error = %q, want the operator variable named", door, msg)
+ }
+ }
+ // The member's own verbs are unaffected: requesting and reading status are
+ // theirs, and always were.
+ if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": false}, token); status != http.StatusOK {
+ t.Fatalf("scan request as a member = %d %v, want 200", status, out)
+ }
+ if status, out := getJSON(t, scanURL(fx, "titogo"), token); status != http.StatusOK {
+ t.Fatalf("scan status as a member = %d %v, want 200", status, out)
+ }
+}
+
+// A client-encrypted store refuses enforce: nothing will ever be tagged
+// infected, so it would be theatre — a record claiming a control that cannot
+// possibly act.
+func TestScanEnforceRefusedOnEncryptedStore(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-encrypted")
+
+ if status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{"encrypted": true}, token); status != http.StatusOK {
+ t.Fatalf("scan request = %d %v", status, out)
+ }
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", status, out)
+ }
+
+ status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("enforce on an encrypted store = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "encrypted") {
+ t.Fatalf("error = %q, want it to say why", msg)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Scanning.Enforce != nil {
+ t.Fatal("a refused enforce still wrote the deny")
+ }
+}
+
+// The attestation has no default. "Are these objects readable" is the question
+// the whole coverage claim rests on, and neither answer is safe to assume.
+func TestScanRequestRequiresAttestation(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-attest")
+
+ status, out := postJSON(t, scanURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("request with no attestation = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "--client-encrypted") {
+ t.Fatalf("error = %q, want it to name the flags", msg)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Scanning != nil && rec.Scanning.Requested != nil {
+ t.Fatal("a refused request still recorded an attestation")
+ }
+}
+
+// Enforcement actually reaches the app's IAM policy, not just the record. A
+// record that said enforcing while the live policy allowed reads would be
+// worse than never having offered the switch.
+func TestScanEnforceWritesTheDenyToThePolicy(t *testing.T) {
+ fx, fake, token, _ := storeArmed(t, "code-scan-policy")
+ grantStore(t, fx, token)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", status, out)
+ }
+ user := objstore.UserName(fx.sqid, "titogo")
+ if strings.Contains(fake.PolicyFor(user), `"Effect":"Deny"`) {
+ t.Fatal("enabling the engine alone wrote a deny — enforcement is a separate switch")
+ }
+
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token); status != http.StatusOK {
+ t.Fatalf("enforce on = %d %v", status, out)
+ }
+ if !strings.Contains(fake.PolicyFor(user), `"Effect":"Deny"`) {
+ t.Fatalf("enforce on never reached the policy:\n%s", fake.PolicyFor(user))
+ }
+
+ if status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": false}, token); status != http.StatusOK {
+ t.Fatalf("enforce off = %d %v", status, out)
+ }
+ if strings.Contains(fake.PolicyFor(user), `"Effect":"Deny"`) {
+ t.Fatalf("enforce off left a live deny standing:\n%s", fake.PolicyFor(user))
+ }
+}
+
+// Enforcement before an engine is enabled is refused. Otherwise a store could
+// carry a Deny keyed on a tag nothing will ever write, which denies nothing
+// and reads in the record as protection.
+func TestScanEnforceRefusedBeforeGrant(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-enforce-early")
+
+ status, out := postJSON(t, scanURL(fx, "titogo")+"/enforce", map[string]any{"on": true}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("enforce before scan grant = %d %v, want 409", status, out)
+ }
+}
+
+// Status reports the engine and its signature database. A clean verdict from
+// three-month-old signatures is not the same claim as a fresh one.
+func TestScanStatusCarriesProvenance(t *testing.T) {
+ fx, _, token := scanArmed(t, "code-scan-status")
+
+ status, out := getJSON(t, scanURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("scan status = %d %v", status, out)
+ }
+ if got := str(t, out, "engine"); got != "clamav" {
+ t.Fatalf("engine = %q", got)
+ }
+ if str(t, out, "signature") == "" {
+ t.Fatal("status carries no signature-database version")
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 232143bbb..3ceb50928 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -726,6 +726,16 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish)
+ // The scan family (internal/console/apiscan.go). `scan` itself is the
+ // member's request-and-status pair; grant, revoke, sweep and enforce are
+ // operator verbs. Literal segments again, so no app or host name reaches
+ // them.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanRequest)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/store/scan", svcrole.Allow(svcrole.Admin), s.handleAPIScanStatus)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/grant", svcrole.Allow(svcrole.Admin), s.handleAPIScanGrant)
+ 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)
}
// clientIP is who the rate limiter counts against.