carlos / platform Public

purge and create clear the app policy (#328, #329)

The purge reads the app's policy key and deletes a present object before
the marker; a delete it cannot make keeps the marker for the next pass.
A create clears an earlier app's policy before inserting the row, inside
the app's policy lock and a live writer holding, after rechecking the
name; a clear that fails refuses the create.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3d71b10c7431a996d615b264e4f6b7e339ea14f5 parent ad52b08
11 files changed, +809 −17
  • internal/console/apicli.go +6 −0
  • internal/console/apipolicy_test.go +423 −0
  • internal/console/apitemporary.go +9 −1
  • internal/console/claim_app_enroll.go +60 −2
  • internal/console/claim_invariant_world_test.go +1 −0
  • internal/console/claim_restore_test.go +2 −1
  • internal/console/regionclaim_test.go +35 −0
  • internal/console/server.go +70 −12
  • internal/console/view_test.go +24 −1
  • internal/release/trash.go +20 −0
  • internal/release/trash_test.go +159 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 69e5f77f4..e6d4cee98 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -24,6 +24,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/apppolicy"
"github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -481,6 +482,11 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
// A bucket error, like handleCLINewAccount's: the detail stays on
// the box (key names and endpoints don't belong in API bodies).
jsonError(w, http.StatusBadGateway, "could not check the release store — try again")
+ case claimPolicyClearFailed:
+ jsonError(w, http.StatusServiceUnavailable, fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(acct.Sqid, name)))
+ case claimPolicyWriterRefused:
+ // placed is the lease refusal's message for this outcome.
+ jsonError(w, http.StatusServiceUnavailable, placed)
case claimOK:
s.audit(acct, name, addr, "create", "created app "+name+" (cli)")
// Stamped in the SAME call that claims the name, after the claim has
diff --git a/internal/console/apipolicy_test.go b/internal/console/apipolicy_test.go
index 906a39933..4c31b5e68 100644
--- a/internal/console/apipolicy_test.go
+++ b/internal/console/apipolicy_test.go
@@ -8,17 +8,22 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"os"
+ "path/filepath"
"reflect"
"slices"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
"github.com/carlosframework/platform/internal/apppolicy"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/svcrole"
)
@@ -1143,3 +1148,421 @@ func TestPolicyRevokeSurvivesALostWriteAndAReplay(t *testing.T) {
t.Fatalf("stored %+v, want revision 3 with no egress allow", stored)
}
}
+
+// ---- the create-time clear (plan Task 11) -----------------------------------
+
+// errDeleteDenied is what an s3:DeleteObject Deny answers (deployments built
+// from infra/modules/region-host deny it across the deployment bucket).
+var errDeleteDenied = errors.New("AccessDenied: s3:DeleteObject denied by an explicit deny")
+
+// policyDeleteFault is the fixture bucket's Delete hook. While on, a Delete
+// of one of keys (of any key, when keys is nil) fails with errDeleteDenied.
+// Every Delete is counted, and observe, when set, sees each one first.
+type policyDeleteFault struct {
+ keys map[string]bool
+ on atomic.Bool
+ observe func(ctx context.Context, key string)
+ mu sync.Mutex
+ deletes []string
+}
+
+func installPolicyDeleteFault(t *testing.T, fx *consoleFixture, on bool, keys ...string) *policyDeleteFault {
+ t.Helper()
+ d, ok := fx.rel.(*deletableStore)
+ if !ok {
+ t.Fatalf("the fixture bucket is %T, want *deletableStore", fx.rel)
+ }
+ f := &policyDeleteFault{}
+ for _, k := range keys {
+ if f.keys == nil {
+ f.keys = map[string]bool{}
+ }
+ f.keys[k] = true
+ }
+ f.on.Store(on)
+ d.setDeleteHook(func(ctx context.Context, key string) error {
+ f.mu.Lock()
+ f.deletes = append(f.deletes, key)
+ observe := f.observe
+ f.mu.Unlock()
+ if observe != nil {
+ observe(ctx, key)
+ }
+ if f.on.Load() && (f.keys == nil || f.keys[key]) {
+ return errDeleteDenied
+ }
+ return nil
+ })
+ return f
+}
+
+func (f *policyDeleteFault) seen() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.deletes...)
+}
+
+// policyCreateWorld is one console of either mode with the owner's CLI token
+// and a signed-in browser. The owner is a system operator in both.
+type policyCreateWorld struct {
+ *consoleFixture
+ claims bool
+ token string
+ browser *http.Client
+ csrf string
+}
+
+func newPolicyCreateWorld(t *testing.T, claims bool) *policyCreateWorld {
+ t.Helper()
+ if claims {
+ cx := newClaimCase(t)
+ return &policyCreateWorld{consoleFixture: cx.consoleFixture, claims: true, token: cx.bearer, browser: cx.browser, csrf: cx.csrf}
+ }
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.TemporaryApps = true
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cli-"+claimSafeName(t.Name()))
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-web-"+claimSafeName(t.Name()))
+ return &policyCreateWorld{consoleFixture: fx, token: token, browser: c, csrf: sessionToken(t, fx, c)}
+}
+
+// forEachCreateMode runs body once with claims off and once with claims on.
+func forEachCreateMode(t *testing.T, body func(t *testing.T, w *policyCreateWorld)) {
+ for _, claims := range []bool{false, true} {
+ name := "claims off"
+ if claims {
+ name = "claims on"
+ }
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ body(t, newPolicyCreateWorld(t, claims))
+ })
+ }
+}
+
+// seedPredecessorPolicy writes, at app's key, the policy an earlier app of
+// the name left: signed by the fixture's key, xlarge, one egress allow.
+func seedPredecessorPolicy(t *testing.T, fx *consoleFixture, app string) []byte {
+ t.Helper()
+ wire := signPolicy(t, fx, apppolicy.Policy{V: 1, Account: fx.sqid, App: app, Revision: 1,
+ SignedAt: time.Now().UTC(), DefaultSize: "xlarge", EgressAllow: []string{"10.0.5.20"}})
+ putAtKey(t, fx.rel, fx.sqid, app, wire)
+ return wire
+}
+
+// policyObject is the bytes at app's key, nil when there is none.
+func policyObject(t *testing.T, fx *consoleFixture, app string) []byte {
+ t.Helper()
+ b, _, err := fx.rel.Get(context.Background(), apppolicy.Key(fx.sqid, app))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func (w *policyCreateWorld) createCLI(t *testing.T, name string) (int, string) {
+ t.Helper()
+ st, _, out := rawReq(t, http.MethodPost, w.ts.URL+"/api/cli/apps/"+w.sqid+"/new", w.token, `{"name":"`+name+`"}`)
+ return st, out
+}
+
+func (w *policyCreateWorld) createTemporary(t *testing.T, name string) (int, string) {
+ t.Helper()
+ st, _, out := rawReq(t, http.MethodPost, w.ts.URL+"/api/cli/apps/"+w.sqid+"/new-temporary", w.token,
+ `{"name":"`+name+`","ttl_seconds":86400}`)
+ return st, out
+}
+
+// createForm posts the browser's New app form and returns the page its
+// redirect lands on, where the flash is shown.
+func (w *policyCreateWorld) createForm(t *testing.T, name string) (string, string) {
+ t.Helper()
+ st, loc, body := postForm(t, w.browser, w.u("/apps/new"), url.Values{"csrf": {w.csrf}, "name": {name}})
+ if st != http.StatusSeeOther {
+ t.Fatalf("form create %s = %d %s", name, st, body)
+ }
+ _, _, page := get(t, w.browser, w.ts.URL+loc)
+ return loc, page
+}
+
+func (w *policyCreateWorld) hasApp(t *testing.T, name string) bool {
+ t.Helper()
+ has, err := w.st.HasAppIn(OpsAccountID, name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return has
+}
+
+// reconcileRow runs a box's reconcile over the fixture bucket into reg and
+// returns host's row, nil when none was minted.
+func reconcileRow(t *testing.T, fx *consoleFixture, reg *registry.Registry, host string) *registry.Route {
+ t.Helper()
+ if err := provision.Reconcile(context.Background(), fx.rel, reg,
+ provision.ReconcileConfig{DataDir: t.TempDir(), ReleasePub: fx.pub}); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+ got, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return got
+}
+
+func tempRegistry(t *testing.T) *registry.Registry {
+ t.Helper()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ return reg
+}
+
+// Astra plan R5-1: a name that never had a policy is created without a
+// delete request, so the region-host Deny does not break every create.
+func TestCreateWithNoPolicySendsNoDelete(t *testing.T) {
+ t.Parallel()
+ w := newPolicyCreateWorld(t, false)
+ f := installPolicyDeleteFault(t, w.consoleFixture, true)
+ if st, out := w.createCLI(t, "shop"); st != http.StatusOK {
+ t.Fatalf("create = %d %s", st, out)
+ }
+ if !w.hasApp(t, "shop") {
+ t.Fatal("no app row")
+ }
+ if got := f.seen(); len(got) != 0 {
+ t.Fatalf("the create sent deletes %v", got)
+ }
+}
+
+func TestCreateRefusedWhenAPredecessorPolicyCannotBeDeleted(t *testing.T) {
+ t.Parallel()
+ w := newPolicyCreateWorld(t, false)
+ installPolicyDeleteFault(t, w.consoleFixture, true)
+ wire := seedPredecessorPolicy(t, w.consoleFixture, "shop")
+ st, out := w.createCLI(t, "shop")
+ if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want {
+ t.Fatalf("create = %d %s, want 503 %q", st, out, want)
+ }
+ if w.hasApp(t, "shop") {
+ t.Fatal("the refused create left an app row")
+ }
+ if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) {
+ t.Fatalf("the object changed: %s", got)
+ }
+}
+
+func TestCreatingAnAppClearsAStalePolicy(t *testing.T) {
+ t.Parallel()
+ forEachCreateMode(t, func(t *testing.T, w *policyCreateWorld) {
+ key := apppolicy.Key(w.sqid, "shop")
+ seedPredecessorPolicy(t, w.consoleFixture, "shop")
+ f := installPolicyDeleteFault(t, w.consoleFixture, false)
+ var action string
+ f.observe = func(ctx context.Context, k string) {
+ if k != key || !w.claims {
+ return
+ }
+ // The operation the delete runs in, read from the raw bucket.
+ b, _, err := w.rel.Get(context.WithoutCancel(ctx), admissionKeyOf(w.sqid, "shop"))
+ var a hostops.AppAdmission
+ if err == nil && json.Unmarshal(b, &a) == nil && a.State == "closed" && a.Op != nil {
+ action = a.Op.Intent["action"]
+ }
+ }
+ if st, out := w.createCLI(t, "shop"); st != http.StatusOK {
+ t.Fatalf("create = %d %s", st, out)
+ }
+ if got := policyObject(t, w.consoleFixture, "shop"); got != nil {
+ t.Fatalf("the predecessor's policy is still at %s: %s", key, got)
+ }
+ if !slices.Contains(f.seen(), key) {
+ t.Fatalf("deletes %v, want %s", f.seen(), key)
+ }
+ if w.claims && action != "app-policy-clear" {
+ t.Fatalf("the delete ran in operation %q, want app-policy-clear", action)
+ }
+ })
+}
+
+// Astra plan R3-3. Fails if any mode lets the create stand after a failed
+// clear.
+func TestCreateIsRefusedWhileAPredecessorsPolicyLingers(t *testing.T) {
+ t.Parallel()
+ forEachCreateMode(t, func(t *testing.T, w *policyCreateWorld) {
+ ctx := context.Background()
+ const host = "shop-a.example.com"
+ wire := seedPredecessorPolicy(t, w.consoleFixture, "shop")
+ tmpWire := seedPredecessorPolicy(t, w.consoleFixture, "tmp-shop")
+ f := installPolicyDeleteFault(t, w.consoleFixture, true,
+ apppolicy.Key(w.sqid, "shop"), apppolicy.Key(w.sqid, "tmp-shop"))
+ st, out := w.createCLI(t, "shop")
+ if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want {
+ t.Fatalf("create = %d %s, want 503 %q", st, out, want)
+ }
+ if w.hasApp(t, "shop") {
+ t.Fatal("the refused create left an app row")
+ }
+ if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) {
+ t.Fatalf("the object changed: %s", got)
+ }
+ evs, err := w.st.Events(OpsAccountID, "shop", 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range evs {
+ if e.Action == "create" {
+ t.Fatalf("a create event for a refused create: %+v", e)
+ }
+ }
+
+ st, out = w.createTemporary(t, "tmp-shop")
+ if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "tmp-shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want {
+ t.Fatalf("temporary create = %d %s, want 503 %q", st, out, want)
+ }
+ if w.hasApp(t, "tmp-shop") {
+ t.Fatal("the refused temporary create left an app row")
+ }
+ if got := policyObject(t, w.consoleFixture, "tmp-shop"); !bytes.Equal(got, tmpWire) {
+ t.Fatalf("the tmp- object changed: %s", got)
+ }
+
+ // Under claims the failed delete was an issued effect, so it leaves
+ // the name's admission closed and uncertain (controller ruling on
+ // Task 11; a follow-up for claim activation): a later create of the
+ // name is refused as a create failure rather than as a failed clear.
+ // It fails closed either way.
+ loc, page := w.createForm(t, "shop")
+ if loc != w.path("/apps/new") || (!w.claims && !strings.Contains(page, "settings under this name, so nothing was created. Please try again.")) {
+ t.Fatalf("form create -> %s, want the refusal on the form:\n%s", loc, page)
+ }
+ if w.hasApp(t, "shop") {
+ t.Fatal("the refused form create left an app row")
+ }
+ if w.claims {
+ if st, out := w.createCLI(t, "shop"); st == http.StatusOK {
+ t.Fatalf("a second create = %d %s, want a refusal", st, out)
+ }
+ if w.hasApp(t, "shop") {
+ t.Fatal("the second refused create left an app row")
+ }
+ }
+ if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) {
+ t.Fatalf("the object changed after the later creates: %s", got)
+ }
+
+ // No app, so no instance and nothing for a box to run.
+ if st, _, out := rawReq(t, http.MethodPost, instancesURL(w.consoleFixture, w.sqid, "shop"), w.token, `{"host":"`+host+`"}`); st != http.StatusNotFound {
+ t.Fatalf("instance create = %d %s, want 404", st, out)
+ }
+ if keys, err := w.rel.List(ctx, "apps/"+w.sqid+"/shop/instances/"); err != nil || len(keys) != 0 {
+ t.Fatalf("instance records %v %v, want none", keys, err)
+ }
+ reg := tempRegistry(t)
+ if got := reconcileRow(t, w.consoleFixture, reg, host); got != nil {
+ t.Fatalf("reconcile minted %+v for an app that does not exist", got)
+ }
+
+ if w.claims {
+ return // no retry under claims: the admission stays closed (above)
+ }
+
+ // The fault cleared: the retry clears the key and creates the app,
+ // which runs at the build's size with no allow.
+ f.on.Store(false)
+ if st, out := w.createCLI(t, "shop"); st != http.StatusOK {
+ t.Fatalf("create after the fault cleared = %d %s", st, out)
+ }
+ if got := policyObject(t, w.consoleFixture, "shop"); got != nil {
+ t.Fatalf("the predecessor's policy survived the create: %s", got)
+ }
+ if err := provision.PutManifest(ctx, w.rel, w.sqid, "shop",
+ provision.Manifest{Schema: 1, App: "shop", Domain: "example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ policyRecord(t, w.rel, w.sqid, "shop", host, time.Now())
+ got := reconcileRow(t, w.consoleFixture, reg, host)
+ if got == nil {
+ t.Fatal("reconcile minted no row for the new app's instance")
+ }
+ if got.Size != "" || got.EgressAllow != "" || got.PolicyExpiresAt != "" {
+ t.Fatalf("row size %q egress %q expires %q, want all empty", got.Size, got.EgressAllow, got.PolicyExpiresAt)
+ }
+ })
+}
+
+// Ruling 10's second-console case at the create doors: no clear, no row.
+func TestCreateIsRefusedWhileAnotherConsoleIsThePolicyWriter(t *testing.T) {
+ t.Parallel()
+ w := newPolicyCreateWorld(t, false)
+ w.srv.cfg.Bucket = "deploy"
+ f := &fakePolicyLeaser{err: fmt.Errorf("acquire lease: %w",
+ &lease.HeldError{Owner: "console-b:4242", ExpiresAt: time.Now().Add(10 * time.Minute)})}
+ w.srv.cfg.PolicyLease = f.leaser
+ wire := seedPredecessorPolicy(t, w.consoleFixture, "shop")
+ tmpWire := seedPredecessorPolicy(t, w.consoleFixture, "tmp-shop")
+ const holder = "another console (console-b:4242)"
+
+ if st, out := w.createCLI(t, "shop"); st != http.StatusServiceUnavailable || !strings.HasPrefix(errorOf(t, out), holder) {
+ t.Fatalf("create = %d %s, want 503 naming console-b:4242", st, out)
+ }
+ if st, out := w.createTemporary(t, "tmp-shop"); st != http.StatusServiceUnavailable || !strings.HasPrefix(errorOf(t, out), holder) {
+ t.Fatalf("temporary create = %d %s, want 503 naming console-b:4242", st, out)
+ }
+ if loc, page := w.createForm(t, "shop"); loc != w.path("/apps/new") || !strings.Contains(page, holder) {
+ t.Fatalf("form create -> %s, want the holder named on the form:\n%s", loc, page)
+ }
+ for app, want := range map[string][]byte{"shop": wire, "tmp-shop": tmpWire} {
+ if w.hasApp(t, app) {
+ t.Fatalf("a refused create left a %s row", app)
+ }
+ if got := policyObject(t, w.consoleFixture, app); !bytes.Equal(got, want) {
+ t.Fatalf("the %s object changed: %s", app, got)
+ }
+ }
+}
+
+// Task 9's review: an app purged with a policy, and its name recreated
+// through the real doors, starts with nothing from its predecessor, at the
+// policy door and on a box.
+func TestPurgeAndRecreateStartsWithoutThePredecessorsPolicy(t *testing.T) {
+ t.Parallel()
+ fx := newPolicyFx(t, "code-policy-purge-recreate")
+ fx.mustPut(t, "kass", `{"egress_allow":["10.0.5.20"],"default_size":"xlarge"}`, policyCreate)
+ c := newBrowser(t)
+ signUp(t, fx.consoleFixture, c, "code-policy-purge-recreate-web")
+ trashKass(t, fx.consoleFixture, "paul@keymail.dev", time.Now())
+ if st, _, body := postGated(t, fx.consoleFixture, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {sessionToken(t, fx.consoleFixture, c)}, "app": {"kass"}, "confirm": {"kass"}}); st != http.StatusSeeOther {
+ t.Fatalf("delete now = %d:\n%s", st, body)
+ }
+ fx.noObject(t, "kass")
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "kass"}, fx.owner); st != http.StatusOK {
+ t.Fatalf("recreate = %d %v", st, out)
+ }
+ if g := fx.get(t, "kass"); len(g.Policy.EgressAllow) != 0 || g.Policy.DefaultSize != "" || g.ETag != "" {
+ t.Fatalf("the recreated app's policy = %+v etag %q, want empty", g.Policy, g.ETag)
+ }
+
+ // A box runs the new app's instance at the build's size with no allow.
+ const host = "kass-a.example.com"
+ if err := provision.PutManifest(context.Background(), fx.rel, fx.sqid, "kass",
+ provision.Manifest{Schema: 1, App: "kass", Domain: "example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ policyRecord(t, fx.rel, fx.sqid, "kass", host, time.Now())
+ got := reconcileRow(t, fx.consoleFixture, tempRegistry(t), host)
+ if got == nil || got.Size != "" || got.EgressAllow != "" || got.PolicyExpiresAt != "" {
+ t.Fatalf("the recreated app's row = %+v, want no size, allow or expiry", got)
+ }
+
+ // The first write builds on nothing, not on the purged app's revision.
+ r := fx.mustPut(t, "kass", `{"default_size":"small"}`, policyCreate)
+ if len(r.Policy.EgressAllow) != 0 || r.Policy.DefaultSize != "small" {
+ t.Fatalf("the first write after the recreate = %+v, want only default small", r.Policy)
+ }
+}
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go
index 23166278d..97936f34b 100644
--- a/internal/console/apitemporary.go
+++ b/internal/console/apitemporary.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/apppolicy"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -194,7 +195,7 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ
})
return
}
- outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl))
+ outcome, msg, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl))
switch outcome {
case claimOK:
case claimInvalid:
@@ -216,6 +217,13 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ
case claimStoreFailed:
jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again")
return
+ case claimPolicyClearFailed:
+ jsonError(w, http.StatusServiceUnavailable, fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(acct.Sqid, name)))
+ return
+ case claimPolicyWriterRefused:
+ // msg is the lease refusal's message for this outcome.
+ jsonError(w, http.StatusServiceUnavailable, msg)
+ return
default:
jsonError(w, http.StatusInternalServerError, "could not create the app. Try again")
return
diff --git a/internal/console/claim_app_enroll.go b/internal/console/claim_app_enroll.go
index 46679998b..66cebc584 100644
--- a/internal/console/claim_app_enroll.go
+++ b/internal/console/claim_app_enroll.go
@@ -1,9 +1,20 @@
package console
-import "context"
+import (
+ "context"
+ "fmt"
+)
+
+// policyWriterRefused is a create that could not take the policy writer
+// holding its clear needs: ref is the lease refusal the door answers.
+type policyWriterRefused struct{ ref *doorRefusal }
+
+func (e policyWriterRefused) Error() string {
+ return "console: policy writer refused: " + e.ref.Msg
+}
// createClaimAppRow inserts a new app's SQL row through insert. Flag-off it is
-// insert on the store, exactly as before.
+// insert on the store, after the create-time clear below.
//
// Enabled, the armed gate refuses an apps row nobody holds a permit for, and a
// permit is derived from canonical admission, so the order is reversed: the
@@ -13,8 +24,28 @@ import "context"
// binds app_id in the same permitted view. A refused enrollment (a foreign
// operation, a name that is not free) leaves no SQL row. A lost SQL race
// reports created false and binds nothing; the open admission is the winner's.
+//
+// In every mode the row is inserted only after an earlier app's policy at the
+// name's key is cleared (#328, #329; spec §3), inside policy publication's
+// exclusion: the app's policyLock, held from first thing until the insert has
+// returned, and a live writer holding (Astra plan R4-1). The policy door and
+// the re-sign take the same lock, so the recheck of the name under it sees any
+// row a racing create inserted, and a policy granted to that winner is never
+// cleared on this create's stale check. A writer refusal is
+// policyWriterRefused, before any enrollment, delete or insert; a failed clear
+// is errAppPolicyClear and inserts nothing.
func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app string, insert func(*Store) (bool, error)) (bool, error) {
+ mu := s.policyLock(acct.Sqid, app)
+ mu.Lock()
+ defer mu.Unlock()
+ hold, ref := s.policyWriter(ctx)
+ if ref != nil {
+ return false, policyWriterRefused{ref}
+ }
if !s.cfg.HostClaim {
+ if free, err := s.clearForCreate(ctx, acct, app, hold); err != nil || !free {
+ return false, err
+ }
return insert(s.store)
}
w, err := s.claimControl()
@@ -29,6 +60,11 @@ func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app strin
if err != nil {
return false, err
}
+ // The enrollment context: its identity is what mutationAppID answers
+ // before the row exists, so the boundary admits the app-scoped delete.
+ if free, err := s.clearForCreate(ctx, acct, app, hold); err != nil || !free {
+ return false, err
+ }
created := false
err = s.store.withAutoAppSQL(ctx, acct.ID, app, "sql.CreateApp", func(st *Store) error {
var err error
@@ -42,3 +78,25 @@ func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app strin
}
return created, nil
}
+
+// clearForCreate is the create's recheck and clear, under the caller's
+// policyLock: a row for the name now is a lost race (free false, nothing
+// deleted); otherwise the clear runs as one attempt of hold, and a refused
+// attempt, or one whose holding ended during the clear, is a failed clear.
+func (s *Server) clearForCreate(ctx context.Context, acct *Account, app string, hold policyHolding) (free bool, err error) {
+ if has, err := s.store.HasAppIn(acct.ID, app); err != nil || has {
+ return false, err
+ }
+ actx, done, ref := s.policyAttempt(ctx, hold)
+ defer done()
+ if ref != nil {
+ return false, fmt.Errorf("%w: %s", errAppPolicyClear, ref.Msg)
+ }
+ if err := s.clearAppPolicy(actx, acct, app); err != nil {
+ return false, err
+ }
+ if !hold.sole && actx.Err() != nil {
+ return false, fmt.Errorf("%w: the writer holding ended during the clear", errAppPolicyClear)
+ }
+ return true, nil
+}
diff --git a/internal/console/claim_invariant_world_test.go b/internal/console/claim_invariant_world_test.go
index 6b56214f3..5032724f6 100644
--- a/internal/console/claim_invariant_world_test.go
+++ b/internal/console/claim_invariant_world_test.go
@@ -514,6 +514,7 @@ func newClaimCaseWith(t *testing.T, opts ...func(*Config)) *claimCase {
HostClaim: true,
HostConsoleID: claimFxConsole,
Bucket: claimFxBucket,
+ PolicyLease: (&fakePolicyLeaser{}).leaser, // Task 8's fake: an app create takes the writer holding
DBVersions: rawVersions,
Origin: ts.URL,
Keymail: fk.srv.URL,
diff --git a/internal/console/claim_restore_test.go b/internal/console/claim_restore_test.go
index 5f66b2d0b..0d18934bd 100644
--- a/internal/console/claim_restore_test.go
+++ b/internal/console/claim_restore_test.go
@@ -78,7 +78,8 @@ func newEnabledRestoreConsole(t *testing.T, raw release.Store, key ed25519.Priva
versions.AddBucket(crBucket)
srv, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: true,
HostConsoleID: name, Bucket: crBucket, Releases: raw, DBVersions: versions,
- Key: key, ReleasePub: key.Public().(ed25519.PublicKey)}, st)
+ PolicyLease: (&fakePolicyLeaser{}).leaser, // a create takes the policy writer holding
+ Key: key, ReleasePub: key.Public().(ed25519.PublicKey)}, st)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/console/regionclaim_test.go b/internal/console/regionclaim_test.go
index c3a98b767..c3557cff1 100644
--- a/internal/console/regionclaim_test.go
+++ b/internal/console/regionclaim_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/apppolicy"
"github.com/carlosframework/platform/internal/geo"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/svcrole"
@@ -204,6 +205,40 @@ func TestClaimLostAfterResolutionWritesNothing(t *testing.T) {
if strings.Contains(logs.String(), "app region bab/racy") {
t.Fatalf("a lost claim logged a region line: %s", logs.String())
}
+
+ // Astra plan R4-1: the create-time clear rechecks inside the policy
+ // lock. A paused create (A, past its HasAppIn check) must not clear the
+ // policy an operator granted the app that took the name meanwhile (B).
+ // Fails if the clear runs on the stale existence check.
+ var (
+ entered bool
+ wB []byte
+ )
+ fx.srv.beforeClaimInsert = func() {
+ if entered {
+ return // B's own create passing the same seam
+ }
+ entered = true
+ if st, out := postJSON(t, newAppURL(fx), map[string]string{"name": "racy2", "region": "uk"}, token); st != http.StatusOK {
+ t.Errorf("B's create = %d %v", st, out)
+ return
+ }
+ if st, _, out := policyReq(t, fx, http.MethodPut, "racy2", token, `{"egress_allow":["10.0.5.20"]}`, policyCreate); st != http.StatusOK {
+ t.Errorf("B's policy PUT = %d %s", st, out)
+ return
+ }
+ wB = policyObject(t, fx, "racy2")
+ }
+ if st, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy2", "region": "uk"}, token); st != http.StatusConflict {
+ t.Fatalf("A's create of racy2 = %d, want 409", st)
+ }
+ if got := policyObject(t, fx, "racy2"); wB == nil || !bytes.Equal(got, wB) {
+ t.Fatalf("the policy at %s = %s, want B's %s", apppolicy.Key(fx.sqid, "racy2"), got, wB)
+ }
+ var rows int
+ if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, OpsAccountID, "racy2").Scan(&rows); err != nil || rows != 1 {
+ t.Fatalf("racy2 rows = %d %v, want exactly one", rows, err)
+ }
}
// Review Focus 5: a held apex refuses before resolution, so nothing is
diff --git a/internal/console/server.go b/internal/console/server.go
index 6ce9dc44b..3a9f54b32 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -34,6 +34,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/apppolicy"
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/dbreads"
"github.com/carlosframework/platform/internal/dnsrec"
@@ -4139,15 +4140,24 @@ func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) {
type claimOutcome int
const (
- claimOK claimOutcome = iota
- claimInvalid // not a ValidApp name
- claimReserved // the literal name the console's own routes shadow (e.g. "new")
- claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race
- claimCheckFailed // a database lookup failed
- claimStoreFailed // the release-store listing failed
- claimCreateFailed // the insert itself failed
- claimApexHeld // another app already holds the name's platform apex (apex.go)
- claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7)
+ claimOK claimOutcome = iota
+ claimInvalid // not a ValidApp name
+ claimReserved // the literal name the console's own routes shadow (e.g. "new")
+ claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race
+ claimCheckFailed // a database lookup failed
+ claimStoreFailed // the release-store listing failed
+ claimCreateFailed // the insert itself failed
+ claimApexHeld // another app already holds the name's platform apex (apex.go)
+ claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7)
+ claimPolicyClearFailed // an earlier app's policy at the name's key could not be cleared (#328, #329)
+ claimPolicyWriterRefused // the create could not take the policy writer holding (another console holds it)
+)
+
+// Create door copy (collected for review in plan Task 15): the create-time
+// clear of an earlier app's policy failed, so nothing was created.
+const (
+ appCreatePolicyClearMsg = "an earlier app's settings under this name could not be cleared, so nothing was created. Try again, or ask an operator to delete %s from the deployment bucket"
+ appCreatePolicyClearFlash = "Could not clear an earlier app's settings under this name, so nothing was created. Please try again."
)
// normalizeAppName is the one normalization both claim doors apply before
@@ -4268,6 +4278,12 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem
// held is set only with claimApexHeld: who holds the name's platform apex, so
// a door can say so without asking again.
//
+// Before the row is inserted, createClaimAppRow clears an earlier app's policy
+// at the name's key (#328, #329). A clear that failed is
+// claimPolicyClearFailed; a create that could not take the policy writer
+// holding is claimPolicyWriterRefused, and for that outcome alone the second
+// value is the refusal's message, not a placement.
+//
// A zero expiresAt is the permanent claim every existing door makes, and it
// refuses a tmp- name: nothing permanent may be created in that namespace
// (spec ruling 7). A non-zero one is /new-temporary's claim: it refuses any
@@ -4389,6 +4405,14 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl
return st.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now())
})
if err != nil {
+ var refused policyWriterRefused
+ switch {
+ case errors.As(err, &refused):
+ return claimPolicyWriterRefused, refused.ref.Msg, nil
+ case errors.Is(err, errAppPolicyClear):
+ log.Printf("console: claim %s/%s: %v", acct.Sqid, name, err)
+ return claimPolicyClearFailed, "", nil
+ }
return claimCreateFailed, "", nil
}
if !created { // lost a claim race after the checks
@@ -4424,6 +4448,35 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl
return claimOK, placed, nil
}
+// errAppPolicyClear is a create-time clear that failed: an earlier app's
+// policy object at the name's key is still there (or may be), so the create
+// inserts no row (spec §3 "before the name is usable").
+var errAppPolicyClear = errors.New("console: an earlier app's policy could not be cleared")
+
+// clearAppPolicy deletes an earlier app's policy at the name's key. It reads
+// first: deployments built from infra/modules/region-host deny
+// s3:DeleteObject across the deployment bucket, and a delete is sent even for
+// an absent key, so confirmed absence sends no delete (Astra plan R5-1). A
+// present object is deleted as the app's "app-policy-clear" mutation; any
+// failure, an access-denied delete included, is errAppPolicyClear. Under
+// claims a failed delete is an issued effect: it settles uncertain and leaves
+// the name's admission closed, so later creates of the name fail closed until
+// claim recovery covers it (a follow-up for claim activation).
+func (s *Server) clearAppPolicy(ctx context.Context, acct *Account, app string) error {
+ key := apppolicy.Key(acct.Sqid, app)
+ if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return fmt.Errorf("%w: read %s: %w", errAppPolicyClear, key, err)
+ }
+ if err := s.appMutation(ctx, acct, app, "app-policy-clear", func(ctx context.Context) error {
+ return apppolicy.Delete(ctx, s.cfg.Releases, acct.Sqid, app)
+ }); err != nil {
+ return fmt.Errorf("%w: %w", errAppPolicyClear, err)
+ }
+ return nil
+}
+
// handleNewAppForm is the create-app page; the claim itself stays on
// POST /a/{sqid}/apps/new (handleNewApp).
func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) {
@@ -4472,8 +4525,9 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
return
}
// The browser door never places at create time, so the second return
- // (what actually stuck) is always "" here and there is nothing to echo
- // it to — this form has no placement concept at all.
+ // (what actually stuck) is "" here and there is nothing to echo it to —
+ // this form has no placement concept at all. With
+ // claimPolicyWriterRefused it is the refusal's message instead.
defer s.planLock(acct)()
if err := s.checkCreateApp(r.Context(), acct, name); err != nil {
var planCap *planCapError
@@ -4485,7 +4539,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
fail("Could not create the app. Please try again.")
return
}
- outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
+ outcome, msg, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
switch outcome {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
@@ -4503,6 +4557,10 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
fail("Could not check the release store. Please try again.")
case claimCreateFailed:
fail("Could not create the app. Please try again.")
+ case claimPolicyClearFailed:
+ fail(appCreatePolicyClearFlash)
+ case claimPolicyWriterRefused:
+ fail(msg)
case claimOK:
detail := "created app " + name
if s.appRegionsOn() {
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 06833ce5d..12436f0b3 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -26,6 +27,22 @@ type deletableStore struct {
// afterPut, when set, runs after each Put the store accepted: a
// test's writer that lands just after another's write.
afterPut func(ctx context.Context, key string)
+ // deleteHook, when set, runs before each Delete and DeleteIfMatch
+ // reaches the store; an error it returns fails the delete there (an
+ // s3:DeleteObject Deny). Set with setDeleteHook, safely while the server
+ // runs.
+ deleteHook atomic.Pointer[func(ctx context.Context, key string) error]
+}
+
+func (d *deletableStore) setDeleteHook(h func(ctx context.Context, key string) error) {
+ d.deleteHook.Store(&h)
+}
+
+func (d *deletableStore) runDeleteHook(ctx context.Context, key string) error {
+ if h := d.deleteHook.Load(); h != nil && *h != nil {
+ return (*h)(ctx, key)
+ }
+ return nil
}
func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
@@ -45,10 +62,16 @@ func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMat
// an unconditional delete (domains.DetachClaim on an instance catch-all)
// would then refuse every time.
func (d *deletableStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ if err := d.runDeleteHook(ctx, key); err != nil {
+ return err
+ }
return release.DeleteIfMatch(ctx, d.Store, key, etag)
}
-func (d *deletableStore) Delete(_ context.Context, key string) error {
+func (d *deletableStore) Delete(ctx context.Context, key string) error {
+ if err := d.runDeleteHook(ctx, key); err != nil {
+ return err
+ }
if key == "" || strings.HasPrefix(key, "/") {
return fmt.Errorf("deletablestore: invalid key %q", key)
}
diff --git a/internal/release/trash.go b/internal/release/trash.go
index 797f09e60..c84fbba81 100644
--- a/internal/release/trash.go
+++ b/internal/release/trash.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log"
"os"
"time"
)
@@ -294,6 +295,25 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str
return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err)
}
keys = append(keys, promoted...)
+ // The app's policy (#328, #329, spec §3) lives outside its prefix too,
+ // at control/app-policy/<acct>/<app>.json, and a new app of this name
+ // must not run with the purged app's egress allow or sizes. Read first,
+ // deleted only when present: deployments built from
+ // infra/modules/region-host deny s3:DeleteObject across the bucket, and
+ // a delete is sent even for an absent key, so an unconditional one would
+ // fail the purge of every app that never had a policy. A present policy
+ // that cannot be deleted fails the purge before the marker, so the next
+ // pass retries until permissions allow.
+ policyKey := AppPolicyKey(acct, app)
+ if _, _, err := s.Get(ctx, policyKey); err == nil {
+ if err := s.Delete(ctx, policyKey); err != nil {
+ log.Printf("release: purge %s/%s: policy object %s not deleted (%v); the purge retries until it can be removed",
+ acct, app, policyKey, err)
+ return fmt.Errorf("release: purge %s: %w", policyKey, err)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("release: read %s: %w", policyKey, err)
+ }
for _, key := range keys {
if key == marker {
continue // last, below
diff --git a/internal/release/trash_test.go b/internal/release/trash_test.go
index bda14bd17..a1d05aeaf 100644
--- a/internal/release/trash_test.go
+++ b/internal/release/trash_test.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"errors"
+ "log"
"os"
"slices"
"strings"
@@ -360,6 +361,164 @@ func TestPurgeAppSweepsTheAppsLedgers(t *testing.T) {
}
}
+// policyDeleteStore fails Delete of one key while deny is set, with the
+// error an S3 DeleteObject Deny answers, and records every Delete it is
+// asked for. Deny is read and set by one goroutine, the test's.
+type policyDeleteStore struct {
+ Store
+ denyKey string
+ deny bool
+ failKey string // a second key whose Delete always fails, "" for none
+ deletes []string
+}
+
+func (s *policyDeleteStore) Delete(ctx context.Context, key string) error {
+ s.deletes = append(s.deletes, key)
+ if (s.deny && key == s.denyKey) || (s.failKey != "" && key == s.failKey) {
+ return errors.New("AccessDenied: s3:DeleteObject denied by an explicit deny")
+ }
+ return s.Store.Delete(ctx, key)
+}
+
+// putPolicyObject lays down bytes at an app's policy key. The purge does not
+// read them, so they need not verify.
+func putPolicyObject(t *testing.T, s Store, acct, app string) {
+ t.Helper()
+ if _, err := s.Put(ctx, AppPolicyKey(acct, app), []byte(`{"payload":"x","sig":"y"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func objectExists(t *testing.T, s Store, key string) bool {
+ t.Helper()
+ _, _, err := s.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ return err == nil
+}
+
+// Contract 4c (#328, #329): the app's policy object goes too, before the
+// marker. It lives at control/app-policy/<acct>/<app>.json, outside the
+// app's prefix, and a purge that left it handed the next app of the name its
+// egress allow and sizes.
+func TestPurgeAppSweepsThePolicyBeforeTheMarker(t *testing.T) {
+ s := &policyDeleteStore{Store: NewDirStore(t.TempDir())}
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "derp")
+ seedApp(t, s, "bab", "derpy")
+ putPolicyObject(t, s, "bab", "derp")
+ putPolicyObject(t, s, "bab", "derpy")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("PurgeApp: %v", err)
+ }
+ if objectExists(t, s, AppPolicyKey("bab", "derp")) {
+ t.Fatal("purge left the app's policy behind")
+ }
+ if !objectExists(t, s, AppPolicyKey("bab", "derpy")) {
+ t.Fatal("purge reached the neighbouring app's policy")
+ }
+ marker := AppTombstoneKey("bab", "derp")
+ if !slices.Contains(s.deletes, AppPolicyKey("bab", "derp")) || s.deletes[len(s.deletes)-1] != marker {
+ t.Fatalf("delete order %v — the policy before the marker, the marker LAST", s.deletes)
+ }
+
+ // The marker's delete fails: the purge errors with the policy already
+ // gone, and the next pass completes.
+ s = &policyDeleteStore{Store: NewDirStore(t.TempDir()), failKey: marker}
+ seedApp(t, s, "bab", "derp")
+ putPolicyObject(t, s, "bab", "derp")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatal("PurgeApp succeeded with the marker's delete failing")
+ }
+ if objectExists(t, s, AppPolicyKey("bab", "derp")) {
+ t.Fatal("the policy outlived a purge that died at the marker")
+ }
+ if !objectExists(t, s, marker) {
+ t.Fatal("the marker is gone after its delete failed")
+ }
+ s.failKey = ""
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("second PurgeApp: %v", err)
+ }
+ if objectExists(t, s, marker) {
+ t.Fatal("the second pass left the marker")
+ }
+}
+
+// Astra plan R6-2: a policy whose delete is denied (deployments built from
+// infra/modules/region-host deny s3:DeleteObject) fails the purge and keeps
+// the marker, so the next pass retries once permissions allow.
+func TestPurgeKeepsTheMarkerWhenThePolicyDeleteIsDenied(t *testing.T) {
+ var logs strings.Builder
+ log.SetOutput(&logs)
+ defer log.SetOutput(os.Stderr)
+ key := AppPolicyKey("bab", "derp")
+ s := &policyDeleteStore{Store: NewDirStore(t.TempDir()), denyKey: key, deny: true}
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "derp")
+ putPolicyObject(t, s, "bab", "derp")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ marker := AppTombstoneKey("bab", "derp")
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatal("PurgeApp succeeded with the policy's delete denied")
+ }
+ if !objectExists(t, s, marker) {
+ t.Fatal("the marker is gone after the policy's delete was denied")
+ }
+ if !objectExists(t, s, key) {
+ t.Fatal("the policy is gone after its delete was denied")
+ }
+ if slices.Contains(s.deletes, marker) {
+ t.Fatalf("deletes %v reached the marker", s.deletes)
+ }
+ want := "release: purge bab/derp: policy object " + key + " not deleted (AccessDenied: s3:DeleteObject denied by an explicit deny); the purge retries until it can be removed"
+ if !strings.Contains(logs.String(), want) {
+ t.Fatalf("log %q, want a line %q", logs.String(), want)
+ }
+
+ // Permissions restored: the next pass deletes the policy, then the marker.
+ s.deny, s.deletes = false, nil
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("PurgeApp after the deny was lifted: %v", err)
+ }
+ if objectExists(t, s, key) || objectExists(t, s, marker) {
+ t.Fatal("the retried purge left the policy or the marker")
+ }
+ if i := slices.Index(s.deletes, key); i < 0 || s.deletes[len(s.deletes)-1] != marker {
+ t.Fatalf("delete order %v — the policy, then the marker last", s.deletes)
+ }
+}
+
+// Astra plan R5-1: confirmed absence sends no delete, so a purge of an app
+// that never had a policy is unaffected by the s3:DeleteObject Deny.
+func TestPurgeOfAnAppWithNoPolicySendsNoPolicyDelete(t *testing.T) {
+ key := AppPolicyKey("bab", "derp")
+ s := &policyDeleteStore{Store: NewDirStore(t.TempDir()), denyKey: key, deny: true}
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "derp")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("PurgeApp of an app with no policy: %v", err)
+ }
+ if slices.Contains(s.deletes, key) {
+ t.Fatalf("deletes %v include the absent policy key", s.deletes)
+ }
+ if objectExists(t, s, AppTombstoneKey("bab", "derp")) {
+ t.Fatal("the purge left the marker")
+ }
+}
+
// Contract 5: idempotent. A half-purged app converges on a second pass, and
// an already-purged one reports ErrNotTrashed — which the caller reads as
// "done", not as a failure.