carlos / platform Public

console: TestNoUnjudgedRecordReads audits console, host and provision for raw instance-record reads

Forbidden and approved callees resolved by symbol through go/types, a pinned
allowlist of permitted enclosing functions each with its claims-on reason, and an
overlay fixture of claims-off copies of each forbidden shape that must be reported.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9028fe349ed0b301457493a5e8e03241545b2971 parent 01d3acb
1 file changed, +447 −0
  • internal/console/lifetimes_audit_test.go +447 −0
diff --git a/internal/console/lifetimes_audit_test.go b/internal/console/lifetimes_audit_test.go
new file mode 100644
index 000000000..7548bbb76
--- /dev/null
+++ b/internal/console/lifetimes_audit_test.go
@@ -0,0 +1,447 @@
+package console
+
+import (
+ "fmt"
+ "go/ast"
+ "go/token"
+ "go/types"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+
+ "golang.org/x/tools/go/packages"
+ "golang.org/x/tools/go/types/typeutil"
+)
+
+// TestNoUnjudgedRecordReads is the audit of self-serve moves plan 1, Task 6:
+// no non-test code in internal/console, internal/host or internal/provision
+// reads an instance record raw, except inside the functions listed in
+// unjudgedReadPermitted. Every claims-off consumer goes through a judged
+// reader (ReadRecordJudged, Observe, ObserveWith, the *Judged listings,
+// FindRecordByHost, (*RecordIndex).Find), which is the unjudged reader under
+// a nil judge, so a claims-on caller is unchanged.
+//
+// Forbidden, resolved by symbol: the raw provision getters and listings,
+// hostops.ReadRecord, a release.Store Get whose key is provision.RecordKey(...)
+// (directly or through a local assigned from it), and provision.DecodeRecord
+// of bytes read from the store in the same function. A use that is not a call
+// (a function value) counts too.
+//
+// The audit loads an overlay fixture beside the real console files: claims-off
+// copies of each forbidden shape, the claims-on alias read's shape among them,
+// which must all be reported.
+func TestNoUnjudgedRecordReads(t *testing.T) {
+ root, err := filepath.Abs(filepath.Join("..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fixture := filepath.Join(root, "internal", "console", "zz_unjudged_reads_fixture.go")
+ cfg := &packages.Config{
+ Dir: root,
+ Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports,
+ Env: append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0"),
+ Overlay: map[string][]byte{fixture: []byte(unjudgedReadsFixture)},
+ }
+ pkgs, err := packages.Load(cfg, "./internal/console", "./internal/host", "./internal/provision")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if packages.PrintErrors(pkgs) > 0 {
+ t.Fatal("loading the audited packages failed")
+ }
+ a, err := newUnjudgedReadAudit(pkgs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ findings, permitted := a.run(pkgs)
+ var used []string
+ for name := range unjudgedReadPermitted {
+ used = append(used, fmt.Sprintf("%s: %d", name, permitted[name]))
+ }
+ sort.Strings(used)
+ t.Logf("forbidden reads inside each permitted function:\n%s", strings.Join(used, "\n"))
+
+ var real, fixed []string
+ for _, f := range findings {
+ if f.file == fixture {
+ fixed = append(fixed, f.enclosing+" -> "+f.what)
+ continue
+ }
+ rel, _ := filepath.Rel(root, f.file)
+ real = append(real, fmt.Sprintf("%s:%d: %s -> %s", filepath.ToSlash(rel), f.line, f.enclosing, f.what))
+ }
+ if len(real) > 0 {
+ t.Errorf("%d unjudged instance-record reads outside the permitted functions (route each through a judged reader; claims-on units go in unjudgedReadPermitted with a reason):\n%s", len(real), strings.Join(real, "\n"))
+ }
+
+ // The fixture: every claims-off copy of a forbidden shape is reported.
+ const c = "(*github.com/carlosframework/platform/internal/console.Server)."
+ const pv = "github.com/carlosframework/platform/internal/provision."
+ want := []string{
+ c + "auditFixtureAliasShape -> release.Store Get of provision.RecordKey(...)",
+ c + "auditFixtureClaimsOffGet -> " + pv + "GetRecord",
+ c + "auditFixtureDecodeListed -> " + pv + "DecodeRecord of bytes read from the store",
+ c + "auditFixtureFuncValue -> " + pv + "ListRecords",
+ c + "auditFixtureHostops -> github.com/carlosframework/platform/internal/hostops.ReadRecord",
+ c + "auditFixtureHostops -> " + pv + "DecodeRecord of bytes read from the store",
+ c + "auditFixtureKeyVar -> " + pv + "DecodeRecord of bytes read from the store",
+ c + "auditFixtureKeyVar -> release.Store Get of provision.RecordKey(...)",
+ c + "auditFixtureListing -> " + pv + "ListAllRecordsTolerant",
+ }
+ sort.Strings(fixed)
+ if strings.Join(fixed, "\n") != strings.Join(want, "\n") {
+ t.Errorf("the fixture's findings:\n%s\nwant:\n%s", strings.Join(fixed, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+// unjudgedReadPermitted is the pinned allowlist: the only functions, by
+// resolved symbol, in which a forbidden read may appear, each with why.
+var unjudgedReadPermitted = map[string]string{
+ // The judged readers themselves: under a nil judge each delegates to its
+ // unjudged sibling, which is the claims-on behaviour.
+ "github.com/carlosframework/platform/internal/provision.ReadRecordJudged": "judged reader; a nil judge is GetRecordETag",
+ "(*github.com/carlosframework/platform/internal/provision.LifetimeJudge).Observe": "the judge's own record read",
+ "(*github.com/carlosframework/platform/internal/provision.LifetimeJudge).ObserveWith": "the judge's own record read",
+ "github.com/carlosframework/platform/internal/provision.ListRecordsJudged": "judged listing; a nil judge is ListRecords",
+ "github.com/carlosframework/platform/internal/provision.ListRecordsCompleteJudged": "judged listing; a nil judge is ListRecordsComplete",
+ "github.com/carlosframework/platform/internal/provision.ListRecordsStrictJudged": "judged listing; a nil judge is ListRecordsStrict",
+ "github.com/carlosframework/platform/internal/provision.ListAllRecordsStrictJudged": "judged listing; a nil judge is ListAllRecordsStrict",
+ "github.com/carlosframework/platform/internal/provision.ListAllRecordsTolerantJudged": "judged listing; a nil judge is ListAllRecordsTolerant",
+ "github.com/carlosframework/platform/internal/provision.FindRecordByHost": "judges each candidate through JudgeFrom(ctx)",
+ "(*github.com/carlosframework/platform/internal/provision.RecordIndex).Find": "judges each candidate through JudgeFrom(ctx)",
+ "github.com/carlosframework/platform/internal/provision.updateRecordSeeding": "judges through JudgeFrom(ctx) before the mutation runs",
+ "github.com/carlosframework/platform/internal/provision.claimRawUpdateRecord": "judges its re-read through JudgeFrom(ctx)",
+ "github.com/carlosframework/platform/internal/provision.WriteRecord": "the one record writer: reads the exact bytes it replaces",
+ "github.com/carlosframework/platform/internal/provision.TombstoneBody": "builds a tombstone from bytes its caller observed",
+ "github.com/carlosframework/platform/internal/provision.GetRecordRaw": "the raw primitive the judge reads through",
+ // The legacy unjudged getters and listings: the claims-on readers, and
+ // what every judged sibling is under a nil judge.
+ "github.com/carlosframework/platform/internal/provision.GetRecord": "legacy unjudged getter (claims-on), delegates to GetRecordRaw",
+ "github.com/carlosframework/platform/internal/provision.GetRecordETag": "legacy unjudged getter (claims-on), delegates to GetRecordRaw",
+ "github.com/carlosframework/platform/internal/provision.ListRecords": "legacy unjudged listing (claims-on)",
+ "github.com/carlosframework/platform/internal/provision.ListRecordsStrict": "legacy unjudged listing (claims-on)",
+ "github.com/carlosframework/platform/internal/provision.ListRecordsComplete": "legacy unjudged listing (claims-on), delegates to getEach",
+ "github.com/carlosframework/platform/internal/provision.ListAllRecords": "legacy unjudged listing (claims-on)",
+ "github.com/carlosframework/platform/internal/provision.ListAllRecordsStrict": "legacy unjudged listing (claims-on)",
+ "github.com/carlosframework/platform/internal/provision.ListAllRecordsTolerant": "legacy unjudged listing (claims-on)",
+ "github.com/carlosframework/platform/internal/provision.journalRecordByKey": "its nil-judge branch is today's claims-on read; a judge goes to journalRecordJudged",
+ // The console's own judged steps: each decodes a body it observed through
+ // the judge (lifetimes_create.go, lifetimes_migrate.go).
+ "(*github.com/carlosframework/platform/internal/console.Server).fenceRecord": "the fence re-reads the body it just wrote under its observation",
+ "(*github.com/carlosframework/platform/internal/console.Server).quarantineCopy": "copies the exact body the migration observed",
+ // Claims-on units: each decodes a body captured in an ETag-bound claim
+ // observation, or runs only under cfg.HostClaim.
+ "(*github.com/carlosframework/platform/internal/console.ClaimPlacementCoordinator).Resume": "claims-on: decodes the record bound to the placement claim's observation",
+ "(*github.com/carlosframework/platform/internal/console.Server).movesRecovery": "claims-on: reads the record bound to the move recovery grant's claim",
+ "(*github.com/carlosframework/platform/internal/console.Server).adoptHostRecord": "claims-on: adopts the record a host claim holds",
+ "(*github.com/carlosframework/platform/internal/console.Server).refreshClaimRetireRecord": "claims-on: refreshes the record a retire claim holds",
+ "(*github.com/carlosframework/platform/internal/console.Server).claimAliasUnbacked": "claims-on: the alias path's presence check of a record key",
+ "github.com/carlosframework/platform/internal/host.relocationRecord": "claims-on: region-HA relocation reads the record a claim holder names",
+}
+
+const (
+ pkgProvision = "github.com/carlosframework/platform/internal/provision"
+ pkgHostops = "github.com/carlosframework/platform/internal/hostops"
+ pkgRelease = "github.com/carlosframework/platform/internal/release"
+)
+
+// unjudgedForbidden are the raw readers, by full name.
+var unjudgedForbidden = []string{
+ pkgProvision + ".GetRecord",
+ pkgProvision + ".GetRecordETag",
+ pkgProvision + ".GetRecordRaw",
+ pkgProvision + ".ListRecords",
+ pkgProvision + ".ListRecordsStrict",
+ pkgProvision + ".ListRecordsComplete",
+ pkgProvision + ".ListAllRecords",
+ pkgProvision + ".ListAllRecordsStrict",
+ pkgProvision + ".ListAllRecordsTolerant",
+ pkgHostops + ".ReadRecord",
+}
+
+type unjudgedFinding struct {
+ file string
+ line int
+ enclosing string
+ what string
+}
+
+type unjudgedReadAudit struct {
+ forbidden map[types.Object]string
+ recordKey types.Object
+ decode types.Object
+ storeReads map[types.Object]bool // calls whose results are store bytes
+ store *types.Interface
+}
+
+func newUnjudgedReadAudit(pkgs []*packages.Package) (*unjudgedReadAudit, error) {
+ a := &unjudgedReadAudit{forbidden: map[types.Object]string{}, storeReads: map[types.Object]bool{}}
+ byPath := map[string]*types.Package{}
+ declared := map[string]bool{}
+ packages.Visit(pkgs, nil, func(p *packages.Package) {
+ if p.Types != nil {
+ byPath[p.PkgPath] = p.Types
+ }
+ })
+ lookup := func(full string) (types.Object, error) {
+ i := strings.LastIndex(full, ".")
+ pkg := byPath[full[:i]]
+ if pkg == nil {
+ return nil, fmt.Errorf("package of %s not loaded", full)
+ }
+ obj := pkg.Scope().Lookup(full[i+1:])
+ if obj == nil {
+ return nil, fmt.Errorf("%s does not resolve", full)
+ }
+ return obj, nil
+ }
+ for _, name := range unjudgedForbidden {
+ obj, err := lookup(name)
+ if err != nil {
+ return nil, err
+ }
+ a.forbidden[obj] = name
+ }
+ var err error
+ if a.recordKey, err = lookup(pkgProvision + ".RecordKey"); err != nil {
+ return nil, err
+ }
+ if a.decode, err = lookup(pkgProvision + ".DecodeRecord"); err != nil {
+ return nil, err
+ }
+ for _, name := range []string{pkgProvision + ".GetRecordRaw", pkgHostops + ".ReadRecord"} {
+ obj, err := lookup(name)
+ if err != nil {
+ return nil, err
+ }
+ a.storeReads[obj] = true
+ }
+ st, err := lookup(pkgRelease + ".Store")
+ if err != nil {
+ return nil, err
+ }
+ a.store = st.Type().Underlying().(*types.Interface)
+ // Pin the allowlist: every entry names a declared function.
+ for _, p := range pkgs {
+ for _, obj := range p.TypesInfo.Defs {
+ if fn, ok := obj.(*types.Func); ok {
+ declared[fn.FullName()] = true
+ }
+ }
+ }
+ for name := range unjudgedReadPermitted {
+ if !declared[name] {
+ return nil, fmt.Errorf("allowlisted %s is not a declared function", name)
+ }
+ }
+ return a, nil
+}
+
+// storeGet reports whether call is a Get on a release.Store.
+func (a *unjudgedReadAudit) storeGet(info *types.Info, call *ast.CallExpr) bool {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "Get" || len(call.Args) != 2 {
+ return false
+ }
+ s, ok := info.Selections[sel]
+ if !ok || s.Kind() != types.MethodVal {
+ return false
+ }
+ recv := s.Recv()
+ return types.Implements(recv, a.store) || types.Implements(types.NewPointer(recv), a.store)
+}
+
+func rootIdent(e ast.Expr) *ast.Ident {
+ for {
+ switch x := e.(type) {
+ case *ast.Ident:
+ return x
+ case *ast.SelectorExpr:
+ e = x.X
+ case *ast.ParenExpr:
+ e = x.X
+ case *ast.StarExpr:
+ e = x.X
+ case *ast.IndexExpr:
+ e = x.X
+ default:
+ return nil
+ }
+ }
+}
+
+// run returns the forbidden reads outside the permitted functions, and how
+// many each permitted function holds.
+func (a *unjudgedReadAudit) run(pkgs []*packages.Package) ([]unjudgedFinding, map[string]int) {
+ var out []unjudgedFinding
+ permitted := map[string]int{}
+ for _, p := range pkgs {
+ info := p.TypesInfo
+ for _, f := range p.Syntax {
+ for _, d := range f.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+ fn, _ := info.Defs[fd.Name].(*types.Func)
+ if fn == nil {
+ continue
+ }
+ enclosing := fn.FullName()
+ found := a.inFunc(p.Fset, info, fd, enclosing)
+ if _, ok := unjudgedReadPermitted[enclosing]; ok {
+ permitted[enclosing] += len(found)
+ continue
+ }
+ out = append(out, found...)
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].file != out[j].file {
+ return out[i].file < out[j].file
+ }
+ if out[i].line != out[j].line {
+ return out[i].line < out[j].line
+ }
+ return out[i].what < out[j].what
+ })
+ return out, permitted
+}
+
+func (a *unjudgedReadAudit) inFunc(fset *token.FileSet, info *types.Info, fd *ast.FuncDecl, enclosing string) []unjudgedFinding {
+ var out []unjudgedFinding
+ report := func(n ast.Node, what string) {
+ pos := fset.Position(n.Pos())
+ out = append(out, unjudgedFinding{file: pos.Filename, line: pos.Line, enclosing: enclosing, what: what})
+ }
+ isCallTo := func(e ast.Expr, obj types.Object) bool {
+ call, ok := ast.Unparen(e).(*ast.CallExpr)
+ return ok && typeutil.Callee(info, call) == obj
+ }
+ // First pass: the locals assigned from provision.RecordKey(...) and the
+ // locals holding what a store read returned.
+ keyVars := map[types.Object]bool{}
+ readVars := map[types.Object]bool{}
+ mark := func(lhs []ast.Expr, rhs []ast.Expr) {
+ if len(rhs) == 1 {
+ call, ok := ast.Unparen(rhs[0]).(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ read := a.storeReads[typeutil.Callee(info, call)] || a.storeGet(info, call)
+ for _, l := range lhs {
+ id, ok := l.(*ast.Ident)
+ if !ok {
+ continue
+ }
+ obj := info.ObjectOf(id)
+ if obj == nil {
+ continue
+ }
+ if read {
+ readVars[obj] = true
+ }
+ if len(lhs) == 1 && typeutil.Callee(info, call) == a.recordKey {
+ keyVars[obj] = true
+ }
+ }
+ return
+ }
+ for i := range lhs {
+ if i < len(rhs) && isCallTo(rhs[i], a.recordKey) {
+ if id, ok := lhs[i].(*ast.Ident); ok && info.ObjectOf(id) != nil {
+ keyVars[info.ObjectOf(id)] = true
+ }
+ }
+ }
+ }
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ switch x := n.(type) {
+ case *ast.AssignStmt:
+ mark(x.Lhs, x.Rhs)
+ case *ast.ValueSpec:
+ lhs := make([]ast.Expr, len(x.Names))
+ for i, id := range x.Names {
+ lhs[i] = id
+ }
+ mark(lhs, x.Values)
+ }
+ return true
+ })
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ switch x := n.(type) {
+ case *ast.Ident:
+ if name, ok := a.forbidden[info.Uses[x]]; ok {
+ report(x, name)
+ }
+ case *ast.CallExpr:
+ if a.storeGet(info, x) {
+ key := ast.Unparen(x.Args[1])
+ if isCallTo(key, a.recordKey) {
+ report(x, "release.Store Get of provision.RecordKey(...)")
+ } else if id, ok := key.(*ast.Ident); ok && keyVars[info.ObjectOf(id)] {
+ report(x, "release.Store Get of provision.RecordKey(...)")
+ }
+ }
+ if typeutil.Callee(info, x) == a.decode && len(x.Args) == 1 {
+ if id := rootIdent(x.Args[0]); id != nil && readVars[info.ObjectOf(id)] {
+ report(x, pkgProvision+".DecodeRecord of bytes read from the store")
+ }
+ }
+ }
+ return true
+ })
+ return out
+}
+
+// unjudgedReadsFixture is overlaid into internal/console for the audit only:
+// claims-off copies of each forbidden shape. None of it is compiled into the
+// console.
+const unjudgedReadsFixture = `package console
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func (s *Server) auditFixtureClaimsOffGet(ctx context.Context, acct, app, host string) {
+ _, _ = provision.GetRecord(ctx, s.cfg.Releases, acct, app, host)
+}
+
+// The claims-on alias read's shape, outside claimAliasUnbacked.
+func (s *Server) auditFixtureAliasShape(ctx context.Context, account, app, route string) error {
+ _, _, err := s.cfg.Releases.Get(ctx, provision.RecordKey(account, app, route))
+ return err
+}
+
+func (s *Server) auditFixtureKeyVar(ctx context.Context, acct, app, host string) {
+ key := provision.RecordKey(acct, app, host)
+ body, _, _ := s.cfg.Releases.Get(ctx, key)
+ _, _ = provision.DecodeRecord(body)
+}
+
+func (s *Server) auditFixtureDecodeListed(ctx context.Context, keys []string) {
+ for _, k := range keys {
+ raw, _, _ := s.cfg.Releases.Get(ctx, k)
+ _, _ = provision.DecodeRecord(raw)
+ }
+}
+
+func (s *Server) auditFixtureHostops(ctx context.Context, key string) {
+ r, _ := hostops.ReadRecord(ctx, s.cfg.Releases, key)
+ _, _ = provision.DecodeRecord(r.Body)
+}
+
+func (s *Server) auditFixtureFuncValue() any {
+ return provision.ListRecords
+}
+
+func (s *Server) auditFixtureListing(ctx context.Context) {
+ _, _, _ = provision.ListAllRecordsTolerant(ctx, s.cfg.Releases)
+}
+`