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>
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) |
| +} |
| +` |