diff --git a/CHANGELOG.md b/CHANGELOG.md index cab7ba69..11aec9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ verify one. - `runs open --key` reads the roster and the key before it makes an empty directory a signed layout, so a roster that does not read leaves the directory as it was. +- `doctor`'s `trust` line names every file the signed start checked, the + roster among them, and counts the roster's `read` grants, which grant + nothing yet: a plane is read through `health.socket` by peer uid and + through files by their modes. - `dev` runs its collector on a unix socket in a directory of its own under the system's temporary directory, and refuses, naming `TMPDIR`, where that path would not fit a socket address. diff --git a/cmd/guardana-gateway/authority.go b/cmd/guardana-gateway/authority.go index c251b045..070e65b8 100644 --- a/cmd/guardana-gateway/authority.go +++ b/cmd/guardana-gateway/authority.go @@ -99,28 +99,37 @@ func checkSignedStart(cfg *gatewayconfig.Config) error { return fmt.Errorf("approvals.authority: signed, and the environment sets %s; the plane's own account sets its environment, so under signed every key is read from the configuration file alone (ADR-0073)", strings.Join(keys, ", ")) } - checked := []struct{ key, path string }{ + for _, f := range signedStartFiles(cfg) { + if err := notPlaneWritable(f.path); err != nil { + return fmt.Errorf("%s: under the signed authority the plane's own account may not be able to change it: %w (ADR-0073)", f.key, err) + } + } + return nil +} + +// signedFile is one path a signed start checks, under the key that names it. +type signedFile struct{ key, path string } + +// signedStartFiles is every path checkSignedStart holds out of the plane's +// account; a key the configuration leaves unset names none. +func signedStartFiles(cfg *gatewayconfig.Config) []signedFile { + checked := []signedFile{ {"the configuration file", cfg.File()}, {"trust.root_public_key", cfg.Resolve(cfg.Trust.RootPublicKey)}, {"trust.roster", cfg.Resolve(cfg.Trust.Roster)}, } if cfg.Pause.File != "" { - checked = append(checked, struct{ key, path string }{"pause.file", cfg.Resolve(cfg.Pause.File)}) + checked = append(checked, signedFile{"pause.file", cfg.Resolve(cfg.Pause.File)}) } if cfg.Reaction.Configured() { checked = append(checked, - struct{ key, path string }{"reaction.route", cfg.Resolve(cfg.Reaction.Route)}, - struct{ key, path string }{"reaction.stops", cfg.Resolve(cfg.Reaction.Stops)}) + signedFile{"reaction.route", cfg.Resolve(cfg.Reaction.Route)}, + signedFile{"reaction.stops", cfg.Resolve(cfg.Reaction.Stops)}) } if cfg.Runs.Dir != "" { - checked = append(checked, struct{ key, path string }{"runs.dir", filepath.Join(cfg.Resolve(cfg.Runs.Dir), runs.RecordsDir)}) - } - for _, f := range checked { - if err := notPlaneWritable(f.path); err != nil { - return fmt.Errorf("%s: under the signed authority the plane's own account may not be able to change it: %w (ADR-0073)", f.key, err) - } + checked = append(checked, signedFile{"runs.dir", filepath.Join(cfg.Resolve(cfg.Runs.Dir), runs.RecordsDir)}) } - return nil + return checked } // pinnedKey is one key the plane pins for an authority other than the trust diff --git a/cmd/guardana-gateway/doctor_authority.go b/cmd/guardana-gateway/doctor_authority.go index acf4535a..2a31a7ea 100644 --- a/cmd/guardana-gateway/doctor_authority.go +++ b/cmd/guardana-gateway/doctor_authority.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "time" "github.com/guardana/control/internal/approvals/inbox" @@ -63,9 +64,39 @@ func (d *examination) trust(context.Context) (string, string, string) { if floor.Serial > doc.Serial() { return verdictFail, doctorTrust, fmt.Sprintf("trust.roster: serial %d is below the floor's %d (%s)", doc.Serial(), floor.Serial, trust.CauseBelowFloor) } - return verdictOK, doctorTrust, fmt.Sprintf( - "roster %s serial %d names %d key(s) and verifies under the pinned root; the floor holds serial %d; %d other pinned key(s) no roster may name; the configuration file and the root key file are not the plane's to change, and the environment sets no key", - doc.ID(), doc.Serial(), len(doc.Keys()), floor.Serial, len(others)) + found := fmt.Sprintf( + "roster %s serial %d names %d key(s) and verifies under the pinned root; the floor holds serial %d; %d other pinned key(s) no roster may name; %s are not the plane's to change, and the environment sets no key", + doc.ID(), doc.Serial(), len(doc.Keys()), floor.Serial, len(others), checkedKeys(d.cfg)) + if n := readGrants(doc); n > 0 { + found += fmt.Sprintf("; %d read grant(s) grant nothing yet: reading is by the health socket's peer uid and by file modes (ADR-0071)", n) + } + return verdictOK, doctorTrust, found +} + +// checkedKeys names the keys of every path the signed start checked, as one +// list. +func checkedKeys(cfg *gatewayconfig.Config) string { + files := signedStartFiles(cfg) + keys := make([]string, 0, len(files)) + for _, f := range files { + keys = append(keys, f.key) + } + last := len(keys) - 1 + return strings.Join(keys[:last], ", ") + " and " + keys[last] +} + +// readGrants counts the roster's grants of level read, which no receiver +// reads by: a read grant is reserved (ADR-0071). +func readGrants(doc trust.Document) int { + n := 0 + for _, k := range doc.Keys() { + for _, g := range k.Grants() { + if g.Level == trust.LevelRead { + n++ + } + } + } + return n } // roster reads the roster file and verifies it under root, of the configured diff --git a/cmd/guardana-gateway/doctor_authority_unix_test.go b/cmd/guardana-gateway/doctor_authority_unix_test.go new file mode 100644 index 00000000..7ce6d91b --- /dev/null +++ b/cmd/guardana-gateway/doctor_authority_unix_test.go @@ -0,0 +1,103 @@ +//go:build unix + +package main + +import ( + "crypto/ed25519" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/guardana/control/internal/canon" + "github.com/guardana/control/internal/policy/bundle" + "github.com/guardana/control/internal/policykey" + "github.com/guardana/control/internal/trust" +) + +// trustFound runs doctor's trust check over tr placed apart and returns what +// it found, failing the test unless the verdict is ok. +func trustFound(t *testing.T, tr tree) string { + t.Helper() + placedApart(t) + verdict, key, found := (&examination{cfg: tr.load(t)}).trust(t.Context()) + if verdict != verdictOK || key != doctorTrust { + t.Fatalf("doctor's trust check = %s %s, %q; want ok", verdict, key, found) + } + return found +} + +// TestDoctorNamesEveryFileTheSignedStartChecked: the trust line names the +// roster beside the configuration file and the root key, and the route, the +// stop list and the run records only where the configuration sets them. +func TestDoctorNamesEveryFileTheSignedStartChecked(t *testing.T) { + bare := trustFound(t, signedPlaneTree(t, "roster-a", []ed25519.PublicKey{signedTestPublic("alice")}, "roster-a")) + if want := "; the configuration file, trust.root_public_key and trust.roster are not the plane's to change, and the environment sets no key"; !strings.HasSuffix(bare, want) { + t.Errorf("the trust line over a tree with no pause, route or runs = %q; want it to end %q", bare, want) + } + for _, unset := range []string{"pause.file", "reaction.route", "reaction.stops", "runs.dir"} { + if strings.Contains(bare, unset) { + t.Errorf("the trust line names %s, which the configuration leaves unset: %q", unset, bare) + } + } + full := trustFound(t, signedStopsTree(t)) + if want := "; the configuration file, trust.root_public_key, trust.roster, reaction.route, reaction.stops and runs.dir are not the plane's to change"; !strings.Contains(full, want) { + t.Errorf("the trust line over a tree with a route, a stop list and runs = %q; want %q", full, want) + } +} + +// TestDoctorSaysAReadGrantGrantsNothingYet: a roster holding read grants, +// one beside an operate grant of the same key, gets one clause counting +// them; a roster of operate and admin grants alone gets none. +func TestDoctorSaysAReadGrantGrantsNothingYet(t *testing.T) { + const clause = "grant nothing yet: reading is by the health socket's peer uid and by file modes (ADR-0071)" + tr := signedPlaneTree(t, "roster-a", nil, "roster-a") + roster := filepath.Join(tr.dir, "trust", "roster.json") + + writeLeveledRoster(t, roster) + if found := trustFound(t, tr); strings.Contains(found, clause) || strings.Contains(found, "read grant") { + t.Errorf("a roster of admin and operate grants: %q names a read grant", found) + } + + writeGrantsRoster(t, roster, map[string][]string{"alice": {"operate", "read"}, "bob": {"read"}, "dave": {"admin"}}) + found := trustFound(t, tr) + if want := "; 2 read grant(s) " + clause; !strings.HasSuffix(found, want) { + t.Errorf("a roster with two read grants: %q; want it to end %q", found, want) + } +} + +// writeGrantsRoster writes a roster of roster-a at serial 1 in which each +// named key holds one grant per level, each over its own project of acme. +func writeGrantsRoster(t *testing.T, path string, levels map[string][]string) { + t.Helper() + var keys []any + for name, of := range levels { + k := signedTestPublic(name) + grants := make([]any, 0, len(of)) + for i, level := range of { + grants = append(grants, map[string]any{"tenant": "acme", "project": "p" + string(rune('a'+i)), "level": level}) + } + keys = append(keys, map[string]any{ + "keyId": policykey.KeyID(k), "alg": "ed25519", "role": "operator", "subject": name, + "publicKey": policykey.FormatPublic(k), "grants": grants, + }) + } + now := time.Now().UTC().Truncate(time.Second) + body, err := canon.Canonicalize(map[string]any{ + "kind": trust.DocumentKind, "rosterId": "roster-a", "serial": 1, + "issuedAt": now.Add(-time.Hour).Format(time.RFC3339), "expiresAt": now.Add(time.Hour).Format(time.RFC3339), "keys": keys, + }) + if err != nil { + t.Fatal(err) + } + sig, err := bundle.SignBytesAs(trust.PayloadType, body, signedTestKey("root")) + if err != nil { + t.Fatal(err) + } + raw, err := trust.MarshalFile(trust.Envelope{PayloadType: trust.PayloadType, Payload: body, + Signatures: []trust.Signature{{KeyID: policykey.KeyID(signedTestPublic("root")), Signature: sig}}}) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, path, raw) +} diff --git a/docs/adr/0071-operators-are-named-by-a-signed-roster.md b/docs/adr/0071-operators-are-named-by-a-signed-roster.md index 088fd2d0..8ec0e008 100644 --- a/docs/adr/0071-operators-are-named-by-a-signed-roster.md +++ b/docs/adr/0071-operators-are-named-by-a-signed-roster.md @@ -21,6 +21,10 @@ route file and the stops directory. A signed start refuses those four, the configuration file and the trust root key file wherever the plane's account owns or may write one or any directory on its path, and any key the environment set. Amended in 0.17 by ADR-0073: the roster file too. +Amended in 0.17: the `read` level parses and is reserved. It grants nothing +until a receiver reads by it; a plane is read through the health socket by +peer uid and through files by their modes, and `doctor` counts the roster's +read grants as granting nothing yet. ## Context diff --git a/docs/backlog.md b/docs/backlog.md index eb4267c8..c425525e 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -66,7 +66,7 @@ mappings. | ID | Work | Depends on | Acceptance | | --- | --- | --- | --- | -| B13 | Authenticate operators and scope authority | none | Reader/operator/admin capabilities are receiver-enforced. Cross-project reads and operations are refused; a stop names its actual agent or principal scope. Two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials. The collector takes evidence only on its unix socket, and the TCP export is removed | +| B13 | Authenticate operators and scope authority | none | Under `approvals.authority: signed`, operate and admin are enforced by each receiver from the signed roster ([ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md), [ADR-0073](adr/0073-an-approval-counts-only-as-the-plane-judges-it.md)); reading is by peer uid against `health.socket_uid` on the health socket and by file modes, and the roster's `read` grant is reserved. Open: reads and operations of another project are refused at every receiver, not only at the signed ones [contracts.md](contracts.md#signed-files-and-their-project-checks) lists; a stop names its actual agent or principal scope; two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials | | B14 | Add retention and recovery | none | Rotation, disk budget, cursor expiry, duplicates, interrupted export and backup/restore are tested for evidence and observations. A restored process cannot reuse spent approval authority. Read-only inspection cannot mutate recovery state without saying so. The findings and observation logs take the RecordLog port, which then moves to its consumer with a read interface, a kind selector, its bounds and its diagnostics ([ADR-0072](adr/0072-the-collector-keeps-bounded-segments-of-one-project-behind-a-credential.md)) | | B26 | Review findings and export an incident package | B13, B14 | A separate review log records scoped author, time, reason and revision for open/in-review/closed and disposition changes without changing the finding's verdict. Grouped occurrences retain source finding ids, rule versions, tenant/project and run references. Reopening, cross-project access, concurrent edits, export gaps and hostile text have cases. A metadata-only package joins the findings, decisions, approvals, policy/procedure digests, coverage and actual reaction results, marks missing inputs, and is read by an external consumer. Closing a case neither lifts a stop nor grants an approval | | B28 | Run continuous supervision and authorized reaction | B12, B13, B14, B21, B35 | An operator-configured service follows versioned evidence and observation exports with durable cursors, checks procedures and delivers findings through bounded queues. Source silence, export gaps, sampling, lag and unreadable inputs remain visible and never look like completed work. Restart and replay do not duplicate an authority-bearing reaction. Every stop uses the existing confirmed-finding bar and a signed, scoped, expiring route; unknown or observation-only findings cannot stop a run. Tests exercise concurrent runs, source outage, partial exports, expired authority, replay and notifier failure. A notifier failure cannot grant authority or change a verdict; no reaction cancels an effect already running | diff --git a/docs/contracts.md b/docs/contracts.md index 59cee739..78432aa0 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -2,7 +2,7 @@ title: Wire contracts summary: What the v1 messages are versioned by, what a receiver refuses them for, and how the evidence events chain. type: spec -covers: [api/proto/guardana/control/v1/**, api/gen/go/guardana/control/v1/**, api/proto/guardana/control/enforce/**, api/gen/go/guardana/control/enforce/**, pkg/contract/**, internal/canon/**, internal/evidence/chain.go, internal/evidence/version.go, internal/trailfile/export.go, internal/trailfile/export_records.go, internal/trailfile/cursor.go, internal/lineexport/**, internal/runs/record.go, internal/runs/state.go, internal/reaction/route.go, internal/reaction/line.go, internal/reaction/lift.go, internal/reaction/envelope.go, internal/reaction/judge.go, internal/reaction/signed.go, internal/reaction/route2.go, internal/reaction/header2.go, internal/reaction/lift2.go, testdata/contracts/**, testdata/digest/**, testdata/enforce/**, testdata/export/**] +covers: [api/proto/guardana/control/v1/**, api/gen/go/guardana/control/v1/**, api/proto/guardana/control/enforce/**, api/gen/go/guardana/control/enforce/**, pkg/contract/**, internal/canon/**, internal/evidence/chain.go, internal/evidence/version.go, internal/trailfile/export.go, internal/trailfile/export_records.go, internal/trailfile/cursor.go, internal/lineexport/**, internal/runs/record.go, internal/runs/state.go, internal/reaction/*.go, testdata/contracts/**, testdata/digest/**, testdata/enforce/**, testdata/export/**, internal/trust/file.go, internal/operators/authorize.go, internal/gateway/authority.go, internal/pause/signedpause/**, internal/runs/signedruns/**, internal/policy/holder.go, internal/gatewayconfig/reaction.go, cmd/guardana-gateway/runs.go, cmd/guardana-gateway/reaction_signed.go, cmd/guardana-gateway/build_pipeline.go] stability: stable --- @@ -1474,6 +1474,35 @@ class before an external decision point is asked, in the order [ADR-0046](adr/0046-a-finding-stops-one-run-through-a-signed-route.md) gives. A decision names no stop's entry id. +## Signed files and their project checks + +The table lists each file or answer a plane accepts on a signature, the check +that holds it to a tenant of the plane's, named per row, and to its +`project_id`, and the test that refuses another project's. The listener's +tenant is `listener.principal.tenant_id` where it is set, and `tenant_id` +otherwise. A row with no project check says so. The floors are unsigned +state the plane writes itself, so none is a row. Outside +`approvals.authority: signed`, answers, pauses and runs are claims of whoever +may write where they are read ([status.md](status.md)). A test in +`internal/docscheck` fails when a function or test cited here is gone. + +| Receiver | What it takes | Tenant and project check | Where | Pinned by | +| --- | --- | --- | --- | --- | +| Trust roster | `trust.roster`, signed by the root key | none: one roster serves every project and each grant names one; its id has to be `trust.roster_id` | `internal/trust/file.go` `fileRoster.candidate` | `internal/trust` `TestARestartNeverTakesARosterOfAnotherID` | +| Approval answer | an operator's answer in `approvals.inbox_dir` | the authorizer is asked about the held call's tenant, `tenant_id`, and its project, and a grant matches both exactly | `internal/gateway/authority.go` `Pipeline.judgeEnvelope` `internal/operators/authorize.go` `rosterAuthorizer.Authorize` | `internal/gateway` `TestBobsAnswerResumesNothingAndAlicesOnce` `internal/operators` `TestTwoOperatorsEachActOnlyWhereTheirGrantsReach` | +| Pause file | `pause.file` of version `3` | its tenant is `tenant_id` and its project the plane's | `internal/pause/signedpause/judge.go` `Authority.Judge` | `internal/pause/signedpause` `TestNoOneButAPermittedOperatorPausesOrLifts` | +| Run record | a `3.0` record in `records/` of `runs.dir` | its tenant is the listener's and its project the plane's | `internal/runs/signedruns/judge.go` `Authority.Judge` | `internal/runs/signedruns` `TestBothJudgesTakeOnlyAnOperatorOfThePlanesProject` | +| Route | `reaction.route` of `v1alpha2` | its tenant is the listener's, and its project and file the plane's | `internal/gatewayconfig/reaction.go` `Config.CheckRoute` `internal/reaction/signed.go` `vouchedWithin` | `internal/gatewayconfig` `TestARouteIsOfTheListenersTenant` `TestARouteIsOfTheAuthoritysFormatAndThePlanesProject` `internal/reaction` `TestASignedRouteIsRefusedWithItsCause` | +| Stop list header | the `2.0` header of `reaction.stops` | its tenant and project are the route's | `internal/reaction/header2.go` `judgeSignedHeader` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` | +| Stop and covered lines | each line of that list | a stop's project, and a covered line's tenant and project, are the route's | `internal/reaction/permits.go` `Route.Permits` `internal/reaction/judge.go` `judging.coveredLine` | `internal/reaction` `TestASignedRoutePermitsOnlyAStopOfItsProjectAndScope` `TestASignedListIsRefusedAndNeverReadAsNoStops` | +| Lift | a `v1alpha2` lift line | its tenant and project are the route's | `internal/reaction/lift2.go` `verifySignedLiftLine` | `internal/reaction` `TestASignedListIsRefusedAndNeverReadAsNoStops` | +| Policy bundle and statement | `policy.bundle_file` and `policy.statement_file`, signed by the pinned policy and freshness keys | none: a bundle names no tenant or project; its id has to be `policy.bundle_id` | `internal/policy/holder.go` `Holder.loadPinned` | `internal/policy` `TestInstallUnconfirmedHoldsToTheFloor` | +| Route under a route key | outside `signed`, a `v1alpha1` route signed by `reaction.public_key`, and its lifts | tenant only: the route's tenant is the listener's, and a stop's the route's; such a route names no project | `internal/gatewayconfig/reaction.go` `Config.CheckRoute` `internal/reaction/permits.go` `Route.Permits` | `internal/gatewayconfig` `TestARouteIsOfTheListenersTenant` `internal/reaction` `TestJudgeHoldsEveryStopToTheRoute` | + +Where `listener.principal.tenant_id` differs from `tenant_id`, an operator +needs a grant over each: answers and pauses are judged under `tenant_id`, runs +and stops under the listener's tenant. + ## The enforcement API How a tool outside MCP asks before it acts and reports what it did: the diff --git a/docs/status.md b/docs/status.md index d449d440..bae4534b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -70,7 +70,7 @@ Nothing here is a security boundary. Do not deploy it as one. | Canonical action digest | `implemented` | `internal/canon/`, goldens in `testdata/digest/`, [ADR-0005](adr/0005-canonical-action-digest.md) and [ADR-0010](adr/0010-digest-domain-separation.md). Cross-language parity is unproved until a second implementation exists | | Policy documents, signed bundles and the matcher | `implemented` | `internal/policy/rules/` parses `agent-policy/v1alpha1` strictly, `internal/policy/bundle/` signs and verifies bundle bytes (Ed25519 over the DSSE PAE); `internal/policy/match/` evaluates three-valued; `internal/policy/` loads a bundle into a snapshot and installs it in order; [ADR-0003](adr/0003-policy-model-and-external-pdp.md), [ADR-0012](adr/0012-policy-kernel-semantics.md). A holder pinned to the bundle id it serves refuses every other. On a plane a snapshot is confirmed only by a freshness statement bound to its bundle and signed under a key apart from the bundle's, and its serial floor is ordered by serial and then `issuedAt` (`internal/policy/statement.go`, `floor.go`, `experimental`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)); an unconfirmed snapshot decides `POLICY_STALE` | | Policy serial floor on disk | `experimental` | `internal/policystate/`. One strict-JSON file per bundle id in an owner-only directory whose marker names a plane's or a signer's kind; raised under the directory's lock against the file's value; `Init` and `Reset` are the operator's, and the gateway binary holds neither ([ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md)). The commands use it (`policy renew`, `policy state init` and `reset`), and so does every plane, through `policy.state_dir`. A floor protects only while its directory outlives the plane, and an account that can write the directory can lower it | -| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime | +| Trust roster, authentication and authorization ports | `experimental` | `internal/trust/` (the `Roster` port and its `file` kind: one DSSE envelope of `trust-roster/v1alpha1` signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the `authority` floor of `internal/policystate/`), `internal/operators/` (the `Authenticator` and `Authorizer` ports and their `roster` kinds, a veto that only denies, a timeout and a bound on calls in flight), `operators roster sign` and `operators floor init` in `cmd/guardana-control/`, the conformance cases in `testdata/ports/` listed in `scripts/ports.txt`, which a test holds to the tree, [ADR-0066](adr/0066-each-decision-about-authority-is-a-port.md), [ADR-0071](adr/0071-operators-are-named-by-a-signed-roster.md). A plane reads a roster only under `approvals.authority: signed`, where `doctor` and `/healthz` report `trust`, `operators.authenticator` and `operators.authorizer`; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime; a `read` grant parses and grants nothing yet, which `doctor` says; [contracts.md](contracts.md#signed-files-and-their-project-checks) lists each signed file a plane takes with its project check | | Policy refresh on a plane | `experimental` | `internal/policywatch/`, wired by `cmd/guardana-gateway/planepolicy.go`, [ADR-0038](adr/0038-a-signed-freshness-statement-and-a-serial-floor.md). At start judges the floor, the bundle and the statement: a bundle below or beside the floor, or above it without its statement, refuses the start, and a missing or expired statement starts the plane unconfirmed. Then reads both files every `policy.poll_interval`, installs a newer bundle only with its statement, refuses and counts every bad replacement without moving the confirmation, and withdraws the confirmation while the wall clock, measured against the monotonic clock, has fallen back more than a second plus 100 parts per million of the time since its highest reading, or reads too far from the first reading to judge, and until a statement issued after that arrives. A clock reading outside 1970 to 9999, or behind the verified time, the latest `issuedAt` the plane verified, decides nothing: every call is `POLICY_STALE`, reads included. `/healthz`, `/metrics` and `doctor` report it. Freshness trusts the plane's clock at start, and the operator schedules `policy renew`; nothing in the repository schedules it outside `dev` | | External policy decision point (PDP) over AuthZEN, able to veto | `experimental` | [ADR-0017](adr/0017-an-external-decision-point-can-veto.md). The kernel takes the answer and the policy format reads it (`internal/core/`, `internal/policy/`); `adapters/authzen/` asks and reads the answer strictly; `internal/gateway/` asks only when the answer can change the decision; `cmd/guardana-gateway` configures it from the `pdp.` keys, checks its published metadata in `doctor` and counts the asks on `/healthz`. The end-to-end tests reach a decision point on the loopback over plaintext; TLS is exercised in the adapter's own tests, against the roots a test hands it, and the command has no setting for a private certificate authority | | Approval binding to one action digest and one bundle digest | `implemented` | `internal/core/approval/` (`Bind`), golden in `testdata/digest/binding.json`. `internal/gateway/` holds a request for approval, keeps the record of what it minted itself, and spends the approval exactly once on the retry, in its own ledger ([ADR-0013](adr/0013-mcp-interception-approvals-and-modes.md)). An approver outside the plane answers through the approval authority below; a hold does not survive a restart, and a plane that keeps a hold journal closes the trail of one it lost at its next start, and counts one it cannot close as left open ([ADR-0016](adr/0016-approval-providers-and-the-lost-hold.md)) | diff --git a/internal/docscheck/receivers_test.go b/internal/docscheck/receivers_test.go new file mode 100644 index 00000000..f62c757e --- /dev/null +++ b/internal/docscheck/receivers_test.go @@ -0,0 +1,264 @@ +package docscheck + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path" + "regexp" + "slices" + "strings" + "testing" + "testing/fstest" +) + +const ( + receiversPage = "docs/contracts.md" + receiversHeading = "## Signed files and their project checks" +) + +// receiversColumns is the table's header; the last two columns hold only +// code spans: a Go file followed by functions declared in it, and a package +// directory followed by tests of that directory. +var receiversColumns = []string{"Receiver", "What it takes", "Tenant and project check", "Where", "Pinned by"} + +var citedSpan = regexp.MustCompile("`([^`]+)`") + +// receiverProblems holds the receiver table of receiversPage in fsys to the +// tree: present, of at least one row, and every function and test a row +// cites declared where it says. +func receiverProblems(fsys fs.FS) []string { + rows, problem := receiverRows(fsys) + if problem != "" { + return []string{problem} + } + var problems []string + for _, row := range rows { + name := row[0] + if strings.TrimSpace(row[2]) == "" { + problems = append(problems, fmt.Sprintf("%s: row %q says nothing of its tenant and project check", receiversPage, name)) + } + for _, p := range citedFunctions(fsys, row[3]) { + problems = append(problems, fmt.Sprintf("%s: row %q: %s", receiversPage, name, p)) + } + for _, p := range citedTests(fsys, row[4]) { + problems = append(problems, fmt.Sprintf("%s: row %q: %s", receiversPage, name, p)) + } + } + return problems +} + +// receiverRows returns the cells of each row of the first table under +// receiversHeading, or why there is no such table. +func receiverRows(fsys fs.FS) ([][]string, string) { + data, err := fs.ReadFile(fsys, receiversPage) + if err != nil { + return nil, err.Error() + } + _, section, found := strings.Cut(string(data), "\n"+receiversHeading+"\n") + if !found { + return nil, fmt.Sprintf("%s holds no section %q", receiversPage, receiversHeading) + } + if next := strings.Index(section, "\n## "); next >= 0 { + section = section[:next] + } + var table [][]string + for _, line := range strings.Split(section, "\n") { + if strings.HasPrefix(line, "|") { + table = append(table, tableCells(line)) + } else if len(table) > 0 { + break + } + } + switch { + case len(table) == 0: + return nil, fmt.Sprintf("%s: the section %q holds no table", receiversPage, receiversHeading) + case !slices.Equal(table[0], receiversColumns): + return nil, fmt.Sprintf("%s: the receiver table's header is %q, want %q", receiversPage, table[0], receiversColumns) + case len(table) < 3: + return nil, fmt.Sprintf("%s: the receiver table holds no row", receiversPage) + } + for _, row := range table[2:] { + if len(row) != len(receiversColumns) { + return nil, fmt.Sprintf("%s: a receiver row has %d cells, want %d: %q", receiversPage, len(row), len(receiversColumns), row) + } + } + return table[2:], "" +} + +// citedFunctions holds each function span of cell to the Go file span +// before it: a non-test file of fsys declaring that function, or that method +// as "Type.Method". +func citedFunctions(fsys fs.FS, cell string) []string { + var problems []string + file, functions := "", 0 + var decls map[string]bool + for _, m := range citedSpan.FindAllStringSubmatch(cell, -1) { + span := m[1] + if strings.HasSuffix(span, ".go") { + file = span + var err error + if decls, err = declaredFunctions(fsys, file); err != nil { + problems = append(problems, err.Error()) + } + continue + } + functions++ + switch { + case file == "": + problems = append(problems, fmt.Sprintf("function %s is cited before any file", span)) + case decls != nil && !decls[span]: + problems = append(problems, fmt.Sprintf("%s declares no function %s", file, span)) + } + } + if functions == 0 { + problems = append(problems, "cites no function that makes the check") + } + return problems +} + +// declaredFunctions names every function of the Go file rel, a method as +// "Type.Method". +func declaredFunctions(fsys fs.FS, rel string) (map[string]bool, error) { + if strings.HasSuffix(rel, "_test.go") { + return nil, fmt.Errorf("%s is a test file, not the code that makes the check", rel) + } + src, err := fs.ReadFile(fsys, rel) + if err != nil { + return nil, err + } + file, err := parser.ParseFile(token.NewFileSet(), rel, src, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + names := map[string]bool{} + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + name := fn.Name.Name + if fn.Recv != nil && len(fn.Recv.List) == 1 { + name = receiverType(fn.Recv.List[0].Type) + "." + name + } + names[name] = true + } + return names, nil +} + +func receiverType(e ast.Expr) string { + switch t := e.(type) { + case *ast.StarExpr: + return receiverType(t.X) + case *ast.IndexExpr: + return receiverType(t.X) + case *ast.IndexListExpr: + return receiverType(t.X) + case *ast.Ident: + return t.Name + } + return "" +} + +// citedTests holds each test span of cell to the package directory span +// before it: one of that directory's _test.go files declares it. +func citedTests(fsys fs.FS, cell string) []string { + var problems []string + dir, tests := "", 0 + for _, m := range citedSpan.FindAllStringSubmatch(cell, -1) { + span := m[1] + if !strings.HasPrefix(span, "Test") { + dir = span + continue + } + tests++ + if dir == "" { + problems = append(problems, fmt.Sprintf("test %s is cited before any package", span)) + continue + } + if !declaresTest(fsys, dir, span) { + problems = append(problems, fmt.Sprintf("no _test.go file of %s declares %s", dir, span)) + } + } + if tests == 0 { + problems = append(problems, "cites no test that pins the check") + } + return problems +} + +func declaresTest(fsys fs.FS, dir, name string) bool { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") { + continue + } + src, err := fs.ReadFile(fsys, path.Join(dir, e.Name())) + if err == nil && strings.Contains(string(src), "\nfunc "+name+"(") { + return true + } + } + return false +} + +func TestTheReceiverTableCitesCodeAndTestsThatExist(t *testing.T) { + for _, problem := range receiverProblems(repoFS(t)) { + t.Error(problem) + } + rows, problem := receiverRows(repoFS(t)) + if problem != "" || len(rows) < 8 { + t.Errorf("the receiver table holds %d row(s), %s; the roster, the answer, the pause, the run record, the route, the header, the lines and the lift each need one", len(rows), problem) + } +} + +// receiverStage is a tree of one page and the two files its table cites. +func receiverStage(table string) fstest.MapFS { + return fstest.MapFS{ + receiversPage: {Data: []byte("# Wire contracts\n\n" + receiversHeading + "\n\nWhat a plane takes.\n\n" + table + "\nAfter.\n\n## Next\n")}, + "internal/p/judge.go": {Data: []byte("package p\n\ntype A struct{}\n\nfunc (a *A) Judge() {}\n\nfunc vouched() {}\n")}, + "internal/p/judge_test.go": {Data: []byte("package p\n\nimport \"testing\"\n\nfunc TestAnotherProjectIsRefused(t *testing.T) {}\n")}, + } +} + +const receiverHeader = "| Receiver | What it takes | Tenant and project check | Where | Pinned by |\n| --- | --- | --- | --- | --- |\n" + +func TestTheReceiverCheckRefusesWhatTheTreeDoesNotHold(t *testing.T) { + good := receiverHeader + "| Pause | a file | the plane's | `internal/p/judge.go` `A.Judge`, `vouched` | `internal/p` `TestAnotherProjectIsRefused` |\n" + if problems := receiverProblems(receiverStage(good)); len(problems) != 0 { + t.Fatalf("the control, a table whose citations all exist: %q", problems) + } + for _, c := range []struct{ name, table, want string }{ + {"a test the package does not declare", strings.Replace(good, "`TestAnotherProjectIsRefused`", "`TestAnotherProjectIsTaken`", 1), "no _test.go file of internal/p declares TestAnotherProjectIsTaken"}, + {"a test of another package", strings.Replace(good, "`internal/p` `Test", "`internal/q` `Test", 1), "no _test.go file of internal/q declares TestAnotherProjectIsRefused"}, + {"no test", strings.Replace(good, "`internal/p` `TestAnotherProjectIsRefused`", "none", 1), "cites no test that pins the check"}, + {"a method the file does not declare", strings.Replace(good, "`A.Judge`", "`B.Judge`", 1), "internal/p/judge.go declares no function B.Judge"}, + {"a function the file does not declare", strings.Replace(good, "`vouched`", "`vouchedWithin`", 1), "internal/p/judge.go declares no function vouchedWithin"}, + {"a file the tree does not hold", strings.Replace(good, "judge.go", "gone.go", 1), "internal/p/gone.go"}, + {"a test file cited as the check", strings.Replace(good, "judge.go", "judge_test.go", 1), "is a test file"}, + {"no function", strings.Replace(good, "`internal/p/judge.go` `A.Judge`, `vouched`", "none", 1), "cites no function that makes the check"}, + {"no rows", receiverHeader, "the receiver table holds no row"}, + {"another header", strings.Replace(good, "| Where |", "| File |", 1), "the receiver table's header is"}, + {"a row of four cells", receiverHeader + "| Pause | a file | `internal/p/judge.go` `A.Judge` | `internal/p` `TestAnotherProjectIsRefused` |\n", "a receiver row has 4 cells"}, + {"an empty check", strings.Replace(good, "the plane's", "", 1), "says nothing of its tenant and project check"}, + {"no table", "", "holds no table"}, + } { + problems := receiverProblems(receiverStage(c.table)) + if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, c.want) }) { + t.Errorf("%s: problems %q, want one saying %q", c.name, problems, c.want) + } + } + stage := receiverStage(good) + delete(stage, receiversPage) + stage["docs/other.md"] = &fstest.MapFile{Data: []byte(receiversHeading + "\n\n" + good)} + if problems := receiverProblems(stage); len(problems) == 0 { + t.Error("a tree without the page passes") + } + stage[receiversPage] = &fstest.MapFile{Data: []byte("# Wire contracts\n\n## Another section\n\n" + good)} + if problems := receiverProblems(stage); len(problems) != 1 || !strings.Contains(problems[0], "holds no section") { + t.Errorf("a page whose table sits under another heading: %q", problems) + } +} diff --git a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html index 59aa18ab..3dd894a1 100644 --- a/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html +++ b/site/docs/adr/0071-operators-are-named-by-a-signed-roster.html @@ -132,7 +132,11 @@

Context

Today an approver is whoever can write the approvals directory, and the approver id is a claim. ADR-0066 says operators are authenticated against a diff --git a/site/docs/backlog.html b/site/docs/backlog.html index 18ac3344..41749b0d 100644 --- a/site/docs/backlog.html +++ b/site/docs/backlog.html @@ -253,7 +253,7 @@

Milestone 3: a small team

B13 Authenticate operators and scope authority none -Reader/operator/admin capabilities are receiver-enforced. Cross-project reads and operations are refused; a stop names its actual agent or principal scope. Two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials. The collector takes evidence only on its unix socket, and the TCP export is removed +Under approvals.authority: signed, operate and admin are enforced by each receiver from the signed roster (ADR-0071, ADR-0073); reading is by peer uid against health.socket_uid on the health socket and by file modes, and the roster's read grant is reserved. Open: reads and operations of another project are refused at every receiver, not only at the signed ones contracts.md lists; a stop names its actual agent or principal scope; two users and overlapping runs pass adversarial tests. MCP upstream credentials remain separate from inbound credentials B14 diff --git a/site/docs/changelog.html b/site/docs/changelog.html index 32120caf..dbbe3b88 100644 --- a/site/docs/changelog.html +++ b/site/docs/changelog.html @@ -136,6 +136,10 @@

Changed

  • runs open --key reads the roster and the key before it makes an empty directory a signed layout, so a roster that does not read leaves the directory as it was.
  • +
  • doctor's trust line names every file the signed start checked, the +roster among them, and counts the roster's read grants, which grant +nothing yet: a plane is read through health.socket by peer uid and +through files by their modes.
  • dev runs its collector on a unix socket in a directory of its own under the system's temporary directory, and refuses, naming TMPDIR, where that path would not fit a socket address.
  • diff --git a/site/docs/contracts.html b/site/docs/contracts.html index 67e19baa..24749a5a 100644 --- a/site/docs/contracts.html +++ b/site/docs/contracts.html @@ -1874,6 +1874,102 @@

    The codes

    class before an external decision point is asked, in the order ADR-0046 gives. A decision names no stop's entry id.

    +

    Signed files and their project checks

    +

    The table lists each file or answer a plane accepts on a signature, the check +that holds it to a tenant of the plane's, named per row, and to its +project_id, and the test that refuses another project's. The listener's +tenant is listener.principal.tenant_id where it is set, and tenant_id +otherwise. A row with no project check says so. The floors are unsigned +state the plane writes itself, so none is a row. Outside +approvals.authority: signed, answers, pauses and runs are claims of whoever +may write where they are read (status.md). A test in +internal/docscheck fails when a function or test cited here is gone.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ReceiverWhat it takesTenant and project checkWherePinned by
    Trust rostertrust.roster, signed by the root keynone: one roster serves every project and each grant names one; its id has to be trust.roster_idinternal/trust/file.go fileRoster.candidateinternal/trust TestARestartNeverTakesARosterOfAnotherID
    Approval answeran operator's answer in approvals.inbox_dirthe authorizer is asked about the held call's tenant, tenant_id, and its project, and a grant matches both exactlyinternal/gateway/authority.go Pipeline.judgeEnvelope internal/operators/authorize.go rosterAuthorizer.Authorizeinternal/gateway TestBobsAnswerResumesNothingAndAlicesOnce internal/operators TestTwoOperatorsEachActOnlyWhereTheirGrantsReach
    Pause filepause.file of version 3its tenant is tenant_id and its project the plane'sinternal/pause/signedpause/judge.go Authority.Judgeinternal/pause/signedpause TestNoOneButAPermittedOperatorPausesOrLifts
    Run recorda 3.0 record in records/ of runs.dirits tenant is the listener's and its project the plane'sinternal/runs/signedruns/judge.go Authority.Judgeinternal/runs/signedruns TestBothJudgesTakeOnlyAnOperatorOfThePlanesProject
    Routereaction.route of v1alpha2its tenant is the listener's, and its project and file the plane'sinternal/gatewayconfig/reaction.go Config.CheckRoute internal/reaction/signed.go vouchedWithininternal/gatewayconfig TestARouteIsOfTheListenersTenant TestARouteIsOfTheAuthoritysFormatAndThePlanesProject internal/reaction TestASignedRouteIsRefusedWithItsCause
    Stop list headerthe 2.0 header of reaction.stopsits tenant and project are the route'sinternal/reaction/header2.go judgeSignedHeaderinternal/reaction TestASignedListIsRefusedAndNeverReadAsNoStops
    Stop and covered lineseach line of that lista stop's project, and a covered line's tenant and project, are the route'sinternal/reaction/permits.go Route.Permits internal/reaction/judge.go judging.coveredLineinternal/reaction TestASignedRoutePermitsOnlyAStopOfItsProjectAndScope TestASignedListIsRefusedAndNeverReadAsNoStops
    Lifta v1alpha2 lift lineits tenant and project are the route'sinternal/reaction/lift2.go verifySignedLiftLineinternal/reaction TestASignedListIsRefusedAndNeverReadAsNoStops
    Policy bundle and statementpolicy.bundle_file and policy.statement_file, signed by the pinned policy and freshness keysnone: a bundle names no tenant or project; its id has to be policy.bundle_idinternal/policy/holder.go Holder.loadPinnedinternal/policy TestInstallUnconfirmedHoldsToTheFloor
    Route under a route keyoutside signed, a v1alpha1 route signed by reaction.public_key, and its liftstenant only: the route's tenant is the listener's, and a stop's the route's; such a route names no projectinternal/gatewayconfig/reaction.go Config.CheckRoute internal/reaction/permits.go Route.Permitsinternal/gatewayconfig TestARouteIsOfTheListenersTenant internal/reaction TestJudgeHoldsEveryStopToTheRoute
    +

    Where listener.principal.tenant_id differs from tenant_id, an operator +needs a grant over each: answers and pauses are judged under tenant_id, runs +and stops under the listener's tenant.

    The enforcement API

    How a tool outside MCP asks before it acts and reports what it did: the package guardana.control.enforce.v1alpha1, in diff --git a/site/docs/status.html b/site/docs/status.html index bc4e0c6a..c7855a57 100644 --- a/site/docs/status.html +++ b/site/docs/status.html @@ -251,7 +251,7 @@

    Components

    Trust roster, authentication and authorization ports experimental -internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime +internal/trust/ (the Roster port and its file kind: one DSSE envelope of trust-roster/v1alpha1 signed by a pinned root, operator keys with grants of tenant, project and level, an expiry of at most 7 days, a serial that only climbs, held by the authority floor of internal/policystate/), internal/operators/ (the Authenticator and Authorizer ports and their roster kinds, a veto that only denies, a timeout and a bound on calls in flight), operators roster sign and operators floor init in cmd/guardana-control/, the conformance cases in testdata/ports/ listed in scripts/ports.txt, which a test holds to the tree, ADR-0066, ADR-0071. A plane reads a roster only under approvals.authority: signed, where doctor and /healthz report trust, operators.authenticator and operators.authorizer; the plane refuses to start when its account may write the roster file, so that account cannot put an older roster in place; it still raises the roster's floor, so it can withhold a newer roster within the held one's lifetime; a read grant parses and grants nothing yet, which doctor says; contracts.md lists each signed file a plane takes with its project check Policy refresh on a plane