carlos / platform Public

hostops: classify the app policy as app-scoped (#328)

control/app-policy/<acct>/<app>.json is family app-policy, scoped to
exactly {acct, app}, for Put and Delete; anything else under the prefix,
and DeleteIfMatch, is refused. A source test in internal/apppolicy keeps
the policy writers and release.AppPolicyKey to their allowed files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev da7e6e6c94d24ea31664cf6611cab1f71b845cfe parent a65288a
3 files changed, +226 −0
  • internal/apppolicy/writers_test.go +156 −0
  • internal/hostops/apppolicy_classifier_test.go +55 −0
  • internal/hostops/mutation_classifier.go +15 −0
diff --git a/internal/apppolicy/writers_test.go b/internal/apppolicy/writers_test.go
new file mode 100644
index 000000000..74c3ad5f1
--- /dev/null
+++ b/internal/apppolicy/writers_test.go
@@ -0,0 +1,156 @@
+package apppolicy
+
+import (
+ "bytes"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+const (
+ apppolicyImport = "github.com/carlosframework/platform/internal/apppolicy"
+ releaseImport = "github.com/carlosframework/platform/internal/release"
+)
+
+// policyWriterSites is the boundary of spec §3, kept by source: the only
+// non-test files outside internal/apppolicy that may name a policy writer
+// or build the policy key by hand. Reading the key through apppolicy.Key
+// is not restricted.
+var policyWriterSites = map[string][]string{
+ // The door (Task 9) and the re-sign pass (Task 10).
+ "apppolicy.PutIfMatch": {"internal/console/apipolicy.go"},
+ "apppolicy.PutIfMatchETag": {"internal/console/apipolicy.go"},
+ "apppolicy.PutWire": {"internal/console/apipolicy.go"},
+ "apppolicy.Sign": {"internal/console/apipolicy.go"},
+ // The create-time clear (Task 11).
+ "apppolicy.Delete": {"internal/console/server.go"},
+ // The purge, the box's store door and the mutation classifier.
+ "release.AppPolicyKey": {"internal/release/trash.go", "internal/edge/storedoor.go", "internal/hostops/mutation_classifier.go"},
+}
+
+// TestPolicyWritersAreTheDoorThePurgeAndCreate walks every non-test .go
+// file of the module and fails naming file:line of any reference to a
+// policy writer, or to release.AppPolicyKey, outside its allowed files.
+func TestPolicyWritersAreTheDoorThePurgeAndCreate(t *testing.T) {
+ root := moduleRoot(t)
+ fset := token.NewFileSet()
+ seen := map[string]int{} // references found, allowed or not, by name
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(root, path)
+ rel = filepath.ToSlash(rel)
+ if d.IsDir() {
+ if path == root {
+ return nil
+ }
+ name := d.Name()
+ if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "vendor" || name == "node_modules" {
+ return filepath.SkipDir
+ }
+ if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
+ return filepath.SkipDir // another module
+ }
+ if rel == "internal/apppolicy" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go") {
+ return nil
+ }
+ src, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if !bytes.Contains(src, []byte(apppolicyImport+`"`)) && !bytes.Contains(src, []byte(releaseImport+`"`)) {
+ return nil
+ }
+ f, err := parser.ParseFile(fset, rel, src, parser.SkipObjectResolution)
+ if err != nil {
+ return err
+ }
+ local := map[string]string{} // file-local package name -> "apppolicy" or "release"
+ for _, imp := range f.Imports {
+ path, _ := strconv.Unquote(imp.Path.Value)
+ var pkg string
+ switch path {
+ case apppolicyImport:
+ pkg = "apppolicy"
+ case releaseImport:
+ pkg = "release"
+ default:
+ continue
+ }
+ name := pkg
+ if imp.Name != nil {
+ name = imp.Name.Name
+ }
+ if name == "." {
+ t.Errorf("%s: dot import of %s hides policy writer calls from this check", fset.Position(imp.Pos()), path)
+ continue
+ }
+ local[name] = pkg
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ sel, ok := n.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ x, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return true
+ }
+ pkg, ok := local[x.Name]
+ if !ok {
+ return true
+ }
+ name := pkg + "." + sel.Sel.Name
+ allowed, restricted := policyWriterSites[name]
+ if !restricted {
+ return true
+ }
+ seen[name]++
+ if !slices.Contains(allowed, rel) {
+ t.Errorf("%s: %s outside its allowed files %v", fset.Position(sel.Pos()), name, allowed)
+ }
+ return true
+ })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The box's store door builds the key today: a walk that finds no
+ // reference there is broken, not clean.
+ if seen["release.AppPolicyKey"] == 0 {
+ t.Fatalf("no release.AppPolicyKey reference found under %s: the scan is broken", root)
+ }
+}
+
+func moduleRoot(t *testing.T) string {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for {
+ if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil &&
+ bytes.HasPrefix(bytes.TrimSpace(b), []byte("module github.com/carlosframework/platform\n")) {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal("module root not found above the test's directory")
+ }
+ dir = parent
+ }
+}
diff --git a/internal/hostops/apppolicy_classifier_test.go b/internal/hostops/apppolicy_classifier_test.go
new file mode 100644
index 000000000..020ad4d34
--- /dev/null
+++ b/internal/hostops/apppolicy_classifier_test.go
@@ -0,0 +1,55 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestClassifyAppPolicyIsExact: an app's operator policy,
+// control/app-policy/<acct>/<app>.json (#328, #329, spec §3), is a reviewed
+// family scoped to exactly its app, put and deleted; nothing else under
+// control/app-policy/ is.
+func TestClassifyAppPolicyIsExact(t *testing.T) {
+ ctx := context.Background()
+ key := release.AppPolicyKey("bab", "shop")
+ for _, c := range []struct {
+ name string
+ target MutationTarget
+ }{
+ {"policy put", MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}},
+ {"policy put over an existing one", MutationTarget{Surface: "bucket", Method: "Put", Key: key, ETag: "x", Before: []byte(`{}`), After: []byte(`{}`)}},
+ {"policy delete", MutationTarget{Surface: "bucket", Method: "Delete", Key: key}},
+ } {
+ got, err := ClassifyMutation(ctx, nil, nil, c.target)
+ if err != nil {
+ t.Errorf("%s: %v", c.name, err)
+ continue
+ }
+ if got.Family != "app-policy" || got.NonApp != "" || got.MailGate ||
+ len(got.Scopes) != 1 || got.Scopes[0] != (AppScope{"bab", "shop"}) {
+ t.Errorf("%s: classified %+v, want family app-policy scoped to exactly {bab shop}", c.name, got)
+ }
+ }
+ for _, c := range []struct {
+ name string
+ target MutationTarget
+ }{
+ {"an invalid account", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/BAB!/shop.json", After: []byte(`{}`)}},
+ {"an invalid app", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/bab/Shop!.json", After: []byte(`{}`)}},
+ {"an empty app", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/bab/.json", After: []byte(`{}`)}},
+ {"a fifth segment", MutationTarget{Surface: "bucket", Method: "Put", Key: key + "/x", After: []byte(`{}`)}},
+ {"another suffix", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/bab/shop.txt", After: []byte(`{}`)}},
+ {"the account alone", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/bab", After: []byte(`{}`)}},
+ {"the prefix alone", MutationTarget{Surface: "bucket", Method: "Put", Key: "control/app-policy/", After: []byte(`{}`)}},
+ {"a delete of the account", MutationTarget{Surface: "bucket", Method: "Delete", Key: "control/app-policy/bab"}},
+ {"a delete-if-match", MutationTarget{Surface: "bucket", Method: "DeleteIfMatch", Key: key, ETag: "x"}},
+ } {
+ got, err := ClassifyMutation(ctx, nil, nil, c.target)
+ if !errors.Is(err, ErrUnclassified) && !errors.Is(err, ErrIdentity) {
+ t.Errorf("%s: classified %+v, err %v; want ErrUnclassified or ErrIdentity", c.name, got, err)
+ }
+ }
+}
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go
index 298489074..6bb692cd3 100644
--- a/internal/hostops/mutation_classifier.go
+++ b/internal/hostops/mutation_classifier.go
@@ -111,6 +111,8 @@ func ClassifyMutation(ctx context.Context, raw release.Store, w *ControlWriter,
return Classification{Family: p[0], Scopes: []AppScope{{p[1], p[2]}}}, nil
case strings.HasPrefix(t.Key, "control/door-keys/") && len(p) == 4 && strings.HasSuffix(p[3], ".json"):
return appScoped("door-keys", p[2], strings.TrimSuffix(p[3], ".json"))
+ case strings.HasPrefix(t.Key, release.AppPolicyPrefix):
+ return classifyAppPolicy(t, p)
case strings.HasPrefix(t.Key, "control/maintenance/apps/") && len(p) >= 6:
return appScoped("maintenance-app", p[3], p[4])
case strings.HasPrefix(t.Key, "domains/") && len(p) == 2 && strings.HasSuffix(p[1], ".json"):
@@ -203,6 +205,19 @@ func classifyPromotionRecord(t MutationTarget, p []string) (Classification, erro
return appScoped("promotion-record", p[1], p[2])
}
+// classifyAppPolicy admits an app's signed operator policy,
+// control/app-policy/<acct>/<app>.json (#328, #329, spec §3), written by the
+// console's policy door and re-sign pass and deleted when the app is
+// created or purged. It is app state, scoped to exactly that app. Anything
+// else under control/app-policy/ stays unreviewed.
+func classifyAppPolicy(t MutationTarget, p []string) (Classification, error) {
+ if len(p) != 4 || !strings.HasSuffix(p[3], ".json") || (t.Method != "Put" && t.Method != "Delete") ||
+ t.Key != release.AppPolicyKey(p[2], strings.TrimSuffix(p[3], ".json")) {
+ return Classification{}, fmt.Errorf("%w: %s", ErrUnclassified, t.Key)
+ }
+ return appScoped("app-policy", p[2], strings.TrimSuffix(p[3], ".json"))
+}
+
func appScoped(family, account, app string) (Classification, error) {
if !controlname.ValidAccount(account) || !controlname.ValidApp(app) {
return Classification{}, ErrIdentity