Skip to content

feat(accounts): narrow authority through the policy-log witness, verified against a pinned key - #1067

Closed
antoinetoussaint-byte wants to merge 6 commits into
mainfrom
issue-1062-policy-log-witness
Closed

antoinetoussaint-byte wants to merge 6 commits into
mainfrom
issue-1062-policy-log-witness

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1062

What we are trying to do

Every narrowing of authority — solution uninstall, solution-target close, scope-grant revocation, team-membership removal — is refused today, because accounts wires no policy log. The module already ships the log: policy-log is the independent signed witness. This PR gives accounts a verified client for it and wires it when composition delivers it.

Stated plainly: this PR alone makes no narrowing succeed in any environment. The client activates only where a composition root binds the witness endpoint AND the platform pins its key in the trust anchor (see "The local-run decision" below). Until both are delivered, every host keeps today's behaviour: it serves, and refuses every narrowing.

What is wired

  • infra.WitnessPolicyLog (pkg/infra/policy_log_witness.go) implements business.PolicyLog over the witness's gRPC API.
    • Append offers an opaque entry: the operation id, a kind naming the subject kind and decision (installation.narrowed), the subject id, and the SHA-256 of the host's canonical encoding of the whole operation (decision, subject, supersedes, envelope revision, actor, and the policy as sorted-key JSON). The witness never sees the policy or the actor; the receipt still binds them.
    • A receipt is accepted only if it is signed by the pinned key, its chain hash is the hash of its own link, it witnesses the bytes offered, its sequence is above everything this client already verified, and it extends the verified chain. Only Unavailable is retried (the witness's "no sequence assigned"), with the identical entry and the same requested_at; every other code is final.
    • Entries refreshes the serving gate's reached_at. It requires a cursor that is signed by the pin, issued within the staleness window of the host's clock (a signature alone cannot tell a replayed cursor from a current one), and at or above the reconciled sequence. It verifies every record's signature, entry hash and chain link, and refuses a page the witness withheld.
    • The stored receipt is the witness's own fields plus the verifying key id. Anyone holding the pin can re-verify the row offline.
  • The pin is policy_log_public_key.json in the existing trust anchor (infra.SolutionHostTrustAnchorPath, a constant, as the witness's README specifies). The witness's Keys answer is compared with the pin, so a mismatch is reported as a mismatch. It is never adopted. A delivered pin that cannot be used refuses the boot.
  • Authentication to the witness: none in the application, by the witness's own contract. Its listener registers the four operations and checks nothing in-band. Its README requires composition to admit only the host's declared client through workload-authenticated mesh policy. A credential minted here would be a second identity scheme nothing verifies. The dial is plaintext to the mesh sidecar, and what makes a receipt believable is the pinned signature.
  • work.go builds the client only when both the endpoint and the pin are delivered, and then starts ReconcilePolicyLogEvery, which nothing started before. Otherwise it calls SetPolicyLog(nil, nil) and warns, naming which half is missing as configuration. The comment and warning that said "no receipt-issuing log exists" are rewritten. pkg/infra/warehouse_policy_log.go, whose whole content was that argument, is deleted.
  • The proto is copied byte-for-byte into accounts/proto/saas/policylog/v1/ (TestSharedProtoCopiesMatchTheirSource holds it equal). Its bindings are the proto companion's output for the service's own template. The companion also adds a WitnessService tag, with no path, to both OpenAPI documents. That is the shape the generator already gives every accounts service without a REST route: IdentityService, ModuleCapabilitiesService and five others.
  • Docs: DATABASE_AUTHORITY.md, DEPLOYMENT_TOPOLOGY.md and the policy-log README now state this truth. The witness's posture and its local configuration file are unchanged.

The local-run decision: option (b), a deployed-only edge (2026-10-18)

The owner asked for the strongest-security option among (a), (b) and (c). That is (b), and today's Codefly can already express it:

  • (a) a local witness profile adds a storage path that is not the witness's. A store with none of the WORM evidence store's guarantees would sign receipts for narrowings a restore can still undo, and a backend-selection setting is one more thing a misconfigured cell could pick. Rejected.
  • (c) a local run in which the witness refuses ends every local run of accounts. That is the owner's required loop (plain codefly run). Rejected.
  • (b) the edge exists only where the witness is provisioned. No second code path, no local key or genesis, and the local host stays fail-closed: it serves, refuses every narrowing, and says so. Chosen.

How: accounts declares no service dependency on policy-log/grpc. It reads the address from a new policy-log-witness workspace group (endpoint). This module ships that group empty, so locally there is no witness. A composition root that provisions the witness binds endpoint=${endpoint:saas/policy-log/grpc} in that environment. The render then resolves it (host:port on a cell) and admits accounts as the endpoint's consumer. The CLI's cell producer derives consumers from configured endpoint references ("declared by the configuration, not by a service dependency"). No Codefly change was needed.

Boot rules:

key pinned endpoint bound result
no no serves with no witness, refuses every narrowing, and warns
no yes serves with no witness and warns; a key read from the witness itself is never trusted
yes no refuses to boot (review F7: the platform-owned pin, not the composition, decides that this environment has a witness)
yes yes the verified client, dialled only under internal-transport/mesh-protected=true to an in-cluster Service address, otherwise refused by name (review F1, handbook security posture rule 1)

A bound value that cannot be read refuses the boot.

Rebased and reviewed (2026-10-18)

Test evidence

  • Fake-witness unit tests (pkg/infra/policy_log_witness_test.go, run in the pure suite):
    • Every refusal is covered: a receipt signed by another key, an altered signature, a chain hash that is not its own link, a receipt for other bytes, a broken genesis link, a sequence regression, and a fork.
    • On the read side: a cursor signed by another key, an altered or stale cursor, a cursor from the future, a head below the reconciled cursor or below what was verified, withheld records, a record off the chain, a record re-signed by another key, an altered record entry, and another published key (refused before any cursor is read).
    • Unavailable is retried with the identical entry, and every other code is not retried.
    • Through business.Service: a verified receipt applies the narrowing and commits it. A bad signature, the wrong key, or a sequence regression refuses it: nothing applies and no receipt is recorded.
  • Mutation check of the verification: 18 mutants of policy_log_witness.go, all killed. Each mutant disabled or bypassed one check: the receipt signature, the receipt chain link, the cursor signature, the entry-hash comparison, the sequence floor, the fork link, cursor freshness, the published-key comparison, the record chain link, the record signature, the record entry hash, withheld records, the head below the cursor, the head below what was verified, the key-id digest, retry-all, no-retry, and dropping the actor from the narrowing hash.
  • The real witness runs in a helper process (TestWitnessHelperProcess in policy-log's server package, over its memory store with a provisioned genesis), not in-process. accounts cannot link the policy-log module, and the protobuf registry refuses the second registration of saas.policylog.v1. This is the same pattern as qualification/module-authority.
    • TestTheClientVerifiesWhatTheRealWitnessSigns (pkg/infra): receipts and records that the real witness signed verify under this host's restated encodings and a pin parsed from the witness's own key document. A host pinning another key believes none of them, and with the witness down nothing appends or reads.
    • TestTheRealWitnessWitnessesUninstallAndScopeRevocation (pkg/business, real PostgreSQL): a real UninstallSolution and a real RevokeScope each succeed and commit the receipt that the witness signed. A second replica reads the log back and finds both operations, with the same receipts and sequences. With the witness stopped, both are refused with ErrPolicyLogUnreachable: the installation stays live, the grant stays, and no receipt and no gap is recorded.
  • Runs:
    • policy-log: go build, go vet, gofmt -l and go test -race ./... all pass.
    • accounts: go build ./... and go vet ./... (with and without -tags=pure) pass, as does go test -tags=pure ./.... ./pkg/business and ./pkg/infra pass against PostgreSQL.
    • module/tools: go test ./... passes, as do all node gates (naming-gate, generated-pins-gate, test-targets-gate, authz-coverage-gate, interface-docs-gate, migration-pairing-gate, migration-reference-gate, rls-migration-gate, and the story-trace-gate tests) and the node --test module/tools/*.test.mjs tests (239 pass).
    • codefly ci run --phase sync-drift and --phase lint,compile pass locally on CLI 0.1.181. CI pins 0.1.179.
  • golangci-lint run ./... in accounts reports nothing in this change. It reports 31 findings in files this PR does not touch, all already on main: 15 errcheck, 12 staticcheck (3 of them SA1019 deprecations, which are design decisions), and 5 unused. They are not fixed here.

Not verified

  • No environment composes the witness, so nothing here ran against a deployed witness, a deployed mesh policy, or real GCS.
  • The serving gate's reconciliation was not driven against the real witness in the PostgreSQL test. policy_log_cursor is a single row that the package shares with the recording log the other tests use, and pointing it at a second log is, correctly, refused as a rollback. The read path is covered at the client level against the real witness.
  • The frontend's generated witness_pb.ts was produced by the companion but not type-checked locally (no node_modules here).

Fix or hack, per change

  • The witness client, the pin read from the trust anchor, and the wiring that is conditional on both being delivered: fix. The narrowing protocol already required a receipt-issuing log, and this is the transport its owner built.
  • The proto copy and the regenerated bindings: fix. This is the repository's mechanism for a cross-module wire contract, and it is gated byte-for-byte.
  • The helper-process witness for tests: fix. It runs the witness's own code over its own test store, and it lives in a _test.go file the served binary never compiles.
  • Not declaring the dependency: neither. It is an open owner decision, stated above, and nothing is stubbed in its place.
  • Rewritten comments and docs: fix. They stated a reason that is no longer true.

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 2 commits October 10, 2026 21:30
…and its generated bindings

accounts is the witness's one client and cannot link the policy-log Go module,
so it carries an identical copy of saas/policylog/v1/witness.proto, held equal
to the source by TestSharedProtoCopiesMatchTheirSource. Everything else is the
proto companion's output for the service's own template (codefly generate proto
--template accounts/proto/buf.gen.yaml, proto:0.0.16) and go generate: the Go,
gRPC and Connect bindings, the frontend's TypeScript, and a WitnessService tag
in the OpenAPI documents with no path under it, the same shape the generator
already gives every accounts service that has no REST route.

Refs #1062

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fied against a pinned key

accounts gets infra.WitnessPolicyLog, the host's PolicyLog over the witness's
private gRPC API. Append offers an opaque entry (operation id, subject kind and
decision, subject id, and the SHA-256 of the host's canonical encoding of the
whole operation) and returns the receipt only once it verifies: signed by the
key pinned in the trust anchor (policy_log_public_key.json), a valid chain
link, for the bytes offered, above every sequence already verified and
extending the verified chain. Only Unavailable is retried, with the identical
entry. Entries, which refreshes the serving gate's reached_at, requires a
signed cursor fresh within the staleness window, at or above the reconciled
sequence, and verifies every record's signature, entry hash and chain link.
The witness's published key is compared with the pin and never adopted.

work.go wires it only when composition delivers both the witness endpoint and
the pin; a delivered pin that cannot be used refuses the boot. Otherwise the
host is configured without a witness and keeps today's behaviour: it serves and
refuses every narrowing, and the startup warning says why as configuration.
The reconciliation loop now runs when a witness is wired.

The service dependency on policy-log/grpc is deliberately NOT declared here:
it would pull the witness into every local run, where the witness refuses to
boot by its own contract. That is an owner decision; see the pull request.

Tests: fake-witness unit tests for each refusal (mutation-checked), and the
real witness (its server package over its memory store, as a helper process
in the policy-log module) verified by the client and driven through a real
uninstall and scope-grant revocation against PostgreSQL, refused with the
witness down.

Refs #1062

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antoinetoussaint-byte and others added 2 commits October 18, 2026 13:12
# Conflicts:
#	module/services/accounts/code/work.go
…onment, dialled only under the mesh, and its cursor judged within seconds (#1062)

The local-run decision #1067 left open is option (b), a deployed-only edge,
and it is expressible with today's Codefly. accounts declares no service
dependency on policy-log/grpc. It reads the witness address from a new
`policy-log-witness` workspace group, which this module ships empty. A
composition root that provisions the witness binds
`endpoint=${endpoint:saas/policy-log/grpc}` in that environment, and the
render both resolves the address and admits accounts as the endpoint's
consumer there. A local run never starts a witness that refuses to boot, and
its host stays fail-closed. (a) would add a storage path that is not the
witness's, and (c) breaks the local loop.

Review of 5f35c93:
- F1 (major): the dial was plaintext to whatever resolved. It is now
  admitted only under internal-transport/mesh-protected=true to an
  in-cluster Service address, and refused by name otherwise, through
  pkg/meshtransport as the Vault binding does.
- F3: cursor freshness drops from the 60 s staleness window to 10 s of
  clock skew. A cursor is asked for in the same pass that judges it.
- F5: the pin is read through the trust anchor's one resolver,
  solutionHostTrustAnchorDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thout a bound witness (#1062)

Review of df82d9b (F7, major): the endpoint is now a value the composition
root sets per environment. Before this fix, a host with a pinned key and an
unbound endpoint only warned, wired no log, and served with the serving gate
off. A restored database could then undo a witnessed narrowing. The pin comes
from the platform-owned trust anchor, so the pin decides that this
environment has a witness: pinned but unbound now refuses the boot, by name.

F10: the witness's manifest comment no longer says the host declares a
dependency on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Superseded by #1257 (3c53033): the policy-log witness is deleted; RBAC narrowings apply directly through the shared authority core, with no witness asked first.

auto-merge was automatically disabled October 27, 2026 13:39

Pull request was closed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

accounts narrows authority through the policy-log witness

1 participant