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>
3 files changed,
+226
−0
internal/apppolicy/writers_test.go+156 −0internal/hostops/apppolicy_classifier_test.go+55 −0internal/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 |