From 3210d3b17804e0fc2ee96d1b117dd499b886c7bd Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:30:07 -0400 Subject: [PATCH 1/4] build(accounts): the policy-log witness proto, copied byte-for-byte, and its generated bindings accounts is the witness's one client and cannot link the policy-log Go module, so it carries an identical copy of saas/policylog/v1/witness.proto, held equal to the source by TestSharedProtoCopiesMatchTheirSource. Everything else is the proto companion's output for the service's own template (codefly generate proto --template accounts/proto/buf.gen.yaml, proto:0.0.16) and go generate: the Go, gRPC and Connect bindings, the frontend's TypeScript, and a WitnessService tag in the OpenAPI documents with no path under it, the same shape the generator already gives every accounts service that has no REST route. Refs #1062 Co-Authored-By: Claude Opus 5.5 --- .../v1/policylogv1connect/witness.connect.go | 207 ++++ .../pkg/gen/saas/policylog/v1/witness.pb.go | 917 ++++++++++++++++++ .../gen/saas/policylog/v1/witness_grpc.pb.go | 266 +++++ .../code/pkg/infra/warehouse_policy_log.go | 55 -- .../generated/openapi-raw/api.swagger.json | 3 + .../accounts/openapi/api.swagger.json | 3 + .../proto/saas/policylog/v1/witness.proto | 172 ++++ .../src/gen/saas/policylog/v1/witness_pb.ts | 467 +++++++++ 8 files changed, 2035 insertions(+), 55 deletions(-) create mode 100644 module/services/accounts/code/pkg/gen/saas/policylog/v1/policylogv1connect/witness.connect.go create mode 100644 module/services/accounts/code/pkg/gen/saas/policylog/v1/witness.pb.go create mode 100644 module/services/accounts/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go delete mode 100644 module/services/accounts/code/pkg/infra/warehouse_policy_log.go create mode 100644 module/services/accounts/proto/saas/policylog/v1/witness.proto create mode 100644 module/services/frontend/code/src/gen/saas/policylog/v1/witness_pb.ts 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/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/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index ae72c9196..dafc61464 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 5528103c3..c4f9a5b49 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -11644,6 +11644,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/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); + From 30b518e622b7dbfadb135e7da9036eef37050945 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:30:07 -0400 Subject: [PATCH 2/4] feat(accounts): narrow authority through the policy-log witness, verified against a pinned key accounts gets infra.WitnessPolicyLog, the host's PolicyLog over the witness's private gRPC API. Append offers an opaque entry (operation id, subject kind and decision, subject id, and the SHA-256 of the host's canonical encoding of the whole operation) and returns the receipt only once it verifies: signed by the key pinned in the trust anchor (policy_log_public_key.json), a valid chain link, for the bytes offered, above every sequence already verified and extending the verified chain. Only Unavailable is retried, with the identical entry. Entries, which refreshes the serving gate's reached_at, requires a signed cursor fresh within the staleness window, at or above the reconciled sequence, and verifies every record's signature, entry hash and chain link. The witness's published key is compared with the pin and never adopted. work.go wires it only when composition delivers both the witness endpoint and the pin; a delivered pin that cannot be used refuses the boot. Otherwise the host is configured without a witness and keeps today's behaviour: it serves and refuses every narrowing, and the startup warning says why as configuration. The reconciliation loop now runs when a witness is wired. The service dependency on policy-log/grpc is deliberately NOT declared here: it would pull the witness into every local run, where the witness refuses to boot by its own contract. That is an owner decision; see the pull request. Tests: fake-witness unit tests for each refusal (mutation-checked), and the real witness (its server package over its memory store, as a helper process in the policy-log module) verified by the client and driven through a real uninstall and scope-grant revocation against PostgreSQL, refused with the witness down. Refs #1062 Co-Authored-By: Claude Opus 5.5 --- module/DATABASE_AUTHORITY.md | 41 +- module/DEPLOYMENT_TOPOLOGY.md | 12 +- .../accounts/code/pkg/business/policy_log.go | 22 +- .../policy_log_production_path_test.go | 10 +- ...policy_log_witness_production_path_test.go | 195 +++++ .../code/pkg/infra/policy_log_witness.go | 706 ++++++++++++++++++ .../policy_log_witness_integration_test.go | 115 +++ .../code/pkg/infra/policy_log_witness_test.go | 573 ++++++++++++++ .../pkg/infra/policylogwitnesstest/witness.go | 175 +++++ module/services/accounts/code/work.go | 137 +++- module/services/accounts/test-targets.json | 3 + module/services/policy-log/README.md | 10 +- .../code/server/witness_helper_test.go | 99 +++ 13 files changed, 2035 insertions(+), 63 deletions(-) create mode 100644 module/services/accounts/code/pkg/business/policy_log_witness_production_path_test.go create mode 100644 module/services/accounts/code/pkg/infra/policy_log_witness.go create mode 100644 module/services/accounts/code/pkg/infra/policy_log_witness_integration_test.go create mode 100644 module/services/accounts/code/pkg/infra/policy_log_witness_test.go create mode 100644 module/services/accounts/code/pkg/infra/policylogwitnesstest/witness.go create mode 100644 module/services/policy-log/code/server/witness_helper_test.go diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index 5af9c1975..5d57eb849 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -996,18 +996,35 @@ 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 does not yet +declare the dependency on `policy-log/grpc`, so today the client activates in no +environment: declaring it pulls the witness into every local run, and the +witness by its own contract refuses to boot on a workstation. That is an owner +decision recorded on the issue, not a missing transport. + +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 76a180eba..eafa870ad 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -32,11 +32,13 @@ 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`) — does not +declare the edge yet: declaring it would pull the witness into every local run, +where it refuses to boot by its own contract, and that is an owner decision. +When it is declared it adds an `accounts` → `policy-log/grpc` row and nothing +else — the witness must never depend on the database whose authority it +witnesses. ## Service graph diff --git a/module/services/accounts/code/pkg/business/policy_log.go b/module/services/accounts/code/pkg/business/policy_log.go index b81bd99b4..b41eaf543 100644 --- a/module/services/accounts/code/pkg/business/policy_log.go +++ b/module/services/accounts/code/pkg/business/policy_log.go @@ -191,9 +191,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) } @@ -247,10 +249,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 @@ -464,9 +466,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 ebea8077a..de16e73a2 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/infra/policy_log_witness.go b/module/services/accounts/code/pkg/infra/policy_log_witness.go new file mode 100644 index 000000000..acc5defe8 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/policy_log_witness.go @@ -0,0 +1,706 @@ +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) { + path := filepath.Join(SolutionHostTrustAnchorPath, 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 bound is the staleness +// window itself: a cursor older than that proves nothing the gate would accept. +const policyLogCursorSkew = business.PolicyLogStaleAfter + +// 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/work.go b/module/services/accounts/code/work.go index 063746438..34ce62142 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -18,6 +18,7 @@ import ( "accounts/pkg/datasource" "accounts/pkg/email" "accounts/pkg/eventcatalog" + policylogv1 "accounts/pkg/gen/saas/policylog/v1" "accounts/pkg/githubconnector" "accounts/pkg/infra" "accounts/pkg/jobs" @@ -37,12 +38,15 @@ import ( "strings" "time" + "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/wool" wooltel "github.com/codefly-dev/core/wool/otel" codefly "github.com/codefly-dev/sdk-go" "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() { @@ -513,37 +517,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 @@ -1268,6 +1284,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) } @@ -1387,6 +1412,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") @@ -2685,3 +2716,51 @@ 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 + } + endpoint, resolveErr := codefly.For(ctx).Service("policy-log").Endpoint("grpc").API(standards.GRPC).ResolveNetworkInstance() + switch { + case resolveErr != nil && !pinned: + return nil, nil, "this service declares no dependency on the policy-log witness endpoint and no witness key is pinned in the trust anchor", nil + case resolveErr != nil: + return nil, nil, fmt.Sprintf("a witness key is pinned in the trust anchor but the policy-log witness endpoint does not resolve for this service: %v", resolveErr), nil + case !pinned: + return nil, nil, "the policy-log witness endpoint resolves but no witness key is pinned in the trust anchor, and a key read from the witness itself is never trusted", nil + } + conn, err := grpc.NewClient(endpoint.Host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, "", fmt.Errorf("dial the policy-log witness at %s: %w", endpoint.Host, err) + } + witness, err := infra.NewWitnessPolicyLog(policylogv1.NewWitnessServiceClient(conn), key) + if err != nil { + _ = conn.Close() + return nil, nil, "", err + } + return witness, conn.Close, "", nil +} diff --git a/module/services/accounts/test-targets.json b/module/services/accounts/test-targets.json index 937d4c4f9..e54921bbe 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": 300, "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/policy-log/README.md b/module/services/policy-log/README.md index 24932a5db..f2cb690a0 100644 --- a/module/services/policy-log/README.md +++ b/module/services/policy-log/README.md @@ -17,8 +17,9 @@ the signing key's fixed path exists on no development machine. Both refusals are deliberate — see [Transport](#transport) for what that means for `codefly run`. 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 does not yet declare the 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. @@ -338,7 +339,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 fixed-path key, 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 " +) From df82d9b44d9a63f3ee3f48b55fb3be75885a0d8c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:24:50 -0400 Subject: [PATCH 3/4] fix(accounts): the witness is bound by the composition root per environment, dialled only under the mesh, and its cursor judged within seconds (#1062) The local-run decision #1067 left open is option (b), a deployed-only edge, and it is expressible with today's Codefly. accounts declares no service dependency on policy-log/grpc. It reads the witness address from a new `policy-log-witness` workspace group, which this module ships empty. A composition root that provisions the witness binds `endpoint=${endpoint:saas/policy-log/grpc}` in that environment, and the render both resolves the address and admits accounts as the endpoint's consumer there. A local run never starts a witness that refuses to boot, and its host stays fail-closed. (a) would add a storage path that is not the witness's, and (c) breaks the local loop. Review of 5f35c931: - F1 (major): the dial was plaintext to whatever resolved. It is now admitted only under internal-transport/mesh-protected=true to an in-cluster Service address, and refused by name otherwise, through pkg/meshtransport as the Vault binding does. - F3: cursor freshness drops from the 60 s staleness window to 10 s of clock skew. A cursor is asked for in the same pass that judges it. - F5: the pin is read through the trust anchor's one resolver, solutionHostTrustAnchorDir. Co-Authored-By: Claude Opus 5.5 --- configurations/local/policy-log-witness.env | 1 + module/DATABASE_AUTHORITY.md | 14 +++-- module/DEPLOYMENT_TOPOLOGY.md | 14 +++-- .../local/policy-log-witness.env | 20 +++++++ .../code/pkg/infra/policy_log_witness.go | 17 ++++-- .../code/policy_log_witness_transport_test.go | 45 ++++++++++++++ module/services/accounts/code/work.go | 59 ++++++++++++++++--- module/services/accounts/service.codefly.yaml | 1 + module/services/policy-log/README.md | 3 +- 9 files changed, 149 insertions(+), 25 deletions(-) create mode 120000 configurations/local/policy-log-witness.env create mode 100644 module/configurations/local/policy-log-witness.env create mode 100644 module/services/accounts/code/policy_log_witness_transport_test.go 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 a08db0f38..c170991bf 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -1122,11 +1122,15 @@ 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 does not yet -declare the dependency on `policy-log/grpc`, so today the client activates in no -environment: declaring it pulls the witness into every local run, and the -witness by its own contract refuses to boot on a workstation. That is an owner -decision recorded on the issue, not a missing transport. +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 diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 3d7fd1658..1326a2474 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -33,12 +33,14 @@ 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. It declares no dependency on anything, and -accounts — whose witness client is built (`infra.WitnessPolicyLog`) — does not -declare the edge yet: declaring it would pull the witness into every local run, -where it refuses to boot by its own contract, and that is an owner decision. -When it is declared it adds an `accounts` → `policy-log/grpc` row and nothing -else — the witness must never depend on the database whose authority it -witnesses. +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/infra/policy_log_witness.go b/module/services/accounts/code/pkg/infra/policy_log_witness.go index acc5defe8..940997d13 100644 --- a/module/services/accounts/code/pkg/infra/policy_log_witness.go +++ b/module/services/accounts/code/pkg/infra/policy_log_witness.go @@ -104,7 +104,13 @@ type policyLogWitnessKeyDocument struct { // 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) { - path := filepath.Join(SolutionHostTrustAnchorPath, policyLogWitnessKeyFileName) + // 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) { @@ -168,9 +174,12 @@ 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 bound is the staleness -// window itself: a cursor older than that proves nothing the gate would accept. -const policyLogCursorSkew = business.PolicyLogStaleAfter +// `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 { 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 49f2c4204..cd8976168 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -25,6 +25,7 @@ import ( "accounts/pkg/jobs" "accounts/pkg/keyservice" "accounts/pkg/membership" + "accounts/pkg/meshtransport" "accounts/pkg/metrics" "accounts/pkg/permissionsplugin" "accounts/pkg/redisstate" @@ -41,7 +42,6 @@ import ( "time" "github.com/codefly-dev/core/resources" - "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/wool" wooltel "github.com/codefly-dev/core/wool/otel" "github.com/codefly-dev/core/workloadidentity/native" @@ -3056,18 +3056,35 @@ func configuredPolicyLogWitness(ctx context.Context) (*infra.WitnessPolicyLog, f if err != nil { return nil, nil, "", err } - endpoint, resolveErr := codefly.For(ctx).Service("policy-log").Endpoint("grpc").API(standards.GRPC).ResolveNetworkInstance() + // 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 resolveErr != nil && !pinned: - return nil, nil, "this service declares no dependency on the policy-log witness endpoint and no witness key is pinned in the trust anchor", nil - case resolveErr != nil: - return nil, nil, fmt.Sprintf("a witness key is pinned in the trust anchor but the policy-log witness endpoint does not resolve for this service: %v", resolveErr), nil + 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 == "": + return nil, nil, fmt.Sprintf("a witness key is pinned in the trust anchor but the composition binds no witness endpoint (%s/%s)", policyLogWitnessGroup, policyLogWitnessEndpointKey), nil case !pinned: - return nil, nil, "the policy-log witness endpoint resolves but no witness key is pinned in the trust anchor, and a key read from the witness itself is never trusted", nil + 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(endpoint.Host, grpc.WithTransportCredentials(insecure.NewCredentials())) + 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", endpoint.Host, err) + 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 { @@ -3076,3 +3093,27 @@ func configuredPolicyLogWitness(ctx context.Context) (*infra.WitnessPolicyLog, f } 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/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/policy-log/README.md b/module/services/policy-log/README.md index a104d40a9..4d8b788af 100644 --- a/module/services/policy-log/README.md +++ b/module/services/policy-log/README.md @@ -19,7 +19,8 @@ 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 lives in accounts (`pkg/infra/policy_log_witness.go`), against a byte-identical copy -of this service's proto; accounts does not yet declare the dependency. Nothing in this directory has +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. From 3f52664a42852b183db18ddd1d99493de6942712 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:29:03 -0400 Subject: [PATCH 4/4] fix(accounts): a host whose witness key is pinned refuses to serve without a bound witness (#1062) Review of df82d9b4 (F7, major): the endpoint is now a value the composition root sets per environment. Before this fix, a host with a pinned key and an unbound endpoint only warned, wired no log, and served with the serving gate off. A restored database could then undo a witnessed narrowing. The pin comes from the platform-owned trust anchor, so the pin decides that this environment has a witness: pinned but unbound now refuses the boot, by name. F10: the witness's manifest comment no longer says the host declares a dependency on it. Co-Authored-By: Claude Opus 5.5 --- module/services/accounts/code/work.go | 7 ++++++- module/services/policy-log/service.codefly.yaml | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index cd8976168..179eb73f2 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -3069,7 +3069,12 @@ func configuredPolicyLogWitness(ctx context.Context) (*infra.WitnessPolicyLog, f 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 == "": - return nil, nil, fmt.Sprintf("a witness key is pinned in the trust anchor but the composition binds no witness endpoint (%s/%s)", policyLogWitnessGroup, policyLogWitnessEndpointKey), nil + // 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 } 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