// 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" "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" ) // 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 for a deployment with no clamd WIRED. It names the // variable, because that is the one thing the person reading it can act on. // // Distinct from scanDisabled below, and deliberately: "there is no engine on // this box" and "an operator turned scanning off" want different actions, and // a single message would send someone to edit a unit file when a command would // have done. func scanNoEngine(w http.ResponseWriter) { jsonError(w, http.StatusServiceUnavailable, "no virus-scanning engine is configured for this deployment (CARLOS_CLAMD_ADDR)") } // scanNoEngineState is scanNoEngine in §5.1's reply SHAPE, for the two // app-facing doors. // // The older operator verbs keep the plain {"error": ...}: a person reading a // CLI message wants the sentence. An APP branches on `reason`, and §5.1 and // §5.2 both promise it one — a door that answered a bare error would leave a // client that did exactly what the spec told it with nothing to switch on. func scanNoEngineState(w http.ResponseWriter) { scanUnavailable(w, http.StatusServiceUnavailable, "no_engine", "no virus-scanning engine is configured for this deployment (CARLOS_CLAMD_ADDR)") } // scanDisabled is the 503 for a deployment whose engine is wired but whose // scanning is switched off. Names the command, not a variable: this one is // restartless. func scanDisabled(w http.ResponseWriter) { jsonError(w, http.StatusServiceUnavailable, "object scanning is disabled for this deployment — run `carlos system antivirus enable`") } // scanEnabledForDeployment is the door-side read of the deployment switch. A // read error is neither on nor off, so it answers 502 rather than guessing. func (s *Server) scanEnabledForDeployment(ctx context.Context, w http.ResponseWriter) bool { on, err := release.AntivirusEnabled(ctx, s.cfg.Releases) if err != nil { log.Printf("console: api scan: read antivirus record: %v", err) jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") return false } if !on { scanDisabled(w) return false } return true } // 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) } queueDepth, err := objstore.QueueDepth(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return nil, 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), "queue_depth": queueDepth, } // THE DEPLOYMENT SWITCH IS PART OF THE ANSWER, not context somebody else // holds. A per-app record saying `enabled: clamav` while the deployment is // disabled describes a control that is not running, and this reply is what // `carlos store scan status` prints — so leaving it out is a new way for // the status output to lie, in exactly the shape the engine_error branch // below already refuses to allow for the wiring half. on, averr := release.AntivirusEnabled(ctx, s.cfg.Releases) out["deployment_enabled"] = on switch { case averr != nil: log.Printf("console: api scan status %s/%s: antivirus record: %v", acct.Sqid, app, averr) out["deployment_error"] = "the deployment's scanning state could not be read — no sweep is running" case !on && rec.Scanning != nil && rec.Scanning.Enabled != nil: out["deployment_error"] = "object scanning is disabled for this deployment " + "(`carlos system antivirus enable`) — this store is enabled, but nothing is sweeping it" } if s.cfg.StoreScanner == nil { out["engine"] = "" // A record can say "enabled" on a deployment whose engine has since // been unset. Silence there reads as health: status would print the // engine, the enforcement state and the coverage, and never mention // that nothing is running. Say it plainly instead. if rec.Scanning != nil && rec.Scanning.Enabled != nil { out["engine_error"] = "no scanning engine is configured on this deployment " + "(CARLOS_CLAMD_ADDR) — the record says one is enabled, but no sweep is running" } 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 } var pub *ecdh.PublicKey if s.cfg.CallbackSigner != nil { var ok bool pub, ok = s.storeSealKey(w) if !ok { return } } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() if !s.scanEnabledForDeployment(ctx, w) { return } 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 } // The member's request is a PRECONDITION, not a formality, and this is // the second place that has to hold the line. // // The attestation only exists in Requested. Enabling without one leaves // Requested nil, and nil reads as "not encrypted" everywhere downstream — // so a client-encrypted store nobody attested would be swept and reported // entirely clean, which is the exact failure §2.2 exists to prevent. It // also skips the consent point: this engine reads tenant object bytes, // and an operator must not be able to start that on their own. // // Same posture as objstore.Grant refusing ErrNotDeclared: an operator // verb never invents the member's half. if rec.Scanning == nil || rec.Scanning.Requested == nil { jsonError(w, http.StatusConflict, "the member has not requested scanning for this app — they run `carlos store scan request` first, "+ "and their attestation is what says whether these objects can be read at all") 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. // // The backend enable and the record write are one app mutation: a // foreign operation on the app refuses both, and neither lands outside it. enabled := false bucket := rec.Provisioned.Bucket err = s.appMutation(ctx, acct, app, "store-scan-grant", func(ctx context.Context) error { if err := s.cfg.StoreScanner.EnsureEnabled(ctx, bucket); err != nil { return err } enabled = true var err error 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, } if s.cfg.CallbackSigner != nil && rec.Scanning.WebhookGen == 0 { gen, err := randomWebhookGeneration() if err != nil { return err } rec.Scanning.WebhookGen = gen } return nil }) return err }) if err != nil && !enabled { 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 } 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 } if s.cfg.CallbackSigner != nil { err = s.appMutation(ctx, acct, app, "scan-regrant", func(ctx context.Context) error { if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil { return err } var syncErr error _, rec, syncErr = s.convergeCallbackKey(ctx, pub, acct, app) return syncErr }) if 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 } } 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. // // The backend disable and the record write are one app mutation, as in // grant; the regrant below opens its own. var rec *objstore.Record err := s.appMutation(ctx, acct, app, "store-scan-revoke", func(ctx context.Context) error { if cur, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && cur != nil && cur.Provisioned != nil && s.cfg.StoreScanner != nil { if derr := s.cfg.StoreScanner.EnsureDisabled(ctx, cur.Provisioned.Bucket); derr != nil { log.Printf("console: api scan revoke %s/%s: disable: %v", acct.Sqid, app, derr) } } var err error 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 rec.Scanning.WebhookGen = 0 rec.Scanning.WebhookKeyID = "" rec.Scanning.WebhookConfigHash = "" return nil }) return err }) 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 } 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) } // 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 } // The grant SOURCE is carried from the record rather than restated. This // is a policy convergence, not a new grant: Grant writes the block only // when it is absent, so a store approved at account level would keep its // account-approval provenance either way — but restating "operator" here // would be a lie the moment that stopped being true, and scanning has no // business relabelling why a store exists. source := objstore.GrantSourceOperator if rec.Grant != nil && rec.Grant.Source != "" { source = rec.Grant.Source } _, err = claimAppCall(s, ctx, acct, app, "scan-regrant", func(ctx context.Context) (*objstore.Record, error) { return objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, pub, acct.Sqid, app, addr, source, "", time.Now()) }) return err } func (s *Server) deliverCallbackSecret(ctx context.Context, pub *ecdh.PublicKey, acct, app string, rec *objstore.Record, gen int) error { _, err := s.deliverCallbackRevision(ctx, pub, acct, app, rec, gen) 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 } // The delivery and the record write are one app mutation, as in grant. errRotateNoStore := errors.New("no store record") errRotateNotEnabled := errors.New("scanning not enabled") err = s.appMutation(ctx, acct, app, "store-scan-callback-rotate", func(ctx context.Context) error { fresh, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) if gerr != nil { return gerr } if fresh == nil { return errRotateNoStore } if fresh.Scanning == nil || fresh.Scanning.Enabled == nil { return errRotateNotEnabled } var err error rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error { if rec.Scanning == nil || rec.Scanning.Enabled == nil { return errRotateNotEnabled } rec.Scanning.WebhookGen = gen rec.Scanning.WebhookKeyID = "" rec.Scanning.WebhookConfigHash = "" return nil }) if err != nil { return err } _, rec, err = s.convergeCallbackKey(ctx, pub, acct, app) return err }) switch { case errors.Is(err, errRotateNoStore): jsonError(w, http.StatusConflict, "no store is declared for this app — run `carlos store create` first") return case errors.Is(err, errRotateNotEnabled), errors.Is(err, objstore.ErrQueueNotPermitted): jsonError(w, http.StatusConflict, "scanning is not enabled on this store — run `carlos store scan grant` first") return } if err != nil { log.Printf("console: api scan callback rotate %s/%s: deliver: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not deliver the rotated callback secret — re-run to resume") 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) { 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) // The two causes an operator can act on are named. Everything else // stays "see the log": a backend error carries bucket names, ARNs and // request ids, and none of that belongs in a client sentence. switch { case errors.Is(err, objstore.ErrScanningDisabled): // Not an incident — a command away. The sweep verb reaches // RunSweep by its own path, so this is where `disable` stops it. scanDisabled(w) case errors.Is(err, objstore.ErrEngineUnreachable): jsonError(w, http.StatusServiceUnavailable, "the scanning engine is not answering — check clamd on the console box, then re-run") case errors.Is(err, objstore.ErrEngineDrift): jsonError(w, http.StatusConflict, "this store was scanned by a different engine — `carlos store scan revoke` then "+ "`carlos store scan grant` to move it, which restarts its coverage history") default: jsonError(w, http.StatusBadGateway, "the sweep did not finish — see the console log; re-run to resume from where it stopped") } 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) } // ---- the on-demand enqueue door (spec §5.1) --------------------------------- // scanEnqueueBody is the request. Two fields, and the second is optional: an // app with no platform-assigned host, or one that would rather poll, sends // only the key. type scanEnqueueBody struct { Key string `json:"key"` CallbackPath string `json:"callback_path"` } // scanUnavailable writes §5.1's refusal shape: a machine-readable reason AND // a sentence. // // The reason is what an app branches on — "not_granted" and "disabled" want // different behaviour from a client, and neither is a retry. The sentence is // for the person reading the log when the client did the wrong thing anyway, // and it uses the same "error" key every other door on this console uses, so // a generic client that only knows how to print an error still prints // something true. func scanUnavailable(w http.ResponseWriter, status int, reason, msg string) { writeJSON(w, status, map[string]string{ "state": "unavailable", "reason": reason, "error": msg, }) } // scanAnswer is how one QueueRefusal is answered at the enqueue door: a // status and a sentence, or the family's 404. type scanAnswer struct { status int notFound bool unknown bool msg string } func (a scanAnswer) refused() bool { return a.status != 0 || a.notFound } // scanRefusalAnswer maps every name the consent chain can produce onto this // door's answer. // // A TABLE, not a switch inside the handler, and the reason is testability // rather than tidiness: the two arms that matter most are the two the handler // cannot be driven into. A trashed app is already a 404 at apiAccountApp, so // the trashed arm is unreachable through HTTP; an unrecognised name does not // exist yet, by definition. Left inline, both would be branches nothing could // assert — and an unasserted refusal branch is exactly where "the door that // did not recognise it let it through" gets written. // // The default is a REFUSAL, so a name added to objstore and not taught to // this door costs a working feature rather than an unscanned object. func scanRefusalAnswer(r objstore.QueueRefusal) scanAnswer { switch r { case objstore.RefusalNone: return scanAnswer{} case objstore.RefusalTrashed: // The SAME 404 apiAccountApp already gives, so that if the two // checks ever drift apart this door does not become the one that // confirms an app exists by refusing differently. return scanAnswer{notFound: true} case objstore.RefusalNotGranted: return scanAnswer{status: http.StatusConflict, msg: "this store has not been granted yet — an operator runs `carlos store grant`"} case objstore.RefusalNotRequested: return scanAnswer{status: http.StatusConflict, msg: "scanning has not been requested for this store — a member runs `carlos store scan request`"} case objstore.RefusalEncrypted: return scanAnswer{status: http.StatusConflict, msg: "this store is attested client-encrypted, so a verdict on its bytes would mean nothing"} case objstore.RefusalNotEnabled: return scanAnswer{status: http.StatusConflict, msg: "scanning has not been granted for this store — an operator runs `carlos store scan grant`"} case objstore.RefusalDisabled: return scanAnswer{status: http.StatusServiceUnavailable, msg: "object scanning is disabled for this deployment — an operator runs `carlos system antivirus enable`"} default: return scanAnswer{status: http.StatusConflict, unknown: true, msg: "this store may not be scanned right now — run `carlos store scan status`"} } } // handleAPIScanEnqueue asks for one object to be scanned soon, and answers // 202 without scanning it. // // IT ENQUEUES; IT DOES NOT SCAN. Scanning inline would hold a console // goroutine for as long as an engine takes on an object of the tenant's // choosing, which hands one app a trivial way to occupy all of them. The // drain reads the item on its own clock (§7.2). // // THE CONSENT CHAIN RUNS HERE TOO, and this is the door where forgetting it // was the hole review 3 found. The sweep will not read a tenant's bytes // without the member's attestation, and `scan grant` will not start on an // operator's say-so alone — so a door that checked only the deployment switch // would let an app's own credential make the console fetch and scan objects // in a store that was never requested, never granted, or attested as // client-encrypted. Every one of those refusals is objstore's // QueuePermitted, not a re-derivation here. // // ORDER: the store's own state before the deployment's. An app learns why ITS // store cannot be scanned before it learns anything about the deployment it // happens to be running on, and the two 503s below stay the last word. func (s *Server) handleAPIScanEnqueue(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 } // STRICT, and this is the door that needs it: an app that misspells // callback_path would otherwise be answered 202 for an item carrying no // callback at all, and would then wait for a delivery nothing ever // promised. A door whose optional field is a PROMISE must not accept a // body it half-understands. var body scanEnqueueBody if !readJSONStrictLimit(w, r, &body, 8<<10) { return } key := body.Key if strings.TrimSpace(key) == "" { jsonError(w, http.StatusBadRequest, `name the object to scan, e.g. {"key": "uploads/x.pdf"}`) return } if body.CallbackPath != "" { if err := validCallbackPath(body.CallbackPath); err != nil { // The validator's own words. They name the shape that was // refused ("protocol-relative", "'..' segment"), which is what // the app author needs, and none of them echoes the input back // into the response. jsonError(w, http.StatusBadRequest, err.Error()+ " — 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 } host, err := s.scanCallbackHost(objstore.AppRef{Account: acct.Sqid, App: app}) if err != nil { log.Printf("console: api scan enqueue %s/%s: platform host: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the app's platform route could not be read — try again") return } if host == "" { jsonError(w, http.StatusConflict, "this app has no ready route yet — omit callback_path and poll scan status") return } } if s.cfg.Releases == nil { scanNoEngineState(w) return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() // The record itself is not wanted here. The door's whole use of the // consent chain is the NAME it comes back with — QueuePermitted returns // a record only for the one reversible refusal, and this door has // nothing to say about a store it is refusing. _, refusal, err := objstore.QueuePermitted(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api scan enqueue %s/%s: consent: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the store record could not be read — try again") return } if ans := scanRefusalAnswer(refusal); ans.refused() { if ans.unknown { log.Printf("console: api scan enqueue %s/%s: unhandled refusal %q", acct.Sqid, app, refusal) } if ans.notFound { http.NotFound(w, r) return } scanUnavailable(w, ans.status, string(refusal), ans.msg) return } // LAST, because it is the deployment's state rather than this store's, // and because it is the one refusal that is about whether the queue can // ever drain: with no engine wired the item would sit until its TTL and // the 202 would have been a lie. if s.cfg.StoreScanner == nil { scanNoEngineState(w) return } if _, err := objstore.Enqueue(ctx, s.cfg.Releases, s.scanQueue, acct.Sqid, app, &objstore.QueueItem{Key: key, By: addr, CallbackPath: body.CallbackPath}, time.Now()); err != nil { log.Printf("console: api scan enqueue %s/%s %q: %v", acct.Sqid, app, key, err) jsonError(w, http.StatusBadGateway, "the scan could not be queued — try again") return } // NO ID IN THE REPLY. The status door (§5.2) is keyed by object key, not // by queue id, so an id here would be a handle with nothing to hand it // to — and one clients would build on before it meant anything. writeJSON(w, http.StatusAccepted, map[string]string{"state": "pending"}) }