carlos / platform Public

console: the scan enqueue door, behind the consent chain

Task 11, spec §5.1. `POST .../store/scan/object` takes a key and an
optional callback path, writes one queue item, and answers 202 without
scanning anything. 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 way to occupy all of them.

THE CONSENT CHAIN RUNS HERE, and this is the door where leaving it out
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 checking 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
client-encrypted. It calls objstore's QueuePermitted rather than
re-deriving the six conditions, which is why that is now exported: two
callers deriving the same fact from the same data is the shape in which
they start disagreeing, and §5.1.1 is precisely that they must not.

`Service`, not `Admin`. Every other scan verb decides POLICY — who may
scan, whether enforcement is on — and belongs to a person. This one asks
for work inside a policy somebody already set, and can add nothing an
operator has not already granted.

The refusal mapping is a TABLE rather than a switch inside the handler,
and that is a testability decision, not a tidiness one. Two of its arms
cannot be reached through the door: a trashed app is a 404 at
apiAccountApp before the chain is consulted, and a refusal name objstore
has not defined yet does not exist to send. Both were mutation-tested
through HTTP and both SURVIVED — not because the code was wrong, but
because no request can distinguish them. Moved out, they are asserted
where they are observable, including the one that matters: an
unrecognised refusal must refuse. Costing a working feature is
survivable; letting an object through on a name the door did not
recognise is not.

Strict decoding, for one reason: an app that misspells `callback_path`
would otherwise be answered 202 for an item carrying no callback, and
would wait forever for a delivery nothing ever promised. A door whose
optional field is a PROMISE must not accept a body it half-understands.

The engine check is last. An app learns why ITS store cannot be scanned
before it learns anything about the deployment it happens to run on —
and with no engine wired the 202 would be a lie, because nothing would
ever drain the item.

Nine mutations, all caught. One of them only after a false result:
the first run patched an identical `allowWrite` line in
handleAPIScanRequest earlier in the same file and reported SURVIVED for
a limiter that was never touched. Anchors are checked for uniqueness now,
not just for having changed something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExnZtUYmt2h8QFcymjPaSL
Paul Campbell pushed by paul@keymail.dev 90adce43fe95d0d91fee550434ac6a7dd1b832f3 parent fe5a099
6 files changed, +654 −8
  • internal/console/apiscan.go +187 −0
  • internal/console/apiscanqueue_test.go +429 −0
  • internal/console/authcli.go +9 −0
  • internal/console/server.go +15 −0
  • internal/objstore/queue.go +10 −4
  • internal/objstore/queue_test.go +4 −4
diff --git a/internal/console/apiscan.go b/internal/console/apiscan.go
index c08d6f06d..1fd16dc21 100644
--- a/internal/console/apiscan.go
+++ b/internal/console/apiscan.go
@@ -26,6 +26,7 @@ import (
"errors"
"log"
"net/http"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/objstore"
@@ -621,3 +622,189 @@ func (s *Server) replyScan(w http.ResponseWriter, r *http.Request, acct *Account
}
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 := strings.TrimSpace(body.Key)
+ if 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.Releases == nil {
+ scanNoEngine(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 {
+ scanNoEngine(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"})
+}
diff --git a/internal/console/apiscanqueue_test.go b/internal/console/apiscanqueue_test.go
new file mode 100644
index 000000000..4001640f6
--- /dev/null
+++ b/internal/console/apiscanqueue_test.go
@@ -0,0 +1,429 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func enqueueURL(fx *consoleFixture, app string) string {
+ return scanURL(fx, app) + "/object"
+}
+
+// appToken is the credential this door is FOR: an app's own service principal,
+// bound to one app, holding the Service role and nothing else.
+func appToken(t *testing.T, fx *consoleFixture, name string) string {
+ t.Helper()
+ return serviceToken(t, fx.st, OpsAccountID, name, "titogo", svcrole.Service)
+}
+
+// queuedKeys is what actually landed in the bucket. The door's 202 is a
+// promise about an object, so every test that reads a 202 checks the object,
+// and every test that reads a refusal checks that there is none — a refusal
+// that still enqueued would be the worst of both.
+func queuedKeys(t *testing.T, fx *consoleFixture, app string) []string {
+ t.Helper()
+ keys, err := fx.rel.List(context.Background(), objstore.QueuePrefix(release.OpsAccount, app))
+ if err != nil {
+ t.Fatalf("list queue: %v", err)
+ }
+ return keys
+}
+
+// enqueueArmed is the whole consent chain satisfied and an engine wired: the
+// only state in which this door says 202.
+func enqueueArmed(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx, _, token, _ := storeArmed(t, code)
+ grantStore(t, fx, token)
+ requestScanning(t, fx, token, false)
+ // Engine, then the deployment switch, THEN the per-store grant. That is
+ // the real sequence and the order is not incidental: `scan grant` refuses
+ // with no engine wired (there is nothing to enable) and refuses again
+ // while the deployment switch is off (it would be enabling a control that
+ // is not running).
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"}
+ if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", s, o)
+ }
+ return fx, appToken(t, fx, "titobot")
+}
+
+// TestEnqueueAcceptsAndWritesTheItem is the positive control every refusal
+// test below is measured against. Without it a door that refused EVERYTHING
+// would pass the entire rest of this file.
+func TestEnqueueAcceptsAndWritesTheItem(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-ok")
+
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf", "callback_path": "/hooks/scan"}, tok)
+ if status != http.StatusAccepted {
+ t.Fatalf("enqueue = %d %v, want 202", status, out)
+ }
+ if out["state"] != "pending" {
+ t.Errorf("state = %v, want pending", out["state"])
+ }
+ // No id in the reply: the status door is keyed by object key, so an id
+ // here would be a handle with nothing to hand it to.
+ if _, ok := out["id"]; ok {
+ t.Errorf("reply carries an id (%v) — nothing consumes one, and a client would build on it", out)
+ }
+ keys := queuedKeys(t, fx, "titogo")
+ if len(keys) != 1 {
+ t.Fatalf("queued %d items, want 1: %v", len(keys), keys)
+ }
+ // And the index knows, or the drain never looks at this app.
+ if n := fx.srv.scanQueue.Len(); n != 1 {
+ t.Errorf("index holds %d apps, want 1 — an item nothing points at is stranded until the next boot", n)
+ }
+}
+
+// TestEnqueueRefusesEveryConsentState is §5.1.1's chain, one case per name.
+//
+// The states are built by STOPPING the real sequence at each step rather than
+// by writing records by hand: a fixture that hand-rolls "granted but not
+// requested" can drift from what the grant door actually writes, and then the
+// test passes against a shape the deployment never produces.
+func TestEnqueueRefusesEveryConsentState(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ reason string
+ status int
+ // arm builds the state and returns the app's token.
+ arm func(t *testing.T, fx *consoleFixture, member string) string
+ }{{
+ name: "no store at all",
+ reason: "not_granted",
+ status: http.StatusConflict,
+ arm: func(t *testing.T, fx *consoleFixture, member string) string {
+ return appToken(t, fx, "bot")
+ },
+ }, {
+ name: "store granted, scanning never requested",
+ reason: "not_requested",
+ status: http.StatusConflict,
+ arm: func(t *testing.T, fx *consoleFixture, member string) string {
+ grantStore(t, fx, member)
+ return appToken(t, fx, "bot")
+ },
+ }, {
+ name: "requested, but attested client-encrypted",
+ reason: "encrypted",
+ status: http.StatusConflict,
+ arm: func(t *testing.T, fx *consoleFixture, member string) string {
+ grantStore(t, fx, member)
+ requestScanning(t, fx, member, true)
+ return appToken(t, fx, "bot")
+ },
+ }, {
+ name: "requested, but no operator grant",
+ reason: "not_enabled",
+ status: http.StatusConflict,
+ arm: func(t *testing.T, fx *consoleFixture, member string) string {
+ grantStore(t, fx, member)
+ requestScanning(t, fx, member, false)
+ return appToken(t, fx, "bot")
+ },
+ }, {
+ name: "everything granted, deployment switched off",
+ reason: "disabled",
+ status: http.StatusServiceUnavailable,
+ arm: func(t *testing.T, fx *consoleFixture, member string) string {
+ grantStore(t, fx, member)
+ requestScanning(t, fx, member, false)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"}
+ if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, member); s != http.StatusOK {
+ t.Fatalf("scan grant = %d %v", s, o)
+ }
+ // Granted, and THEN switched off: the one refusal that is
+ // reversible, and the only one where the store itself is ready.
+ disableDeploymentScanning(t, fx)
+ return appToken(t, fx, "bot")
+ },
+ }} {
+ t.Run(tc.name, func(t *testing.T) {
+ fx, _, member, _ := storeArmed(t, "code-enq-"+strings.ReplaceAll(tc.name, " ", "-"))
+ tok := tc.arm(t, fx, member)
+
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf"}, tok)
+ if status != tc.status {
+ t.Fatalf("enqueue = %d %v, want %d", status, out, tc.status)
+ }
+ if out["state"] != "unavailable" {
+ t.Errorf("state = %v, want unavailable", out["state"])
+ }
+ if out["reason"] != tc.reason {
+ t.Errorf("reason = %v, want %q", out["reason"], tc.reason)
+ }
+ // A reason with no sentence beside it leaves whoever reads the
+ // log with a token and no next step.
+ if msg, _ := out["error"].(string); strings.TrimSpace(msg) == "" {
+ t.Errorf("no error sentence beside reason %q: %v", tc.reason, out)
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Errorf("refused with %q and still queued %v", tc.reason, keys)
+ }
+ })
+ }
+}
+
+// TestEnqueueRefusesWithoutAttestation is the hole review 3 found, on its own
+// rather than as one row of the table above, because it is the one a future
+// change is most likely to reintroduce.
+//
+// Everything the DEPLOYMENT controls is in place: an engine is wired, the
+// switch is on, the store is granted and provisioned. The only thing missing
+// is the member's own `carlos store scan request` — the attestation that says
+// whether these bytes are readable at all. A door that checked the deployment
+// switch and stopped would go straight through, and the console would fetch
+// and scan a tenant's objects on the say-so of that tenant's own app.
+func TestEnqueueRefusesWithoutAttestation(t *testing.T) {
+ fx, _, member, _ := storeArmed(t, "code-enq-attest")
+ grantStore(t, fx, member)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableDeploymentScanning(t, fx)
+ tok := appToken(t, fx, "titobot")
+
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf"}, tok)
+ if status != http.StatusConflict || out["reason"] != "not_requested" {
+ t.Fatalf("enqueue = %d %v, want 409 not_requested — the sweep will not read these bytes either", status, out)
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Fatalf("queued %v without the member's attestation", keys)
+ }
+}
+
+// TestEnqueueRequiresServiceRole pins the role, both ways.
+//
+// Admin reaching it is stated rather than fought: svcrole.Admin is the union
+// of every role by construction, so a test asserting Admin is refused here
+// would be asserting the opposite of how the role set works, and the next
+// person would "fix" the role table to satisfy it.
+func TestEnqueueRequiresServiceRole(t *testing.T) {
+ fx, _ := enqueueArmed(t, "code-enq-role")
+
+ for _, tc := range []struct {
+ role svcrole.Role
+ want int
+ }{
+ {svcrole.Publish, http.StatusForbidden},
+ {svcrole.Instance, http.StatusForbidden},
+ {svcrole.Operate, http.StatusForbidden},
+ {svcrole.Service, http.StatusAccepted},
+ {svcrole.Admin, http.StatusAccepted},
+ } {
+ t.Run(string(tc.role), func(t *testing.T) {
+ other := serviceToken(t, fx.st, OpsAccountID, "bot-"+string(tc.role), "titogo", tc.role)
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/" + string(tc.role) + ".pdf"}, other)
+ if status != tc.want {
+ t.Fatalf("role %s = %d %v, want %d", tc.role, status, out, tc.want)
+ }
+ })
+ }
+}
+
+// TestEnqueueRefusesAHostileCallbackPath proves the DOOR calls the validator.
+//
+// scanhook_test.go proves the validator is right about each shape; that is a
+// different claim from "nothing hostile can be stored", which is only true if
+// the door runs it on the way in. One row per family is enough here — the
+// exhaustive table belongs where the rule lives — and what this asserts on
+// top of the 400 is that the bucket stayed empty.
+func TestEnqueueRefusesAHostileCallbackPath(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-path")
+
+ for _, p := range []string{
+ "https://evil.example/x", // absolute
+ "//evil.example/x", // protocol-relative
+ "/\\evil.example/x", // backslash, read as protocol-relative by enough parsers
+ "/hooks/../../admin", // traversal
+ "/hooks\r\nX-Evil: 1", // header injection
+ "/hooks%0d%0aX-Evil:1", // and its encoded form
+ "/hooks#frag", // fragment
+ "/x@evil.example/y", // smuggled authority
+ "/" + strings.Repeat("a", maxCallbackPath), // over the cap
+ } {
+ t.Run(p, func(t *testing.T) {
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf", "callback_path": p}, tok)
+ if status != http.StatusBadRequest {
+ t.Fatalf("callback_path %q = %d %v, want 400", p, status, out)
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Fatalf("stored an item carrying %q: %v", p, keys)
+ }
+ })
+ }
+}
+
+// TestEnqueueRefusesABodyItHalfUnderstands is why this door decodes strictly.
+//
+// An app that misspells callback_path would otherwise be answered 202 for an
+// item carrying no callback, and would wait forever for a delivery nobody ever
+// promised. The 400 names the field, which is the whole of the fix.
+func TestEnqueueRefusesABodyItHalfUnderstands(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-strict")
+
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf", "callback_url": "/hooks/scan"}, tok)
+ if status != http.StatusBadRequest {
+ t.Fatalf("unknown field = %d %v, want 400", status, out)
+ }
+ if msg, _ := out["error"].(string); !strings.Contains(msg, "callback_url") {
+ t.Errorf("error %q does not name the field the caller got wrong", msg)
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Fatalf("queued %v from a body it did not fully understand", keys)
+ }
+}
+
+// TestEnqueueNeedsAKey: a 202 is a promise about an object, and there is no
+// object here.
+func TestEnqueueNeedsAKey(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-nokey")
+
+ for _, key := range []string{"", " "} {
+ status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": key}, tok)
+ if status != http.StatusBadRequest {
+ t.Fatalf("key %q = %d %v, want 400", key, status, out)
+ }
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Fatalf("queued %v with no key", keys)
+ }
+}
+
+// TestEnqueueRefusesWithNoEngineWired is the 503 that keeps the 202 honest:
+// with no engine the drain can never run, so an accepted item would sit until
+// its TTL and the app would wait on an answer that was never coming.
+//
+// It is checked LAST, after the consent chain, so an app learns why ITS store
+// cannot be scanned before it learns anything about the deployment.
+func TestEnqueueRefusesWithNoEngineWired(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-noengine")
+ fx.srv.cfg.StoreScanner = nil
+
+ status, out := postJSON(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf"}, tok)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("enqueue with no engine = %d %v, want 503", status, out)
+ }
+ if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
+ t.Fatalf("queued %v with no engine to drain it", keys)
+ }
+}
+
+// TestEnqueueIsRateLimited: the door writes an object per call, so an app in a
+// loop is an app filling its own bucket. 429 is the same limiter every other
+// write door on this console shares.
+func TestEnqueueIsRateLimited(t *testing.T) {
+ fx, tok := enqueueArmed(t, "code-enq-429")
+
+ saw429 := false
+ for i := 0; i < 200; i++ {
+ status, _ := postRaw(t, enqueueURL(fx, "titogo"),
+ map[string]any{"key": "uploads/x.pdf"}, tok)
+ if status == http.StatusTooManyRequests {
+ saw429 = true
+ break
+ }
+ if status != http.StatusAccepted {
+ t.Fatalf("enqueue #%d = %d, want 202 until the limiter bites", i, status)
+ }
+ }
+ if !saw429 {
+ t.Fatal("200 enqueues and no 429 — this door writes an object per call")
+ }
+}
+
+// TestEnqueueIsScopedToItsOwnApp: an app-bound service credential reaches
+// exactly one app, and the refusal is the family's 404 rather than a 403 that
+// would confirm the other app exists.
+func TestEnqueueIsScopedToItsOwnApp(t *testing.T) {
+ fx, _ := enqueueArmed(t, "code-enq-scope")
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "otherapp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ tok := appToken(t, fx, "scopedbot")
+
+ status, _ := postRaw(t, enqueueURL(fx, "otherapp"), map[string]any{"key": "uploads/x.pdf"}, tok)
+ if status != http.StatusNotFound {
+ t.Fatalf("cross-app enqueue = %d, want 404", status)
+ }
+}
+
+// TestScanRefusalAnswerCoversEveryName is the table's own test, and it exists
+// because two of its arms cannot be reached through the door.
+//
+// A trashed app is a 404 at apiAccountApp before the consent chain is
+// consulted, and a refusal name objstore has not defined yet does not exist to
+// send. Both were mutation-tested through HTTP and both survived — not because
+// the code was wrong, but because no request can distinguish them. So they are
+// asserted where they ARE observable.
+//
+// The list is written out rather than derived: Go cannot enumerate the
+// constants of a string type, so a derived list would be this list anyway,
+// with the compiler no longer checking that each name still exists.
+func TestScanRefusalAnswerCoversEveryName(t *testing.T) {
+ named := []objstore.QueueRefusal{
+ objstore.RefusalNotGranted,
+ objstore.RefusalTrashed,
+ objstore.RefusalNotRequested,
+ objstore.RefusalEncrypted,
+ objstore.RefusalNotEnabled,
+ objstore.RefusalDisabled,
+ }
+ for _, r := range named {
+ ans := scanRefusalAnswer(r)
+ if !ans.refused() {
+ t.Errorf("%q: the door does not refuse it — a consent refusal that enqueues is the hole this chain exists to close", r)
+ }
+ if ans.unknown {
+ t.Errorf("%q: answered as an unrecognised name, so the door has not been taught it", r)
+ }
+ if ans.notFound {
+ continue // the 404 arm carries no sentence, by design
+ }
+ if ans.status < 400 {
+ t.Errorf("%q: status %d is not a refusal", r, ans.status)
+ }
+ if strings.TrimSpace(ans.msg) == "" {
+ t.Errorf("%q: refused with no sentence beside the reason", r)
+ }
+ }
+
+ // The trashed arm answers as the family's 404 and says nothing else. A
+ // reason here would make this door the one that confirms an app exists.
+ if ans := scanRefusalAnswer(objstore.RefusalTrashed); !ans.notFound || ans.status != 0 {
+ t.Errorf("trashed = %+v, want the bare 404 apiAccountApp already gives", ans)
+ }
+
+ // RefusalNone is the ONLY value that means yes.
+ if ans := scanRefusalAnswer(objstore.RefusalNone); ans.refused() {
+ t.Errorf("RefusalNone = %+v, want no refusal at all", ans)
+ }
+
+ // A name objstore might add tomorrow. The door has never seen it, and the
+ // answer must still be a refusal: costing a working feature is survivable,
+ // letting an unscanned object through on an unrecognised refusal is not.
+ ans := scanRefusalAnswer(objstore.QueueRefusal("some_future_reason"))
+ if !ans.refused() {
+ t.Fatal("an unrecognised refusal enqueues — a name added to objstore would silently open this door")
+ }
+ if !ans.unknown {
+ t.Error("an unrecognised refusal is not flagged unknown, so nothing would be logged about it")
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index c46294bd6..c6e05b4b5 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -756,6 +756,15 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce)
+ // The ONE scan door an app's own credential reaches: "scan this object
+ // now" (spec §5.1). Service, not Admin, and the difference is the point —
+ // every verb above decides POLICY (who may scan, whether enforcement is
+ // on) and belongs to a person, while this one asks for work inside a
+ // policy somebody already set. It can add nothing an operator has not
+ // already granted: the consent chain in the handler refuses unless the
+ // store was requested, granted and enabled, so the worst a stolen service
+ // token achieves is queueing scans of its own app's objects.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/object", svcrole.Allow(svcrole.Service), s.handleAPIScanEnqueue)
// The deployment-wide doors (internal/console/apisystem.go). No {account}
// segment, so no service credential can be scoped to them at all: an empty
// Set shuts them to every credential, and closedDoors pins that shut.
diff --git a/internal/console/server.go b/internal/console/server.go
index 52a89ce80..f1e393f74 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -681,6 +681,20 @@ type Server struct {
// sweep is driven against a MemProvisioner's buckets with no S3 and no
// credentials.
storeListerFor func(ctx context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error)
+
+ // scanQueue is which apps have on-demand scan work waiting (spec §7.1).
+ //
+ // SOFT STATE, and never the queue itself: the items are objects in each
+ // app's own prefix, and this only says where to look. Losing it costs
+ // latency, not items — the boot rebuild finds anything stranded and the
+ // hourly sweep reaches every object regardless.
+ //
+ // Built in NewServer rather than lazily, because both writers reach it
+ // without coordination: the enqueue door bumps it from a request
+ // goroutine while the drain reads it from its own. A nil map assigned on
+ // first use would be the race, and a Server that always has one cannot
+ // have it.
+ scanQueue *objstore.QueueIndex
// ceCosts is billPass's test/fake seam for Cost Explorer (cepull.go,
// economicspass.go): nil means "use the real, lazily-built CE client"
// (s.realCECosts); tests inject a fake so a pull can be proven to
@@ -961,6 +975,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
delegationZone: normalizeDelegationZone(cfg.DelegationZone),
steeringHost: normalizeSteeringHost(cfg.SteeringHost),
lookupCNAME: net.DefaultResolver.LookupCNAME,
+ scanQueue: objstore.NewQueueIndex(),
}
if s.km != nil {
s.sendApp = s.km.SendApp
diff --git a/internal/objstore/queue.go b/internal/objstore/queue.go
index 17674eb8d..f1bdda53e 100644
--- a/internal/objstore/queue.go
+++ b/internal/objstore/queue.go
@@ -273,7 +273,7 @@ func DrainApp(ctx context.Context, s release.Store, sc Scanner, idx *QueueIndex,
// The consent chain, re-checked here and not only at the door: the record
// can change between enqueue and drain, and THIS is where bytes are read.
- rec, refusal, err := queuePermitted(ctx, s, acct, app)
+ rec, refusal, err := QueuePermitted(ctx, s, acct, app)
if err != nil {
return nil, false, err
}
@@ -477,7 +477,7 @@ func drainOne(ctx context.Context, s release.Store, sc Scanner, acct, app, bucke
// "will this item ever be useful" but "may this app be scanned right now",
// and a result object written after the answer turned to no is a verdict
// no door may answer from.
- if _, refusal, err := queuePermitted(ctx, s, acct, app); err != nil || refusal != RefusalNone {
+ if _, refusal, err := QueuePermitted(ctx, s, acct, app); err != nil || refusal != RefusalNone {
return out, err
}
if err := WriteScanResult(ctx, s, acct, app, &ScanResult{
@@ -489,9 +489,15 @@ func drainOne(ctx context.Context, s release.Store, sc Scanner, acct, app, bucke
return out, nil
}
-// queuePermitted is the consent chain, in one place so the door and the drain
+// QueuePermitted is the consent chain, in one place so the door and the drain
// cannot answer it differently.
-func queuePermitted(ctx context.Context, s release.Store, acct, app string) (*Record, QueueRefusal, error) {
+//
+// EXPORTED because the door lives in another package. That is the whole
+// reason, and it is worth stating: the alternative — console re-deriving the
+// same six conditions from a record it fetched itself — is the exact shape
+// §5.1.1 exists to forbid. One implementation, two callers, and the only
+// difference between them is what they do with the answer.
+func QueuePermitted(ctx context.Context, s release.Store, acct, app string) (*Record, QueueRefusal, error) {
rec, _, err := Get(ctx, s, acct, app)
if err != nil {
return nil, RefusalNone, err
diff --git a/internal/objstore/queue_test.go b/internal/objstore/queue_test.go
index dccb34ab3..833ca50cb 100644
--- a/internal/objstore/queue_test.go
+++ b/internal/objstore/queue_test.go
@@ -658,7 +658,7 @@ func TestDrainAppKeepsItemOnTransientReadError(t *testing.T) {
}
// The record itself failing to read is reversible too, and it is the SIBLING
-// of the check above: queuePermitted honours "absence, not error" for the
+// of the check above: QueuePermitted honours "absence, not error" for the
// record, and an earlier draft honoured it there and nowhere else.
func TestDrainAppKeepsItemsOnRecordReadError(t *testing.T) {
ctx := context.Background()
@@ -982,9 +982,9 @@ func TestQueueRefusalNamesEachLinkAndItsConsequence(t *testing.T) {
s, _, _, _ := queueFixture(t, 1)
tc.snap(t, s)
- rec, refusal, err := queuePermitted(ctx, s, "bab", "titogo")
+ rec, refusal, err := QueuePermitted(ctx, s, "bab", "titogo")
if err != nil {
- t.Fatalf("queuePermitted: %v", err)
+ t.Fatalf("QueuePermitted: %v", err)
}
if refusal != tc.want {
t.Fatalf("refusal = %q, want %q", refusal, tc.want)
@@ -1017,7 +1017,7 @@ func TestQueuePermittedReportsAReadErrorAsAnError(t *testing.T) {
s, _, _, _ := queueFixture(t, 1)
broken := &queueStore{Store: s, failGetKey: "apps/bab/titogo/store.json"}
- rec, refusal, err := queuePermitted(context.Background(), broken, "bab", "titogo")
+ rec, refusal, err := QueuePermitted(context.Background(), broken, "bab", "titogo")
if err == nil {
t.Fatal("a failing store.json read came back as a clean answer")
}