carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's object-storage scanning API (spec §9): `carlos store scan`.
2//
3// Same gate order as apistore.go, for the same reason: the apiAccountApp 404
4// runs FIRST, so an unknown account, someone else's account and an app that
5// isn't there are one byte-identical answer, and the operator check that
6// follows can never confirm which apps exist.
7//
8// TWO AUTHORITIES AGAIN, drawn in the same place. A member REQUESTS scanning
9// and attests whether their objects are readable; a deployment operator
10// ENABLES an engine, sweeps on demand, and turns enforcement on. Enabling
11// spends money and CPU in the deployment's own cloud account, so it needs an
12// address on CARLOS_STORE_OPERATORS.
13//
14// WHICH DOORS NEED AN ENGINE, and why it is not simply "all of them". grant
15// and sweep need one: there is nothing to enable or run without it. request,
16// revoke and enforce-off must work with NO engine configured — otherwise
17// unsetting CARLOS_STORE_SCANNER on a deployment with an enforcing record
18// would leave the Deny unclearable and the store unrepairable, with the only
19// door that could fix it refusing for the very reason it needs fixing. That
20// deadlock is the whole point of the split.
21package console
22
23import (
24 "context"
25 "crypto/ecdh"
26 "crypto/rand"
27 "encoding/binary"
28 "errors"
29 "fmt"
30 "log"
31 "net/http"
32 "strings"
33 "time"
34
35 "github.com/carlosframework/platform/internal/appconfig"
36 "github.com/carlosframework/platform/internal/objstore"
37 "github.com/carlosframework/platform/internal/release"
38)
39
40// scanSweepTimeout bounds an on-demand sweep. Longer than storeTimeout
41// because a sweep is a budgeted walk over a bucket, not a single round trip,
42// and shorter than the budget could theoretically run so a wedged engine
43// still costs one request rather than a console goroutine.
44const scanSweepTimeout = 2 * time.Minute
45
46// scanNoEngine is the 503 for a deployment with no clamd WIRED. It names the
47// variable, because that is the one thing the person reading it can act on.
48//
49// Distinct from scanDisabled below, and deliberately: "there is no engine on
50// this box" and "an operator turned scanning off" want different actions, and
51// a single message would send someone to edit a unit file when a command would
52// have done.
53func scanNoEngine(w http.ResponseWriter) {
54 jsonError(w, http.StatusServiceUnavailable,
55 "no virus-scanning engine is configured for this deployment (CARLOS_CLAMD_ADDR)")
56}
57
58// scanNoEngineState is scanNoEngine in §5.1's reply SHAPE, for the two
59// app-facing doors.
60//
61// The older operator verbs keep the plain {"error": ...}: a person reading a
62// CLI message wants the sentence. An APP branches on `reason`, and §5.1 and
63// §5.2 both promise it one — a door that answered a bare error would leave a
64// client that did exactly what the spec told it with nothing to switch on.
65func scanNoEngineState(w http.ResponseWriter) {
66 scanUnavailable(w, http.StatusServiceUnavailable, "no_engine",
67 "no virus-scanning engine is configured for this deployment (CARLOS_CLAMD_ADDR)")
68}
69
70// scanDisabled is the 503 for a deployment whose engine is wired but whose
71// scanning is switched off. Names the command, not a variable: this one is
72// restartless.
73func scanDisabled(w http.ResponseWriter) {
74 jsonError(w, http.StatusServiceUnavailable,
75 "object scanning is disabled for this deployment — run `carlos system antivirus enable`")
76}
77
78// scanEnabledForDeployment is the door-side read of the deployment switch. A
79// read error is neither on nor off, so it answers 502 rather than guessing.
80func (s *Server) scanEnabledForDeployment(ctx context.Context, w http.ResponseWriter) bool {
81 on, err := release.AntivirusEnabled(ctx, s.cfg.Releases)
82 if err != nil {
83 log.Printf("console: api scan: read antivirus record: %v", err)
84 jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again")
85 return false
86 }
87 if !on {
88 scanDisabled(w)
89 return false
90 }
91 return true
92}
93
94// scanReply is the single shape every door here answers with, so status and
95// the mutating doors can never describe the same record differently.
96//
97// The engine's signature version is fetched live. That is deliberate: an
98// unreachable clamd is exactly what an operator needs to see, and it surfaces
99// here rather than failing the door — a status call that 502'd because the
100// engine was down would hide the state of the record, which is the thing
101// being asked about.
102func (s *Server) scanReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) {
103 side, _, err := objstore.GetSidecar(ctx, s.cfg.Releases, acct.Sqid, app)
104 if err != nil {
105 // A sidecar that cannot be read is a real answer — a schema refusal or
106 // an ownership mismatch an operator has to look at — not a reason to
107 // fail the whole door.
108 log.Printf("console: api scan status %s/%s: sidecar: %v", acct.Sqid, app, err)
109 }
110 queueDepth, err := objstore.QueueDepth(ctx, s.cfg.Releases, acct.Sqid, app)
111 if err != nil {
112 return nil, err
113 }
114 out := map[string]any{
115 "app": app,
116 "backend": s.cfg.StoreDeployment.Backend,
117 "scanning": rec.Scanning,
118 "coverage": side,
119 "max_signature_age_seconds": int64(s.scanMaxSigAge() / time.Second),
120 "queue_depth": queueDepth,
121 }
122 // THE DEPLOYMENT SWITCH IS PART OF THE ANSWER, not context somebody else
123 // holds. A per-app record saying `enabled: clamav` while the deployment is
124 // disabled describes a control that is not running, and this reply is what
125 // `carlos store scan status` prints — so leaving it out is a new way for
126 // the status output to lie, in exactly the shape the engine_error branch
127 // below already refuses to allow for the wiring half.
128 on, averr := release.AntivirusEnabled(ctx, s.cfg.Releases)
129 out["deployment_enabled"] = on
130 switch {
131 case averr != nil:
132 log.Printf("console: api scan status %s/%s: antivirus record: %v", acct.Sqid, app, averr)
133 out["deployment_error"] = "the deployment's scanning state could not be read — no sweep is running"
134 case !on && rec.Scanning != nil && rec.Scanning.Enabled != nil:
135 out["deployment_error"] = "object scanning is disabled for this deployment " +
136 "(`carlos system antivirus enable`) — this store is enabled, but nothing is sweeping it"
137 }
138 if s.cfg.StoreScanner == nil {
139 out["engine"] = ""
140 // A record can say "enabled" on a deployment whose engine has since
141 // been unset. Silence there reads as health: status would print the
142 // engine, the enforcement state and the coverage, and never mention
143 // that nothing is running. Say it plainly instead.
144 if rec.Scanning != nil && rec.Scanning.Enabled != nil {
145 out["engine_error"] = "no scanning engine is configured on this deployment " +
146 "(CARLOS_CLAMD_ADDR) — the record says one is enabled, but no sweep is running"
147 }
148 return out, nil
149 }
150 engine, version, perr := s.cfg.StoreScanner.Provenance(ctx)
151 out["engine"] = engine
152 if perr != nil {
153 out["engine_error"] = perr.Error()
154 return out, nil
155 }
156 out["signature"] = version
157 return out, nil
158}
159
160func (s *Server) scanMaxSigAge() time.Duration {
161 if s.cfg.ScanMaxSigAge > 0 {
162 return s.cfg.ScanMaxSigAge
163 }
164 return 168 * time.Hour
165}
166
167// scanRecord resolves the store record behind a scan door, answering 404-shaped
168// conflicts the way the store doors do.
169func (s *Server) scanRecord(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*objstore.Record, string, bool) {
170 rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
171 if err != nil {
172 log.Printf("console: api scan %s/%s: read record: %v", acct.Sqid, app, err)
173 jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
174 return nil, "", false
175 }
176 if rec == nil {
177 jsonError(w, http.StatusConflict,
178 "no store is declared for this app — run `carlos store create` first")
179 return nil, "", false
180 }
181 return rec, etag, true
182}
183
184// writeScanRecord is one read-modify-write against store.json, retried once on
185// a lost CAS. Every scan door changes one small block of a record other doors
186// also write, and losing that race to a concurrent grant is ordinary, not
187// exceptional.
188func (s *Server) writeScanRecord(ctx context.Context, acct *Account, app string,
189 mutate func(rec *objstore.Record) error) (*objstore.Record, error) {
190
191 var lastErr error
192 for attempt := 0; attempt < 3; attempt++ {
193 rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
194 if err != nil {
195 return nil, err
196 }
197 if rec == nil {
198 return nil, objstore.ErrNotDeclared
199 }
200 if err := mutate(rec); err != nil {
201 return nil, err
202 }
203 if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil {
204 if errors.Is(err, release.ErrCASConflict) {
205 lastErr = err
206 continue
207 }
208 return nil, err
209 }
210 return rec, nil
211 }
212 return nil, lastErr
213}
214
215// handleAPIScanStatus answers `carlos store scan status`. A MEMBER verb: the
216// person whose data it is may always ask what is being claimed about it.
217func (s *Server) handleAPIScanStatus(w http.ResponseWriter, r *http.Request) {
218 _, acct, app, ok := s.apiAccountApp(w, r)
219 if !ok {
220 return
221 }
222 if s.cfg.Releases == nil {
223 storeNoBucket(w)
224 return
225 }
226 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
227 defer cancel()
228
229 rec, _, ok := s.scanRecord(ctx, w, acct, app)
230 if !ok {
231 return
232 }
233 reply, err := s.scanReply(ctx, acct, app, rec)
234 if err != nil {
235 log.Printf("console: api scan status %s/%s: %v", acct.Sqid, app, err)
236 jsonError(w, http.StatusBadGateway, "could not read the scan state — try again")
237 return
238 }
239 writeJSON(w, http.StatusOK, reply)
240}
241
242// handleAPIScanRequest answers `carlos store scan request`. A MEMBER verb, and
243// it needs no engine: a member cannot see whether their deployment has one,
244// and a request is what an operator needs in order to act.
245//
246// THE ATTESTATION IS REQUIRED, with no default. A scanner cannot read
247// ciphertext, and server-blindness is this platform family's default posture,
248// so "are these objects readable" has no defensible default answer — guessing
249// plaintext would let a client-encrypted store collect a coverage claim that
250// means nothing, and guessing encrypted would silently disable a control
251// someone asked for.
252func (s *Server) handleAPIScanRequest(w http.ResponseWriter, r *http.Request) {
253 addr, acct, app, ok := s.apiAccountApp(w, r)
254 if !ok {
255 return
256 }
257 p, _ := principalFrom(r.Context())
258 if !s.allowWrite(p, time.Now()) {
259 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
260 return
261 }
262 if s.cfg.Releases == nil {
263 storeNoBucket(w)
264 return
265 }
266 var req struct {
267 Encrypted *bool `json:"encrypted"`
268 }
269 if !readJSON(w, r, &req) {
270 return
271 }
272 if req.Encrypted == nil {
273 jsonError(w, http.StatusBadRequest,
274 "say whether this store holds readable objects: pass --plaintext or --client-encrypted (a scanner cannot read ciphertext)")
275 return
276 }
277 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
278 defer cancel()
279
280 rec, err := s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
281 if rec.Scanning == nil {
282 rec.Scanning = &objstore.ScanBlock{}
283 }
284 rec.Scanning.Requested = &objstore.ScanRequested{
285 At: time.Now().UTC(), By: addr, Encrypted: *req.Encrypted,
286 }
287 return nil
288 })
289 if errors.Is(err, objstore.ErrNotDeclared) {
290 jsonError(w, http.StatusConflict,
291 "no store is declared for this app — run `carlos store create` first")
292 return
293 }
294 if err != nil {
295 log.Printf("console: api scan request %s/%s: %v", acct.Sqid, app, err)
296 jsonError(w, http.StatusBadGateway, "could not record the request — try again")
297 return
298 }
299 s.audit(acct, app, addr, "store-scan-request", "requested object scanning (cli)")
300 s.replyScan(w, r, acct, app, rec)
301}
302
303// handleAPIScanGrant answers `carlos store scan grant`: an OPERATOR turning
304// the engine on for one store.
305func (s *Server) handleAPIScanGrant(w http.ResponseWriter, r *http.Request) {
306 addr, acct, app, ok := s.apiAccountApp(w, r)
307 if !ok {
308 return
309 }
310 if !s.storeOperator(addr) {
311 jsonError(w, http.StatusForbidden,
312 "enabling object scanning needs a deployment operator (CARLOS_STORE_OPERATORS)")
313 return
314 }
315 p, _ := principalFrom(r.Context())
316 if !s.storeWalkGates(w, p) {
317 return
318 }
319 if s.cfg.StoreScanner == nil {
320 scanNoEngine(w)
321 return
322 }
323 var pub *ecdh.PublicKey
324 if s.cfg.CallbackSigner != nil {
325 var ok bool
326 pub, ok = s.storeSealKey(w)
327 if !ok {
328 return
329 }
330 }
331 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
332 defer cancel()
333
334 if !s.scanEnabledForDeployment(ctx, w) {
335 return
336 }
337 rec, _, ok := s.scanRecord(ctx, w, acct, app)
338 if !ok {
339 return
340 }
341 if rec.Provisioned == nil {
342 jsonError(w, http.StatusConflict,
343 "this store has not been granted yet — run `carlos store grant` first")
344 return
345 }
346 // The member's request is a PRECONDITION, not a formality, and this is
347 // the second place that has to hold the line.
348 //
349 // The attestation only exists in Requested. Enabling without one leaves
350 // Requested nil, and nil reads as "not encrypted" everywhere downstream —
351 // so a client-encrypted store nobody attested would be swept and reported
352 // entirely clean, which is the exact failure §2.2 exists to prevent. It
353 // also skips the consent point: this engine reads tenant object bytes,
354 // and an operator must not be able to start that on their own.
355 //
356 // Same posture as objstore.Grant refusing ErrNotDeclared: an operator
357 // verb never invents the member's half.
358 if rec.Scanning == nil || rec.Scanning.Requested == nil {
359 jsonError(w, http.StatusConflict,
360 "the member has not requested scanning for this app — they run `carlos store scan request` first, "+
361 "and their attestation is what says whether these objects can be read at all")
362 return
363 }
364 engine, _, err := s.cfg.StoreScanner.Provenance(ctx)
365 if err != nil {
366 log.Printf("console: api scan grant %s/%s: engine: %v", acct.Sqid, app, err)
367 jsonError(w, http.StatusServiceUnavailable,
368 "the scanning engine is not answering — check clamd and try again")
369 return
370 }
371 // Enable on the backend BEFORE recording it. If the record said enabled
372 // and the backend was not, the sweep would run against a store nothing had
373 // prepared; the other order merely repeats an idempotent enable.
374 //
375 // The backend enable and the record write are one app mutation: a
376 // foreign operation on the app refuses both, and neither lands outside it.
377 enabled := false
378 bucket := rec.Provisioned.Bucket
379 err = s.appMutation(ctx, acct, app, "store-scan-grant", func(ctx context.Context) error {
380 if err := s.cfg.StoreScanner.EnsureEnabled(ctx, bucket); err != nil {
381 return err
382 }
383 enabled = true
384 var err error
385 rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
386 if rec.Scanning == nil {
387 rec.Scanning = &objstore.ScanBlock{}
388 }
389 rec.Scanning.Enabled = &objstore.ScanEnabled{
390 Engine: engine, At: time.Now().UTC(), By: addr,
391 }
392 if s.cfg.CallbackSigner != nil && rec.Scanning.WebhookGen == 0 {
393 gen, err := randomWebhookGeneration()
394 if err != nil {
395 return err
396 }
397 rec.Scanning.WebhookGen = gen
398 }
399 return nil
400 })
401 return err
402 })
403 if err != nil && !enabled {
404 log.Printf("console: api scan grant %s/%s: enable: %v", acct.Sqid, app, err)
405 jsonError(w, http.StatusBadGateway, "could not enable scanning on the store — try again")
406 return
407 }
408 if err != nil {
409 log.Printf("console: api scan grant %s/%s: %v", acct.Sqid, app, err)
410 jsonError(w, http.StatusBadGateway, "could not record the engine — try again")
411 return
412 }
413 if s.cfg.CallbackSigner != nil {
414 err = s.appMutation(ctx, acct, app, "scan-regrant", func(ctx context.Context) error {
415 if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil {
416 return err
417 }
418 var syncErr error
419 _, rec, syncErr = s.convergeCallbackKey(ctx, pub, acct, app)
420 return syncErr
421 })
422 if err != nil {
423 log.Printf("console: api scan grant %s/%s: deliver callback key: %v", acct.Sqid, app, err)
424 jsonError(w, http.StatusBadGateway,
425 "scanning was enabled but its callback secret was not delivered — re-run the grant to resume")
426 return
427 }
428 }
429 s.audit(acct, app, addr, "store-scan-grant", "enabled "+engine+" scanning on the store (cli)")
430 s.replyScan(w, r, acct, app, rec)
431}
432
433// handleAPIScanRevoke answers `carlos store scan revoke`: an OPERATOR turning
434// the engine off.
435//
436// It does NOT require an engine. This is the door that gets an operator out of
437// trouble after CARLOS_STORE_SCANNER is unset, and a version of it that
438// refused without one would strand every enforcing record it was meant to
439// clear.
440func (s *Server) handleAPIScanRevoke(w http.ResponseWriter, r *http.Request) {
441 addr, acct, app, ok := s.apiAccountApp(w, r)
442 if !ok {
443 return
444 }
445 if !s.storeOperator(addr) {
446 jsonError(w, http.StatusForbidden,
447 "disabling object scanning needs a deployment operator (CARLOS_STORE_OPERATORS)")
448 return
449 }
450 p, _ := principalFrom(r.Context())
451 if !s.storeWalkGates(w, p) {
452 return
453 }
454 pub, ok := s.storeSealKey(w)
455 if !ok {
456 return
457 }
458 ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
459 defer cancel()
460
461 // Best effort on the backend, and only when there IS one. A revoke whose
462 // EnsureDisabled failed must still clear the record, or the operator has
463 // no way to stop enforcement.
464 //
465 // The backend disable and the record write are one app mutation, as in
466 // grant; the regrant below opens its own.
467 var rec *objstore.Record
468 err := s.appMutation(ctx, acct, app, "store-scan-revoke", func(ctx context.Context) error {
469 if cur, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app); err == nil &&
470 cur != nil && cur.Provisioned != nil && s.cfg.StoreScanner != nil {
471 if derr := s.cfg.StoreScanner.EnsureDisabled(ctx, cur.Provisioned.Bucket); derr != nil {
472 log.Printf("console: api scan revoke %s/%s: disable: %v", acct.Sqid, app, derr)
473 }
474 }
475 var err error
476 rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
477 if rec.Scanning == nil {
478 return nil
479 }
480 rec.Scanning.Enabled = nil
481 rec.Scanning.Enforce = nil
482 rec.Scanning.WebhookGen = 0
483 rec.Scanning.WebhookKeyID = ""
484 rec.Scanning.WebhookConfigHash = ""
485 return nil
486 })
487 return err
488 })
489 if err != nil {
490 log.Printf("console: api scan revoke %s/%s: %v", acct.Sqid, app, err)
491 jsonError(w, http.StatusBadGateway, "could not clear the engine — try again")
492 return
493 }
494 // Re-walk so the Deny actually comes back out of the app's policy. Without
495 // this the record would say enforcement is off while the live policy still
496 // denied reads — the exact drift the whole enforcement design exists to
497 // avoid.
498 if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil {
499 log.Printf("console: api scan revoke %s/%s: regrant: %v", acct.Sqid, app, err)
500 jsonError(w, http.StatusBadGateway,
501 "the record was cleared but the store policy was not rewritten — re-run `carlos store grant` to converge it")
502 return
503 }
504 if rec.Declared != nil {
505 callbackName := rec.Declared.EnvPrefix + "_SCAN_CALLBACK_SECRET"
506 if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{callbackName}); err != nil {
507 log.Printf("console: api scan revoke %s/%s: unset callback secret: %v", acct.Sqid, app, err)
508 jsonError(w, http.StatusBadGateway, "scanning was revoked but its callback secret could not be removed — re-run to resume")
509 return
510 }
511 }
512 s.audit(acct, app, addr, "store-scan-revoke", "disabled object scanning on the store (cli)")
513 s.replyScan(w, r, acct, app, rec)
514}
515
516// handleAPIScanEnforce answers `carlos store scan enforce on|off`: the Deny in
517// the app's own IAM policy.
518//
519// on needs an engine, because the tag denied on comes from it. off does not,
520// for the deadlock reason at the top of this file.
521func (s *Server) handleAPIScanEnforce(w http.ResponseWriter, r *http.Request) {
522 addr, acct, app, ok := s.apiAccountApp(w, r)
523 if !ok {
524 return
525 }
526 if !s.storeOperator(addr) {
527 jsonError(w, http.StatusForbidden,
528 "changing scan enforcement needs a deployment operator (CARLOS_STORE_OPERATORS)")
529 return
530 }
531 p, _ := principalFrom(r.Context())
532 if !s.storeWalkGates(w, p) {
533 return
534 }
535 var req struct {
536 On *bool `json:"on"`
537 }
538 if !readJSON(w, r, &req) {
539 return
540 }
541 if req.On == nil {
542 jsonError(w, http.StatusBadRequest, "say which: `carlos store scan enforce on` or `... off`")
543 return
544 }
545 pub, ok := s.storeSealKey(w)
546 if !ok {
547 return
548 }
549 var deny *objstore.DenyTag
550 if *req.On {
551 if s.cfg.StoreScanner == nil {
552 scanNoEngine(w)
553 return
554 }
555 d := s.cfg.StoreScanner.Enforcement()
556 deny = &d
557 }
558
559 ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
560 defer cancel()
561
562 rec, err := s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
563 if rec.Scanning == nil {
564 rec.Scanning = &objstore.ScanBlock{}
565 }
566 if *req.On {
567 if rec.Scanning.Enabled == nil {
568 return errEnforceWithoutEngine
569 }
570 // Enforcement on a store nobody can scan is theatre: no object
571 // will ever carry the infected tag, so the Deny can never fire,
572 // and the record would claim a control that cannot act.
573 if rec.Scanning.Requested != nil && rec.Scanning.Requested.Encrypted {
574 return errEnforceOnEncrypted
575 }
576 }
577 rec.Scanning.Enforce = deny
578 return nil
579 })
580 switch {
581 case errors.Is(err, errEnforceWithoutEngine):
582 jsonError(w, http.StatusConflict,
583 "scanning is not enabled on this store — run `carlos store scan grant` first")
584 return
585 case errors.Is(err, errEnforceOnEncrypted):
586 jsonError(w, http.StatusConflict,
587 "this store is attested client-encrypted — nothing can ever be tagged infected, so enforcement would deny nothing")
588 return
589 case errors.Is(err, objstore.ErrNotDeclared):
590 jsonError(w, http.StatusConflict,
591 "no store is declared for this app — run `carlos store create` first")
592 return
593 case err != nil:
594 log.Printf("console: api scan enforce %s/%s: %v", acct.Sqid, app, err)
595 jsonError(w, http.StatusBadGateway, "could not record enforcement — try again")
596 return
597 }
598
599 if err := s.regrantForScanning(ctx, pub, acct, app, addr); err != nil {
600 log.Printf("console: api scan enforce %s/%s: regrant: %v", acct.Sqid, app, err)
601 jsonError(w, http.StatusBadGateway,
602 "the record was updated but the store policy was not rewritten — re-run `carlos store grant` to converge it")
603 return
604 }
605 what := "off"
606 if *req.On {
607 what = "on"
608 }
609 s.audit(acct, app, addr, "store-scan-enforce", "turned scan enforcement "+what+" (cli)")
610 s.replyScan(w, r, acct, app, rec)
611}
612
613var (
614 errEnforceWithoutEngine = errors.New("console: enforcement without an enabled engine")
615 errEnforceOnEncrypted = errors.New("console: enforcement on a client-encrypted store")
616)
617
618// regrantForScanning re-runs the store walk so the app's inline policy catches
619// up with the record. Grant is the only writer of that policy, and it is
620// idempotent on a healthy store: everything but the policy short-circuits.
621//
622// A store that was never granted has no policy to converge, so this is a
623// no-op for it rather than an error.
624func (s *Server) regrantForScanning(ctx context.Context, pub *ecdh.PublicKey, acct *Account, app, addr string) error {
625 rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
626 if err != nil {
627 return err
628 }
629 if rec == nil || rec.Provisioned == nil {
630 return nil
631 }
632
633 // The grant SOURCE is carried from the record rather than restated. This
634 // is a policy convergence, not a new grant: Grant writes the block only
635 // when it is absent, so a store approved at account level would keep its
636 // account-approval provenance either way — but restating "operator" here
637 // would be a lie the moment that stopped being true, and scanning has no
638 // business relabelling why a store exists.
639 source := objstore.GrantSourceOperator
640 if rec.Grant != nil && rec.Grant.Source != "" {
641 source = rec.Grant.Source
642 }
643 _, err = claimAppCall(s, ctx, acct, app, "scan-regrant", func(ctx context.Context) (*objstore.Record, error) {
644 return objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
645 pub, acct.Sqid, app, addr, source, "", time.Now())
646 })
647 return err
648}
649
650func (s *Server) deliverCallbackSecret(ctx context.Context, pub *ecdh.PublicKey, acct, app string, rec *objstore.Record, gen int) error {
651 _, err := s.deliverCallbackRevision(ctx, pub, acct, app, rec, gen)
652 return err
653}
654
655// randomWebhookGeneration deliberately does not survive app deletion. A
656// reclaimed name starts from a fresh random value rather than repeating a
657// counter whose record was swept with the old app. Keep it below 2^53 so the
658// value round-trips through JSON implementations that store numbers as
659// IEEE-754 doubles; zero remains reserved for records written before this
660// field existed.
661func randomWebhookGeneration() (int, error) {
662 var raw [8]byte
663 if _, err := rand.Read(raw[:]); err != nil {
664 return 0, fmt.Errorf("mint callback generation: %w", err)
665 }
666 return int(binary.BigEndian.Uint64(raw[:])%((1<<53)-1)) + 1, nil
667}
668
669// handleAPIScanCallbackRotate changes only this app's derived key. The
670// generation is recorded before it is used again for signing; a deployment
671// master rotation leaves it untouched.
672func (s *Server) handleAPIScanCallbackRotate(w http.ResponseWriter, r *http.Request) {
673 addr, acct, app, ok := s.apiAccountApp(w, r)
674 if !ok {
675 return
676 }
677 if !s.storeOperator(addr) {
678 jsonError(w, http.StatusForbidden, "rotating a scan callback key needs a deployment operator (CARLOS_STORE_OPERATORS)")
679 return
680 }
681 p, _ := principalFrom(r.Context())
682 if !s.storeWalkGates(w, p) {
683 return
684 }
685 pub, ok := s.storeSealKey(w)
686 if !ok {
687 return
688 }
689 if s.cfg.CallbackSigner == nil {
690 scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured", "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
691 return
692 }
693 ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout)
694 defer cancel()
695 rec, _, ok := s.scanRecord(ctx, w, acct, app)
696 if !ok {
697 return
698 }
699 if rec.Scanning == nil || rec.Scanning.Enabled == nil {
700 jsonError(w, http.StatusConflict, "scanning is not enabled on this store — run `carlos store scan grant` first")
701 return
702 }
703 gen, err := randomWebhookGeneration()
704 if err != nil {
705 jsonError(w, http.StatusInternalServerError, "could not mint a new callback key — try again")
706 return
707 }
708 // The delivery and the record write are one app mutation, as in grant.
709 errRotateNoStore := errors.New("no store record")
710 errRotateNotEnabled := errors.New("scanning not enabled")
711 err = s.appMutation(ctx, acct, app, "store-scan-callback-rotate", func(ctx context.Context) error {
712 fresh, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
713 if gerr != nil {
714 return gerr
715 }
716 if fresh == nil {
717 return errRotateNoStore
718 }
719 if fresh.Scanning == nil || fresh.Scanning.Enabled == nil {
720 return errRotateNotEnabled
721 }
722 var err error
723 rec, err = s.writeScanRecord(ctx, acct, app, func(rec *objstore.Record) error {
724 if rec.Scanning == nil || rec.Scanning.Enabled == nil {
725 return errRotateNotEnabled
726 }
727 rec.Scanning.WebhookGen = gen
728 rec.Scanning.WebhookKeyID = ""
729 rec.Scanning.WebhookConfigHash = ""
730 return nil
731 })
732 if err != nil {
733 return err
734 }
735 _, rec, err = s.convergeCallbackKey(ctx, pub, acct, app)
736 return err
737 })
738 switch {
739 case errors.Is(err, errRotateNoStore):
740 jsonError(w, http.StatusConflict,
741 "no store is declared for this app — run `carlos store create` first")
742 return
743 case errors.Is(err, errRotateNotEnabled), errors.Is(err, objstore.ErrQueueNotPermitted):
744 jsonError(w, http.StatusConflict, "scanning is not enabled on this store — run `carlos store scan grant` first")
745 return
746 }
747 if err != nil {
748 log.Printf("console: api scan callback rotate %s/%s: deliver: %v", acct.Sqid, app, err)
749 jsonError(w, http.StatusBadGateway, "could not deliver the rotated callback secret — re-run to resume")
750 return
751 }
752 s.bumpSerial(ctx)
753 s.replyScan(w, r, acct, app, rec)
754}
755
756// handleAPIScanSweep answers `carlos store scan sweep`: run one budget of
757// scanning now rather than waiting for the tick.
758func (s *Server) handleAPIScanSweep(w http.ResponseWriter, r *http.Request) {
759 addr, acct, app, ok := s.apiAccountApp(w, r)
760 if !ok {
761 return
762 }
763 if !s.storeOperator(addr) {
764 jsonError(w, http.StatusForbidden,
765 "running a scan sweep needs a deployment operator (CARLOS_STORE_OPERATORS)")
766 return
767 }
768 p, _ := principalFrom(r.Context())
769 if !s.storeWalkGates(w, p) {
770 return
771 }
772 if s.cfg.StoreScanner == nil {
773 scanNoEngine(w)
774 return
775 }
776 ctx, cancel := context.WithTimeout(r.Context(), scanSweepTimeout)
777 defer cancel()
778
779 if _, err := s.sweepNow(ctx, acct.Sqid, app); err != nil {
780 if errors.Is(err, errNoStore) {
781 jsonError(w, http.StatusConflict,
782 "this store has not been granted yet — run `carlos store grant` first")
783 return
784 }
785 log.Printf("console: api scan sweep %s/%s: %v", acct.Sqid, app, err)
786 // The two causes an operator can act on are named. Everything else
787 // stays "see the log": a backend error carries bucket names, ARNs and
788 // request ids, and none of that belongs in a client sentence.
789 switch {
790 case errors.Is(err, objstore.ErrScanningDisabled):
791 // Not an incident — a command away. The sweep verb reaches
792 // RunSweep by its own path, so this is where `disable` stops it.
793 scanDisabled(w)
794 case errors.Is(err, objstore.ErrEngineUnreachable):
795 jsonError(w, http.StatusServiceUnavailable,
796 "the scanning engine is not answering — check clamd on the console box, then re-run")
797 case errors.Is(err, objstore.ErrEngineDrift):
798 jsonError(w, http.StatusConflict,
799 "this store was scanned by a different engine — `carlos store scan revoke` then "+
800 "`carlos store scan grant` to move it, which restarts its coverage history")
801 default:
802 jsonError(w, http.StatusBadGateway,
803 "the sweep did not finish — see the console log; re-run to resume from where it stopped")
804 }
805 return
806 }
807 rec, _, ok := s.scanRecord(ctx, w, acct, app)
808 if !ok {
809 return
810 }
811 s.audit(acct, app, addr, "store-scan-sweep", "ran a scan sweep (cli)")
812 s.replyScan(w, r, acct, app, rec)
813}
814
815// replyScan writes the shared reply, or a 502 if it cannot be assembled.
816func (s *Server) replyScan(w http.ResponseWriter, r *http.Request, acct *Account, app string, rec *objstore.Record) {
817 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
818 defer cancel()
819 reply, err := s.scanReply(ctx, acct, app, rec)
820 if err != nil {
821 log.Printf("console: api scan reply %s/%s: %v", acct.Sqid, app, err)
822 jsonError(w, http.StatusBadGateway, "the change was made but could not be read back — run `carlos store scan status`")
823 return
824 }
825 writeJSON(w, http.StatusOK, reply)
826}
827
828// ---- the on-demand enqueue door (spec §5.1) ---------------------------------
829
830// scanEnqueueBody is the request. Two fields, and the second is optional: an
831// app with no platform-assigned host, or one that would rather poll, sends
832// only the key.
833type scanEnqueueBody struct {
834 Key string `json:"key"`
835 CallbackPath string `json:"callback_path"`
836}
837
838// scanUnavailable writes §5.1's refusal shape: a machine-readable reason AND
839// a sentence.
840//
841// The reason is what an app branches on — "not_granted" and "disabled" want
842// different behaviour from a client, and neither is a retry. The sentence is
843// for the person reading the log when the client did the wrong thing anyway,
844// and it uses the same "error" key every other door on this console uses, so
845// a generic client that only knows how to print an error still prints
846// something true.
847func scanUnavailable(w http.ResponseWriter, status int, reason, msg string) {
848 writeJSON(w, status, map[string]string{
849 "state": "unavailable",
850 "reason": reason,
851 "error": msg,
852 })
853}
854
855// scanAnswer is how one QueueRefusal is answered at the enqueue door: a
856// status and a sentence, or the family's 404.
857type scanAnswer struct {
858 status int
859 notFound bool
860 unknown bool
861 msg string
862}
863
864func (a scanAnswer) refused() bool { return a.status != 0 || a.notFound }
865
866// scanRefusalAnswer maps every name the consent chain can produce onto this
867// door's answer.
868//
869// A TABLE, not a switch inside the handler, and the reason is testability
870// rather than tidiness: the two arms that matter most are the two the handler
871// cannot be driven into. A trashed app is already a 404 at apiAccountApp, so
872// the trashed arm is unreachable through HTTP; an unrecognised name does not
873// exist yet, by definition. Left inline, both would be branches nothing could
874// assert — and an unasserted refusal branch is exactly where "the door that
875// did not recognise it let it through" gets written.
876//
877// The default is a REFUSAL, so a name added to objstore and not taught to
878// this door costs a working feature rather than an unscanned object.
879func scanRefusalAnswer(r objstore.QueueRefusal) scanAnswer {
880 switch r {
881 case objstore.RefusalNone:
882 return scanAnswer{}
883 case objstore.RefusalTrashed:
884 // The SAME 404 apiAccountApp already gives, so that if the two
885 // checks ever drift apart this door does not become the one that
886 // confirms an app exists by refusing differently.
887 return scanAnswer{notFound: true}
888 case objstore.RefusalNotGranted:
889 return scanAnswer{status: http.StatusConflict,
890 msg: "this store has not been granted yet — an operator runs `carlos store grant`"}
891 case objstore.RefusalNotRequested:
892 return scanAnswer{status: http.StatusConflict,
893 msg: "scanning has not been requested for this store — a member runs `carlos store scan request`"}
894 case objstore.RefusalEncrypted:
895 return scanAnswer{status: http.StatusConflict,
896 msg: "this store is attested client-encrypted, so a verdict on its bytes would mean nothing"}
897 case objstore.RefusalNotEnabled:
898 return scanAnswer{status: http.StatusConflict,
899 msg: "scanning has not been granted for this store — an operator runs `carlos store scan grant`"}
900 case objstore.RefusalDisabled:
901 return scanAnswer{status: http.StatusServiceUnavailable,
902 msg: "object scanning is disabled for this deployment — an operator runs `carlos system antivirus enable`"}
903 default:
904 return scanAnswer{status: http.StatusConflict, unknown: true,
905 msg: "this store may not be scanned right now — run `carlos store scan status`"}
906 }
907}
908
909// handleAPIScanEnqueue asks for one object to be scanned soon, and answers
910// 202 without scanning it.
911//
912// IT ENQUEUES; IT DOES NOT SCAN. Scanning inline would hold a console
913// goroutine for as long as an engine takes on an object of the tenant's
914// choosing, which hands one app a trivial way to occupy all of them. The
915// drain reads the item on its own clock (§7.2).
916//
917// THE CONSENT CHAIN RUNS HERE TOO, and this is the door where forgetting it
918// was the hole review 3 found. The sweep will not read a tenant's bytes
919// without the member's attestation, and `scan grant` will not start on an
920// operator's say-so alone — so a door that checked only the deployment switch
921// would let an app's own credential make the console fetch and scan objects
922// in a store that was never requested, never granted, or attested as
923// client-encrypted. Every one of those refusals is objstore's
924// QueuePermitted, not a re-derivation here.
925//
926// ORDER: the store's own state before the deployment's. An app learns why ITS
927// store cannot be scanned before it learns anything about the deployment it
928// happens to be running on, and the two 503s below stay the last word.
929func (s *Server) handleAPIScanEnqueue(w http.ResponseWriter, r *http.Request) {
930 addr, acct, app, ok := s.apiAccountApp(w, r)
931 if !ok {
932 return
933 }
934 p, _ := principalFrom(r.Context())
935 if !s.allowWrite(p, time.Now()) {
936 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
937 return
938 }
939 // STRICT, and this is the door that needs it: an app that misspells
940 // callback_path would otherwise be answered 202 for an item carrying no
941 // callback at all, and would then wait for a delivery nothing ever
942 // promised. A door whose optional field is a PROMISE must not accept a
943 // body it half-understands.
944 var body scanEnqueueBody
945 if !readJSONStrictLimit(w, r, &body, 8<<10) {
946 return
947 }
948 key := body.Key
949 if strings.TrimSpace(key) == "" {
950 jsonError(w, http.StatusBadRequest,
951 `name the object to scan, e.g. {"key": "uploads/x.pdf"}`)
952 return
953 }
954 if body.CallbackPath != "" {
955 if err := validCallbackPath(body.CallbackPath); err != nil {
956 // The validator's own words. They name the shape that was
957 // refused ("protocol-relative", "'..' segment"), which is what
958 // the app author needs, and none of them echoes the input back
959 // into the response.
960 jsonError(w, http.StatusBadRequest, err.Error()+
961 " — callback_path is a path on your app's platform host, not a URL")
962 return
963 }
964 if s.cfg.CallbackSigner == nil {
965 scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured",
966 "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)")
967 return
968 }
969 host, err := s.scanCallbackHost(objstore.AppRef{Account: acct.Sqid, App: app})
970 if err != nil {
971 log.Printf("console: api scan enqueue %s/%s: platform host: %v", acct.Sqid, app, err)
972 jsonError(w, http.StatusBadGateway, "the app's platform route could not be read — try again")
973 return
974 }
975 if host == "" {
976 jsonError(w, http.StatusConflict, "this app has no ready route yet — omit callback_path and poll scan status")
977 return
978 }
979 }
980 if s.cfg.Releases == nil {
981 scanNoEngineState(w)
982 return
983 }
984 ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
985 defer cancel()
986
987 // The record itself is not wanted here. The door's whole use of the
988 // consent chain is the NAME it comes back with — QueuePermitted returns
989 // a record only for the one reversible refusal, and this door has
990 // nothing to say about a store it is refusing.
991 _, refusal, err := objstore.QueuePermitted(ctx, s.cfg.Releases, acct.Sqid, app)
992 if err != nil {
993 log.Printf("console: api scan enqueue %s/%s: consent: %v", acct.Sqid, app, err)
994 jsonError(w, http.StatusBadGateway, "the store record could not be read — try again")
995 return
996 }
997 if ans := scanRefusalAnswer(refusal); ans.refused() {
998 if ans.unknown {
999 log.Printf("console: api scan enqueue %s/%s: unhandled refusal %q", acct.Sqid, app, refusal)
1000 }
1001 if ans.notFound {
1002 http.NotFound(w, r)
1003 return
1004 }
1005 scanUnavailable(w, ans.status, string(refusal), ans.msg)
1006 return
1007 }
1008 // LAST, because it is the deployment's state rather than this store's,
1009 // and because it is the one refusal that is about whether the queue can
1010 // ever drain: with no engine wired the item would sit until its TTL and
1011 // the 202 would have been a lie.
1012 if s.cfg.StoreScanner == nil {
1013 scanNoEngineState(w)
1014 return
1015 }
1016 if _, err := objstore.Enqueue(ctx, s.cfg.Releases, s.scanQueue, acct.Sqid, app,
1017 &objstore.QueueItem{Key: key, By: addr, CallbackPath: body.CallbackPath},
1018 time.Now()); err != nil {
1019 log.Printf("console: api scan enqueue %s/%s %q: %v", acct.Sqid, app, key, err)
1020 jsonError(w, http.StatusBadGateway, "the scan could not be queued — try again")
1021 return
1022 }
1023 // NO ID IN THE REPLY. The status door (§5.2) is keyed by object key, not
1024 // by queue id, so an id here would be a handle with nothing to hand it
1025 // to — and one clients would build on before it meant anything.
1026 writeJSON(w, http.StatusAccepted, map[string]string{"state": "pending"})
1027}
1028