Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 20 additions & 11 deletions cmd/guardana-gateway/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions cmd/guardana-gateway/doctor_authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"

"github.com/guardana/control/internal/approvals/inbox"
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions cmd/guardana-gateway/doctor_authority_unix_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions docs/adr/0071-operators-are-named-by-a-signed-roster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 30 additions & 1 deletion docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading