carlos / platform Public

console: the four doors an account applies and an operator decides through

Stage 4 of #381. apply is the account owner's verb; approvals, approve and
decline are the deployment operator's. Nothing reads an entitlement yet, so
this changes no behaviour — the doors are dormant until the provision door
lands beside them.

The operator three are gated the way the mail pause door is gated, not the
way store grant is: cliUser, then the fleet-operator bit, then
CARLOS_STORE_OPERATORS, then a bare http.NotFound, and only then the account
resolved by sqid without reference to the caller's memberships. Grant's
member-then-allowlist shape would have deadlocked approval, because
apiAccountApp resolves the account against the caller's OWN memberships and
an operator approving an account has no reason to be a member of it. That is
the same argument apimail.go already makes about a tenant who has no reason
to add the operator investigating them.

Also adds the edge-ACL test §4 made an implementation gate: stage 3 named
that test in a doc comment and never wrote it. store/entitlements/ is in
fact unreachable through the edge store door — aclRead's subtrees do not
cover it — but until now nothing proved it, and a decline reason is operator
commentary about a tenant that must never become tenant-readable.

Built by codex (gpt-6-astra) from the brief committed at 7c93be2; commits,
gate and review are ours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfUNFHm89j2mj5q7vBHsfH
Paul Campbell pushed by paul@keymail.dev e09833cb377b931a955bcbb571ac391fc0964ab5 parent 7c93be2
6 files changed, +787 −3
  • internal/console/apiservices.go +9 −1
  • internal/console/apistore_entitlement.go +226 −0
  • internal/console/apistore_entitlement_test.go +511 −0
  • internal/console/authcli.go +7 −0
  • internal/console/svcroutes_test.go +11 −2
  • internal/edge/storedoor_test.go +23 −0
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index 2447b6053..3d91759d7 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -100,12 +100,20 @@ type serviceView struct {
// Account.Role is populated by AccountsFor's join, so the check is one
// comparison rather than a second query.
func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
+ return s.apiOwnerAccountFor(w, r, "manage service credentials")
+}
+
+// apiOwnerAccountFor is the shared owner gate for account-level CLI doors.
+// purpose completes the refusal in the vocabulary of the door the caller
+// actually used; the membership-hiding 404 remains owned by
+// apiPrincipalAccount and is byte-identical across all callers.
+func (s *Server) apiOwnerAccountFor(w http.ResponseWriter, r *http.Request, purpose string) (Principal, *Account, bool) {
p, acct, ok := s.apiPrincipalAccount(w, r)
if !ok {
return Principal{}, nil, false
}
if acct.Role != RoleOwner {
- jsonError(w, http.StatusForbidden, "only an account owner can manage service credentials")
+ jsonError(w, http.StatusForbidden, "only an account owner can "+purpose)
return Principal{}, nil, false
}
return p, acct, true
diff --git a/internal/console/apistore_entitlement.go b/internal/console/apistore_entitlement.go
new file mode 100644
index 000000000..1f5d8013f
--- /dev/null
+++ b/internal/console/apistore_entitlement.go
@@ -0,0 +1,226 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type storeEntitlementReply struct {
+ Account string `json:"account"`
+ Entitlement *objstore.Entitlement `json:"entitlement"`
+}
+
+type storeApprovalsReply struct {
+ Applications []*objstore.Entitlement `json:"applications"`
+}
+
+func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.apiOwnerAccountFor(w, r, "apply for object storage")
+ if !ok {
+ return
+ }
+ 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 {
+ Note string `json:"note"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
+ if err != nil {
+ log.Printf("console: api store apply %s: read entitlement: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not read the storage application — try again")
+ return
+ }
+ if e != nil && e.Approved() {
+ writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e})
+ return
+ }
+ e = &objstore.Entitlement{Applied: &objstore.Applied{By: p.Address, At: time.Now(), Note: strings.TrimSpace(req.Note)}}
+ if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "the storage application changed — try again")
+ return
+ }
+ log.Printf("console: api store apply %s: write entitlement: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not save the storage application — try again")
+ return
+ }
+ s.audit(acct, "", p.Address, "store-apply", e.Applied.Note)
+ writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e})
+}
+
+func (s *Server) storeEntitlementOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return "", false
+ }
+ isOperator, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: api store entitlement: is-operator: %v", err)
+ http.NotFound(w, r)
+ return "", false
+ }
+ if !isOperator || !s.storeOperator(addr) {
+ http.NotFound(w, r)
+ return "", false
+ }
+ return addr, true
+}
+
+func (s *Server) storeEntitlementTarget(w http.ResponseWriter, r *http.Request) (*Account, bool) {
+ acct, err := s.store.AccountBySqid(r.PathValue("account"))
+ if err != nil {
+ log.Printf("console: api store entitlement: account lookup: %v", err)
+ http.NotFound(w, r)
+ return nil, false
+ }
+ if acct == nil {
+ http.NotFound(w, r)
+ return nil, false
+ }
+ return acct, true
+}
+
+func (s *Server) handleAPIStoreApprove(w http.ResponseWriter, r *http.Request) {
+ s.handleAPIStoreDecision(w, r, true)
+}
+
+func (s *Server) handleAPIStoreDecline(w http.ResponseWriter, r *http.Request) {
+ s.handleAPIStoreDecision(w, r, false)
+}
+
+func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, approved bool) {
+ addr, ok := s.storeEntitlementOperator(w, r)
+ if !ok {
+ return
+ }
+ acct, ok := s.storeEntitlementTarget(w, r)
+ if !ok {
+ return
+ }
+ if !s.allowWrite(Principal{Address: addr}, 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 {
+ Reason string `json:"reason"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ reason := strings.TrimSpace(req.Reason)
+ if !approved && reason == "" {
+ jsonError(w, http.StatusBadRequest, "a reason is required to decline storage")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ e, etag, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
+ if err != nil {
+ log.Printf("console: api store decision %s: read entitlement: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not read the storage application — try again")
+ return
+ }
+ if e == nil || e.Applied == nil {
+ jsonError(w, http.StatusConflict, "this account has not applied for object storage")
+ return
+ }
+ e.Decision = &objstore.Decision{Approved: approved, By: addr, At: time.Now(), Reason: reason}
+ if approved {
+ e.Decision.Reason = ""
+ }
+ if _, err := objstore.PutEntitlement(ctx, s.cfg.Releases, acct.Sqid, e, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "the storage application changed — try again")
+ return
+ }
+ log.Printf("console: api store decision %s: write entitlement: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not save the storage decision — try again")
+ return
+ }
+ detail := reason
+ action := "store-decline"
+ if approved {
+ detail = "approved"
+ action = "store-approve"
+ }
+ s.audit(acct, "", addr, action, detail)
+ writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e})
+}
+
+func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.storeEntitlementOperator(w, r); !ok {
+ return
+ }
+ if s.cfg.Releases == nil {
+ storeNoBucket(w)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ keys, err := s.cfg.Releases.List(ctx, "store/entitlements/")
+ if err != nil {
+ log.Printf("console: api store approvals: list entitlements: %v", err)
+ jsonError(w, http.StatusBadGateway, "could not list storage applications — try again")
+ return
+ }
+ applications := make([]*objstore.Entitlement, 0)
+ for _, key := range keys {
+ const prefix, suffix = "store/entitlements/", ".json"
+ if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, suffix) {
+ continue
+ }
+ acctID := strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix)
+ if !release.ValidAccount(acctID) || objstore.EntitlementKey(acctID) != key {
+ continue
+ }
+ acct, err := s.store.AccountBySqid(acctID)
+ if err != nil {
+ log.Printf("console: api store approvals: account lookup %s: %v", acctID, err)
+ jsonError(w, http.StatusBadGateway, "could not list storage applications — try again")
+ return
+ }
+ if acct == nil {
+ continue
+ }
+ e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acctID)
+ if err != nil {
+ log.Printf("console: api store approvals: read %s: %v", key, err)
+ jsonError(w, http.StatusBadGateway, "could not list storage applications — try again")
+ return
+ }
+ if e != nil && e.Pending() {
+ e.Account = acctID
+ applications = append(applications, e)
+ }
+ }
+ sort.Slice(applications, func(i, j int) bool {
+ if applications[i].Applied.At.Equal(applications[j].Applied.At) {
+ return applications[i].Account < applications[j].Account
+ }
+ return applications[i].Applied.At.Before(applications[j].Applied.At)
+ })
+ writeJSON(w, http.StatusOK, storeApprovalsReply{Applications: applications})
+}
diff --git a/internal/console/apistore_entitlement_test.go b/internal/console/apistore_entitlement_test.go
new file mode 100644
index 000000000..b90f85917
--- /dev/null
+++ b/internal/console/apistore_entitlement_test.go
@@ -0,0 +1,511 @@
+package console
+
+// The account-level object-storage entitlement doors (issue #381). These
+// tests pin the authority boundary first: only an account owner may apply,
+// while decisions belong to a deployment operator who need not be a member
+// of the account. They then pin the record transitions and the operator queue.
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func entitlementApplyURL(fx *consoleFixture, account string) string {
+ return fx.ts.URL + "/api/cli/accounts/" + account + "/store/apply"
+}
+
+func entitlementDecisionURL(fx *consoleFixture, account, decision string) string {
+ return fx.ts.URL + "/api/cli/accounts/" + account + "/store/" + decision
+}
+
+func entitlementApprovalsURL(fx *consoleFixture) string {
+ return fx.ts.URL + "/api/cli/store/approvals"
+}
+
+func storedEntitlement(t *testing.T, fx *consoleFixture, account string) *objstore.Entitlement {
+ t.Helper()
+ e, _, err := objstore.GetEntitlement(context.Background(), fx.rel, account)
+ if err != nil {
+ t.Fatalf("read entitlement for %s: %v", account, err)
+ }
+ return e
+}
+
+func putEntitlement(t *testing.T, fx *consoleFixture, account string, e *objstore.Entitlement) {
+ t.Helper()
+ if _, err := objstore.PutEntitlement(context.Background(), fx.rel, account, e, ""); err != nil {
+ t.Fatalf("seed entitlement for %s: %v", account, err)
+ }
+}
+
+func entitlementOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string {
+ t.Helper()
+ if err := fx.st.SetOperator(address, true); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.StoreOperators = []string{address}
+ return cliToken(t, fx, address, code)
+}
+
+func entitlementBlock(t *testing.T, out map[string]any) map[string]any {
+ t.Helper()
+ return block(t, out, "entitlement")
+}
+
+func TestAPIStoreApplyNeedsAnOwner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "member@keymail.dev", "code-store-apply-member")
+
+ status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "uploads"}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("apply by a non-owner = %d, want 403", status)
+ }
+ if got := storedEntitlement(t, fx, fx.sqid); got != nil {
+ t.Fatalf("refused apply wrote %+v", got)
+ }
+}
+
+func TestAPIStoreApplyHidesUnknownAndOtherAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ eventoken := cliToken(t, fx, "eve@keymail.dev", "code-store-apply-stranger")
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-unknown")
+
+ statusA, bodyA := postRaw(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, eventoken)
+ statusB, bodyB := postRaw(t, entitlementApplyURL(fx, "zzzz"), map[string]any{}, owner)
+ if statusA != http.StatusNotFound || statusB != http.StatusNotFound {
+ t.Fatalf("apply probes = %d / %d, want 404 for both", statusA, statusB)
+ }
+ if bodyA != bodyB || bodyA != "404 page not found\n" {
+ t.Fatalf("apply probe bodies = %q / %q, want byte-identical stdlib 404", bodyA, bodyB)
+ }
+}
+
+func TestAPIStoreApplyWritesAndRepliesWithTheEntitlement(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply")
+
+ status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "customer uploads"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("apply = %d %v, want 200", status, out)
+ }
+ if str(t, out, "account") != fx.sqid {
+ t.Fatalf("account = %v, want %s", out["account"], fx.sqid)
+ }
+ e := entitlementBlock(t, out)
+ applied := block(t, e, "applied")
+ if applied["by"] != "paul@keymail.dev" || applied["note"] != "customer uploads" {
+ t.Fatalf("applied = %v", applied)
+ }
+ if e["decision"] != nil {
+ t.Fatalf("decision = %v, want null while pending", e["decision"])
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Pending() || got.Applied.By != "paul@keymail.dev" || got.Applied.Note != "customer uploads" {
+ t.Fatalf("stored entitlement = %+v, want the pending application", got)
+ }
+ auditedBy(t, fx, "", "store-apply", "paul@keymail.dev", "customer uploads")
+}
+
+func TestAPIStoreApplyReopensADeclineUsingCAS(t *testing.T) {
+ fx := newConsoleFixture(t)
+ oldApplied := time.Now().Add(-time.Hour).UTC()
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: oldApplied, Note: "first ask"},
+ Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "more detail needed"},
+ })
+ token := cliToken(t, fx, "paul@keymail.dev", "code-store-reapply")
+
+ status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "expanded plan"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("re-apply = %d %v, want 200", status, out)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Pending() || got.Decision != nil {
+ t.Fatalf("re-applied entitlement = %+v, want pending with the decision cleared", got)
+ }
+ if got.Applied.Note != "expanded plan" || !got.Applied.At.After(oldApplied) {
+ t.Fatalf("re-applied block = %+v, want a fresh application", got.Applied)
+ }
+}
+
+func TestAPIStoreApplySurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour), Note: "first ask"},
+ Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "revise"},
+ })
+ racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)}
+ racer.race = func(inner release.Store) {
+ winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ winner.Applied.Note = "the concurrent winner"
+ if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fx.srv.cfg.Releases = racer
+ token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-cas")
+
+ status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "the losing write"}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("apply losing its CAS = %d, want 409", status)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got.Applied.Note != "the concurrent winner" {
+ t.Fatalf("application after conflict = %+v, want the concurrent winner preserved", got.Applied)
+ }
+}
+
+func TestAPIStoreApplyUsesTheSharedWriteBudget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-limit")
+
+ if status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("first apply = %d %v, want 200", status, out)
+ }
+ if status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusTooManyRequests {
+ t.Fatalf("second apply on an exhausted budget = %d, want 429", status)
+ }
+}
+
+func TestAPIStoreApplyToAnApprovedAccountIsANoOp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ want := &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour).UTC(), Note: "original"},
+ Decision: &objstore.Decision{Approved: true, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute).UTC()},
+ }
+ putEntitlement(t, fx, fx.sqid, want)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-approved")
+
+ status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "must not replace approval"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("apply when approved = %d %v, want 200", status, out)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Approved() || got.Decision.By != want.Decision.By || !got.Decision.At.Equal(want.Decision.At) {
+ t.Fatalf("approved decision changed: got %+v, want %+v", got, want)
+ }
+ if got.Applied.Note != "original" || !got.Applied.At.Equal(want.Applied.At) {
+ t.Fatalf("approved application changed: got %+v, want %+v", got.Applied, want.Applied)
+ }
+}
+
+func TestAPIStoreDecisionNeedsBothOperatorAuthorities(t *testing.T) {
+ tests := []struct {
+ name string
+ fleet bool
+ allowed bool
+ }{
+ {"ordinary user on allowlist", false, true},
+ {"fleet operator absent from allowlist", true, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now()}})
+ const address = "candidate@keymail.dev"
+ if tt.fleet {
+ if err := fx.st.SetOperator(address, true); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tt.allowed {
+ fx.srv.cfg.StoreOperators = []string{address}
+ }
+ token := cliToken(t, fx, address, "code-store-authority-"+tt.name)
+
+ status, body := postRaw(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("approve with only one authority bit = %d %q, want bare 404", status, body)
+ }
+ status, body = getRaw(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("approvals with only one authority bit = %d %q, want bare 404", status, body)
+ }
+ if !storedEntitlement(t, fx, fx.sqid).Pending() {
+ t.Fatal("refused operator changed the pending entitlement")
+ }
+ })
+ }
+}
+
+func TestAPIStoreOperatorMatchingIsExact(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const lookalike = "owner@example.teſt"
+ if err := fx.st.SetOperator(lookalike, true); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.StoreOperators = []string{"owner@example.test"}
+ token := cliToken(t, fx, lookalike, "code-store-long-s")
+
+ status, body := getRaw(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("lookalike operator = %d %q, want bare 404", status, body)
+ }
+}
+
+func TestAPIStoreNonMemberOperatorCanApprove(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now(), Note: "backups"}})
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-nonmember")
+ role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if role != "" {
+ t.Fatalf("operator role in target account = %q, want none", role)
+ }
+
+ status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("approve by non-member operator = %d %v, want 200", status, out)
+ }
+ if str(t, out, "account") != fx.sqid {
+ t.Fatalf("account = %v, want %s", out["account"], fx.sqid)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Approved() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "" {
+ t.Fatalf("approved entitlement = %+v", got)
+ }
+ auditedBy(t, fx, "", "store-approve", "ops@keymail.dev", "approved")
+}
+
+func TestAPIStoreOperatorDecisionsHideUnknownAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-unknown")
+
+ for _, decision := range []string{"approve", "decline"} {
+ status, body := postRaw(t, entitlementDecisionURL(fx, "zzzz", decision), map[string]any{"reason": "irrelevant"}, token)
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Errorf("%s unknown account = %d %q, want bare 404", decision, status, body)
+ }
+ }
+}
+
+func TestAPIStoreApproveNeedsAnApplication(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-empty")
+
+ status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("approve without an application = %d, want 409", status)
+ }
+ if got := storedEntitlement(t, fx, fx.sqid); got != nil {
+ t.Fatalf("approve without an application wrote %+v", got)
+ }
+}
+
+func TestAPIStoreDeclineRequiresAReasonAndMayReplaceApproval(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)},
+ Decision: &objstore.Decision{Approved: true, By: "first-op@keymail.dev", At: time.Now().Add(-time.Minute)},
+ })
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decline")
+
+ status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("decline without a reason = %d, want 400", status)
+ }
+ if !storedEntitlement(t, fx, fx.sqid).Approved() {
+ t.Fatal("a refused decline changed the approval")
+ }
+
+ status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{"reason": "capacity review"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("decline = %d %v, want 200", status, out)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Declined() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "capacity review" {
+ t.Fatalf("declined entitlement = %+v", got)
+ }
+ auditedBy(t, fx, "", "store-decline", "ops@keymail.dev", "capacity review")
+}
+
+func TestAPIStoreDecisionSurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)},
+ })
+ racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)}
+ racer.race = func(inner release.Store) {
+ winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ winner.Decision = &objstore.Decision{Approved: false, By: "winner@keymail.dev", At: time.Now(), Reason: "winner's decision"}
+ if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fx.srv.cfg.Releases = racer
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-cas")
+
+ status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("decision losing its CAS = %d, want 409", status)
+ }
+ got := storedEntitlement(t, fx, fx.sqid)
+ if got == nil || !got.Declined() || got.Decision.By != "winner@keymail.dev" {
+ t.Fatalf("entitlement after conflict = %+v, want the concurrent winner preserved", got)
+ }
+}
+
+func TestAPIStoreApprovalsListsPendingOldestFirst(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now().UTC()
+ const secondID int64 = 4242
+ secondSqid := accountSqid(secondID)
+ if err := fx.st.CreateAccount(secondID, secondSqid, "Second", "second@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ putEntitlement(t, fx, secondSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "second@keymail.dev", At: now.Add(-2 * time.Hour), Note: "oldest"}})
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour), Note: "newer"}})
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-order")
+
+ status, out := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusOK {
+ t.Fatalf("approvals = %d %v, want 200", status, out)
+ }
+ raw, ok := out["applications"].([]any)
+ if !ok || len(raw) != 2 {
+ t.Fatalf("applications = %#v, want two entries", out["applications"])
+ }
+ first, ok := raw[0].(map[string]any)
+ if !ok {
+ t.Fatalf("applications[0] = %#v, want an object", raw[0])
+ }
+ second, ok := raw[1].(map[string]any)
+ if !ok {
+ t.Fatalf("applications[1] = %#v, want an object", raw[1])
+ }
+ if first["account"] != secondSqid || second["account"] != fx.sqid {
+ t.Fatalf("application order = %v then %v, want %s then %s", first["account"], second["account"], secondSqid, fx.sqid)
+ }
+ if _, ok := first["applied"].(map[string]any); !ok {
+ t.Fatalf("first application = %#v, want an entitlement object", first)
+ }
+}
+
+func TestAPIStoreApprovalsFiltersDecidedDeletedAndUnknownAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now().UTC()
+
+ const deletedID int64 = 4343
+ deletedSqid := accountSqid(deletedID)
+ if err := fx.st.CreateAccount(deletedID, deletedSqid, "Deleted", "deleted@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.DeleteAccount(deletedID, now.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ putEntitlement(t, fx, deletedSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "deleted@keymail.dev", At: now.Add(-3 * time.Hour)}})
+ putEntitlement(t, fx, "bde", &objstore.Entitlement{Applied: &objstore.Applied{By: "orphan@keymail.dev", At: now.Add(-2 * time.Hour)}})
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{
+ Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour)},
+ Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: now, Reason: "decided"},
+ })
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-filter")
+
+ status, out := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusOK {
+ t.Fatalf("approvals = %d %v, want 200", status, out)
+ }
+ raw, ok := out["applications"].([]any)
+ if !ok {
+ t.Fatalf("applications = %#v, want a list", out["applications"])
+ }
+ if len(raw) != 0 {
+ t.Fatalf("applications = %#v, want decided, deleted, and unknown accounts filtered", raw)
+ }
+}
+
+type entitlementListErrorStore struct{ release.Store }
+
+func (s entitlementListErrorStore) List(ctx context.Context, prefix string) ([]string, error) {
+ if prefix == "store/entitlements/" {
+ return nil, errors.New("list unavailable")
+ }
+ return s.Store.List(ctx, prefix)
+}
+
+func TestAPIStoreApprovalsFailsWhenTheQueueCannotBeListed(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.Releases = entitlementListErrorStore{Store: fx.rel}
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-list-error")
+
+ status, _ := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("approvals with a failed list = %d, want 502", status)
+ }
+}
+
+func TestAPIStoreApprovalsFailsOnAMalformedEntitlement(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-malformed")
+
+ status, _ := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusBadGateway {
+ t.Fatalf("approvals with malformed entitlement = %d, want 502", status)
+ }
+}
+
+func TestAPIStoreApprovalsIgnoresNonCanonicalKeys(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.rel.Put(context.Background(), "store/entitlements/nested/bab.json", []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), "store/entitlements/readme", []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-keys")
+
+ status, out := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusOK {
+ t.Fatalf("approvals with unrelated keys = %d %v, want 200", status, out)
+ }
+ if raw, ok := out["applications"].([]any); !ok || len(raw) != 0 {
+ t.Fatalf("applications = %#v, want an empty list", out["applications"])
+ }
+}
+
+func TestAPIStoreApprovalsTrustsTheKeyForTheAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now().UTC()
+ putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now}})
+ raw, etag, err := fx.rel.Get(context.Background(), objstore.EntitlementKey(fx.sqid))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw = []byte(strings.Replace(string(raw), `"account": "`+fx.sqid+`"`, `"account": "zzz"`, 1))
+ if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-account")
+
+ status, out := getJSON(t, entitlementApprovalsURL(fx), token)
+ if status != http.StatusOK {
+ t.Fatalf("approvals = %d %v, want 200", status, out)
+ }
+ applications := out["applications"].([]any)
+ got := applications[0].(map[string]any)["account"]
+ if got != fx.sqid {
+ t.Fatalf("application account = %v, want key account %s", got, fx.sqid)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index cd8e3f795..03af570e0 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -702,6 +702,13 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate)
s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish)
+ // Account-level storage entitlement doors are person-only. Applying speaks
+ // for an account owner; approvals spend deployment money and deliberately
+ // resolve their target without requiring operator membership in it.
+ s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply)
+ s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove)
+ s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline)
+ s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index f5fac7a98..36b75429e 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -124,6 +124,15 @@ var closedDoors = []string{
// It also raises the daily cap in the same request, which is an
// authority grant rather than a runtime toggle.
"POST /api/cli/apps/{account}/{app}/email/pause",
+ // Applying for and deciding an account-level storage entitlement are
+ // person-only acts. An admin service credential belongs to an app and may
+ // neither speak for the account owner nor grant its account permission to
+ // mint deployment-owned cloud estate. The queue is closed with its writes
+ // so machine credentials cannot enumerate applicants and their notes.
+ "POST /api/cli/accounts/{account}/store/apply",
+ "POST /api/cli/accounts/{account}/store/approve",
+ "POST /api/cli/accounts/{account}/store/decline",
+ "GET /api/cli/store/approvals",
// Moving a route between channels decides what that route SERVES, which
// is release authority wearing a config-shaped hat: a credential the app
// itself holds must not be able to walk its own route onto a channel
@@ -673,8 +682,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 17 {
- t.Errorf("walked %d closed doors, want all %d (and 17 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause)",
+ if seen != len(closedDoors) || seen != 21 {
+ t.Errorf("walked %d closed doors, want all %d (and 21 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, and four store-entitlement doors)",
seen, len(closedDoors))
}
}
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go
index 14afdc9a0..c84a96772 100644
--- a/internal/edge/storedoor_test.go
+++ b/internal/edge/storedoor_test.go
@@ -66,6 +66,29 @@ func aclAllows(key string, op aclOp) bool {
return aclWrite(testSqid, testFleet, k)
}
+func TestEntitlementsAreNotReadableThroughTheEdgeDoor(t *testing.T) {
+ for _, key := range []string{
+ "store/entitlements/" + testSqid + ".json",
+ "store/entitlements/other.json",
+ "accounts/" + testSqid + "/../../store/entitlements/" + testSqid + ".json",
+ "apps/" + testSqid + "/../../store/entitlements/" + testSqid + ".json",
+ } {
+ for _, op := range []aclOp{opRead, opWrite, opDelete} {
+ if aclAllows(key, op) {
+ t.Errorf("%s allowed for %q", op, key)
+ }
+ }
+ }
+ for _, prefix := range []string{"store/", "store/entitlements/", "store/entitlements/" + testSqid} {
+ if _, ok := aclListScope(testSqid, testFleet, prefix); ok {
+ t.Errorf("listing allowed for %q", prefix)
+ }
+ if aclDir(testSqid, testFleet, prefix) {
+ t.Errorf("directory visible: %q", prefix)
+ }
+ }
+}
+
func TestStoreDoorACLMatrix(t *testing.T) {
rows := []struct {
key string