diff --git a/configurations/local/policy-log-witness.env b/configurations/local/policy-log-witness.env new file mode 120000 index 000000000..663214499 --- /dev/null +++ b/configurations/local/policy-log-witness.env @@ -0,0 +1 @@ +../../module/configurations/local/policy-log-witness.env \ No newline at end of file diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index c1aa31212..c170991bf 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -1105,18 +1105,39 @@ and deliberately holds no read or job-creation permission to recover its row. Those limitations are why the receipt issuer is a separate service rather than an accounts adapter that manufactures its own evidence. -**This checkout does not yet have a runnable log transport.** The new service's -witness, storage, signing-key loader, mirror, and private service declaration are -implemented; protobuf and OpenAPI generation and the RPC listener remain blocked -by the required generator prerequisite. Its README records that boundary and -the remaining real-GCS qualification. The host's Go code and client wiring are -unchanged, so this source addition must not be read as a running deployment or -as completion of the policy-log condition. - -Until the transport and host client are wired, the existing host still serves -normally and refuses the four narrowings above. Once wired, inability to reach -the log expires the serving gate's freshness window and refuses service; an -appended operation without a local commit remains a gap until reconciled. +**The host's client is built; no environment composes it yet.** +`infra.WitnessPolicyLog` implements the host's `PolicyLog` over the witness's +private gRPC API. It verifies every receipt, record and cursor against the +witness key pinned in the trust anchor (`policy_log_public_key.json`), compares +the key the witness publishes with that pin without ever adopting it, refuses a +receipt for bytes other than the ones offered, a sequence at or below one it +has already verified, a broken chain link, and a cursor whose signed +`issued_at` is outside the staleness window of the host's clock. The receipt it +stores is the witness's own fields plus the verifying key id, so the row is +evidence anyone holding the pin can re-verify offline. The witness holds 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 — never the +policy or the actor. + +`work.go` wires it only when composition has delivered BOTH the witness endpoint +and the pinned key; a delivered key that cannot be used refuses the boot. With +either absent the host is configured without a witness: it serves normally and +refuses the four narrowings above, and says so at startup. accounts declares no +service dependency on `policy-log/grpc`: the endpoint is read from the +`policy-log-witness` workspace group, which this module ships EMPTY and the +composition root binds (`endpoint=${endpoint:saas/policy-log/grpc}`) only in an +environment that provisions the witness. The edge, and the mesh admission the +render derives from it, therefore exist only there; a local run never starts a +witness it could not boot, and its host stays fail-closed. The dial is +plaintext only under `internal-transport/mesh-protected=true` to an in-cluster +Service address, and is refused by name otherwise. + +Once composed, inability to reach the witness expires the serving gate's +freshness window and refuses service; an appended operation without a local +commit remains a gap until reconciled. The real witness is exercised end to end +by `TestTheRealWitnessWitnessesUninstallAndScopeRevocation`: an uninstall and a +scope-grant revocation each commit the receipt the witness signed, and with the +witness down both are refused and nothing narrows. ## Control-plane boundary diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 3ec5dab26..1326a2474 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -32,11 +32,15 @@ The accounts descriptor catalog is an input: if its RPCs use gRPC, Connect, or REST without a corresponding accounts endpoint, generation fails. `policy-log` is in the inventory with one private `grpc` endpoint and appears in -NO row of the service graph below, which is correct rather than an omission: it -declares no dependency on anything, and the host's client for it is not built -yet, so there is no edge to draw. When that client lands it adds an -`accounts` → `policy-log/grpc` row and nothing else — the witness must never -depend on the database whose authority it witnesses. +NO row of the service graph below. It declares no dependency on anything, and +accounts — whose witness client is built (`infra.WitnessPolicyLog`) — reaches it +through the `policy-log-witness` workspace group rather than a declared edge. +The module ships that group empty; the composition root binds +`endpoint=${endpoint:saas/policy-log/grpc}` only in an environment that +provisions the witness, and the render then admits `accounts` → `policy-log/grpc` +there and nowhere else — so a local run never pulls in a witness that refuses +to boot by its own contract. The witness must never depend on the database +whose authority it witnesses. ## Service graph diff --git a/module/configurations/local/policy-log-witness.env b/module/configurations/local/policy-log-witness.env new file mode 100644 index 000000000..6a19dc46e --- /dev/null +++ b/module/configurations/local/policy-log-witness.env @@ -0,0 +1,20 @@ +# Workspace group `policy-log-witness`: where this host reaches the policy-log +# witness, as the composition root binds it. +# +# The edge to the witness exists only where the composition root binds it: an +# environment that provisions the witness (its evidence store, its signing key +# and the host's pinned public key) sets +# +# endpoint=${endpoint:saas/policy-log/grpc} +# +# in its own copy of this group, and the render then both resolves the address +# and admits this host as the witness endpoint's one consumer. This module does +# not declare a service dependency on the witness, so a local run never starts +# one: the witness refuses to boot without its bucket and its key, and a run +# whose dependency refuses to boot ends. +# +# Empty here, and that is the local profile: no witness. The host then serves +# and refuses every narrowing of authority, and says so at boot. Nothing locally +# stands in for the witness, because a narrowing witnessed by a store with none +# of the witness's guarantees would be a narrowing a restore can still undo. +endpoint= diff --git a/module/services/accounts/code/pkg/business/policy_log.go b/module/services/accounts/code/pkg/business/policy_log.go index 574a41dce..d155f9d81 100644 --- a/module/services/accounts/code/pkg/business/policy_log.go +++ b/module/services/accounts/code/pkg/business/policy_log.go @@ -192,9 +192,11 @@ type PolicyLog interface { // Entries returns every entry after a sequence, in order. // - // Used by reconciliation, never on a request path. The reader deliberately - // holds no job-creation permission on the log's warehouse, so this is a - // bounded row read with a restriction rather than a query. + // Used by reconciliation, never on a request path. A bounded page read + // from a sequence, never a query: the witness serves at most 500 records + // per read. Success is also what proves the log was REACHED, so an + // implementation must fail rather than return a page it could not verify + // is current. Entries(ctx context.Context, afterSequence uint64, limit int) ([]*PolicyLogRecord, error) } @@ -248,10 +250,10 @@ type PolicyLogGap struct { // log still holds its request, its connection and whatever the caller above it // holds. A bounded wait converts that into a refusal the caller can act on. // -// Five seconds: long enough that an ordinary warehouse append succeeds, short -// enough that an authority change fails visibly rather than hanging. A narrowing -// that cannot be logged must not proceed, so the refusal is the correct outcome -// and not a degraded one. +// Five seconds: the witness's own append budget, long enough that an ordinary +// append succeeds, short enough that an authority change fails visibly rather +// than hanging. A narrowing that cannot be logged must not proceed, so the +// refusal is the correct outcome and not a degraded one. const PolicyLogDeadline = 5 * time.Second // PolicyLogStaleAfter is how long a host may serve without having reached the @@ -465,9 +467,9 @@ func (s *Service) ReconcilePolicyLog(ctx context.Context) (*PolicyLogServingStat return state, err } -// policyLogReadBatch bounds one reconciliation read. Bounded because the reader -// holds no job-creation permission on the log's warehouse — this is a row read -// with a restriction, and an unbounded one would be a scan. +// policyLogReadBatch bounds one reconciliation read: the witness's own page +// limit, so a pass reads at most one page and the next pass continues from the +// cursor it advanced. const policyLogReadBatch = 500 // policyNow is the host's clock, overridable in tests. Separate from the log's diff --git a/module/services/accounts/code/pkg/business/policy_log_production_path_test.go b/module/services/accounts/code/pkg/business/policy_log_production_path_test.go index b4bfff0ff..4aab50953 100644 --- a/module/services/accounts/code/pkg/business/policy_log_production_path_test.go +++ b/module/services/accounts/code/pkg/business/policy_log_production_path_test.go @@ -28,9 +28,11 @@ import ( // `Service.UninstallSolution`, `WithPolicyLoggedNarrowing`, the receipts in // `policy_log_commits` through `infra.PostgresStore`, and `RequireServing`. // -// The external log is the one collaborator that is a double, and it has to be: -// it is external by design, it lives in a warehouse, and no deployment of it -// exists (pkg/infra/warehouse_policy_log.go says why). A double for a +// The external log is the one collaborator that is a double here, because these +// tests make it misbehave in ways the real witness never would — refusing on +// demand, failing a read — and assert what the host does about it. The real +// witness is exercised separately, end to end, in +// policy_log_witness_production_path_test.go. A double for a // collaborator is not a double for the thing under test — the mistake this PR // has already paid for twice is faking the PolicyLogStore, which IS the thing // under test, and that one is real postgres here. @@ -39,7 +41,7 @@ import ( // errors from real production statements given input they cannot accept, which // is why the gap they leave is a gap the real store wrote. -// recordingPolicyLog stands in for the external warehouse log: it issues real +// recordingPolicyLog stands in for the external witness: it issues real // monotonic sequences and remembers every entry, so a test can assert what the // host offered it and in what order. type recordingPolicyLog struct { diff --git a/module/services/accounts/code/pkg/business/policy_log_witness_production_path_test.go b/module/services/accounts/code/pkg/business/policy_log_witness_production_path_test.go new file mode 100644 index 000000000..28268270f --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_witness_production_path_test.go @@ -0,0 +1,195 @@ +//go:build !pure + +package business_test + +import ( + "context" + "testing" + "time" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" + "accounts/pkg/infra" + "accounts/pkg/infra/policylogwitnesstest" + "accounts/pkg/infra/storetx" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// The policy log on the production path with the REAL witness: real narrowings, +// through the real service, against real PostgreSQL, witnessed by the module's +// own policy-log witness (its server package and witness log over its in-memory +// object store, in a helper process — policylogwitnesstest says why it cannot +// be linked here). +// +// Unlike policy_log_production_path_test.go, nothing on either side is a +// double: the receipt each narrowing commits was sequenced, chained and signed +// by the witness's code, and verified by this host's client under a key pinned +// from the witness's own key document before the narrowing applied. +// +// The serving gate's reconciliation is not driven here. `policy_log_cursor` is +// one row this package shares with the recording log every other test uses, and +// pointing a reconciliation at a second log with its own sequence would — rightly +// — be refused as a rollback. The gate's staleness behaviour is proven against +// that log in the file next door. +func TestTheRealWitnessWitnessesUninstallAndScopeRevocation(t *testing.T) { + clearData(t) + ctx := testCtx + closeEveryGap(t, ctx) + t.Cleanup(func() { closeEveryGap(t, ctx) }) + + witness := policylogwitnesstest.Start(t) + pin, err := infra.ParsePolicyLogWitnessKey(witness.KeyDocument) + require.NoError(t, err) + conn, err := grpc.NewClient(witness.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + witnessLog, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), pin) + require.NoError(t, err) + + testService.SetPolicyLog(witnessLog, testStore) + t.Cleanup(func() { wirePolicyLogForTests(testService, testStore) }) + + adminID, orgID := mustUserAndOrg(t, ctx, "plwitness@example.com", "plwitness", "Witness Co") + memberID, _ := mustUserAndOrg(t, ctx, "plwitnessmember@example.com", "plwitnessmember", "Witness Member Co") + require.NoError(t, testService.AddOrgMember(ctx, adminID, &gen.AddOrgMemberRequest{ + OrgId: orgID, UserId: memberID, Role: gen.OrgRole_ORG_ROLE_MEMBER, + })) + roleID := seedInstallableRole(t, ctx, orgID) + install := func(label string) string { + t.Helper() + targetID, _ := declarePresence(t, label) + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/" + label + ":1.0.0", + TargetID: targetID, + RootScopeLabel: label, + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + return installation.Id + } + grant := func(scopePath string) *gen.RevokeScopeRequest { + t.Helper() + _, err := testService.RegisterScopeNode(ctx, adminID, &gen.RegisterScopeNodeRequest{ + OrgId: orgID, ScopePath: scopePath, Kind: business.ScopeNodeKindSolution, Label: scopePath, + }) + require.NoError(t, err) + granted, err := testService.GrantScope(ctx, adminID, &gen.GrantScopeRequest{ + OrgId: orgID, SubjectId: memberID, SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, + ScopePath: scopePath, RoleId: roleID, + }) + require.NoError(t, err) + return &gen.RevokeScopeRequest{ + OrgId: orgID, SubjectId: memberID, SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, + ScopePath: granted.Grant.ScopePath, RoleId: granted.Grant.RoleId, + } + } + + // --- The witness is up: both narrowings succeed, each with its receipt. --- + + installed := install("plwitness-up") + require.NoError(t, testService.UninstallSolution(ctx, adminID, orgID, installed)) + _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID("plwitness-up"), orgID, installed) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive, "the uninstall really applied") + uninstallRow := committedReceipt(t, ctx, business.PolicyLogInstallation, installed) + + revoke := grant("plwitnessup") + require.Equal(t, 1, scopeGrantCount(t, ctx, orgID, memberID, revoke.ScopePath)) + require.NoError(t, testService.RevokeScope(ctx, adminID, revoke)) + require.Zero(t, scopeGrantCount(t, ctx, orgID, memberID, revoke.ScopePath), "the revocation really applied") + revokeRow := committedReceipt(t, ctx, business.PolicyLogScopeGrant, orgID+"/") + + // Each committed receipt IS the witness's: a second replica reads the log + // back, verifying every signature and chain link, and finds both operations + // at the sequences, and with the receipts, this host committed. + reader, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), pin) + require.NoError(t, err) + records, err := reader.Entries(ctx, 0, 500) + require.NoError(t, err) + require.Len(t, records, 2, "one entry per narrowing, and nothing else") + for _, row := range []receiptRow{uninstallRow, revokeRow} { + var found *business.PolicyLogRecord + for _, record := range records { + if record.OperationID == row.operationID { + found = record + } + } + require.NotNil(t, found, "operation %s reached the witness", row.operationID) + require.Equal(t, row.receipt, found.Receipt, "the stored receipt is the one the witness signed") + require.Equal(t, row.sequence, found.Sequence) + requireCommitted(t, ctx, row.operationID) + } + + // --- The witness is down: both are refused, and nothing narrows. --- + + stillInstalled := install("plwitness-down") + stillGranted := grant("plwitnessdown") + witness.Stop() + + err = testService.UninstallSolution(ctx, adminID, orgID, stillInstalled) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID("plwitness-down"), orgID, stillInstalled) + require.NoError(t, err, "an unwitnessed uninstall must leave the installation live") + + err = testService.RevokeScope(ctx, adminID, stillGranted) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + require.Equal(t, 1, scopeGrantCount(t, ctx, orgID, memberID, stillGranted.ScopePath), + "an unwitnessed revocation must leave the grant in place") + + require.Zero(t, receiptCount(t, ctx, business.PolicyLogInstallation, stillInstalled), + "a refused append records no receipt, so no gap opens and nothing narrows silently") + require.Empty(t, uncommittedOperations(t, ctx)) +} + +type receiptRow struct { + operationID string + receipt string + sequence uint64 +} + +// committedReceipt reads the one committed policy_log_commits row for a subject +// (or, for a subject id ending in "/", the one whose id starts with it). +func committedReceipt(t *testing.T, ctx context.Context, kind business.PolicyLogSubjectKind, subject string) receiptRow { + t.Helper() + var row receiptRow + var sequence int64 + var committedAt *time.Time + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, ` + SELECT operation_id, receipt, log_sequence, committed_at + FROM policy_log_commits + WHERE subject_kind = $1 + AND (subject_id = $2 OR (right($2, 1) = '/' AND starts_with(subject_id, $2)))`, + string(kind), subject).Scan(&row.operationID, &row.receipt, &sequence, &committedAt) + })) + require.NotNil(t, committedAt, "the receipt committed in the narrowing's transaction") + row.sequence = uint64(sequence) + return row +} + +func receiptCount(t *testing.T, ctx context.Context, kind business.PolicyLogSubjectKind, subject string) int { + t.Helper() + var count int + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, + `SELECT count(*) FROM policy_log_commits WHERE subject_kind = $1 AND subject_id = $2`, + string(kind), subject).Scan(&count) + })) + return count +} + +func scopeGrantCount(t *testing.T, ctx context.Context, orgID, subjectID, scopePath string) int { + t.Helper() + var count int + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, + `SELECT count(*) FROM scope_grants WHERE org_id = $1 AND subject_id = $2 AND scope_path::text = $3`, + orgID, subjectID, scopePath).Scan(&count) + })) + return count +} diff --git a/module/services/accounts/code/pkg/gen/saas/policylog/v1/policylogv1connect/witness.connect.go b/module/services/accounts/code/pkg/gen/saas/policylog/v1/policylogv1connect/witness.connect.go new file mode 100644 index 000000000..7bc54851c --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/policylog/v1/policylogv1connect/witness.connect.go @@ -0,0 +1,207 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: saas/policylog/v1/witness.proto + +package policylogv1connect + +import ( + v1 "accounts/pkg/gen/saas/policylog/v1" + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // WitnessServiceName is the fully-qualified name of the WitnessService service. + WitnessServiceName = "saas.policylog.v1.WitnessService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // WitnessServiceAppendProcedure is the fully-qualified name of the WitnessService's Append RPC. + WitnessServiceAppendProcedure = "/saas.policylog.v1.WitnessService/Append" + // WitnessServiceCursorProcedure is the fully-qualified name of the WitnessService's Cursor RPC. + WitnessServiceCursorProcedure = "/saas.policylog.v1.WitnessService/Cursor" + // WitnessServiceReadProcedure is the fully-qualified name of the WitnessService's Read RPC. + WitnessServiceReadProcedure = "/saas.policylog.v1.WitnessService/Read" + // WitnessServiceKeysProcedure is the fully-qualified name of the WitnessService's Keys RPC. + WitnessServiceKeysProcedure = "/saas.policylog.v1.WitnessService/Keys" +) + +// WitnessServiceClient is a client for the saas.policylog.v1.WitnessService service. +type WitnessServiceClient interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(context.Context, *connect.Request[v1.AppendRequest]) (*connect.Response[v1.AppendResponse], error) + // Cursor returns the signed committed head. + Cursor(context.Context, *connect.Request[v1.CursorRequest]) (*connect.Response[v1.CursorResponse], error) + // Read returns committed records with their original receipts. + Read(context.Context, *connect.Request[v1.ReadRequest]) (*connect.Response[v1.ReadResponse], error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) +} + +// NewWitnessServiceClient constructs a client for the saas.policylog.v1.WitnessService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewWitnessServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) WitnessServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + witnessServiceMethods := v1.File_saas_policylog_v1_witness_proto.Services().ByName("WitnessService").Methods() + return &witnessServiceClient{ + append: connect.NewClient[v1.AppendRequest, v1.AppendResponse]( + httpClient, + baseURL+WitnessServiceAppendProcedure, + connect.WithSchema(witnessServiceMethods.ByName("Append")), + connect.WithClientOptions(opts...), + ), + cursor: connect.NewClient[v1.CursorRequest, v1.CursorResponse]( + httpClient, + baseURL+WitnessServiceCursorProcedure, + connect.WithSchema(witnessServiceMethods.ByName("Cursor")), + connect.WithClientOptions(opts...), + ), + read: connect.NewClient[v1.ReadRequest, v1.ReadResponse]( + httpClient, + baseURL+WitnessServiceReadProcedure, + connect.WithSchema(witnessServiceMethods.ByName("Read")), + connect.WithClientOptions(opts...), + ), + keys: connect.NewClient[v1.KeysRequest, v1.KeysResponse]( + httpClient, + baseURL+WitnessServiceKeysProcedure, + connect.WithSchema(witnessServiceMethods.ByName("Keys")), + connect.WithClientOptions(opts...), + ), + } +} + +// witnessServiceClient implements WitnessServiceClient. +type witnessServiceClient struct { + append *connect.Client[v1.AppendRequest, v1.AppendResponse] + cursor *connect.Client[v1.CursorRequest, v1.CursorResponse] + read *connect.Client[v1.ReadRequest, v1.ReadResponse] + keys *connect.Client[v1.KeysRequest, v1.KeysResponse] +} + +// Append calls saas.policylog.v1.WitnessService.Append. +func (c *witnessServiceClient) Append(ctx context.Context, req *connect.Request[v1.AppendRequest]) (*connect.Response[v1.AppendResponse], error) { + return c.append.CallUnary(ctx, req) +} + +// Cursor calls saas.policylog.v1.WitnessService.Cursor. +func (c *witnessServiceClient) Cursor(ctx context.Context, req *connect.Request[v1.CursorRequest]) (*connect.Response[v1.CursorResponse], error) { + return c.cursor.CallUnary(ctx, req) +} + +// Read calls saas.policylog.v1.WitnessService.Read. +func (c *witnessServiceClient) Read(ctx context.Context, req *connect.Request[v1.ReadRequest]) (*connect.Response[v1.ReadResponse], error) { + return c.read.CallUnary(ctx, req) +} + +// Keys calls saas.policylog.v1.WitnessService.Keys. +func (c *witnessServiceClient) Keys(ctx context.Context, req *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) { + return c.keys.CallUnary(ctx, req) +} + +// WitnessServiceHandler is an implementation of the saas.policylog.v1.WitnessService service. +type WitnessServiceHandler interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(context.Context, *connect.Request[v1.AppendRequest]) (*connect.Response[v1.AppendResponse], error) + // Cursor returns the signed committed head. + Cursor(context.Context, *connect.Request[v1.CursorRequest]) (*connect.Response[v1.CursorResponse], error) + // Read returns committed records with their original receipts. + Read(context.Context, *connect.Request[v1.ReadRequest]) (*connect.Response[v1.ReadResponse], error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) +} + +// NewWitnessServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewWitnessServiceHandler(svc WitnessServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + witnessServiceMethods := v1.File_saas_policylog_v1_witness_proto.Services().ByName("WitnessService").Methods() + witnessServiceAppendHandler := connect.NewUnaryHandler( + WitnessServiceAppendProcedure, + svc.Append, + connect.WithSchema(witnessServiceMethods.ByName("Append")), + connect.WithHandlerOptions(opts...), + ) + witnessServiceCursorHandler := connect.NewUnaryHandler( + WitnessServiceCursorProcedure, + svc.Cursor, + connect.WithSchema(witnessServiceMethods.ByName("Cursor")), + connect.WithHandlerOptions(opts...), + ) + witnessServiceReadHandler := connect.NewUnaryHandler( + WitnessServiceReadProcedure, + svc.Read, + connect.WithSchema(witnessServiceMethods.ByName("Read")), + connect.WithHandlerOptions(opts...), + ) + witnessServiceKeysHandler := connect.NewUnaryHandler( + WitnessServiceKeysProcedure, + svc.Keys, + connect.WithSchema(witnessServiceMethods.ByName("Keys")), + connect.WithHandlerOptions(opts...), + ) + return "/saas.policylog.v1.WitnessService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case WitnessServiceAppendProcedure: + witnessServiceAppendHandler.ServeHTTP(w, r) + case WitnessServiceCursorProcedure: + witnessServiceCursorHandler.ServeHTTP(w, r) + case WitnessServiceReadProcedure: + witnessServiceReadHandler.ServeHTTP(w, r) + case WitnessServiceKeysProcedure: + witnessServiceKeysHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedWitnessServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedWitnessServiceHandler struct{} + +func (UnimplementedWitnessServiceHandler) Append(context.Context, *connect.Request[v1.AppendRequest]) (*connect.Response[v1.AppendResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.policylog.v1.WitnessService.Append is not implemented")) +} + +func (UnimplementedWitnessServiceHandler) Cursor(context.Context, *connect.Request[v1.CursorRequest]) (*connect.Response[v1.CursorResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.policylog.v1.WitnessService.Cursor is not implemented")) +} + +func (UnimplementedWitnessServiceHandler) Read(context.Context, *connect.Request[v1.ReadRequest]) (*connect.Response[v1.ReadResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.policylog.v1.WitnessService.Read is not implemented")) +} + +func (UnimplementedWitnessServiceHandler) Keys(context.Context, *connect.Request[v1.KeysRequest]) (*connect.Response[v1.KeysResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.policylog.v1.WitnessService.Keys is not implemented")) +} diff --git a/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness.pb.go b/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness.pb.go new file mode 100644 index 000000000..7e217180c --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness.pb.go @@ -0,0 +1,917 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/policylog/v1/witness.proto + +package policylogv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Entry is one operation offered to the witness. +// +// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: +// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and +// the witness stores that digest rather than the operation. Reconciliation can +// identify an operation and verify its bytes; it cannot reconstruct them, so +// the caller must retain or recover the canonical bytes to apply it. +// +// Every field takes part in entry_hash, so every field must stay IDENTICAL on a +// retry of the same operation_id — requested_at included. The witness refuses a +// reused operation_id that names different bytes. +type Entry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // operation_id identifies one logical operation for the lifetime of the log, + // and it is the caller's: only the caller knows that two attempts are the + // same operation. At most 1024 bytes of control-character-free UTF-8. + OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + // kind is what the operation is about, at most 128 bytes. + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + // subject is what the operation acts on, at most 1024 bytes. + Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` + // tenant may be empty for a global operation. It grants no authority: this + // log performs no tenant scoping and the field is an identifier, not a claim. + Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` + // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical + // operation bytes, carried raw rather than hex or base64 so the digest the + // signature covers is the digest on the wire. + NarrowingHash []byte `protobuf:"bytes,5,opt,name=narrowing_hash,json=narrowingHash,proto3" json:"narrowing_hash,omitempty"` + // requested_at is the CALLER'S logical timestamp for the operation, not a + // deadline and not an ordering authority. It is covered by entry_hash, so an + // old operation can be retried without changing its identity only as long as + // this value is sent back unchanged. + RequestedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=requested_at,json=requestedAt,proto3" json:"requested_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Entry) Reset() { + *x = Entry{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Entry) ProtoMessage() {} + +func (x *Entry) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Entry.ProtoReflect.Descriptor instead. +func (*Entry) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{0} +} + +func (x *Entry) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *Entry) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *Entry) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *Entry) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *Entry) GetNarrowingHash() []byte { + if x != nil { + return x.NarrowingHash + } + return nil +} + +func (x *Entry) GetRequestedAt() *timestamppb.Timestamp { + if x != nil { + return x.RequestedAt + } + return nil +} + +// Receipt is the witness's signed acknowledgement of one committed entry. +// +// Only a committed head makes a receipt exist. A signature over a prepared +// candidate that never reached the head is not a receipt and is never returned. +type Receipt struct { + state protoimpl.MessageState `protogen:"open.v1"` + // seq is the global sequence the witness assigned, never zero and never + // supplied by a caller. The first sequence is 1. Sequence, not clock time, + // orders operations. + Seq uint64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + // entry_hash is the 32-byte digest of the appended entry. A caller compares + // it against the bytes it asked to append. + EntryHash []byte `protobuf:"bytes,2,opt,name=entry_hash,json=entryHash,proto3" json:"entry_hash,omitempty"` + // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. + PrevHash []byte `protobuf:"bytes,3,opt,name=prev_hash,json=prevHash,proto3" json:"prev_hash,omitempty"` + // chain_hash is the 32-byte chain hash at seq. + ChainHash []byte `protobuf:"bytes,4,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` + // issued_at comes from the witness, never from a caller. + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which + // depends on no protobuf field ordering. + Signature []byte `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Receipt) Reset() { + *x = Receipt{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Receipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Receipt) ProtoMessage() {} + +func (x *Receipt) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Receipt.ProtoReflect.Descriptor instead. +func (*Receipt) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{1} +} + +func (x *Receipt) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *Receipt) GetEntryHash() []byte { + if x != nil { + return x.EntryHash + } + return nil +} + +func (x *Receipt) GetPrevHash() []byte { + if x != nil { + return x.PrevHash + } + return nil +} + +func (x *Receipt) GetChainHash() []byte { + if x != nil { + return x.ChainHash + } + return nil +} + +func (x *Receipt) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *Receipt) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// Record is one entry together with the original receipt the witness issued for +// it, which is what makes a read independently verifiable. +type Record struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + Receipt *Receipt `protobuf:"bytes,2,opt,name=receipt,proto3" json:"receipt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Record) Reset() { + *x = Record{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Record) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Record) ProtoMessage() {} + +func (x *Record) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Record.ProtoReflect.Descriptor instead. +func (*Record) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{2} +} + +func (x *Record) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +func (x *Record) GetReceipt() *Receipt { + if x != nil { + return x.Receipt + } + return nil +} + +// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt +// nor a reset instruction: a verifying client never lowers a persisted cursor, +// and a valid signature cannot by itself distinguish a historical cursor from a +// current one, so the client also enforces its own freshness policy. +type Cursor struct { + state protoimpl.MessageState `protogen:"open.v1"` + HeadSeq uint64 `protobuf:"varint,1,opt,name=head_seq,json=headSeq,proto3" json:"head_seq,omitempty"` + ChainHash []byte `protobuf:"bytes,2,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Cursor) Reset() { + *x = Cursor{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Cursor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Cursor) ProtoMessage() {} + +func (x *Cursor) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Cursor.ProtoReflect.Descriptor instead. +func (*Cursor) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{3} +} + +func (x *Cursor) GetHeadSeq() uint64 { + if x != nil { + return x.HeadSeq + } + return 0 +} + +func (x *Cursor) GetChainHash() []byte { + if x != nil { + return x.ChainHash + } + return nil +} + +func (x *Cursor) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *Cursor) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// Key is the public half of the witness signing key, and the same schema as the +// trust-anchor document composition delivers to the host. Publishing it is +// discovery, never authority to replace a caller's pinned key. +type Key struct { + state protoimpl.MessageState `protogen:"open.v1"` + // algorithm is "Ed25519". + Algorithm string `protobuf:"bytes,1,opt,name=algorithm,proto3" json:"algorithm,omitempty"` + // key_id is the lowercase SHA-256 hex of the raw public key. + KeyId string `protobuf:"bytes,2,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + // public_key is the raw 32-byte public key. + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Key) Reset() { + *x = Key{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Key) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Key) ProtoMessage() {} + +func (x *Key) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Key.ProtoReflect.Descriptor instead. +func (*Key) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{4} +} + +func (x *Key) GetAlgorithm() string { + if x != nil { + return x.Algorithm + } + return "" +} + +func (x *Key) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *Key) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// AppendRequest offers one entry. +type AppendRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendRequest) Reset() { + *x = AppendRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendRequest) ProtoMessage() {} + +func (x *AppendRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendRequest.ProtoReflect.Descriptor instead. +func (*AppendRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{5} +} + +func (x *AppendRequest) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +// AppendResponse carries the committed receipt. A retry of the identical entry +// returns the exact original receipt rather than appending again. +type AppendResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *Receipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendResponse) Reset() { + *x = AppendResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendResponse) ProtoMessage() {} + +func (x *AppendResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendResponse.ProtoReflect.Descriptor instead. +func (*AppendResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{6} +} + +func (x *AppendResponse) GetReceipt() *Receipt { + if x != nil { + return x.Receipt + } + return nil +} + +// CursorRequest takes no argument: there is one log and one head. +type CursorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CursorRequest) Reset() { + *x = CursorRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CursorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CursorRequest) ProtoMessage() {} + +func (x *CursorRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CursorRequest.ProtoReflect.Descriptor instead. +func (*CursorRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{7} +} + +// CursorResponse carries the signed head observation. +type CursorResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cursor *Cursor `protobuf:"bytes,1,opt,name=cursor,proto3" json:"cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CursorResponse) Reset() { + *x = CursorResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CursorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CursorResponse) ProtoMessage() {} + +func (x *CursorResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CursorResponse.ProtoReflect.Descriptor instead. +func (*CursorResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{8} +} + +func (x *CursorResponse) GetCursor() *Cursor { + if x != nil { + return x.Cursor + } + return nil +} + +// ReadRequest asks for a bounded page of committed records. +type ReadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an + // empty page; asking beyond it is refused, so a reconciler that is ahead of + // the log cannot mistake a rollback for inactivity. A caller holding an + // exclusive cursor sends cursor + 1. + FromSeq uint64 `protobuf:"varint,1,opt,name=from_seq,json=fromSeq,proto3" json:"from_seq,omitempty"` + // limit is 1 through 500. + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadRequest) Reset() { + *x = ReadRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadRequest) ProtoMessage() {} + +func (x *ReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. +func (*ReadRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{9} +} + +func (x *ReadRequest) GetFromSeq() uint64 { + if x != nil { + return x.FromSeq + } + return 0 +} + +func (x *ReadRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +// ReadResponse carries the page in ascending sequence order. +type ReadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadResponse) Reset() { + *x = ReadResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadResponse) ProtoMessage() {} + +func (x *ReadResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. +func (*ReadResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{10} +} + +func (x *ReadResponse) GetRecords() []*Record { + if x != nil { + return x.Records + } + return nil +} + +// KeysRequest takes no argument. +type KeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeysRequest) Reset() { + *x = KeysRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeysRequest) ProtoMessage() {} + +func (x *KeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeysRequest.ProtoReflect.Descriptor instead. +func (*KeysRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{11} +} + +// KeysResponse carries the public signing key document. +type KeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key *Key `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeysResponse) Reset() { + *x = KeysResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeysResponse) ProtoMessage() {} + +func (x *KeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. +func (*KeysResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{12} +} + +func (x *KeysResponse) GetKey() *Key { + if x != nil { + return x.Key + } + return nil +} + +var File_saas_policylog_v1_witness_proto protoreflect.FileDescriptor + +const file_saas_policylog_v1_witness_proto_rawDesc = "" + + "\n" + + "\x1fsaas/policylog/v1/witness.proto\x12\x11saas.policylog.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd6\x01\n" + + "\x05Entry\x12!\n" + + "\foperation_id\x18\x01 \x01(\tR\voperationId\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + + "\asubject\x18\x03 \x01(\tR\asubject\x12\x16\n" + + "\x06tenant\x18\x04 \x01(\tR\x06tenant\x12%\n" + + "\x0enarrowing_hash\x18\x05 \x01(\fR\rnarrowingHash\x12=\n" + + "\frequested_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vrequestedAt\"\xcd\x01\n" + + "\aReceipt\x12\x10\n" + + "\x03seq\x18\x01 \x01(\x04R\x03seq\x12\x1d\n" + + "\n" + + "entry_hash\x18\x02 \x01(\fR\tentryHash\x12\x1b\n" + + "\tprev_hash\x18\x03 \x01(\fR\bprevHash\x12\x1d\n" + + "\n" + + "chain_hash\x18\x04 \x01(\fR\tchainHash\x127\n" + + "\tissued_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + + "\tsignature\x18\x06 \x01(\fR\tsignature\"n\n" + + "\x06Record\x12.\n" + + "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\x124\n" + + "\areceipt\x18\x02 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x99\x01\n" + + "\x06Cursor\x12\x19\n" + + "\bhead_seq\x18\x01 \x01(\x04R\aheadSeq\x12\x1d\n" + + "\n" + + "chain_hash\x18\x02 \x01(\fR\tchainHash\x127\n" + + "\tissued_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\fR\tsignature\"Y\n" + + "\x03Key\x12\x1c\n" + + "\talgorithm\x18\x01 \x01(\tR\talgorithm\x12\x15\n" + + "\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x1d\n" + + "\n" + + "public_key\x18\x03 \x01(\fR\tpublicKey\"?\n" + + "\rAppendRequest\x12.\n" + + "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\"F\n" + + "\x0eAppendResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x0f\n" + + "\rCursorRequest\"C\n" + + "\x0eCursorResponse\x121\n" + + "\x06cursor\x18\x01 \x01(\v2\x19.saas.policylog.v1.CursorR\x06cursor\">\n" + + "\vReadRequest\x12\x19\n" + + "\bfrom_seq\x18\x01 \x01(\x04R\afromSeq\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\"C\n" + + "\fReadResponse\x123\n" + + "\arecords\x18\x01 \x03(\v2\x19.saas.policylog.v1.RecordR\arecords\"\r\n" + + "\vKeysRequest\"8\n" + + "\fKeysResponse\x12(\n" + + "\x03key\x18\x01 \x01(\v2\x16.saas.policylog.v1.KeyR\x03key2\xc0\x02\n" + + "\x0eWitnessService\x12M\n" + + "\x06Append\x12 .saas.policylog.v1.AppendRequest\x1a!.saas.policylog.v1.AppendResponse\x12M\n" + + "\x06Cursor\x12 .saas.policylog.v1.CursorRequest\x1a!.saas.policylog.v1.CursorResponse\x12G\n" + + "\x04Read\x12\x1e.saas.policylog.v1.ReadRequest\x1a\x1f.saas.policylog.v1.ReadResponse\x12G\n" + + "\x04Keys\x12\x1e.saas.policylog.v1.KeysRequest\x1a\x1f.saas.policylog.v1.KeysResponseB\xbb\x01\n" + + "\x15com.saas.policylog.v1B\fWitnessProtoP\x01Z.accounts/pkg/gen/saas/policylog/v1;policylogv1\xa2\x02\x03SPX\xaa\x02\x11Saas.Policylog.V1\xca\x02\x11Saas\\Policylog\\V1\xe2\x02\x1dSaas\\Policylog\\V1\\GPBMetadata\xea\x02\x13Saas::Policylog::V1b\x06proto3" + +var ( + file_saas_policylog_v1_witness_proto_rawDescOnce sync.Once + file_saas_policylog_v1_witness_proto_rawDescData []byte +) + +func file_saas_policylog_v1_witness_proto_rawDescGZIP() []byte { + file_saas_policylog_v1_witness_proto_rawDescOnce.Do(func() { + file_saas_policylog_v1_witness_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc))) + }) + return file_saas_policylog_v1_witness_proto_rawDescData +} + +var file_saas_policylog_v1_witness_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_saas_policylog_v1_witness_proto_goTypes = []any{ + (*Entry)(nil), // 0: saas.policylog.v1.Entry + (*Receipt)(nil), // 1: saas.policylog.v1.Receipt + (*Record)(nil), // 2: saas.policylog.v1.Record + (*Cursor)(nil), // 3: saas.policylog.v1.Cursor + (*Key)(nil), // 4: saas.policylog.v1.Key + (*AppendRequest)(nil), // 5: saas.policylog.v1.AppendRequest + (*AppendResponse)(nil), // 6: saas.policylog.v1.AppendResponse + (*CursorRequest)(nil), // 7: saas.policylog.v1.CursorRequest + (*CursorResponse)(nil), // 8: saas.policylog.v1.CursorResponse + (*ReadRequest)(nil), // 9: saas.policylog.v1.ReadRequest + (*ReadResponse)(nil), // 10: saas.policylog.v1.ReadResponse + (*KeysRequest)(nil), // 11: saas.policylog.v1.KeysRequest + (*KeysResponse)(nil), // 12: saas.policylog.v1.KeysResponse + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp +} +var file_saas_policylog_v1_witness_proto_depIdxs = []int32{ + 13, // 0: saas.policylog.v1.Entry.requested_at:type_name -> google.protobuf.Timestamp + 13, // 1: saas.policylog.v1.Receipt.issued_at:type_name -> google.protobuf.Timestamp + 0, // 2: saas.policylog.v1.Record.entry:type_name -> saas.policylog.v1.Entry + 1, // 3: saas.policylog.v1.Record.receipt:type_name -> saas.policylog.v1.Receipt + 13, // 4: saas.policylog.v1.Cursor.issued_at:type_name -> google.protobuf.Timestamp + 0, // 5: saas.policylog.v1.AppendRequest.entry:type_name -> saas.policylog.v1.Entry + 1, // 6: saas.policylog.v1.AppendResponse.receipt:type_name -> saas.policylog.v1.Receipt + 3, // 7: saas.policylog.v1.CursorResponse.cursor:type_name -> saas.policylog.v1.Cursor + 2, // 8: saas.policylog.v1.ReadResponse.records:type_name -> saas.policylog.v1.Record + 4, // 9: saas.policylog.v1.KeysResponse.key:type_name -> saas.policylog.v1.Key + 5, // 10: saas.policylog.v1.WitnessService.Append:input_type -> saas.policylog.v1.AppendRequest + 7, // 11: saas.policylog.v1.WitnessService.Cursor:input_type -> saas.policylog.v1.CursorRequest + 9, // 12: saas.policylog.v1.WitnessService.Read:input_type -> saas.policylog.v1.ReadRequest + 11, // 13: saas.policylog.v1.WitnessService.Keys:input_type -> saas.policylog.v1.KeysRequest + 6, // 14: saas.policylog.v1.WitnessService.Append:output_type -> saas.policylog.v1.AppendResponse + 8, // 15: saas.policylog.v1.WitnessService.Cursor:output_type -> saas.policylog.v1.CursorResponse + 10, // 16: saas.policylog.v1.WitnessService.Read:output_type -> saas.policylog.v1.ReadResponse + 12, // 17: saas.policylog.v1.WitnessService.Keys:output_type -> saas.policylog.v1.KeysResponse + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name +} + +func init() { file_saas_policylog_v1_witness_proto_init() } +func file_saas_policylog_v1_witness_proto_init() { + if File_saas_policylog_v1_witness_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc)), + NumEnums: 0, + NumMessages: 13, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_saas_policylog_v1_witness_proto_goTypes, + DependencyIndexes: file_saas_policylog_v1_witness_proto_depIdxs, + MessageInfos: file_saas_policylog_v1_witness_proto_msgTypes, + }.Build() + File_saas_policylog_v1_witness_proto = out.File + file_saas_policylog_v1_witness_proto_goTypes = nil + file_saas_policylog_v1_witness_proto_depIdxs = nil +} diff --git a/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go new file mode 100644 index 000000000..e0d957df2 --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go @@ -0,0 +1,266 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: saas/policylog/v1/witness.proto + +package policylogv1 + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + WitnessService_Append_FullMethodName = "/saas.policylog.v1.WitnessService/Append" + WitnessService_Cursor_FullMethodName = "/saas.policylog.v1.WitnessService/Cursor" + WitnessService_Read_FullMethodName = "/saas.policylog.v1.WitnessService/Read" + WitnessService_Keys_FullMethodName = "/saas.policylog.v1.WitnessService/Keys" +) + +// WitnessServiceClient is the client API for WitnessService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +type WitnessServiceClient interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) + // Cursor returns the signed committed head. + Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) + // Read returns committed records with their original receipts. + Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) +} + +type witnessServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewWitnessServiceClient(cc grpc.ClientConnInterface) WitnessServiceClient { + return &witnessServiceClient{cc} +} + +func (c *witnessServiceClient) Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendResponse) + err := c.cc.Invoke(ctx, WitnessService_Append_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CursorResponse) + err := c.cc.Invoke(ctx, WitnessService_Cursor_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReadResponse) + err := c.cc.Invoke(ctx, WitnessService_Read_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(KeysResponse) + err := c.cc.Invoke(ctx, WitnessService_Keys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WitnessServiceServer is the server API for WitnessService service. +// All implementations must embed UnimplementedWitnessServiceServer +// for forward compatibility. +// +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +type WitnessServiceServer interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(context.Context, *AppendRequest) (*AppendResponse, error) + // Cursor returns the signed committed head. + Cursor(context.Context, *CursorRequest) (*CursorResponse, error) + // Read returns committed records with their original receipts. + Read(context.Context, *ReadRequest) (*ReadResponse, error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(context.Context, *KeysRequest) (*KeysResponse, error) + mustEmbedUnimplementedWitnessServiceServer() +} + +// UnimplementedWitnessServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedWitnessServiceServer struct{} + +func (UnimplementedWitnessServiceServer) Append(context.Context, *AppendRequest) (*AppendResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Append not implemented") +} +func (UnimplementedWitnessServiceServer) Cursor(context.Context, *CursorRequest) (*CursorResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Cursor not implemented") +} +func (UnimplementedWitnessServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Read not implemented") +} +func (UnimplementedWitnessServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Keys not implemented") +} +func (UnimplementedWitnessServiceServer) mustEmbedUnimplementedWitnessServiceServer() {} +func (UnimplementedWitnessServiceServer) testEmbeddedByValue() {} + +// UnsafeWitnessServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WitnessServiceServer will +// result in compilation errors. +type UnsafeWitnessServiceServer interface { + mustEmbedUnimplementedWitnessServiceServer() +} + +func RegisterWitnessServiceServer(s grpc.ServiceRegistrar, srv WitnessServiceServer) { + // If the following call panics, it indicates UnimplementedWitnessServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&WitnessService_ServiceDesc, srv) +} + +func _WitnessService_Append_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Append(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Append_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Append(ctx, req.(*AppendRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Cursor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CursorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Cursor(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Cursor_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Cursor(ctx, req.(*CursorRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Read(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Read_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Read(ctx, req.(*ReadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Keys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(KeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Keys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Keys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Keys(ctx, req.(*KeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WitnessService_ServiceDesc is the grpc.ServiceDesc for WitnessService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WitnessService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "saas.policylog.v1.WitnessService", + HandlerType: (*WitnessServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Append", + Handler: _WitnessService_Append_Handler, + }, + { + MethodName: "Cursor", + Handler: _WitnessService_Cursor_Handler, + }, + { + MethodName: "Read", + Handler: _WitnessService_Read_Handler, + }, + { + MethodName: "Keys", + Handler: _WitnessService_Keys_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "saas/policylog/v1/witness.proto", +} diff --git a/module/services/accounts/code/pkg/infra/policy_log_witness.go b/module/services/accounts/code/pkg/infra/policy_log_witness.go new file mode 100644 index 000000000..940997d13 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/policy_log_witness.go @@ -0,0 +1,715 @@ +package infra + +// The policy log's transport: a client for the module's independent signed +// witness (services/policy-log). +// +// WHY THIS IS A RECEIPT AND NOT A HOST DECISION. `business.PolicyLog.Append` +// must return a token the LOG minted and a sequence the LOG assigned, because +// that is the whole difference between "the log witnessed this narrowing" and +// "this host decided it". The witness assigns a global sequence, chains every +// entry to the one before it, and signs the receipt with an Ed25519 key that +// lives in its own trust domain — this host never holds it. What this client +// adds is VERIFICATION: every receipt and every cursor is checked against a key +// this host PINNED, so nothing the network says can make a receipt this host +// would have had to invent. +// +// WHERE TRUST COMES FROM. The pinned key is the trust-anchor document +// composition delivers beside the solution-host anchor, +// `policy_log_public_key.json` under SolutionHostTrustAnchorPath. The witness's +// own `Keys` operation is DISCOVERY: it is read only to name a mismatch +// precisely, and never adopted. A client that trusted the key the witness +// published would accept any witness that published a key — including one an +// attacker stood up — which is the trust-on-first-use hole the pin exists to +// close. +// +// HOW THIS HOST AUTHENTICATES TO THE WITNESS. It does not, in the application, +// and that is the witness's own contract rather than an omission here: its +// listener registers the four witness operations and nothing else, and its +// README requires composition to admit only the host's declared client through +// workload-authenticated mesh policy. A second credential minted by this host +// would be a second identity scheme the witness does not check. The mesh +// carries this workload's identity; the pin carries the witness's. +// +// WHAT THE WITNESS HOLDS. Opaque identifiers and a digest, never the narrowing: +// the entry carries the operation id, a kind naming the subject kind and the +// decision, the subject id, and the SHA-256 of this host's canonical encoding +// of the whole PolicyLogEntry (narrowingHash below). A record read back can +// therefore name the gap it is — which is all reconciliation does with it, see +// business.ReconcilePolicyLog — but cannot reconstruct the policy or the actor. + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "accounts/pkg/business" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// policyLogWitnessKeyFileName is the pinned witness key's name inside the trust +// anchor. Fixed, like the anchor's location and the other documents in it: the +// anchor is written by one platform-owned path, and a configurable name is one +// more thing a composition could point elsewhere. +const policyLogWitnessKeyFileName = "policy_log_public_key.json" + +// ErrPolicyLogWitnessKeyUnusable reports a pinned key that was delivered and +// cannot be used. A host holding one must not start: it would refuse every +// narrowing for a reason nobody could see. +var ErrPolicyLogWitnessKeyUnusable = errors.New("pinned policy log witness key is unusable") + +// ErrPolicyLogWitnessEvidence reports evidence the witness returned that does +// not verify under the pinned key, does not describe the bytes this host +// offered, or contradicts what this client has already verified. +// +// It is never treated as a refusal by the log. A refusal is a decision; this is +// a witness this host cannot believe, so the narrowing does not proceed and +// reconciliation does not advance — and the serving gate's staleness window +// closes on its own. +var ErrPolicyLogWitnessEvidence = errors.New("policy log witness evidence does not verify") + +// PolicyLogWitnessKey is the pinned public half of the witness signing key. +type PolicyLogWitnessKey struct { + KeyID string + PublicKey ed25519.PublicKey +} + +// policyLogWitnessKeyDocument is the trust-anchor document's wire form, the +// schema the witness's Keys operation also uses. public_key is standard base64 +// of the raw 32-byte key, which is what encoding/json does with []byte. +type policyLogWitnessKeyDocument struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + PublicKey []byte `json:"public_key"` +} + +// ReadPolicyLogWitnessKey reads the pinned witness key from the trust anchor. +// +// The second return says whether a key is DELIVERED at all. False with no error +// is a host composition did not give a witness to; an error is a host that must +// not start, because the key is there and says something this host cannot use. +func ReadPolicyLogWitnessKey() (PolicyLogWitnessKey, bool, error) { + // The anchor's one resolver: the fixed pod path, or a declared local anchor + // outside a pod — never a local anchor inside one. + directory, err := solutionHostTrustAnchorDir() + if err != nil { + return PolicyLogWitnessKey{}, false, err + } + path := filepath.Join(directory, policyLogWitnessKeyFileName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return PolicyLogWitnessKey{}, false, nil + } + return PolicyLogWitnessKey{}, false, fmt.Errorf("%w: %s is present on the trust anchor and cannot be read: %w", + ErrPolicyLogWitnessKeyUnusable, path, err) + } + key, err := ParsePolicyLogWitnessKey(data) + if err != nil { + return PolicyLogWitnessKey{}, false, fmt.Errorf("%s: %w", path, err) + } + return key, true, nil +} + +// ParsePolicyLogWitnessKey decodes and checks one key document. +// +// Strict: an unknown field is a version step, not something to ignore, and a +// key id that is not the digest of the key beside it is a document that names +// one key and pins another. +func ParsePolicyLogWitnessKey(data []byte) (PolicyLogWitnessKey, error) { + var document policyLogWitnessKeyDocument + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + return PolicyLogWitnessKey{}, fmt.Errorf("%w: does not decode: %w", ErrPolicyLogWitnessKeyUnusable, err) + } + if decoder.More() { + return PolicyLogWitnessKey{}, fmt.Errorf("%w: trailing content after the key document", ErrPolicyLogWitnessKeyUnusable) + } + return policyLogWitnessKeyFrom(document.Algorithm, document.KeyID, document.PublicKey) +} + +func policyLogWitnessKeyFrom(algorithm, keyID string, public []byte) (PolicyLogWitnessKey, error) { + if algorithm != "Ed25519" { + return PolicyLogWitnessKey{}, fmt.Errorf("%w: algorithm %q, want Ed25519", ErrPolicyLogWitnessKeyUnusable, algorithm) + } + if len(public) != ed25519.PublicKeySize { + return PolicyLogWitnessKey{}, fmt.Errorf("%w: public key is %d bytes, want %d", + ErrPolicyLogWitnessKeyUnusable, len(public), ed25519.PublicKeySize) + } + digest := sha256.Sum256(public) + if keyID != hex.EncodeToString(digest[:]) { + return PolicyLogWitnessKey{}, fmt.Errorf("%w: key_id is not the lowercase SHA-256 hex of the public key", + ErrPolicyLogWitnessKeyUnusable) + } + return PolicyLogWitnessKey{KeyID: keyID, PublicKey: bytes.Clone(public)}, nil +} + +// policyLogWitnessAttempts bounds how often one Append offers the identical +// entry. Only Unavailable is retried: the witness answers it exactly when no +// sequence was assigned and no receipt exists, so the retry cannot mint a +// second entry — and a retry of the identical bytes returns the original +// receipt if an earlier attempt did commit. Every other answer is final. +const policyLogWitnessAttempts = 3 + +// policyLogWitnessRetryPause is the pause between those attempts. All of them +// run inside the caller's five-second append budget. +const policyLogWitnessRetryPause = 100 * time.Millisecond + +// policyLogCursorSkew is how far a signed cursor's issued_at may sit from this +// host's clock. A valid signature cannot tell a historical cursor from a +// current one, so without a freshness bound a replayed old cursor would keep +// `reached_at` fresh while the witness was gone. The cursor is asked for in the +// same pass that judges it, so it is seconds old at most; the bound is the clock +// skew two meshed pods can carry, not the staleness window — at the window, a +// cursor just under it kept a host serving nearly two windows past the +// witness's last signature. +const policyLogCursorSkew = 10 * time.Second + +// WitnessPolicyLog is business.PolicyLog over the witness's gRPC API. +type WitnessPolicyLog struct { + client policylogv1.WitnessServiceClient + key PolicyLogWitnessKey + now func() time.Time + + mu sync.Mutex + // highest and chain are the highest sequence this client has VERIFIED, from + // a receipt, a record or a cursor, and the chain hash at it. They only move + // forward: a witness that later answers below them, or with a different + // hash at the same sequence, has rolled back or forked, and is refused. + highest uint64 + chain []byte + // keyMatched records that the witness's published key equals the pin. It + // decides nothing about verification, which always uses the pin; it is what + // lets a mismatch be reported as one rather than as a bad signature. + keyMatched bool +} + +// NewWitnessPolicyLog builds the client. The key must be the pinned one. +func NewWitnessPolicyLog(client policylogv1.WitnessServiceClient, key PolicyLogWitnessKey) (*WitnessPolicyLog, error) { + if client == nil { + return nil, errors.New("policy log witness client is required") + } + if _, err := policyLogWitnessKeyFrom("Ed25519", key.KeyID, key.PublicKey); err != nil { + return nil, err + } + return &WitnessPolicyLog{client: client, key: key, now: func() time.Time { return time.Now().UTC() }}, nil +} + +var _ business.PolicyLog = (*WitnessPolicyLog)(nil) + +// Append offers one entry and returns its verified receipt. +func (l *WitnessPolicyLog) Append(ctx context.Context, entry *business.PolicyLogEntry) (*business.PolicyLogReceipt, error) { + if err := entry.Validate(); err != nil { + return nil, err + } + // requested_at is fixed ONCE per operation: it is covered by the entry hash, + // so every retry below must carry the identical value or the witness would + // read the retry as a different entry under the same operation id. + wire, err := witnessEntry(entry, l.now()) + if err != nil { + return nil, err + } + expected, err := witnessEntryHash(wire) + if err != nil { + return nil, err + } + + // Anything this client verified BEFORE offering the entry precedes it in + // the log, so the receipt's sequence must be above it. + floor, floorChain := l.verified() + + var response *policylogv1.AppendResponse + for attempt := 1; ; attempt++ { + response, err = l.client.Append(ctx, &policylogv1.AppendRequest{Entry: wire}) + if err == nil || status.Code(err) != codes.Unavailable || attempt == policyLogWitnessAttempts { + break + } + select { + case <-ctx.Done(): + return nil, err + case <-time.After(policyLogWitnessRetryPause): + } + } + if err != nil { + return nil, fmt.Errorf("policy log witness append: %w", err) + } + + receipt, err := receiptFrom(response.GetReceipt()) + if err != nil { + return nil, err + } + if !verifyWitnessReceipt(l.key.PublicKey, receipt) { + return nil, fmt.Errorf("%w: the receipt for operation %q is not signed by the pinned key %s", + ErrPolicyLogWitnessEvidence, entry.OperationID, l.key.KeyID) + } + if !bytes.Equal(receipt.entryHash, expected) { + return nil, fmt.Errorf("%w: the receipt for operation %q witnesses different bytes from the ones offered", + ErrPolicyLogWitnessEvidence, entry.OperationID) + } + if receipt.seq <= floor { + return nil, fmt.Errorf("%w: the receipt for operation %q has sequence %d, at or below %d already verified; the witness has rolled back", + ErrPolicyLogWitnessEvidence, entry.OperationID, receipt.seq, floor) + } + if receipt.seq == floor+1 && !bytes.Equal(receipt.prevHash, floorChain) { + return nil, fmt.Errorf("%w: the receipt for operation %q does not chain to sequence %d as verified; the witness has forked", + ErrPolicyLogWitnessEvidence, entry.OperationID, floor) + } + l.observe(receipt.seq, receipt.chainHash) + + encoded, err := encodeWitnessReceipt(l.key.KeyID, receipt) + if err != nil { + return nil, err + } + return &business.PolicyLogReceipt{Receipt: encoded, Sequence: receipt.seq, AppendedAt: receipt.issuedAt}, nil +} + +// Entries returns the verified records after a sequence, and is what refreshes +// the serving gate's `reached_at`: it succeeds only when the witness answered +// with a signed, fresh cursor at or above everything this host has accounted +// for, and every record it returned verifies and chains. +func (l *WitnessPolicyLog) Entries(ctx context.Context, afterSequence uint64, limit int) ([]*business.PolicyLogRecord, error) { + if limit <= 0 { + return nil, fmt.Errorf("policy log read limit must be positive, got %d", limit) + } + if err := l.checkPublishedKey(ctx); err != nil { + return nil, err + } + + cursorResponse, err := l.client.Cursor(ctx, &policylogv1.CursorRequest{}) + if err != nil { + return nil, fmt.Errorf("policy log witness cursor: %w", err) + } + cursor, err := cursorFrom(cursorResponse.GetCursor()) + if err != nil { + return nil, err + } + if !verifyWitnessCursor(l.key.PublicKey, cursor) { + return nil, fmt.Errorf("%w: the cursor is not signed by the pinned key %s", ErrPolicyLogWitnessEvidence, l.key.KeyID) + } + if skew := l.now().Sub(cursor.issuedAt); skew > policyLogCursorSkew || skew < -policyLogCursorSkew { + return nil, fmt.Errorf("%w: the cursor was issued at %s, outside %s of this host's clock, so it does not show the witness is reachable now", + ErrPolicyLogWitnessEvidence, cursor.issuedAt.Format(time.RFC3339Nano), policyLogCursorSkew) + } + if cursor.headSeq < afterSequence { + return nil, fmt.Errorf("%w: the witness head is %d, below the %d this host has reconciled; the log has rolled back", + ErrPolicyLogWitnessEvidence, cursor.headSeq, afterSequence) + } + floor, floorChain := l.verified() + if cursor.headSeq < floor { + return nil, fmt.Errorf("%w: the witness head is %d, below the %d this client has verified; the log has rolled back", + ErrPolicyLogWitnessEvidence, cursor.headSeq, floor) + } + if cursor.headSeq == floor && floor != 0 && !bytes.Equal(cursor.chainHash, floorChain) { + return nil, fmt.Errorf("%w: the witness head at %d names a different chain hash from the one verified; the log has forked", + ErrPolicyLogWitnessEvidence, floor) + } + if cursor.headSeq == afterSequence { + l.observe(cursor.headSeq, cursor.chainHash) + return []*business.PolicyLogRecord{}, nil + } + + if limit > policyLogWitnessMaxRead { + limit = policyLogWitnessMaxRead + } + readResponse, err := l.client.Read(ctx, &policylogv1.ReadRequest{FromSeq: afterSequence + 1, Limit: uint32(limit)}) + if err != nil { + return nil, fmt.Errorf("policy log witness read: %w", err) + } + if len(readResponse.GetRecords()) == 0 { + // The head says there is more and the page says there is nothing: a + // witness withholding entries reads exactly like a quiet log otherwise. + return nil, fmt.Errorf("%w: the witness head is %d but a read from %d returned nothing", + ErrPolicyLogWitnessEvidence, cursor.headSeq, afterSequence+1) + } + + // The chain hash the first record must extend, when this client knows it. + var previous []byte + switch afterSequence { + case 0: + previous = make([]byte, sha256.Size) + case floor: + previous = floorChain + } + records := make([]*business.PolicyLogRecord, 0, len(readResponse.GetRecords())) + var last *witnessReceipt + for index, wire := range readResponse.GetRecords() { + want := afterSequence + 1 + uint64(index) + record, receipt, err := l.verifiedRecord(wire, want, previous) + if err != nil { + return nil, err + } + if want == floor && !bytes.Equal(receipt.chainHash, floorChain) { + return nil, fmt.Errorf("%w: record %d names a different chain hash from the one verified; the log has forked", + ErrPolicyLogWitnessEvidence, want) + } + if want == cursor.headSeq && !bytes.Equal(receipt.chainHash, cursor.chainHash) { + return nil, fmt.Errorf("%w: record %d does not end the chain the signed cursor names", ErrPolicyLogWitnessEvidence, want) + } + previous = receipt.chainHash + last = receipt + records = append(records, record) + } + l.observe(last.seq, last.chainHash) + return records, nil +} + +// policyLogWitnessMaxRead is the witness's own page bound (witness.MaxReadLimit). +const policyLogWitnessMaxRead = 500 + +func (l *WitnessPolicyLog) verifiedRecord( + wire *policylogv1.Record, want uint64, previous []byte, +) (*business.PolicyLogRecord, *witnessReceipt, error) { + receipt, err := receiptFrom(wire.GetReceipt()) + if err != nil { + return nil, nil, err + } + if receipt.seq != want { + return nil, nil, fmt.Errorf("%w: expected record %d, the witness returned %d", ErrPolicyLogWitnessEvidence, want, receipt.seq) + } + if !verifyWitnessReceipt(l.key.PublicKey, receipt) { + return nil, nil, fmt.Errorf("%w: record %d is not signed by the pinned key %s", ErrPolicyLogWitnessEvidence, want, l.key.KeyID) + } + entryHash, err := witnessEntryHash(wire.GetEntry()) + if err != nil { + return nil, nil, fmt.Errorf("%w: record %d: %w", ErrPolicyLogWitnessEvidence, want, err) + } + if !bytes.Equal(entryHash, receipt.entryHash) { + return nil, nil, fmt.Errorf("%w: record %d's entry does not hash to the digest its receipt signs", ErrPolicyLogWitnessEvidence, want) + } + if previous != nil && !bytes.Equal(receipt.prevHash, previous) { + return nil, nil, fmt.Errorf("%w: record %d does not chain to the record before it", ErrPolicyLogWitnessEvidence, want) + } + kind, decision, err := parseWitnessKind(wire.GetEntry().GetKind()) + if err != nil { + // A record this host cannot name is a gap no reconciliation could + // act on. Refusing it keeps the cursor below it, so the staleness + // window closes rather than the host serving past an entry it skipped. + return nil, nil, fmt.Errorf("%w: record %d: %w", ErrPolicyLogWitnessEvidence, want, err) + } + encoded, err := encodeWitnessReceipt(l.key.KeyID, receipt) + if err != nil { + return nil, nil, err + } + return &business.PolicyLogRecord{ + // Actor, Policy, Supersedes and EnvelopeRevision are NOT here: the + // witness holds their digest, never their bytes. Reconciliation needs + // only the operation and its subject to name a gap. + PolicyLogEntry: business.PolicyLogEntry{ + OperationID: wire.GetEntry().GetOperationId(), + Decision: decision, + SubjectKind: kind, + SubjectID: wire.GetEntry().GetSubject(), + }, + Receipt: encoded, + Sequence: receipt.seq, + LoggedAt: receipt.issuedAt, + }, receipt, nil +} + +// checkPublishedKey compares the witness's published key with the pin until +// they have been seen to match once. It never replaces the pin. +func (l *WitnessPolicyLog) checkPublishedKey(ctx context.Context) error { + l.mu.Lock() + matched := l.keyMatched + l.mu.Unlock() + if matched { + return nil + } + response, err := l.client.Keys(ctx, &policylogv1.KeysRequest{}) + if err != nil { + return fmt.Errorf("policy log witness keys: %w", err) + } + published := response.GetKey() + if published.GetKeyId() != l.key.KeyID || !bytes.Equal(published.GetPublicKey(), l.key.PublicKey) { + return fmt.Errorf("%w: the witness publishes key %q and this host pins %q; nothing it signs can verify here, and a published key is never adopted in place of the pin", + ErrPolicyLogWitnessEvidence, published.GetKeyId(), l.key.KeyID) + } + l.mu.Lock() + l.keyMatched = true + l.mu.Unlock() + return nil +} + +// verified returns the highest verified sequence and its chain hash. Before +// anything is verified that is sequence 0, whose chain hash is the witness's +// genesis value of 32 zero bytes — so the first receipt a fresh witness issues +// must chain to it. +func (l *WitnessPolicyLog) verified() (uint64, []byte) { + l.mu.Lock() + defer l.mu.Unlock() + if l.highest == 0 { + return 0, make([]byte, sha256.Size) + } + return l.highest, bytes.Clone(l.chain) +} + +func (l *WitnessPolicyLog) observe(seq uint64, chain []byte) { + l.mu.Lock() + defer l.mu.Unlock() + if seq > l.highest { + l.highest, l.chain = seq, bytes.Clone(chain) + } +} + +// --- The canonical encodings. --- +// +// These restate the witness's published contract (its README "Canonical +// bytes", and witness/contract.go) rather than importing it: the witness is a +// separate Go module this service cannot link, which is the same reason its +// proto is copied here. The integration test verifies receipts the REAL witness +// signed with these functions, so the restatement is checked against the +// implementation it restates rather than against itself. + +type witnessReceipt struct { + seq uint64 + entryHash []byte + prevHash []byte + chainHash []byte + issuedAt time.Time + signature []byte +} + +type witnessCursor struct { + headSeq uint64 + chainHash []byte + issuedAt time.Time + signature []byte +} + +func put64(b *bytes.Buffer, n uint64) { + var raw [8]byte + binary.BigEndian.PutUint64(raw[:], n) + b.Write(raw[:]) +} + +func put32(b *bytes.Buffer, n uint32) { + var raw [4]byte + binary.BigEndian.PutUint32(raw[:], n) + b.Write(raw[:]) +} + +func putString(b *bytes.Buffer, s string) { + put32(b, uint32(len(s))) + b.WriteString(s) +} + +func putTime(b *bytes.Buffer, t time.Time) { + put64(b, uint64(t.Unix())) + put32(b, uint32(t.Nanosecond())) +} + +// witnessEntryHash is entry_hash over a wire entry, after the checks the +// witness's own Entry.Validate makes on the parts the hash covers. +func witnessEntryHash(entry *policylogv1.Entry) ([]byte, error) { + if entry == nil { + return nil, errors.New("policy log entry is absent") + } + if len(entry.GetNarrowingHash()) != sha256.Size { + return nil, errors.New("policy log entry carries no SHA-256 narrowing hash") + } + if err := entry.GetRequestedAt().CheckValid(); err != nil { + return nil, fmt.Errorf("policy log entry has no valid requested_at: %w", err) + } + b := bytes.NewBufferString("policy-log.entry.v1\x00") + for _, s := range []string{entry.GetOperationId(), entry.GetKind(), entry.GetSubject(), entry.GetTenant()} { + putString(b, s) + } + b.Write(entry.GetNarrowingHash()) + putTime(b, entry.GetRequestedAt().AsTime()) + digest := sha256.Sum256(b.Bytes()) + return digest[:], nil +} + +func witnessChainHash(seq uint64, prev, entry []byte) []byte { + b := bytes.NewBufferString("policy-log.chain.v1\x00") + put64(b, seq) + b.Write(prev) + b.Write(entry) + digest := sha256.Sum256(b.Bytes()) + return digest[:] +} + +func witnessReceiptBytes(r *witnessReceipt) []byte { + b := bytes.NewBufferString("policy-log.receipt.v1\x00") + put64(b, r.seq) + b.Write(r.entryHash) + b.Write(r.prevHash) + b.Write(r.chainHash) + putTime(b, r.issuedAt) + return b.Bytes() +} + +func witnessCursorBytes(c *witnessCursor) []byte { + b := bytes.NewBufferString("policy-log.cursor.v1\x00") + put64(b, c.headSeq) + b.Write(c.chainHash) + putTime(b, c.issuedAt) + return b.Bytes() +} + +// verifyWitnessReceipt checks the signature AND the chain link the signature +// covers: a receipt whose chain_hash is not the hash of its own sequence, +// previous hash and entry hash is not a link in any chain, however it is signed. +func verifyWitnessReceipt(key ed25519.PublicKey, r *witnessReceipt) bool { + return len(key) == ed25519.PublicKeySize && r.seq != 0 && + bytes.Equal(r.chainHash, witnessChainHash(r.seq, r.prevHash, r.entryHash)) && + ed25519.Verify(key, witnessReceiptBytes(r), r.signature) +} + +func verifyWitnessCursor(key ed25519.PublicKey, c *witnessCursor) bool { + return len(key) == ed25519.PublicKeySize && + (c.headSeq != 0 || bytes.Equal(c.chainHash, make([]byte, sha256.Size))) && + ed25519.Verify(key, witnessCursorBytes(c), c.signature) +} + +func receiptFrom(wire *policylogv1.Receipt) (*witnessReceipt, error) { + if wire == nil { + return nil, fmt.Errorf("%w: the witness returned no receipt", ErrPolicyLogWitnessEvidence) + } + if len(wire.GetEntryHash()) != sha256.Size || len(wire.GetPrevHash()) != sha256.Size || + len(wire.GetChainHash()) != sha256.Size || len(wire.GetSignature()) != ed25519.SignatureSize { + return nil, fmt.Errorf("%w: the receipt's digests or signature have the wrong length", ErrPolicyLogWitnessEvidence) + } + if err := wire.GetIssuedAt().CheckValid(); err != nil { + return nil, fmt.Errorf("%w: the receipt has no valid issued_at", ErrPolicyLogWitnessEvidence) + } + return &witnessReceipt{ + seq: wire.GetSeq(), + entryHash: bytes.Clone(wire.GetEntryHash()), + prevHash: bytes.Clone(wire.GetPrevHash()), + chainHash: bytes.Clone(wire.GetChainHash()), + issuedAt: wire.GetIssuedAt().AsTime(), + signature: bytes.Clone(wire.GetSignature()), + }, nil +} + +func cursorFrom(wire *policylogv1.Cursor) (*witnessCursor, error) { + if wire == nil { + return nil, fmt.Errorf("%w: the witness returned no cursor", ErrPolicyLogWitnessEvidence) + } + if len(wire.GetChainHash()) != sha256.Size || len(wire.GetSignature()) != ed25519.SignatureSize { + return nil, fmt.Errorf("%w: the cursor's digest or signature has the wrong length", ErrPolicyLogWitnessEvidence) + } + if err := wire.GetIssuedAt().CheckValid(); err != nil { + return nil, fmt.Errorf("%w: the cursor has no valid issued_at", ErrPolicyLogWitnessEvidence) + } + return &witnessCursor{ + headSeq: wire.GetHeadSeq(), + chainHash: bytes.Clone(wire.GetChainHash()), + issuedAt: wire.GetIssuedAt().AsTime(), + signature: bytes.Clone(wire.GetSignature()), + }, nil +} + +// witnessKind names the subject kind and the decision in the entry's kind, so a +// record read back can be named as the gap it is. Both are closed sets. +func witnessKind(entry *business.PolicyLogEntry) string { + return string(entry.SubjectKind) + "." + string(entry.Decision) +} + +func parseWitnessKind(kind string) (business.PolicyLogSubjectKind, business.PolicyLogDecision, error) { + cut := strings.LastIndex(kind, ".") + if cut <= 0 { + return "", "", fmt.Errorf("entry kind %q is not one this host writes", kind) + } + subject := business.PolicyLogSubjectKind(kind[:cut]) + decision := business.PolicyLogDecision(kind[cut+1:]) + if !subject.Valid() || (decision != business.PolicyLogNarrowed && decision != business.PolicyLogRegranted) { + return "", "", fmt.Errorf("entry kind %q is not one this host writes", kind) + } + return subject, decision, nil +} + +// witnessEntry is the wire entry for one operation, at one requested_at. +// +// Tenant is empty on purpose: the witness's tenant grants no authority and +// scopes nothing, and an organisation id is domain content the narrowing hash +// already covers. +func witnessEntry(entry *business.PolicyLogEntry, requestedAt time.Time) (*policylogv1.Entry, error) { + digest, err := narrowingHash(entry) + if err != nil { + return nil, err + } + return &policylogv1.Entry{ + OperationId: entry.OperationID, + Kind: witnessKind(entry), + Subject: entry.SubjectID, + NarrowingHash: digest, + RequestedAt: timestamppb.New(requestedAt), + }, nil +} + +// narrowingHash is the SHA-256 of this host's canonical encoding of the whole +// operation. Every field of the entry is covered, so the receipt binds the +// policy and the actor even though the witness never sees either. +// +// SHA256("accounts.policy-log.narrowing.v1\0" || +// S(operation_id) || S(decision) || S(subject_kind) || S(subject_id) || +// S(supersedes) || U64(envelope_revision) || S(actor) || S(policy_json)) +// +// policy_json is encoding/json's rendering of the policy map, whose object keys +// are sorted at every depth, so equal policies encode to equal bytes. +func narrowingHash(entry *business.PolicyLogEntry) ([]byte, error) { + policy, err := json.Marshal(entry.Policy) + if err != nil { + return nil, fmt.Errorf("policy log entry %q: policy does not encode: %w", entry.OperationID, err) + } + b := bytes.NewBufferString("accounts.policy-log.narrowing.v1\x00") + putString(b, entry.OperationID) + putString(b, string(entry.Decision)) + putString(b, string(entry.SubjectKind)) + putString(b, entry.SubjectID) + putString(b, entry.Supersedes) + put64(b, entry.EnvelopeRevision) + putString(b, entry.Actor) + putString(b, string(policy)) + digest := sha256.Sum256(b.Bytes()) + return digest[:], nil +} + +// witnessReceiptDocument is the receipt as this host stores it: the witness's +// own fields plus the id of the key that verified it, so the stored row is +// evidence anyone holding the pinned key can re-verify offline. +type witnessReceiptDocument struct { + Version int `json:"v"` + KeyID string `json:"key_id"` + Seq uint64 `json:"seq"` + EntryHash string `json:"entry_hash"` + PrevHash string `json:"prev_hash"` + ChainHash string `json:"chain_hash"` + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` +} + +func encodeWitnessReceipt(keyID string, r *witnessReceipt) (string, error) { + encoded, err := json.Marshal(witnessReceiptDocument{ + Version: 1, + KeyID: keyID, + Seq: r.seq, + EntryHash: base64.StdEncoding.EncodeToString(r.entryHash), + PrevHash: base64.StdEncoding.EncodeToString(r.prevHash), + ChainHash: base64.StdEncoding.EncodeToString(r.chainHash), + IssuedAt: r.issuedAt.UTC().Format(time.RFC3339Nano), + Signature: base64.StdEncoding.EncodeToString(r.signature), + }) + if err != nil { + return "", err + } + return string(encoded), nil +} diff --git a/module/services/accounts/code/pkg/infra/policy_log_witness_integration_test.go b/module/services/accounts/code/pkg/infra/policy_log_witness_integration_test.go new file mode 100644 index 000000000..d331cffb4 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/policy_log_witness_integration_test.go @@ -0,0 +1,115 @@ +package infra_test + +import ( + "context" + "testing" + + "accounts/pkg/business" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" + "accounts/pkg/infra" + "accounts/pkg/infra/policylogwitnesstest" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// The client against the REAL witness: the policy-log module's own server, +// service and witness log over its in-memory store, in a helper process. +// +// This is the test the fake cannot be. Every receipt, record and cursor below +// was signed by the witness's code with the witness's key, and verified here by +// this service's restatement of the canonical encodings under a key pinned +// from the witness's own key document. If the two encodings disagreed in a +// single byte, nothing here would verify. +func TestTheClientVerifiesWhatTheRealWitnessSigns(t *testing.T) { + witness := policylogwitnesstest.Start(t) + pin, err := infra.ParsePolicyLogWitnessKey(witness.KeyDocument) + require.NoError(t, err, "the witness's published key document is the trust-anchor schema") + + conn, err := grpc.NewClient(witness.Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + writer, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), pin) + require.NoError(t, err) + + ctx := context.Background() + entries := []*business.PolicyLogEntry{ + { + OperationID: "uninstall-solution/1", Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogInstallation, SubjectID: "installation-1", + Policy: map[string]any{"org_id": "org-1", "status": "revoked"}, Actor: "actor-1", + }, + { + OperationID: "revoke-scope/1", Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogScopeGrant, SubjectID: "org-1/subject/plsol/role", + Policy: map[string]any{"status": "revoked"}, Actor: "actor-1", + }, + { + OperationID: "regrant/1", Decision: business.PolicyLogRegranted, + SubjectKind: business.PolicyLogPrincipal, SubjectID: "principal-1", + Policy: map[string]any{"status": "active"}, Actor: "actor-2", EnvelopeRevision: 7, + }, + } + receipts := make([]*business.PolicyLogReceipt, 0, len(entries)) + for i, entry := range entries { + receipt, err := writer.Append(ctx, entry) + require.NoError(t, err, "a receipt the real witness signed verifies under the pin") + require.Equal(t, uint64(i+1), receipt.Sequence, "the witness assigns the sequence") + receipts = append(receipts, receipt) + } + + // A retry of the identical operation returns the original receipt: the + // witness's idempotency, through this client. + again, err := writer.Append(ctx, entries[0]) + // The retry is a NEW Append call, so it carries a new requested_at and is + // therefore different bytes under the same operation id — which the + // witness refuses rather than re-sequencing. That is the property that + // keeps one operation id one entry. + require.Error(t, err) + require.Nil(t, again) + + // Another replica reads the chain back, verifying every link and the signed + // cursor at its head. + reader, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), pin) + require.NoError(t, err) + records, err := reader.Entries(ctx, 0, 500) + require.NoError(t, err) + require.Len(t, records, len(entries)) + for i, record := range records { + require.Equal(t, entries[i].OperationID, record.OperationID) + require.Equal(t, entries[i].SubjectKind, record.SubjectKind) + require.Equal(t, entries[i].SubjectID, record.SubjectID) + require.Equal(t, entries[i].Decision, record.Decision) + require.Equal(t, receipts[i].Sequence, record.Sequence) + require.Equal(t, receipts[i].Receipt, record.Receipt, + "the record read back carries the very receipt the append returned") + } + records, err = reader.Entries(ctx, uint64(len(entries)), 500) + require.NoError(t, err) + require.Empty(t, records) + + // A host that pins a different key believes none of it. + impostor := policylogwitnesstest.Start(t) + otherPin, err := infra.ParsePolicyLogWitnessKey(impostor.KeyDocument) + require.NoError(t, err) + mispinned, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), otherPin) + require.NoError(t, err) + _, err = mispinned.Entries(ctx, 0, 500) + require.ErrorIs(t, err, infra.ErrPolicyLogWitnessEvidence) + _, err = mispinned.Append(ctx, &business.PolicyLogEntry{ + OperationID: "uninstall-solution/2", Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogInstallation, SubjectID: "installation-2", Actor: "actor-1", + }) + require.ErrorIs(t, err, infra.ErrPolicyLogWitnessEvidence) + + // With the witness down, nothing appends and nothing reads. + witness.Stop() + _, err = writer.Append(ctx, &business.PolicyLogEntry{ + OperationID: "uninstall-solution/3", Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogInstallation, SubjectID: "installation-3", Actor: "actor-1", + }) + require.Error(t, err) + _, err = reader.Entries(ctx, uint64(len(entries)), 500) + require.Error(t, err) +} diff --git a/module/services/accounts/code/pkg/infra/policy_log_witness_test.go b/module/services/accounts/code/pkg/infra/policy_log_witness_test.go new file mode 100644 index 000000000..6c36c7900 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/policy_log_witness_test.go @@ -0,0 +1,573 @@ +package infra + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "accounts/pkg/business" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// The witness client against a FAKE witness: one that signs, sequences and +// chains like the real one, and can be made to lie in each way the client must +// refuse. What a fake cannot show is that these encodings agree with the real +// witness's — that is policy_log_witness_integration_test.go, which verifies +// receipts the real witness signed. + +// fakeWitness is policylogv1.WitnessServiceClient over an in-memory chain. +type fakeWitness struct { + mu sync.Mutex + private ed25519.PrivateKey + public ed25519.PublicKey + records []*policylogv1.Record + byOp map[string]*policylogv1.Receipt + now time.Time + + // appendRequests records every entry offered, retries included. + appendRequests []*policylogv1.Entry + cursorCalls int + + // Tampering, each applied to the next answer of its kind. + appendErrs []error + signWith ed25519.PrivateKey // receipts and cursors signed by another key + flipSignature bool + forceSeq uint64 // receipt claims this sequence + forcePrev []byte // receipt claims this previous hash + forceEntryHash []byte // receipt witnesses these bytes instead + forceChain []byte // receipt signs a chain hash that is not its own link + cursorIssuedAt time.Time + cursorHeadDelta int64 // cursor claims head + delta + publishKey ed25519.PublicKey + dropRecords bool + breakChainAt uint64 // the record at this sequence claims a wrong prev hash + resignAt uint64 // the record at this sequence is re-signed by signWithAtRead + signWithAtRead ed25519.PrivateKey + alterEntryAt uint64 // the record at this sequence names another subject +} + +func newFakeWitness(t *testing.T) *fakeWitness { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + return &fakeWitness{ + private: private, public: public, byOp: map[string]*policylogv1.Receipt{}, + now: time.Date(2026, 10, 10, 12, 0, 0, 123456789, time.UTC), + } +} + +func (f *fakeWitness) pin(t *testing.T) PolicyLogWitnessKey { + t.Helper() + digest := sha256.Sum256(f.public) + key, err := policyLogWitnessKeyFrom("Ed25519", hex.EncodeToString(digest[:]), f.public) + require.NoError(t, err) + return key +} + +func (f *fakeWitness) signer() ed25519.PrivateKey { + if f.signWith != nil { + return f.signWith + } + return f.private +} + +func (f *fakeWitness) head() (uint64, []byte) { + if len(f.records) == 0 { + return 0, make([]byte, sha256.Size) + } + last := f.records[len(f.records)-1].GetReceipt() + return last.GetSeq(), last.GetChainHash() +} + +func (f *fakeWitness) Append(_ context.Context, in *policylogv1.AppendRequest, _ ...grpc.CallOption) (*policylogv1.AppendResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.appendRequests = append(f.appendRequests, proto.Clone(in.GetEntry()).(*policylogv1.Entry)) + if len(f.appendErrs) > 0 { + err := f.appendErrs[0] + f.appendErrs = f.appendErrs[1:] + if err != nil { + return nil, err + } + } + if existing, ok := f.byOp[in.GetEntry().GetOperationId()]; ok { + return &policylogv1.AppendResponse{Receipt: existing}, nil + } + entryHash, err := witnessEntryHash(in.GetEntry()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + seq, prev := f.head() + seq++ + if f.forceEntryHash != nil { + entryHash = f.forceEntryHash + } + if f.forceSeq != 0 { + seq = f.forceSeq + } + if f.forcePrev != nil { + prev = f.forcePrev + } + receipt := f.sign(seq, prev, entryHash) + f.records = append(f.records, &policylogv1.Record{Entry: in.GetEntry(), Receipt: receipt}) + f.byOp[in.GetEntry().GetOperationId()] = receipt + return &policylogv1.AppendResponse{Receipt: receipt}, nil +} + +func (f *fakeWitness) sign(seq uint64, prev, entryHash []byte) *policylogv1.Receipt { + r := &witnessReceipt{ + seq: seq, entryHash: entryHash, prevHash: prev, + chainHash: witnessChainHash(seq, prev, entryHash), issuedAt: f.now, + } + if f.forceChain != nil { + r.chainHash = f.forceChain + } + r.signature = ed25519.Sign(f.signer(), witnessReceiptBytes(r)) + if f.flipSignature { + r.signature[0] ^= 0x01 + } + return &policylogv1.Receipt{ + Seq: r.seq, EntryHash: r.entryHash, PrevHash: r.prevHash, ChainHash: r.chainHash, + IssuedAt: timestamppb.New(r.issuedAt), Signature: r.signature, + } +} + +func (f *fakeWitness) Cursor(context.Context, *policylogv1.CursorRequest, ...grpc.CallOption) (*policylogv1.CursorResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.cursorCalls++ + seq, chain := f.head() + seq = uint64(int64(seq) + f.cursorHeadDelta) + issued := f.now + if !f.cursorIssuedAt.IsZero() { + issued = f.cursorIssuedAt + } + c := &witnessCursor{headSeq: seq, chainHash: chain, issuedAt: issued} + c.signature = ed25519.Sign(f.signer(), witnessCursorBytes(c)) + if f.flipSignature { + c.signature[0] ^= 0x01 + } + return &policylogv1.CursorResponse{Cursor: &policylogv1.Cursor{ + HeadSeq: c.headSeq, ChainHash: c.chainHash, IssuedAt: timestamppb.New(c.issuedAt), Signature: c.signature, + }}, nil +} + +func (f *fakeWitness) Read(_ context.Context, in *policylogv1.ReadRequest, _ ...grpc.CallOption) (*policylogv1.ReadResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := &policylogv1.ReadResponse{} + if f.dropRecords { + return out, nil + } + for _, record := range f.records { + if record.GetReceipt().GetSeq() >= in.GetFromSeq() && uint32(len(out.Records)) < in.GetLimit() { + copied := proto.Clone(record).(*policylogv1.Record) + if copied.GetReceipt().GetSeq() == f.breakChainAt { + copied.Receipt = f.sign(copied.GetReceipt().GetSeq(), bytes.Repeat([]byte{7}, sha256.Size), copied.GetReceipt().GetEntryHash()) + } + if copied.GetReceipt().GetSeq() == f.resignAt { + r := copied.GetReceipt() + signed := &witnessReceipt{seq: r.GetSeq(), entryHash: r.GetEntryHash(), prevHash: r.GetPrevHash(), + chainHash: r.GetChainHash(), issuedAt: r.GetIssuedAt().AsTime()} + r.Signature = ed25519.Sign(f.signWithAtRead, witnessReceiptBytes(signed)) + } + if copied.GetReceipt().GetSeq() == f.alterEntryAt { + copied.Entry.Subject = "another-subject" + } + out.Records = append(out.Records, copied) + } + } + return out, nil +} + +func (f *fakeWitness) Keys(context.Context, *policylogv1.KeysRequest, ...grpc.CallOption) (*policylogv1.KeysResponse, error) { + public := f.public + if f.publishKey != nil { + public = f.publishKey + } + digest := sha256.Sum256(public) + return &policylogv1.KeysResponse{Key: &policylogv1.Key{ + Algorithm: "Ed25519", KeyId: hex.EncodeToString(digest[:]), PublicKey: public, + }}, nil +} + +func newTestWitnessLog(t *testing.T, fake *fakeWitness) *WitnessPolicyLog { + t.Helper() + log, err := NewWitnessPolicyLog(fake, fake.pin(t)) + require.NoError(t, err) + log.now = func() time.Time { return fake.now } + return log +} + +func narrowing(operation string) *business.PolicyLogEntry { + return &business.PolicyLogEntry{ + OperationID: operation, + Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogInstallation, + SubjectID: "installation-1", + Policy: map[string]any{"org_id": "org-1", "status": "revoked"}, + Actor: "actor-1", + } +} + +func TestWitnessAppendReturnsTheVerifiedReceipt(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + + receipt, err := log.Append(context.Background(), narrowing("uninstall-solution/1")) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Sequence) + require.True(t, receipt.AppendedAt.Equal(fake.now), "the receipt carries the WITNESS's clock") + + // The stored receipt is evidence anyone holding the pin can re-verify. + var stored witnessReceiptDocument + require.NoError(t, json.Unmarshal([]byte(receipt.Receipt), &stored)) + require.Equal(t, fake.pin(t).KeyID, stored.KeyID) + require.Equal(t, uint64(1), stored.Seq) + require.LessOrEqual(t, len(receipt.Receipt), 1024, "policy_log_commits.receipt holds at most 1024 characters") + signature, err := base64.StdEncoding.DecodeString(stored.Signature) + require.NoError(t, err) + require.Equal(t, fake.records[0].GetReceipt().GetSignature(), signature) + + // The narrowing hash covers the whole entry, policy and actor included. + other := narrowing("uninstall-solution/1") + other.Actor = "actor-2" + first, err := narrowingHash(narrowing("uninstall-solution/1")) + require.NoError(t, err) + second, err := narrowingHash(other) + require.NoError(t, err) + require.NotEqual(t, first, second) + require.Equal(t, first, fake.records[0].GetEntry().GetNarrowingHash()) + require.Equal(t, "installation.narrowed", fake.records[0].GetEntry().GetKind()) +} + +// Each way a receipt can fail to be evidence, and each is refused. +func TestWitnessAppendRefusesEvidenceThatDoesNotVerify(t *testing.T) { + _, stranger, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + for name, tamper := range map[string]func(*fakeWitness){ + "signed by another key": func(f *fakeWitness) { f.signWith = stranger }, + "signature altered": func(f *fakeWitness) { f.flipSignature = true }, + "witnesses other bytes": func(f *fakeWitness) { f.forceEntryHash = bytes.Repeat([]byte{9}, sha256.Size) }, + "breaks the genesis link": func(f *fakeWitness) { f.forcePrev = bytes.Repeat([]byte{3}, sha256.Size) }, + // Signed, and over a chain hash that is not the hash of its own + // sequence, previous hash and entry: not a link in any chain. + "signs a chain hash it did not compute": func(f *fakeWitness) { f.forceChain = bytes.Repeat([]byte{4}, sha256.Size) }, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + tamper(fake) + receipt, err := log.Append(context.Background(), narrowing("op/"+name)) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Nil(t, receipt) + }) + } +} + +// A receipt at or below a sequence this client already verified is a witness +// that has rolled back, however well it is signed. +func TestWitnessAppendRefusesASequenceRegression(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + for i := range 3 { + _, err := log.Append(context.Background(), narrowing("op/"+string(rune('a'+i)))) + require.NoError(t, err) + } + fake.forceSeq = 2 + _, err := log.Append(context.Background(), narrowing("op/regressed")) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Contains(t, err.Error(), "rolled back") +} + +// The next sequence must extend the chain this client verified. +func TestWitnessAppendRefusesAFork(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + _, err := log.Append(context.Background(), narrowing("op/1")) + require.NoError(t, err) + fake.forcePrev = bytes.Repeat([]byte{5}, sha256.Size) + _, err = log.Append(context.Background(), narrowing("op/2")) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Contains(t, err.Error(), "forked") +} + +// Unavailable means no sequence was assigned, so the identical entry is offered +// again — requested_at included, or the witness would read a different entry. +func TestWitnessAppendRetriesUnavailableWithTheIdenticalEntry(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + calls := 0 + log.now = func() time.Time { calls++; return fake.now.Add(time.Duration(calls) * time.Second) } + fake.appendErrs = []error{status.Error(codes.Unavailable, "no sequence assigned")} + + receipt, err := log.Append(context.Background(), narrowing("op/retry")) + require.NoError(t, err) + require.Equal(t, uint64(1), receipt.Sequence) + require.Len(t, fake.appendRequests, 2) + require.True(t, proto.Equal(fake.appendRequests[0], fake.appendRequests[1]), + "a retry must offer the identical entry") +} + +// Every other answer is final: a conflict or a pending slot is not cleared by +// offering the same bytes again. +func TestWitnessAppendDoesNotRetryARefusal(t *testing.T) { + for _, code := range []codes.Code{codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted, codes.DataLoss, codes.InvalidArgument} { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + fake.appendErrs = []error{status.Error(code, "refused")} + _, err := log.Append(context.Background(), narrowing("op/refused")) + require.Error(t, err) + require.Equal(t, code, status.Code(errors.Unwrap(err)), "the witness's code reaches the caller") + require.Len(t, fake.appendRequests, 1, "%s is final and must not be retried", code) + } +} + +func TestWitnessEntriesReturnsVerifiedRecords(t *testing.T) { + fake := newFakeWitness(t) + writer := newTestWitnessLog(t, fake) + for _, op := range []string{"op/1", "op/2", "op/3"} { + _, err := writer.Append(context.Background(), narrowing(op)) + require.NoError(t, err) + } + + // A different replica: it has verified nothing yet. + reader := newTestWitnessLog(t, fake) + records, err := reader.Entries(context.Background(), 0, 500) + require.NoError(t, err) + require.Len(t, records, 3) + for i, record := range records { + require.Equal(t, uint64(i+1), record.Sequence) + require.Equal(t, business.PolicyLogInstallation, record.SubjectKind) + require.Equal(t, business.PolicyLogNarrowed, record.Decision) + require.Equal(t, "installation-1", record.SubjectID) + require.NotEmpty(t, record.Receipt) + } + + // Nothing new past the head is an empty page, not an error. + records, err = reader.Entries(context.Background(), 3, 500) + require.NoError(t, err) + require.Empty(t, records) + + // Partial pages chain across the boundary. + other := newTestWitnessLog(t, fake) + records, err = other.Entries(context.Background(), 1, 1) + require.NoError(t, err) + require.Len(t, records, 1) + records, err = other.Entries(context.Background(), 2, 500) + require.NoError(t, err) + require.Len(t, records, 1) +} + +// Each way a read can fail to show the witness's current, unbroken chain. +func TestWitnessEntriesRefusesEvidenceThatDoesNotVerify(t *testing.T) { + _, stranger, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + strangerPublic := stranger.Public().(ed25519.PublicKey) + for name, tc := range map[string]struct { + tamper func(*fakeWitness) + after uint64 + says string + }{ + "cursor signed by another key": {tamper: func(f *fakeWitness) { f.signWith = stranger }}, + "cursor signature altered": {tamper: func(f *fakeWitness) { f.flipSignature = true }}, + "cursor too old": {tamper: func(f *fakeWitness) { f.cursorIssuedAt = f.now.Add(-2 * time.Minute) }}, + "cursor from the future": {tamper: func(f *fakeWitness) { f.cursorIssuedAt = f.now.Add(2 * time.Minute) }}, + "head below the reconciled cursor": {tamper: func(*fakeWitness) {}, after: 5, says: "rolled back"}, + "records withheld": {tamper: func(f *fakeWitness) { f.dropRecords = true }}, + "a record off the chain": {tamper: func(f *fakeWitness) { f.breakChainAt = 2 }, says: "does not chain"}, + "a record signed by another key": {tamper: func(f *fakeWitness) { f.resignAt, f.signWithAtRead = 2, stranger }, says: "record 2 is not signed"}, + "a record whose entry was altered": {tamper: func(f *fakeWitness) { f.alterEntryAt = 2 }, says: "does not hash"}, + "another key published": {tamper: func(f *fakeWitness) { f.publishKey = strangerPublic }}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeWitness(t) + writer := newTestWitnessLog(t, fake) + for _, op := range []string{"op/1", "op/2", "op/3"} { + _, err := writer.Append(context.Background(), narrowing(op)) + require.NoError(t, err) + } + reader := newTestWitnessLog(t, fake) + tc.tamper(fake) + records, err := reader.Entries(context.Background(), tc.after, 500) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Contains(t, err.Error(), tc.says) + require.Nil(t, records) + }) + } +} + +// The published key is compared, never adopted: a mismatch is refused before a +// cursor is even asked for, and the pin still decides every later verification. +func TestWitnessNeverAdoptsThePublishedKey(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + _, stranger, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + fake.publishKey = stranger.Public().(ed25519.PublicKey) + + _, err = log.Entries(context.Background(), 0, 500) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Contains(t, err.Error(), "never adopted") + require.Zero(t, fake.cursorCalls) + require.Equal(t, fake.pin(t).KeyID, log.key.KeyID) +} + +// A cursor below what this client verified is a rollback even when the host's +// persisted cursor has not caught up yet. +func TestWitnessEntriesRefusesAHeadBelowWhatWasVerified(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + for _, op := range []string{"op/1", "op/2"} { + _, err := log.Append(context.Background(), narrowing(op)) + require.NoError(t, err) + } + fake.cursorHeadDelta = -1 + _, err := log.Entries(context.Background(), 0, 500) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.Contains(t, err.Error(), "rolled back") +} + +func TestPolicyLogWitnessKeyDocument(t *testing.T) { + public, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + digest := sha256.Sum256(public) + document := func(mutate func(map[string]any)) []byte { + fields := map[string]any{ + "algorithm": "Ed25519", + "key_id": hex.EncodeToString(digest[:]), + "public_key": base64.StdEncoding.EncodeToString(public), + } + mutate(fields) + encoded, err := json.Marshal(fields) + require.NoError(t, err) + return encoded + } + + key, err := ParsePolicyLogWitnessKey(document(func(map[string]any) {})) + require.NoError(t, err) + require.Equal(t, hex.EncodeToString(digest[:]), key.KeyID) + require.True(t, key.PublicKey.Equal(public)) + + for name, mutate := range map[string]func(map[string]any){ + "another algorithm": func(f map[string]any) { f["algorithm"] = "ECDSA" }, + "key id of another key": func(f map[string]any) { f["key_id"] = hex.EncodeToString(make([]byte, 32)) }, + "short key": func(f map[string]any) { f["public_key"] = base64.StdEncoding.EncodeToString(public[:16]) }, + "a field this host does not model": func(f map[string]any) { f["rotates_to"] = "x" }, + } { + t.Run(name, func(t *testing.T) { + _, err := ParsePolicyLogWitnessKey(document(mutate)) + require.ErrorIs(t, err, ErrPolicyLogWitnessKeyUnusable) + }) + } +} + +// --- Through the protocol: what the client's verdict does to a narrowing. --- + +// controlPlaneOnly runs a control-plane transaction as a plain call; every other +// Store method panics, because these tests reach nothing else. +type controlPlaneOnly struct{ business.Store } + +func (controlPlaneOnly) WithControlPlane(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +// receiptLedger is the local half: the receipts recorded and the commits. +type receiptLedger struct { + recorded map[string]*business.PolicyLogReceipt + committed map[string]bool +} + +func newReceiptLedger() *receiptLedger { + return &receiptLedger{recorded: map[string]*business.PolicyLogReceipt{}, committed: map[string]bool{}} +} + +func (l *receiptLedger) RecordPolicyLogAppend(_ context.Context, receipt *business.PolicyLogReceipt, entry *business.PolicyLogEntry) (bool, error) { + if _, ok := l.recorded[entry.OperationID]; ok { + return false, nil + } + l.recorded[entry.OperationID] = receipt + return true, nil +} + +func (l *receiptLedger) CommitPolicyLogOperation(_ context.Context, operationID string, _ time.Time) error { + if _, ok := l.recorded[operationID]; !ok { + return errors.New("no append was recorded for " + operationID) + } + l.committed[operationID] = true + return nil +} + +func (l *receiptLedger) UncommittedPolicyLogOperations(context.Context) ([]*business.PolicyLogGap, error) { + return nil, nil +} + +func (l *receiptLedger) PolicyLogCursorState(context.Context) (uint64, *time.Time, error) { + return 0, nil, nil +} + +func (l *receiptLedger) AdvancePolicyLogCursor(context.Context, uint64, time.Time) error { return nil } + +func TestANarrowingCommitsOnlyAgainstAVerifiedReceipt(t *testing.T) { + _, stranger, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + for name, tc := range map[string]struct { + tamper func(*fakeWitness) + commits bool + }{ + "a receipt the pinned key signed": {tamper: func(*fakeWitness) {}, commits: true}, + "a bad signature": {tamper: func(f *fakeWitness) { f.flipSignature = true }}, + "the wrong key": {tamper: func(f *fakeWitness) { f.signWith = stranger }}, + "a sequence regression": {tamper: func(f *fakeWitness) { f.forceSeq = 1 }}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeWitness(t) + log := newTestWitnessLog(t, fake) + // One earlier narrowing, so a regression has something to regress + // below. + _, err := log.Append(context.Background(), narrowing("op/earlier")) + require.NoError(t, err) + + service, err := business.NewService(controlPlaneOnly{}) + require.NoError(t, err) + ledger := newReceiptLedger() + service.SetPolicyLog(log, ledger) + + tc.tamper(fake) + applied := false + entry := narrowing("op/narrowing") + err = service.WithPolicyLoggedNarrowing(context.Background(), entry, func(context.Context) error { + applied = true + return nil + }) + if tc.commits { + require.NoError(t, err) + require.True(t, applied, "a witnessed narrowing applies") + require.True(t, ledger.committed[entry.OperationID], "and commits its receipt") + require.Equal(t, uint64(2), ledger.recorded[entry.OperationID].Sequence) + return + } + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + require.ErrorIs(t, err, ErrPolicyLogWitnessEvidence) + require.False(t, applied, "nothing narrows against evidence that does not verify") + require.Empty(t, ledger.recorded, "and no receipt is recorded, so no gap opens") + }) + } +} diff --git a/module/services/accounts/code/pkg/infra/policylogwitnesstest/witness.go b/module/services/accounts/code/pkg/infra/policylogwitnesstest/witness.go new file mode 100644 index 000000000..ba75c0f80 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/policylogwitnesstest/witness.go @@ -0,0 +1,175 @@ +// Package policylogwitnesstest starts the module's REAL policy-log witness for +// a test in this service: the witness's own server package, service and +// witness log over its in-memory object store, compiled from the policy-log +// module and run as a helper process (its TestWitnessHelperProcess). +// +// A helper process rather than an import, because neither half can link the +// other: policy-log is a separate Go module, and this service carries its own +// generated copy of the saas.policylog.v1 types, which the protobuf registry +// refuses to register twice. So the test reaches the witness the way a deployed +// host does — over gRPC, through the generated client — and checks what it +// signed against a key it pinned from the witness's own key document. +// +// Test support only: nothing outside a _test.go file imports this package. +package policylogwitnesstest + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +const ( + helperEnv = "POLICY_LOG_WITNESS_HELPER" + helperReady = "POLICY_LOG_WITNESS_READY " + helperTest = "TestWitnessHelperProcess" + startDeadline = 60 * time.Second +) + +// Witness is one running witness helper. +type Witness struct { + // Address is its gRPC listener, host:port on loopback. + Address string + // KeyDocument is the public key document it published, in the schema of + // the trust-anchor document a host pins. + KeyDocument []byte + + stop sync.Once + done chan struct{} + in io.WriteCloser + cmd *exec.Cmd +} + +// Stop takes the witness down and waits for the process to exit. Safe to call +// more than once; also registered as the test's cleanup. +func (w *Witness) Stop() { + w.stop.Do(func() { + _ = w.in.Close() + select { + case <-w.done: + case <-time.After(10 * time.Second): + _ = w.cmd.Process.Kill() + <-w.done + } + }) +} + +var ( + compileOnce sync.Once + binary string + compileErr error +) + +// Start compiles the helper once per test process and starts a fresh witness: +// a new signing key and an empty log at genesis. +func Start(t testing.TB) *Witness { + t.Helper() + dir, err := policyLogCode() + if err != nil { + t.Fatal(err) + } + compileOnce.Do(func() { binary, compileErr = compile(dir) }) + if compileErr != nil { + t.Fatalf("compile the policy-log witness helper: %v", compileErr) + } + + cmd := exec.Command(binary, "-test.run=^"+helperTest+"$", "-test.count=1", "-test.v") + cmd.Dir = filepath.Join(dir, "server") + cmd.Env = append(environment(), helperEnv+"=1") + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + w := &Witness{in: in, cmd: cmd, done: make(chan struct{})} + go func() { _ = cmd.Wait(); close(w.done) }() + t.Cleanup(w.Stop) + + lines := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(out) + announced := false + for scanner.Scan() { + if text := scanner.Text(); !announced && strings.HasPrefix(text, helperReady) { + announced = true + lines <- strings.TrimPrefix(text, helperReady) + } + } + }() + select { + case line := <-lines: + var announcement struct { + Address string `json:"address"` + Key json.RawMessage `json:"key"` + } + if err := json.Unmarshal([]byte(line), &announcement); err != nil { + t.Fatalf("the witness helper announced %q: %v", line, err) + } + w.Address, w.KeyDocument = announcement.Address, announcement.Key + return w + case <-w.done: + t.Fatalf("the witness helper exited before announcing itself:\n%s", stderr.String()) + case <-time.After(startDeadline): + t.Fatalf("the witness helper did not announce itself within %s:\n%s", startDeadline, stderr.String()) + } + return nil +} + +// policyLogCode is the policy-log module beside this service in the checkout. +func policyLogCode() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("cannot locate this file") + } + // .../services/accounts/code/pkg/infra/policylogwitnesstest + dir := filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..", "policy-log", "code") + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil { + return "", fmt.Errorf("the policy-log module is not beside this service at %s: %w", dir, err) + } + return dir, nil +} + +func compile(dir string) (string, error) { + out, err := os.MkdirTemp("", "policy-log-witness-") + if err != nil { + return "", err + } + bin := filepath.Join(out, "witness.test") + cmd := exec.Command("go", "test", "-c", "-o", bin, "./server") + cmd.Dir = dir + cmd.Env = environment() + if output, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("go test -c ./server in %s: %w\n%s", dir, err, output) + } + return bin, nil +} + +// environment is this process's, without GOFLAGS: a flag meant for this module +// (a -modfile, a build tag) must not leak into the witness module's build. +func environment() []string { + var env []string + for _, kv := range os.Environ() { + if !strings.HasPrefix(kv, "GOFLAGS=") && !strings.HasPrefix(kv, helperEnv+"=") { + env = append(env, kv) + } + } + return append(env, "GOWORK=off") +} diff --git a/module/services/accounts/code/pkg/infra/warehouse_policy_log.go b/module/services/accounts/code/pkg/infra/warehouse_policy_log.go deleted file mode 100644 index ffaedf98a..000000000 --- a/module/services/accounts/code/pkg/infra/warehouse_policy_log.go +++ /dev/null @@ -1,55 +0,0 @@ -package infra - -// The external policy log's transport, and why this deployment has none. -// -// SHAPE, from the infrastructure that provisions it: a DAY-partitioned -// `policy_log` table clustered on (operation_id, subject_id) with deletion -// protection, plus a `policy_log_replay` view that dedupes by ROW_NUMBER() over -// operation_id ordered by sequence. Writer and reader are distinct roles, and -// the reader deliberately holds no job-creation permission — so a replay is a -// bounded row read with a restriction, never a query. -// -// WHY NO CLIENT IS WIRED. Not for lack of a transport library: because that -// dataset cannot issue the RECEIPT the protocol is built on. -// `business.PolicyLog.Append` must return a token the LOG minted and a -// MONOTONIC SEQUENCE the LOG assigned, and the presence of those is the whole -// difference between "the log witnessed this narrowing" and "this host decided -// it". A BigQuery dataset gives a writer neither: -// -// - the non-job write paths return no ordinal. `tabledata.insertAll` returns -// only per-row errors, and the Storage Write API's offsets are monotonic -// within one stream, so two replicas appending produce two independent -// counters and no global order for the host's cursor to compare against. -// - ingestion time, which the warehouse does assign, is not readable until -// the streaming buffer flushes — minutes, against an append the protocol -// bounds at five seconds, deliberately, so a slow log costs a refusal -// rather than held locks. -// - reading the row back to learn either is not available to the writer at -// all: the roles are separate precisely so the appender cannot read, and -// the reader holds no job-creation permission. -// -// So the missing piece is a RECEIPT-ISSUING APPENDER inside the warehouse's -// trust domain — the component that owns the writer credential, assigns the -// sequence and hands back the token — and that is infrastructure this service -// does not own. Until it exists, the host wires NO policy log (work.go), which -// means it serves normally and refuses every narrowing of authority. That is -// the protocol's own answer to a host that cannot witness a narrowing. -// -// WHAT MUST NOT GO HERE, and why this file is a comment rather than a type. The -// obvious way to make the four narrowing paths work again is a transport that -// returns a receipt it made up — any sequence, any token. That is the exact -// tautology the protocol exists to prevent: the host would record that the log -// witnessed the narrowing, nothing would have, and a restore of this database -// would silently reinstate the revoked authority with the receipt still sitting -// beside it as evidence that it had not. A client that cannot be exercised -// against the real service, in the service that owns authority, is worth less -// than an honest absence; a client that fabricates the one value the protocol -// checks is worth less than nothing. -// -// The previous occupant of this file, `UnavailablePolicyLog`, refused every -// append and every read. It was the right answer while nothing consulted the -// serving gate, and it became the wrong one the moment the gate reached the -// request path: a host holding a log it can never READ never refreshes -// `reached_at`, so the staleness window closes and the host stops answering -// anything at all. Refusing to narrow authority and refusing to serve are -// different refusals, and a deployment with no transport owes the first only. diff --git a/module/services/accounts/code/policy_log_witness_transport_test.go b/module/services/accounts/code/policy_log_witness_transport_test.go new file mode 100644 index 000000000..32cae12f4 --- /dev/null +++ b/module/services/accounts/code/policy_log_witness_transport_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +type meshAssertion map[string]string + +func (m meshAssertion) WorkspaceValue(group, key string) (string, error) { + value, ok := m[group+"/"+key] + if !ok { + return "", errors.New("not configured") + } + return value, nil +} + +// TestThePolicyLogWitnessIsDialledInPlaintextOnlyUnderTheMesh: handbook security +// posture rule 1. The dial carries no transport security of its own, so it is +// admitted only when the composition asserts the mesh AND the address is an +// in-cluster Service; anything else is refused by name, never dialled. +func TestThePolicyLogWitnessIsDialledInPlaintextOnlyUnderTheMesh(t *testing.T) { + asserted := meshAssertion{"internal-transport/mesh-protected": "true"} + if err := policyLogWitnessTransport(asserted, "policy-log.platform-saas.svc.cluster.local:9090"); err != nil { + t.Fatalf("an in-cluster Service under the mesh assertion was refused: %v", err) + } + for name, tc := range map[string]struct { + source meshAssertion + host string + }{ + "no assertion": {meshAssertion{}, "policy-log.platform-saas.svc.cluster.local:9090"}, + "asserted false": {meshAssertion{"internal-transport/mesh-protected": "false"}, "policy-log.platform-saas.svc:9090"}, + "loopback": {asserted, "localhost:9090"}, + "public suffix": {asserted, "policy-log.platform-saas.svc.example.com:9090"}, + "bare name": {asserted, "policy-log:9090"}, + } { + t.Run(name, func(t *testing.T) { + err := policyLogWitnessTransport(tc.source, tc.host) + if err == nil || !strings.Contains(err.Error(), "refusing cleartext gRPC to the policy-log witness") { + t.Fatalf("dial to %q admitted or refused without naming why: %v", tc.host, err) + } + }) + } +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 3cb7c729c..179eb73f2 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -19,11 +19,13 @@ import ( "accounts/pkg/datasource/apisource" "accounts/pkg/email" "accounts/pkg/eventcatalog" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" "accounts/pkg/githubconnector" "accounts/pkg/infra" "accounts/pkg/jobs" "accounts/pkg/keyservice" "accounts/pkg/membership" + "accounts/pkg/meshtransport" "accounts/pkg/metrics" "accounts/pkg/permissionsplugin" "accounts/pkg/redisstate" @@ -48,6 +50,8 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/otel" otelmetric "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) func init() { @@ -535,37 +539,49 @@ func doWork(ctx context.Context) (Clean, error) { // The policy log: append → receipt → commit, before authority is narrowed. // - // THIS DEPLOYMENT HAS NO LOG TRANSPORT, and that is stated by wiring none - // rather than by wiring something that refuses. The difference matters now - // that the serving gate is on the request path: a host holding a log it can - // never reach never refreshes `reached_at`, so the staleness window closes - // and the host stops answering anything — a total outage dressed as - // fail-closed. A host holding NO log has nothing unreconciled to honour, so - // `MayServe` admits and the host serves. + // The log is the module's independent signed witness (services/policy-log), + // and this host is its one client. Two things must be DELIVERED for the + // client to be wired, and both are composition's to deliver, never this + // host's to invent: // - // What it cannot do is NARROW authority. `WithPolicyLoggedNarrowing` refuses - // outright, so uninstalling a solution, closing a withdrawn solution - // target, revoking a scope grant and removing a team membership all refuse - // with ErrPolicyLogUnreachable until a transport exists. That is the - // protocol's own answer — a narrowing nothing witnessed is one a restore - // silently undoes — and it is loud rather than silent, below. + // - the witness's private gRPC endpoint, which resolves only when this + // service declares the dependency and composition grants it; and + // - the witness's public key, pinned in the trust anchor. Every receipt + // and cursor is verified against that pin, and the key the witness + // publishes about itself is only compared with it, never adopted. // - // WHY THERE IS NO TRANSPORT. The warehouse the log lives in cannot issue the - // receipt the protocol requires. `PolicyLog.Append` must return a token the - // LOG minted and a monotonic sequence the LOG assigned, because the receipt - // is what separates "the log witnessed this" from "this host decided it". A - // BigQuery dataset assigns neither to a writer: the non-job write paths - // return no ordinal, ingestion time is not readable until the streaming - // buffer flushes, and the writer role deliberately holds no read and no - // job-creation permission, so it cannot read its own row back to learn - // either. Closing that needs a receipt-issuing appender inside the - // warehouse's trust domain, which is infrastructure this service does not - // own. Anything else would be this host minting its own receipt — the exact - // tautology the protocol exists to prevent. - service.SetPolicyLog(nil, nil) - w.Warn("no policy log transport is configured: this host SERVES normally and REFUSES every " + - "narrowing of authority (solution uninstall, solution-target close, scope-grant revocation, " + - "team-membership removal), because a narrowing nothing witnessed is one a restore undoes silently") + // With both, every narrowing — solution uninstall, solution-target close, + // scope-grant revocation, team-membership removal — appends to the witness + // and commits the verified receipt in the transaction that narrows, and the + // reconciliation loop below keeps `reached_at` fresh from a signed, fresh + // cursor. A witness that stops answering then closes the staleness window + // and this host stops serving, rather than narrowing unwitnessed. + // + // Without both, the host is CONFIGURED without a witness, and wires none + // rather than wiring something that refuses. The difference matters because + // the serving gate is on the request path: a host holding a log it can never + // reach never refreshes `reached_at`, so the window closes and the host + // stops answering anything — a total outage dressed as fail-closed. A host + // holding NO log has nothing unreconciled to honour, so `MayServe` admits and + // it serves, and `WithPolicyLoggedNarrowing` refuses every narrowing with + // ErrPolicyLogUnreachable: a narrowing nothing witnessed is one a restore of + // this database undoes silently. That refusal is loud, below. + policyLogWitness, policyLogWitnessClose, policyLogWitnessAbsent, err := configuredPolicyLogWitness(ctx) + if err != nil { + return nil, fmt.Errorf("configure policy log witness: %w", err) + } + if policyLogWitness != nil { + service.SetPolicyLog(policyLogWitness, store) + w.Info("policy log witness configured: narrowings append to the witness and commit its verified receipt; "+ + "this host stops serving if the witness is not reached within the staleness window", + wool.Field("staleness_window", business.PolicyLogStaleAfter.String())) + } else { + service.SetPolicyLog(nil, nil) + w.Warn("no policy log witness is configured for this host: it SERVES normally and REFUSES every "+ + "narrowing of authority (solution uninstall, solution-target close, scope-grant revocation, "+ + "team-membership removal), because a narrowing nothing witnessed is one a restore undoes silently", + wool.Field("reason", policyLogWitnessAbsent)) + } // The serving gate, on the request path. Registered for both transports' // authorization interceptors; with no log wired it admits, which is what @@ -1315,6 +1331,15 @@ func doWork(ctx context.Context) (Clean, error) { if solutionHostBindingReconciler != nil { solutionHostBindingReconciler.Start(ctx) } + // Reconciliation is what keeps a witnessed host serving: each pass reads a + // signed cursor and every entry past this host's own, records the ones it + // has not applied as gaps, and refreshes `reached_at`. It runs at once and + // then on an interval shorter than the staleness window. With no witness + // wired it returns immediately. + policyLogCtx, policyLogCancel := context.WithCancel(ctx) + go service.ReconcilePolicyLogEvery(policyLogCtx, business.PolicyLogReconcileInterval, func(err error) { + wool.Get(policyLogCtx).In("policy-log").Warn("reconciliation pass failed; the cursor is not advanced", wool.ErrField(err)) + }) if analyticsWorker != nil { analyticsWorker.Start(ctx) } @@ -1434,6 +1459,12 @@ func doWork(ctx context.Context) (Clean, error) { } sw.Info("stopping retention goroutine") retentionCancel() + policyLogCancel() + if policyLogWitnessClose != nil { + if err := policyLogWitnessClose(); err != nil { + sw.Warn("policy log witness connection close failed", wool.ErrField(err)) + } + } sw.Info("closing audit emitter") auditEmitter.Close() sw.Info("audit emitter closed") @@ -2997,3 +3028,97 @@ func registerMembershipCacheHealth(c *membership.Cache) { return nil }, gauge) } + +// configuredPolicyLogWitness builds the witness client when composition has +// delivered both halves of it: the endpoint and the pinned key. +// +// It answers in one of three ways. A client and its closer, when both are +// delivered. No client and the reason, when either is absent — the host then +// serves and refuses narrowings, which is a configuration and is reported as +// one. An error, which refuses the boot, only when a pinned key IS delivered and +// cannot be used: that host would refuse every narrowing for a reason nobody +// could see, and a broken trust anchor is a delivery fault an operator must be +// shown, not absorbed. +// +// THE PIN'S LOCATION IS NOT CONFIGURABLE. It is a fixed file in the trust +// anchor (infra.SolutionHostTrustAnchorPath), for the reason the anchor itself +// is fixed: workspace configuration is delivered by the composition, and a +// composition that could choose which key verifies the witness could stand up +// a witness of its own. +// +// THE TRANSPORT CARRIES NO APPLICATION CREDENTIAL. The witness listener admits +// its one client through workload-authenticated mesh policy derived from this +// service's declared dependency, and checks nothing in-band; minting a token +// for it here would be a second identity scheme nothing verifies. What makes a +// receipt believable is not the channel but the pinned signature on it. +func configuredPolicyLogWitness(ctx context.Context) (*infra.WitnessPolicyLog, func() error, string, error) { + key, pinned, err := infra.ReadPolicyLogWitnessKey() + if err != nil { + return nil, nil, "", err + } + // The composition root binds the witness, per environment, in the + // `policy-log-witness` group (`endpoint=${endpoint:saas/policy-log/grpc}`); + // the module ships it empty. A value that is there and cannot be read is a + // delivery fault and refuses the boot, never "no witness". + address, bound, readErr := codefly.For(ctx).WorkspaceValueIfSet(policyLogWitnessGroup, policyLogWitnessEndpointKey) + if readErr != nil { + return nil, nil, "", fmt.Errorf("%s/%s is delivered and cannot be read: %w", policyLogWitnessGroup, policyLogWitnessEndpointKey, readErr) + } + address = strings.TrimSpace(address) + switch { + case (!bound || address == "") && !pinned: + return nil, nil, "the composition binds no policy-log witness for this environment and no witness key is pinned in the trust anchor", nil + case !bound || address == "": + // The pin comes from the platform-owned anchor, so it — not the + // composition — decides that this environment HAS a witness. Serving here + // with no log wired would switch the serving gate off on a host whose + // database may already depend on the witness: a restore could then undo + // a witnessed narrowing with only a warning in the log. + return nil, nil, "", fmt.Errorf("a witness key is pinned in the trust anchor but the composition binds no witness endpoint (%s/%s): refusing to serve without the witness this environment is pinned to", policyLogWitnessGroup, policyLogWitnessEndpointKey) + case !pinned: + return nil, nil, "the composition binds a policy-log witness but no witness key is pinned in the trust anchor, and a key read from the witness itself is never trusted", nil + } + // Plaintext only where the mesh carries the hop (handbook security posture, + // rule 1): the composition asserts internal-transport/mesh-protected=true + // and the witness is an in-cluster Service address. Otherwise refused by + // name — without the mesh the witness's "only my declared client" admission + // does not hold, and anyone on the path could append to it or replay a + // cursor. There is no loopback exception: no local run delivers a pin. + if err := policyLogWitnessTransport(codefly.For(ctx), address); err != nil { + return nil, nil, "", err + } + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, "", fmt.Errorf("dial the policy-log witness at %s: %w", address, err) + } + witness, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), key) + if err != nil { + _ = conn.Close() + return nil, nil, "", err + } + return witness, conn.Close, "", nil +} + +// policyLogWitnessGroup is the workspace configuration group the composition +// root binds the witness's address in, per environment; the module ships it +// empty. Read rather than declared as a service dependency, so the edge exists +// only where an environment provisions the witness — a local run never starts +// one it could not boot. +const ( + policyLogWitnessGroup = "policy-log-witness" + policyLogWitnessEndpointKey = "endpoint" +) + +// policyLogWitnessTransport admits the plaintext dial to the witness only under +// the mesh assertion and to an in-cluster Service address, and refuses it by +// name otherwise. +func policyLogWitnessTransport(source meshtransport.Source, host string) error { + protected, err := meshtransport.Protected(source) + if err != nil { + return err + } + if !protected || !meshtransport.ClusterServiceHost(host) { + return fmt.Errorf("refusing cleartext gRPC to the policy-log witness at %q: %s", host, meshtransport.Remedy) + } + return nil +} diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 96d349aaf..d5d6d27f7 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -112,6 +112,9 @@ }, { "name": "WebhookService" + }, + { + "name": "WitnessService" } ], "consumes": [ diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 61dd71601..698041795 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -12367,6 +12367,9 @@ }, { "name": "WebhookService" + }, + { + "name": "WitnessService" } ], "x-codefly-owner": { diff --git a/module/services/accounts/proto/saas/policylog/v1/witness.proto b/module/services/accounts/proto/saas/policylog/v1/witness.proto new file mode 100644 index 000000000..2ce7ac221 --- /dev/null +++ b/module/services/accounts/proto/saas/policylog/v1/witness.proto @@ -0,0 +1,172 @@ +syntax = "proto3"; + +package saas.policylog.v1; + +import "google/protobuf/timestamp.proto"; + +// Entry is one operation offered to the witness. +// +// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: +// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and +// the witness stores that digest rather than the operation. Reconciliation can +// identify an operation and verify its bytes; it cannot reconstruct them, so +// the caller must retain or recover the canonical bytes to apply it. +// +// Every field takes part in entry_hash, so every field must stay IDENTICAL on a +// retry of the same operation_id — requested_at included. The witness refuses a +// reused operation_id that names different bytes. +message Entry { + // operation_id identifies one logical operation for the lifetime of the log, + // and it is the caller's: only the caller knows that two attempts are the + // same operation. At most 1024 bytes of control-character-free UTF-8. + string operation_id = 1; + + // kind is what the operation is about, at most 128 bytes. + string kind = 2; + + // subject is what the operation acts on, at most 1024 bytes. + string subject = 3; + + // tenant may be empty for a global operation. It grants no authority: this + // log performs no tenant scoping and the field is an identifier, not a claim. + string tenant = 4; + + // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical + // operation bytes, carried raw rather than hex or base64 so the digest the + // signature covers is the digest on the wire. + bytes narrowing_hash = 5; + + // requested_at is the CALLER'S logical timestamp for the operation, not a + // deadline and not an ordering authority. It is covered by entry_hash, so an + // old operation can be retried without changing its identity only as long as + // this value is sent back unchanged. + google.protobuf.Timestamp requested_at = 6; +} + +// Receipt is the witness's signed acknowledgement of one committed entry. +// +// Only a committed head makes a receipt exist. A signature over a prepared +// candidate that never reached the head is not a receipt and is never returned. +message Receipt { + // seq is the global sequence the witness assigned, never zero and never + // supplied by a caller. The first sequence is 1. Sequence, not clock time, + // orders operations. + uint64 seq = 1; + + // entry_hash is the 32-byte digest of the appended entry. A caller compares + // it against the bytes it asked to append. + bytes entry_hash = 2; + + // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. + bytes prev_hash = 3; + + // chain_hash is the 32-byte chain hash at seq. + bytes chain_hash = 4; + + // issued_at comes from the witness, never from a caller. + google.protobuf.Timestamp issued_at = 5; + + // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which + // depends on no protobuf field ordering. + bytes signature = 6; +} + +// Record is one entry together with the original receipt the witness issued for +// it, which is what makes a read independently verifiable. +message Record { + Entry entry = 1; + Receipt receipt = 2; +} + +// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt +// nor a reset instruction: a verifying client never lowers a persisted cursor, +// and a valid signature cannot by itself distinguish a historical cursor from a +// current one, so the client also enforces its own freshness policy. +message Cursor { + uint64 head_seq = 1; + bytes chain_hash = 2; + google.protobuf.Timestamp issued_at = 3; + bytes signature = 4; +} + +// Key is the public half of the witness signing key, and the same schema as the +// trust-anchor document composition delivers to the host. Publishing it is +// discovery, never authority to replace a caller's pinned key. +message Key { + // algorithm is "Ed25519". + string algorithm = 1; + + // key_id is the lowercase SHA-256 hex of the raw public key. + string key_id = 2; + + // public_key is the raw 32-byte public key. + bytes public_key = 3; +} + +// AppendRequest offers one entry. +message AppendRequest { + Entry entry = 1; +} + +// AppendResponse carries the committed receipt. A retry of the identical entry +// returns the exact original receipt rather than appending again. +message AppendResponse { + Receipt receipt = 1; +} + +// CursorRequest takes no argument: there is one log and one head. +message CursorRequest {} + +// CursorResponse carries the signed head observation. +message CursorResponse { + Cursor cursor = 1; +} + +// ReadRequest asks for a bounded page of committed records. +message ReadRequest { + // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an + // empty page; asking beyond it is refused, so a reconciler that is ahead of + // the log cannot mistake a rollback for inactivity. A caller holding an + // exclusive cursor sends cursor + 1. + uint64 from_seq = 1; + + // limit is 1 through 500. + uint32 limit = 2; +} + +// ReadResponse carries the page in ascending sequence order. +message ReadResponse { + repeated Record records = 1; +} + +// KeysRequest takes no argument. +message KeysRequest {} + +// KeysResponse carries the public signing key document. +message KeysResponse { + Key key = 1; +} + +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +service WitnessService { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + rpc Append(AppendRequest) returns (AppendResponse); + + // Cursor returns the signed committed head. + rpc Cursor(CursorRequest) returns (CursorResponse); + + // Read returns committed records with their original receipts. + rpc Read(ReadRequest) returns (ReadResponse); + + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + rpc Keys(KeysRequest) returns (KeysResponse); +} diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 7bfb81632..d6eaad6d6 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -44,6 +44,7 @@ workspace-configuration-dependencies: - internal-transport - key-service - observability + - policy-log-witness - product-analytics - security - vault diff --git a/module/services/accounts/test-targets.json b/module/services/accounts/test-targets.json index 986263095..a1f3133a7 100644 --- a/module/services/accounts/test-targets.json +++ b/module/services/accounts/test-targets.json @@ -18,6 +18,7 @@ "setup_budget_seconds": 0, "input_roots": [ "module/services/accounts/", + "module/services/policy-log/", "module/services/store/migrations/", "module/tools/", "module/deployment/", @@ -48,6 +49,7 @@ "setup_budget_seconds": 600, "input_roots": [ "module/services/accounts/", + "module/services/policy-log/", "module/services/store/migrations/", "module/tools/", "module/deployment/", @@ -79,6 +81,7 @@ "setup_budget_seconds": 90, "input_roots": [ "module/services/accounts/", + "module/services/policy-log/", "module/services/store/migrations/", "module/tools/", "module/deployment/", diff --git a/module/services/frontend/code/src/gen/saas/policylog/v1/witness_pb.ts b/module/services/frontend/code/src/gen/saas/policylog/v1/witness_pb.ts new file mode 100644 index 000000000..ef0559312 --- /dev/null +++ b/module/services/frontend/code/src/gen/saas/policylog/v1/witness_pb.ts @@ -0,0 +1,467 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/policylog/v1/witness.proto (package saas.policylog.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file saas/policylog/v1/witness.proto. + */ +export const file_saas_policylog_v1_witness: GenFile = /*@__PURE__*/ + fileDesc("Ch9zYWFzL3BvbGljeWxvZy92MS93aXRuZXNzLnByb3RvEhFzYWFzLnBvbGljeWxvZy52MSKWAQoFRW50cnkSFAoMb3BlcmF0aW9uX2lkGAEgASgJEgwKBGtpbmQYAiABKAkSDwoHc3ViamVjdBgDIAEoCRIOCgZ0ZW5hbnQYBCABKAkSFgoObmFycm93aW5nX2hhc2gYBSABKAwSMAoMcmVxdWVzdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKTAQoHUmVjZWlwdBILCgNzZXEYASABKAQSEgoKZW50cnlfaGFzaBgCIAEoDBIRCglwcmV2X2hhc2gYAyABKAwSEgoKY2hhaW5faGFzaBgEIAEoDBItCglpc3N1ZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhEKCXNpZ25hdHVyZRgGIAEoDCJeCgZSZWNvcmQSJwoFZW50cnkYASABKAsyGC5zYWFzLnBvbGljeWxvZy52MS5FbnRyeRIrCgdyZWNlaXB0GAIgASgLMhouc2Fhcy5wb2xpY3lsb2cudjEuUmVjZWlwdCJwCgZDdXJzb3ISEAoIaGVhZF9zZXEYASABKAQSEgoKY2hhaW5faGFzaBgCIAEoDBItCglpc3N1ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhEKCXNpZ25hdHVyZRgEIAEoDCI8CgNLZXkSEQoJYWxnb3JpdGhtGAEgASgJEg4KBmtleV9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMIjgKDUFwcGVuZFJlcXVlc3QSJwoFZW50cnkYASABKAsyGC5zYWFzLnBvbGljeWxvZy52MS5FbnRyeSI9Cg5BcHBlbmRSZXNwb25zZRIrCgdyZWNlaXB0GAEgASgLMhouc2Fhcy5wb2xpY3lsb2cudjEuUmVjZWlwdCIPCg1DdXJzb3JSZXF1ZXN0IjsKDkN1cnNvclJlc3BvbnNlEikKBmN1cnNvchgBIAEoCzIZLnNhYXMucG9saWN5bG9nLnYxLkN1cnNvciIuCgtSZWFkUmVxdWVzdBIQCghmcm9tX3NlcRgBIAEoBBINCgVsaW1pdBgCIAEoDSI6CgxSZWFkUmVzcG9uc2USKgoHcmVjb3JkcxgBIAMoCzIZLnNhYXMucG9saWN5bG9nLnYxLlJlY29yZCINCgtLZXlzUmVxdWVzdCIzCgxLZXlzUmVzcG9uc2USIwoDa2V5GAEgASgLMhYuc2Fhcy5wb2xpY3lsb2cudjEuS2V5MsACCg5XaXRuZXNzU2VydmljZRJNCgZBcHBlbmQSIC5zYWFzLnBvbGljeWxvZy52MS5BcHBlbmRSZXF1ZXN0GiEuc2Fhcy5wb2xpY3lsb2cudjEuQXBwZW5kUmVzcG9uc2USTQoGQ3Vyc29yEiAuc2Fhcy5wb2xpY3lsb2cudjEuQ3Vyc29yUmVxdWVzdBohLnNhYXMucG9saWN5bG9nLnYxLkN1cnNvclJlc3BvbnNlEkcKBFJlYWQSHi5zYWFzLnBvbGljeWxvZy52MS5SZWFkUmVxdWVzdBofLnNhYXMucG9saWN5bG9nLnYxLlJlYWRSZXNwb25zZRJHCgRLZXlzEh4uc2Fhcy5wb2xpY3lsb2cudjEuS2V5c1JlcXVlc3QaHy5zYWFzLnBvbGljeWxvZy52MS5LZXlzUmVzcG9uc2VCuwEKFWNvbS5zYWFzLnBvbGljeWxvZy52MUIMV2l0bmVzc1Byb3RvUAFaLmFjY291bnRzL3BrZy9nZW4vc2Fhcy9wb2xpY3lsb2cvdjE7cG9saWN5bG9ndjGiAgNTUFiqAhFTYWFzLlBvbGljeWxvZy5WMcoCEVNhYXNcUG9saWN5bG9nXFYx4gIdU2Fhc1xQb2xpY3lsb2dcVjFcR1BCTWV0YWRhdGHqAhNTYWFzOjpQb2xpY3lsb2c6OlYxYgZwcm90bzM", [file_google_protobuf_timestamp]); + +/** + * Entry is one operation offered to the witness. + * + * It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: + * narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and + * the witness stores that digest rather than the operation. Reconciliation can + * identify an operation and verify its bytes; it cannot reconstruct them, so + * the caller must retain or recover the canonical bytes to apply it. + * + * Every field takes part in entry_hash, so every field must stay IDENTICAL on a + * retry of the same operation_id — requested_at included. The witness refuses a + * reused operation_id that names different bytes. + * + * @generated from message saas.policylog.v1.Entry + */ +export type Entry = Message<"saas.policylog.v1.Entry"> & { + /** + * operation_id identifies one logical operation for the lifetime of the log, + * and it is the caller's: only the caller knows that two attempts are the + * same operation. At most 1024 bytes of control-character-free UTF-8. + * + * @generated from field: string operation_id = 1; + */ + operationId: string; + + /** + * kind is what the operation is about, at most 128 bytes. + * + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * subject is what the operation acts on, at most 1024 bytes. + * + * @generated from field: string subject = 3; + */ + subject: string; + + /** + * tenant may be empty for a global operation. It grants no authority: this + * log performs no tenant scoping and the field is an identifier, not a claim. + * + * @generated from field: string tenant = 4; + */ + tenant: string; + + /** + * narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical + * operation bytes, carried raw rather than hex or base64 so the digest the + * signature covers is the digest on the wire. + * + * @generated from field: bytes narrowing_hash = 5; + */ + narrowingHash: Uint8Array; + + /** + * requested_at is the CALLER'S logical timestamp for the operation, not a + * deadline and not an ordering authority. It is covered by entry_hash, so an + * old operation can be retried without changing its identity only as long as + * this value is sent back unchanged. + * + * @generated from field: google.protobuf.Timestamp requested_at = 6; + */ + requestedAt?: Timestamp; +}; + +/** + * Describes the message saas.policylog.v1.Entry. + * Use `create(EntrySchema)` to create a new message. + */ +export const EntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 0); + +/** + * Receipt is the witness's signed acknowledgement of one committed entry. + * + * Only a committed head makes a receipt exist. A signature over a prepared + * candidate that never reached the head is not a receipt and is never returned. + * + * @generated from message saas.policylog.v1.Receipt + */ +export type Receipt = Message<"saas.policylog.v1.Receipt"> & { + /** + * seq is the global sequence the witness assigned, never zero and never + * supplied by a caller. The first sequence is 1. Sequence, not clock time, + * orders operations. + * + * @generated from field: uint64 seq = 1; + */ + seq: bigint; + + /** + * entry_hash is the 32-byte digest of the appended entry. A caller compares + * it against the bytes it asked to append. + * + * @generated from field: bytes entry_hash = 2; + */ + entryHash: Uint8Array; + + /** + * prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. + * + * @generated from field: bytes prev_hash = 3; + */ + prevHash: Uint8Array; + + /** + * chain_hash is the 32-byte chain hash at seq. + * + * @generated from field: bytes chain_hash = 4; + */ + chainHash: Uint8Array; + + /** + * issued_at comes from the witness, never from a caller. + * + * @generated from field: google.protobuf.Timestamp issued_at = 5; + */ + issuedAt?: Timestamp; + + /** + * signature is 64 bytes of Ed25519 over the canonical receipt encoding, which + * depends on no protobuf field ordering. + * + * @generated from field: bytes signature = 6; + */ + signature: Uint8Array; +}; + +/** + * Describes the message saas.policylog.v1.Receipt. + * Use `create(ReceiptSchema)` to create a new message. + */ +export const ReceiptSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 1); + +/** + * Record is one entry together with the original receipt the witness issued for + * it, which is what makes a read independently verifiable. + * + * @generated from message saas.policylog.v1.Record + */ +export type Record = Message<"saas.policylog.v1.Record"> & { + /** + * @generated from field: saas.policylog.v1.Entry entry = 1; + */ + entry?: Entry; + + /** + * @generated from field: saas.policylog.v1.Receipt receipt = 2; + */ + receipt?: Receipt; +}; + +/** + * Describes the message saas.policylog.v1.Record. + * Use `create(RecordSchema)` to create a new message. + */ +export const RecordSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 2); + +/** + * Cursor is a signed OBSERVATION of the committed head. It is neither a receipt + * nor a reset instruction: a verifying client never lowers a persisted cursor, + * and a valid signature cannot by itself distinguish a historical cursor from a + * current one, so the client also enforces its own freshness policy. + * + * @generated from message saas.policylog.v1.Cursor + */ +export type Cursor = Message<"saas.policylog.v1.Cursor"> & { + /** + * @generated from field: uint64 head_seq = 1; + */ + headSeq: bigint; + + /** + * @generated from field: bytes chain_hash = 2; + */ + chainHash: Uint8Array; + + /** + * @generated from field: google.protobuf.Timestamp issued_at = 3; + */ + issuedAt?: Timestamp; + + /** + * @generated from field: bytes signature = 4; + */ + signature: Uint8Array; +}; + +/** + * Describes the message saas.policylog.v1.Cursor. + * Use `create(CursorSchema)` to create a new message. + */ +export const CursorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 3); + +/** + * Key is the public half of the witness signing key, and the same schema as the + * trust-anchor document composition delivers to the host. Publishing it is + * discovery, never authority to replace a caller's pinned key. + * + * @generated from message saas.policylog.v1.Key + */ +export type Key = Message<"saas.policylog.v1.Key"> & { + /** + * algorithm is "Ed25519". + * + * @generated from field: string algorithm = 1; + */ + algorithm: string; + + /** + * key_id is the lowercase SHA-256 hex of the raw public key. + * + * @generated from field: string key_id = 2; + */ + keyId: string; + + /** + * public_key is the raw 32-byte public key. + * + * @generated from field: bytes public_key = 3; + */ + publicKey: Uint8Array; +}; + +/** + * Describes the message saas.policylog.v1.Key. + * Use `create(KeySchema)` to create a new message. + */ +export const KeySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 4); + +/** + * AppendRequest offers one entry. + * + * @generated from message saas.policylog.v1.AppendRequest + */ +export type AppendRequest = Message<"saas.policylog.v1.AppendRequest"> & { + /** + * @generated from field: saas.policylog.v1.Entry entry = 1; + */ + entry?: Entry; +}; + +/** + * Describes the message saas.policylog.v1.AppendRequest. + * Use `create(AppendRequestSchema)` to create a new message. + */ +export const AppendRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 5); + +/** + * AppendResponse carries the committed receipt. A retry of the identical entry + * returns the exact original receipt rather than appending again. + * + * @generated from message saas.policylog.v1.AppendResponse + */ +export type AppendResponse = Message<"saas.policylog.v1.AppendResponse"> & { + /** + * @generated from field: saas.policylog.v1.Receipt receipt = 1; + */ + receipt?: Receipt; +}; + +/** + * Describes the message saas.policylog.v1.AppendResponse. + * Use `create(AppendResponseSchema)` to create a new message. + */ +export const AppendResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 6); + +/** + * CursorRequest takes no argument: there is one log and one head. + * + * @generated from message saas.policylog.v1.CursorRequest + */ +export type CursorRequest = Message<"saas.policylog.v1.CursorRequest"> & { +}; + +/** + * Describes the message saas.policylog.v1.CursorRequest. + * Use `create(CursorRequestSchema)` to create a new message. + */ +export const CursorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 7); + +/** + * CursorResponse carries the signed head observation. + * + * @generated from message saas.policylog.v1.CursorResponse + */ +export type CursorResponse = Message<"saas.policylog.v1.CursorResponse"> & { + /** + * @generated from field: saas.policylog.v1.Cursor cursor = 1; + */ + cursor?: Cursor; +}; + +/** + * Describes the message saas.policylog.v1.CursorResponse. + * Use `create(CursorResponseSchema)` to create a new message. + */ +export const CursorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 8); + +/** + * ReadRequest asks for a bounded page of committed records. + * + * @generated from message saas.policylog.v1.ReadRequest + */ +export type ReadRequest = Message<"saas.policylog.v1.ReadRequest"> & { + /** + * from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an + * empty page; asking beyond it is refused, so a reconciler that is ahead of + * the log cannot mistake a rollback for inactivity. A caller holding an + * exclusive cursor sends cursor + 1. + * + * @generated from field: uint64 from_seq = 1; + */ + fromSeq: bigint; + + /** + * limit is 1 through 500. + * + * @generated from field: uint32 limit = 2; + */ + limit: number; +}; + +/** + * Describes the message saas.policylog.v1.ReadRequest. + * Use `create(ReadRequestSchema)` to create a new message. + */ +export const ReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 9); + +/** + * ReadResponse carries the page in ascending sequence order. + * + * @generated from message saas.policylog.v1.ReadResponse + */ +export type ReadResponse = Message<"saas.policylog.v1.ReadResponse"> & { + /** + * @generated from field: repeated saas.policylog.v1.Record records = 1; + */ + records: Record[]; +}; + +/** + * Describes the message saas.policylog.v1.ReadResponse. + * Use `create(ReadResponseSchema)` to create a new message. + */ +export const ReadResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 10); + +/** + * KeysRequest takes no argument. + * + * @generated from message saas.policylog.v1.KeysRequest + */ +export type KeysRequest = Message<"saas.policylog.v1.KeysRequest"> & { +}; + +/** + * Describes the message saas.policylog.v1.KeysRequest. + * Use `create(KeysRequestSchema)` to create a new message. + */ +export const KeysRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 11); + +/** + * KeysResponse carries the public signing key document. + * + * @generated from message saas.policylog.v1.KeysResponse + */ +export type KeysResponse = Message<"saas.policylog.v1.KeysResponse"> & { + /** + * @generated from field: saas.policylog.v1.Key key = 1; + */ + key?: Key; +}; + +/** + * Describes the message saas.policylog.v1.KeysResponse. + * Use `create(KeysResponseSchema)` to create a new message. + */ +export const KeysResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_policylog_v1_witness, 12); + +/** + * WitnessService is the independent append-only witness for authority changes. + * + * Its protocol is append -> signed receipt -> the CALLER commits with the + * receipt in the same transaction. This listener is PRIVATE to the module: it is + * never published through the public gateway and never offered to a composed + * module. Composition admits only the host's declared client, through + * workload-authenticated mesh policy. + * + * @generated from service saas.policylog.v1.WitnessService + */ +export const WitnessService: GenService<{ + /** + * Append commits one entry and returns its receipt, within a five-second + * budget that includes queueing. A refusal carries no receipt and never + * mints a new sequence for a retried operation. + * + * @generated from rpc saas.policylog.v1.WitnessService.Append + */ + append: { + methodKind: "unary"; + input: typeof AppendRequestSchema; + output: typeof AppendResponseSchema; + }, + /** + * Cursor returns the signed committed head. + * + * @generated from rpc saas.policylog.v1.WitnessService.Cursor + */ + cursor: { + methodKind: "unary"; + input: typeof CursorRequestSchema; + output: typeof CursorResponseSchema; + }, + /** + * Read returns committed records with their original receipts. + * + * @generated from rpc saas.policylog.v1.WitnessService.Read + */ + read: { + methodKind: "unary"; + input: typeof ReadRequestSchema; + output: typeof ReadResponseSchema; + }, + /** + * Keys returns the public signing key. It never returns private material and + * does not authorize trust-anchor replacement. + * + * @generated from rpc saas.policylog.v1.WitnessService.Keys + */ + keys: { + methodKind: "unary"; + input: typeof KeysRequestSchema; + output: typeof KeysResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_saas_policylog_v1_witness, 0); + diff --git a/module/services/policy-log/README.md b/module/services/policy-log/README.md index bb0150636..4d8b788af 100644 --- a/module/services/policy-log/README.md +++ b/module/services/policy-log/README.md @@ -17,8 +17,10 @@ client. Native caller authentication, retained local storage and bootstrap remai owner work. Supplying a key alone does not qualify this service or revocation. No generated code was hand-written: the required no-change Codefly proto-generation check ran and came back byte-identical on two sibling services, -and this service's own tree regenerates reproducibly. The host's client is -separate work; no host Go code is changed here. Nothing in this directory has +and this service's own tree regenerates reproducibly. The host's client lives +in accounts (`pkg/infra/policy_log_witness.go`), against a byte-identical copy +of this service's proto; accounts reaches it through the `policy-log-witness` +group the composition root binds per environment, never a declared dependency. Nothing in this directory has been deployed or booted, and the OpenAPI projection does not exist because the endpoint is private gRPC and reaches no gateway. @@ -339,7 +341,10 @@ empty while `head + 2` refuses. Not exercised, by name: real GCS, deployed IAM and retention policy, the container build, `codefly ci run`, `codefly run` and the Codefly service graph, -a deployed mesh policy, and a running host client. **Nothing here has been +a deployed mesh policy, and a deployed host client. The host's client is +exercised against this service's real server package, over loopback, by +`TestWitnessHelperProcess` (a helper process started by accounts' tests, which +cannot link this module). **Nothing here has been deployed or booted** — the binary's own startup path (`codefly.Init`, the injected port, the delivered signing-key secret, the GCS client, `witness.Open` against a real bucket) is covered by no test and has never executed. The signature of diff --git a/module/services/policy-log/code/server/witness_helper_test.go b/module/services/policy-log/code/server/witness_helper_test.go new file mode 100644 index 000000000..85f4234dc --- /dev/null +++ b/module/services/policy-log/code/server/witness_helper_test.go @@ -0,0 +1,99 @@ +package server_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net" + "os" + "testing" + + "policy-log/mirror" + policylogv1 "policy-log/pkg/gen/saas/policylog/v1" + "policy-log/server" + "policy-log/service" + "policy-log/witness" + + "google.golang.org/grpc" +) + +// TestWitnessHelperProcess serves the REAL witness stack — witness.Log, +// service.Service and this package's projection, over the in-memory object +// store with a provisioned genesis head and a fresh signing key — for a host's +// integration test to reach over the wire. +// +// It is a helper process, not a test: it runs only when a host test starts it +// with POLICY_LOG_WITNESS_HELPER set, serves until its stdin closes, and +// otherwise skips. The host's own test cannot link this module — accounts is a +// separate Go module, and it carries its own generated saas.policylog.v1 types, +// which the protobuf registry refuses to register twice at init — so the host +// runs in its own process and reaches this one exactly as it reaches a deployed +// witness: through the generated client, on a gRPC listener. +// +// This is test code and stays test code. The served binary never imports the +// memory store, never generates a key and never writes a genesis head; nothing +// here is a way to run the witness outside a test. +// +// The one line it prints, after the prefix below, is the listener address and +// the public key document a host pins — the same schema as Keys and as the +// trust-anchor document composition delivers. +func TestWitnessHelperProcess(t *testing.T) { + if os.Getenv(witnessHelperEnv) == "" { + t.Skip("helper process for a host integration test; started with " + witnessHelperEnv + " set") + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + log, err := witness.Open(ctx, genesisStore(t), private) + if err != nil { + t.Fatal(err) + } + worker, err := mirror.Start(ctx, discard{}, func(error) {}) + if err != nil { + t.Fatal(err) + } + witnessService, err := service.New(log, worker) + if err != nil { + t.Fatal(err) + } + handler, err := server.New(witnessService, func(err error) { t.Logf("witness: %v", err) }) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + grpcServer := grpc.NewServer() + policylogv1.RegisterWitnessServiceServer(grpcServer, handler) + go func() { _ = grpcServer.Serve(listener) }() + defer grpcServer.Stop() + + if !ed25519.PublicKey(log.Keys().PublicKey).Equal(public) { + t.Fatal("the witness publishes a key other than the one it was opened with") + } + announcement, err := json.Marshal(struct { + Address string `json:"address"` + Key witness.Key `json:"key"` + }{Address: listener.Addr().String(), Key: log.Keys()}) + if err != nil { + t.Fatal(err) + } + fmt.Println(witnessHelperReady + string(announcement)) + + // Serve until the host closes stdin: that is how it takes the witness + // down mid-test, and how it stops the helper when it is done. + _, _ = io.Copy(io.Discard, os.Stdin) +} + +const ( + witnessHelperEnv = "POLICY_LOG_WITNESS_HELPER" + witnessHelperReady = "POLICY_LOG_WITNESS_READY " +) diff --git a/module/services/policy-log/service.codefly.yaml b/module/services/policy-log/service.codefly.yaml index b0a8c8ea0..25920bd9c 100644 --- a/module/services/policy-log/service.codefly.yaml +++ b/module/services/policy-log/service.codefly.yaml @@ -9,8 +9,10 @@ agent: workspace-configuration-dependencies: - policy-log endpoints: - # Private to this module. The host declares the dependency when its client - # is wired; neither the public gateway nor composed modules get this API. + # Private to this module. The host reaches it through the + # `policy-log-witness` group a composition root binds per environment, never + # a declared dependency; neither the public gateway nor composed modules get + # this API. - name: grpc spec: hot-reload: true