| 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) |
| +} |