From 93fa57c4d16d18414d22e17546cad7c4da98dcda Mon Sep 17 00:00:00 2001 From: Gael Date: Fri, 9 Oct 2026 15:22:06 -0400 Subject: [PATCH 1/3] feat(moduleauthority): expose gateway transport and audit declarations --- README.md | 13 +++++ moduleauthority/audit.go | 20 +++++++ moduleauthority/audit_test.go | 24 +++++++++ moduleauthority/gateway.go | 35 ++++++++++++ moduleauthority/gateway_test.go | 90 +++++++++++++++++++++++++++++++ moduleauthority/host_fake_test.go | 8 +++ moduleauthority/types.go | 19 +++++++ 7 files changed, 209 insertions(+) create mode 100644 moduleauthority/audit.go create mode 100644 moduleauthority/audit_test.go create mode 100644 moduleauthority/gateway.go create mode 100644 moduleauthority/gateway_test.go diff --git a/README.md b/README.md index a95b60e..a299f6e 100644 --- a/README.md +++ b/README.md @@ -260,3 +260,16 @@ The host preserves its organization-member metadata-read rule: no new permission or binding is required. It checks current parent authority and delegation, authenticated module/audience/tenant alignment, current membership, and exact active record identity. A refusal never invokes a bearer-based fallback. + +### Gateway authority transport and audit declarations + +`moduleauthority.NewGatewayAuthority(seams, credentials)` explicitly selects +the host's binary-protobuf Connect authority bridge on the gateway base URL. +It refuses a simultaneous direct Authority address and does not fall back to +Accounts. The selected host must serve this bridge; a generated client alone +does not establish that a deployed host supports it. The existing `New` +constructor retains the direct authority transport. + +`Client.DeclareAuditEventTypes` presents the module's event schemas through the +selected authority seam. Ownership and compatibility remain host decisions. +Public declaration types and enum constants are re-exported by moduleauthority. diff --git a/moduleauthority/audit.go b/moduleauthority/audit.go new file mode 100644 index 0000000..67ff861 --- /dev/null +++ b/moduleauthority/audit.go @@ -0,0 +1,20 @@ +package moduleauthority + +import ( + "context" + "fmt" + v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" + "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" +) + +// DeclareAuditEventTypes admits this module's event schemas through the same +// authenticated authority seam as emission. The host validates ownership and +// compatibility; the SDK neither invents a namespace nor widens the declaration. +func (c *Client) DeclareAuditEventTypes(ctx context.Context, types []*ModuleAuditEventTypeDeclaration) error { + _, err := callAsModule(ctx, c, accountsv1connect.ModuleCapabilitiesServiceClient.DeclareAuditEventTypes, + &v1.ModuleDeclareAuditEventTypesRequest{Prefix: c.credentials.Prefix, Types: types}) + if err != nil { + return fmt.Errorf("module authority: declare audit event types: %w", err) + } + return nil +} diff --git a/moduleauthority/audit_test.go b/moduleauthority/audit_test.go new file mode 100644 index 0000000..e5f7892 --- /dev/null +++ b/moduleauthority/audit_test.go @@ -0,0 +1,24 @@ +package moduleauthority + +import "testing" + +func TestAuditDeclarationUsesOwnModuleIdentity(t *testing.T) { + h := newHost(t) + client := h.client(t, moduleCredentials) + types := []*ModuleAuditEventTypeDeclaration{{Type: "example.record.changed", Description: "Record changed", Visibility: ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT, Fields: []*ModuleAuditFieldDeclaration{{Name: "record_id", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID}}}} + if err := client.DeclareAuditEventTypes(t.Context(), types); err != nil { + t.Fatal(err) + } + calls := h.authority.snapshot() + if len(calls) != 1 { + t.Fatalf("calls=%d", len(calls)) + } + call := calls[0] + if call.declaration.GetPrefix() != moduleCredentials.Prefix || call.declaration.GetTypes()[0].GetType() != types[0].Type { + t.Fatal("declaration owner or type changed") + } + if len(call.internalToken) != 1 || call.internalToken[0] != testInternalToken || len(call.workContext) != 1 || call.workContext[0] != "module.token1" || len(call.authorization) != 0 { + t.Fatal("wrong declaration authority") + } + h.assertNoStrayPaths(t) +} diff --git a/moduleauthority/gateway.go b/moduleauthority/gateway.go new file mode 100644 index 0000000..dc9d0bc --- /dev/null +++ b/moduleauthority/gateway.go @@ -0,0 +1,35 @@ +package moduleauthority + +import ( + "fmt" + + "connectrpc.com/connect" + "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" +) + +// NewGatewayAuthority uses the host's operation-authority bridge instead of a +// direct accounts connection. It supports ExchangeOperation, runtime-boundary +// verification, audit emission and audit receipt lookup, plus the ordinary +// broker mints. The selected host must implement /modules/_authority; there is +// no fallback to direct accounts access or another authority surface. +// +// Authority must be empty. Gateway, credentials and transport protections are +// validated exactly as for New. The bridge retains the existing host decisions; +// choosing this transport grants no additional capability. +func NewGatewayAuthority(seams Seams, credentials Credentials) (*Client, error) { + if seams.Authority != (Authority{}) { + return nil, fmt.Errorf("%w: gateway authority must not also select a direct endpoint", ErrInvalidSeams) + } + c, err := New(seams, credentials) + if err != nil { + return nil, err + } + c.authority = authorityEndpoint{ + capabilities: accountsv1connect.NewModuleCapabilitiesServiceClient( + c.broker.client, c.broker.baseURL+"/modules/_authority", + connect.WithReadMaxBytes(maxBrokerResponse), + ), + token: seams.InternalToken, + } + return c, nil +} diff --git a/moduleauthority/gateway_test.go b/moduleauthority/gateway_test.go new file mode 100644 index 0000000..3be183c --- /dev/null +++ b/moduleauthority/gateway_test.go @@ -0,0 +1,90 @@ +package moduleauthority + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "connectrpc.com/connect" + v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" + codefly "github.com/codefly-dev/sdk-go/workcontext" +) + +type bridgeGateway struct{ server *httptest.Server } + +func (g bridgeGateway) BaseURL() string { return g.server.URL } +func (g bridgeGateway) HTTPClient() *http.Client { return g.server.Client() } + +func TestGatewayAuthorityExchangesThroughOnlyTheGateway(t *testing.T) { + broker := newFakeGateway(t) + const procedure = "/modules/_authority/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedOperationAudience" + var calls atomic.Int32 + bridge := connect.NewUnaryHandler(procedure, func(ctx context.Context, req *connect.Request[v1.ModuleExchangeDelegatedOperationAudienceRequest]) (*connect.Response[v1.IssuedWorkContext], error) { + calls.Add(1) + if req.Header().Get("Content-Type") != "application/proto" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("bridge requires canonical binary protobuf")) + } + if req.Header().Get(internalTokenHeader) != testInternalToken || req.Header().Get(codefly.WorkContextHeaderName) != "module.token1" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid fixture proof")) + } + if req.Msg.GetBindingId() != "binding-fixture" || req.Msg.GetParentWorkContextToken() != "parent.token" || !req.Msg.GetLookup() { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("intent changed")) + } + return connect.NewResponse(&v1.IssuedWorkContext{Token: "child.token"}), nil + }) + mux := http.NewServeMux() + mux.Handle(procedure, bridge) + mux.HandleFunc("/", broker.serve) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + c, err := NewGatewayAuthority(Seams{Gateway: bridgeGateway{server}, InternalToken: testInternalToken}, moduleCredentials) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + issued, err := c.ExchangeOperation(t.Context(), ExchangeRequest{BindingID: "binding-fixture", Parent: token(t, "parent.token"), Lookup: true}) + if err != nil { + t.Fatal(err) + } + if issued.Encoded() != "child.token" || calls.Load() != 1 { + t.Fatal("exchange did not preserve its outcome") + } + paths, stray, _, _ := broker.snapshot() + if len(paths) != 1 || paths[0] != hostWorkContextPath || len(stray) != 0 { + t.Fatalf("unexpected broker calls: %v, stray: %v", paths, stray) + } +} + +func TestGatewayAuthorityRejectsAmbiguousTransport(t *testing.T) { + _, err := NewGatewayAuthority(Seams{Authority: Authority{Address: "direct.invalid:443"}}, moduleCredentials) + if !errors.Is(err, ErrInvalidSeams) { + t.Fatalf("got %v, want invalid seams", err) + } +} + +func TestGatewayAuthorityDoesNotFollowRedirects(t *testing.T) { + broker := newFakeGateway(t) + var received atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { received.Add(1) })) + t.Cleanup(target.Close) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == hostWorkContextPath { + broker.serve(w, r) + return + } + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(server.Close) + c, err := NewGatewayAuthority(Seams{Gateway: bridgeGateway{server}, InternalToken: testInternalToken}, moduleCredentials) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + _, err = c.ExchangeOperation(t.Context(), ExchangeRequest{BindingID: "binding-fixture", Parent: token(t, "parent.token")}) + if err == nil || received.Load() != 0 { + t.Fatalf("redirected capability: err=%v, target calls=%d", err, received.Load()) + } +} diff --git a/moduleauthority/host_fake_test.go b/moduleauthority/host_fake_test.go index 4d54fb2..12c22ef 100644 --- a/moduleauthority/host_fake_test.go +++ b/moduleauthority/host_fake_test.go @@ -249,6 +249,7 @@ func (g *fakeGateway) snapshot() (calls, stray []string, bodies map[string][]map // authorityCall is one call the fake authority endpoint received. type authorityCall struct { + declaration *v1.ModuleDeclareAuditEventTypesRequest installation *v1.ModuleCurrentInstallationRequest authorization []string artifact *v1.ModuleExecutableArtifactRequest @@ -264,6 +265,7 @@ type authorityCall struct { // servedOnAuthority is the slice of business.ModuleAuthorityProcedures this // package calls; Mint* are never among them. var servedOnAuthority = map[string]bool{ + accountsv1connect.ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure: true, accountsv1connect.ModuleCapabilitiesServiceGetCurrentInstallationProcedure: true, accountsv1connect.ModuleCapabilitiesServiceApproveExecutableArtifactProcedure: true, accountsv1connect.ModuleCapabilitiesServiceAuthorizeExecutableArtifactProcedure: true, @@ -309,6 +311,9 @@ func (a *fakeAuthority) handle(_ any, stream grpc.ServerStream) error { request := &v1.ModuleExchangeDelegatedOperationAudienceRequest{} var recvErr error switch method { + case accountsv1connect.ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure: + call.declaration = &v1.ModuleDeclareAuditEventTypesRequest{} + recvErr = stream.RecvMsg(call.declaration) case accountsv1connect.ModuleCapabilitiesServiceGetCurrentInstallationProcedure: call.installation = &v1.ModuleCurrentInstallationRequest{} recvErr = stream.RecvMsg(call.installation) @@ -348,6 +353,9 @@ func (a *fakeAuthority) handle(_ any, stream grpc.ServerStream) error { if !a.gateway.accepts(call.workContext[0]) { return status.Error(codes.Unauthenticated, "module work context is not a valid capability") } + if call.declaration != nil { + return stream.SendMsg(&v1.ModuleDeclareAuditEventTypesResponse{}) + } if call.installation != nil { if call.installation.ParentWorkContextToken == "denied.token" { return status.Error(codes.PermissionDenied, "current authority denied") diff --git a/moduleauthority/types.go b/moduleauthority/types.go index 804ede3..c43db5a 100644 --- a/moduleauthority/types.go +++ b/moduleauthority/types.go @@ -5,6 +5,11 @@ import v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" // Generated contract aliases used by the facade. They keep consumers on this // package boundary when they need the complete response projection. type ( + ModuleAuditEventTypeDeclaration = v1.ModuleAuditEventTypeDeclaration + ModuleAuditFieldDeclaration = v1.ModuleAuditFieldDeclaration + ModuleAuditFieldKind = v1.ModuleAuditFieldKind + ModuleAuditEventVisibility = v1.ModuleAuditEventVisibility + ModuleMintWorkContextRequest = v1.ModuleMintWorkContextRequest ModuleMintWorkContextResponse = v1.ModuleMintWorkContextResponse ModuleMintOperationContextRequest = v1.ModuleMintOperationContextRequest @@ -13,3 +18,17 @@ type ( ModuleMintSourceOperationContextResponse = v1.ModuleMintSourceOperationContextResponse IssuedWorkContext = v1.IssuedWorkContext ) + +const ( + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UNSPECIFIED = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UNSPECIFIED + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_INT = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_INT + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_NUMBER = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_NUMBER + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_BOOL = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_BOOL + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_ENUM = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_ENUM + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING_ARRAY = v1.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING_ARRAY + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED = v1.ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT = v1.ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL = v1.ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL +) From 0d5d8c3dcdf9c1fb289191d32e6227bc9de03da4 Mon Sep 17 00:00:00 2001 From: Gael Date: Fri, 9 Oct 2026 15:24:01 -0400 Subject: [PATCH 2/3] docs(moduleauthority): bound gateway support to selected host procedures --- AGENTS.md | 2 +- moduleauthority/authority.go | 6 +++--- moduleauthority/client.go | 8 +++++--- moduleauthority/gateway.go | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e919edd..00d9219 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ workflow) and fails on any difference — the reproduction check from the | `settings/` | presence-aware `Field[M, T]` runtime; `JSONCodec` is the only typed↔ProtoJSON boundary | | `settings/catalog/` | Go / TypeScript / proto catalog renderers that `module-compose` calls | | `workcontext/` | callee-side verification: HTTP, Connect, gRPC middleware plus scope checks | -| `moduleauthority/` | a module's own authority: Work Context mints at the gateway broker, exchanges on accounts' `authority` gRPC endpoint | +| `moduleauthority/` | a module's own authority: gateway broker mints; direct Accounts authority or explicitly selected gateway authority transport | | `gen/` | generated output. Regenerated, never edited | | `internal/apiboundary` | the public-API boundary gate, with fixtures that prove it still looks | | `internal/agentcontext` | the budget and frontmatter contract for this file and the skills | diff --git a/moduleauthority/authority.go b/moduleauthority/authority.go index 4c74163..24558e4 100644 --- a/moduleauthority/authority.go +++ b/moduleauthority/authority.go @@ -12,9 +12,9 @@ import ( "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" ) -// authorityEndpoint is this client's connection to accounts' `authority` -// gRPC endpoint: the generated ModuleCapabilitiesService client, speaking the -// gRPC protocol over HTTP/2 (TLS, or h2c where the transport rule admits it). +// authorityEndpoint holds the generated ModuleCapabilitiesService client. +// New selects Accounts authority over gRPC/HTTP2; NewGatewayAuthority selects +// the gateway binary-protobuf Connect bridge. // // Every procedure the host serves there authenticates the calling module from // its module Work Context; call them through callAsModule, never directly, so diff --git a/moduleauthority/client.go b/moduleauthority/client.go index 1d569f6..3293717 100644 --- a/moduleauthority/client.go +++ b/moduleauthority/client.go @@ -1,6 +1,6 @@ // Package moduleauthority exchanges a composed module's installed authority -// with the SaaS host, over the two seams the host actually serves a composed -// module: +// with the SaaS host, through a broker and an explicitly selected authority +// transport: // // - the gateway's module broker (REST, JSON over HTTP on the gateway base // URL) — where a module presents its identity secret and is minted a Work @@ -10,7 +10,9 @@ // the gateway edge; the broker is how a module reaches them. // - accounts' named `authority` gRPC endpoint — where a module calls, with // its module Work Context, the procedures the host exports to composed -// modules (ExchangeDelegatedOperationAudience among them). +// modules (ExchangeDelegatedOperationAudience among them). New selects this +// transport. NewGatewayAuthority instead uses the host gateway bridge for +// procedures that the selected host exposes there, without direct fallback. // // Neither address is configured here: the consumer passes what Codefly // resolved for it, in Seams. diff --git a/moduleauthority/gateway.go b/moduleauthority/gateway.go index dc9d0bc..aa03c25 100644 --- a/moduleauthority/gateway.go +++ b/moduleauthority/gateway.go @@ -8,10 +8,10 @@ import ( ) // NewGatewayAuthority uses the host's operation-authority bridge instead of a -// direct accounts connection. It supports ExchangeOperation, runtime-boundary -// verification, audit emission and audit receipt lookup, plus the ordinary -// broker mints. The selected host must implement /modules/_authority; there is -// no fallback to direct accounts access or another authority surface. +// direct accounts connection. Authority calls use binary-protobuf Connect; +// broker mints keep their existing transport. The selected host must expose +// each requested procedure under /modules/_authority. A generated client does +// not establish host support. There is no fallback to direct accounts access. // // Authority must be empty. Gateway, credentials and transport protections are // validated exactly as for New. The bridge retains the existing host decisions; From 83b5d5621a791314757c1b0edae6d444de7c979c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:54 -0400 Subject: [PATCH 3/3] fix(moduleauthority): verify declaration contracts and Go 1.27 tooling --- AGENTS.md | 2 + CHANGELOG.md | 10 ++ README.md | 54 ++++++- go.mod | 4 +- go.sum | 4 +- moduleauthority/audit.go | 13 +- moduleauthority/audit_test.go | 253 ++++++++++++++++++++++++++++-- moduleauthority/gateway_test.go | 8 +- moduleauthority/host_fake_test.go | 8 +- 9 files changed, 325 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00d9219..0ccde9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ gofmt -l . | grep -v '^gen/' # expect no output type-checks the entire public surface against the boundary rule below, and `internal/agentcontext` checks these context files. There is no lint config — `golangci-lint` being installed on your machine does not make it a gate here. +Keep `golang.org/x/tools` at v0.50.0 or newer: earlier export readers cannot +load the Go 1.27.2 export-data format used by the API-boundary gate. The same workflow's `gen-drift` job regenerates `gen/` from the ref in `SOURCE.txt` with this repo's `buf.gen.yaml` (buf itself pinned in the diff --git a/CHANGELOG.md b/CHANGELOG.md index 81965c5..1c4cb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — module audit declarations and gateway authority + +- Added `Client.DeclareAuditEventTypes` over the existing module-authority + credential cache and refresh path, with declaration types and enum constants + re-exported from `moduleauthority`. Host admission rules remain authoritative. +- Added explicit `NewGatewayAuthority` for hosts serving the binary-protobuf + Connect bridge. Existing direct and mint-only clients retain their behavior. +- Updated `golang.org/x/tools` to v0.50.0 so the public-API boundary gate can + read Go 1.27.2 export data, without weakening its checks or fixtures. + ## Unreleased — current installation identity - Added `moduleauthority.Client.GetCurrentInstallation` with independent module diff --git a/README.md b/README.md index a299f6e..108e7c3 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,10 @@ The SDK surfaces are: }) ``` - `New` refuses a missing internal token, gateway base URL or authority - address, and refuses to send the internal token or the module secret in - plaintext to anything but `localhost` or a loopback IP unless + `New` refuses a missing internal token, gateway base URL or an incomplete + authority seam. Leaving `Authority` zero builds a mint-only client; authority + calls then return `ErrNoAuthority`. It refuses to send the internal token or + the module secret in plaintext to anything but `localhost` or a loopback IP unless `AllowInsecureHTTP` asserts the hop is mesh-protected. SaaS owns the installed binding's audience, scopes, lifetime, verification, and audit record. @@ -264,12 +265,49 @@ active record identity. A refusal never invokes a bearer-based fallback. ### Gateway authority transport and audit declarations `moduleauthority.NewGatewayAuthority(seams, credentials)` explicitly selects -the host's binary-protobuf Connect authority bridge on the gateway base URL. -It refuses a simultaneous direct Authority address and does not fall back to -Accounts. The selected host must serve this bridge; a generated client alone -does not establish that a deployed host supports it. The existing `New` -constructor retains the direct authority transport. +the host's binary-protobuf Connect authority bridge at +`/modules/_authority`. Leave `Seams.Authority` zero when using +this constructor. It refuses any simultaneous direct Authority configuration, +applies the same credential and TLS/mesh checks as `New`, blocks redirects, +and does not fall back to Accounts. The selected host must serve this bridge; +a generated client alone +does not establish that a deployed host supports it or any particular procedure. +The existing `New` constructor retains the direct authority transport and its +mint-only mode. Audit declarations work with the existing direct authority +endpoint; they do not require the gateway bridge. `Client.DeclareAuditEventTypes` presents the module's event schemas through the selected authority seam. Ownership and compatibility remain host decisions. Public declaration types and enum constants are re-exported by moduleauthority. +The SDK supplies `Credentials.Prefix`, uses the same cached module Work Context +as other authority calls, and preserves every field, enum value, visibility and +PII flag. It does not create a second registry or infer a namespace from a prefix. + +After constructing `authority` with the resolved seams and projected credentials +as above, declare schemas during startup and propagate any refusal: + +```go +err = authority.DeclareAuditEventTypes(ctx, []*moduleauthority.ModuleAuditEventTypeDeclaration{ + { + Type: "example.item.changed", // namespace bound to this module by the operator + Description: "An item changed", + Visibility: moduleauthority.ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT, + Fields: []*moduleauthority.ModuleAuditFieldDeclaration{ + {Name: "item_id", Kind: moduleauthority.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID}, + {Name: "contact", Kind: moduleauthority.ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING, Pii: true}, + }, + }, +}) +if err != nil { + return err +} +``` + +The host admits only operator-bound namespaces. Re-declaring the identical schema +is idempotent; fields and enum values may grow, while removal, retyping and changes +to visibility are refused. A rejected module capability (`Unauthenticated`) is +refreshed and retried once. Other authority refusals retain their Connect codes +and details (`connect.CodeOf(err)` / `errors.As`) and are not retried. Broker +failures retain `*moduleauthority.BrokerError` and their existing sentinels. A +mint-only client's declaration fails with `ErrNoAuthority` before any network +request. A missing bridge fails without switching transports. diff --git a/go.mod b/go.mod index dad3056..a41d7c7 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,9 @@ require ( connectrpc.com/connect v1.21.0 github.com/codefly-dev/core v0.3.32 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 - golang.org/x/tools v0.49.0 + golang.org/x/tools v0.50.0 google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 ) @@ -19,5 +20,4 @@ require ( golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect ) diff --git a/go.sum b/go.sum index e5832c6..d4eda4e 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= diff --git a/moduleauthority/audit.go b/moduleauthority/audit.go index 67ff861..4391f6e 100644 --- a/moduleauthority/audit.go +++ b/moduleauthority/audit.go @@ -3,13 +3,20 @@ package moduleauthority import ( "context" "fmt" + v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" ) -// DeclareAuditEventTypes admits this module's event schemas through the same -// authenticated authority seam as emission. The host validates ownership and -// compatibility; the SDK neither invents a namespace nor widens the declaration. +// DeclareAuditEventTypes presents this module's event schemas through the +// client's selected authority seam, using its own prefix and cached module +// capability. The host validates ownership and compatibility; the SDK forwards +// the complete declaration without changing it. An identical re-declaration is +// idempotent at the host, so modules may declare on startup. +// +// Like other authority calls, it refreshes an Unauthenticated module capability +// and retries once. Other refusals retain their Connect codes and details and +// are not retried. A mint-only client returns ErrNoAuthority before any request. func (c *Client) DeclareAuditEventTypes(ctx context.Context, types []*ModuleAuditEventTypeDeclaration) error { _, err := callAsModule(ctx, c, accountsv1connect.ModuleCapabilitiesServiceClient.DeclareAuditEventTypes, &v1.ModuleDeclareAuditEventTypesRequest{Prefix: c.credentials.Prefix, Types: types}) diff --git a/moduleauthority/audit_test.go b/moduleauthority/audit_test.go index e5f7892..cb5f8e6 100644 --- a/moduleauthority/audit_test.go +++ b/moduleauthority/audit_test.go @@ -1,24 +1,249 @@ package moduleauthority -import "testing" +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" -func TestAuditDeclarationUsesOwnModuleIdentity(t *testing.T) { + "connectrpc.com/connect" + v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" + codefly "github.com/codefly-dev/sdk-go/workcontext" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const auditDeclarationProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/DeclareAuditEventTypes" + +// Exercise real gRPC and binary-protobuf Connect. This bridge fixture models +// an explicitly selected host serving declarations, not every deployed host. +func forAuditTransports(t *testing.T, test func(*testing.T, host, *Client)) { + t.Helper() + for _, transport := range []string{"direct", "gateway"} { + t.Run(transport, func(t *testing.T) { + h := newHost(t) + if transport == "direct" { + test(t, h, h.client(t, moduleCredentials)) + return + } + procedure := "/modules/_authority" + auditDeclarationProcedure + mux := http.NewServeMux() + mux.Handle(procedure, connect.NewUnaryHandler(procedure, func(_ context.Context, req *connect.Request[v1.ModuleDeclareAuditEventTypesRequest]) (*connect.Response[v1.ModuleDeclareAuditEventTypesResponse], error) { + call := authorityCall{ + method: auditDeclarationProcedure, declaration: req.Msg, + internalToken: req.Header().Values("X-Codefly-Internal-Token"), + workContext: req.Header().Values(codefly.WorkContextHeaderName), + authorization: req.Header().Values("Authorization"), + moduleSecret: req.Header().Values("X-Codefly-Module-Secret"), + } + h.authority.mu.Lock() + h.authority.calls = append(h.authority.calls, call) + h.authority.mu.Unlock() + if req.Header().Get("Content-Type") != "application/proto" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("binary protobuf required")) + } + if len(call.internalToken) != 1 || call.internalToken[0] != testInternalToken { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("internal credential required")) + } + if len(call.workContext) != 1 || !h.gateway.accepts(call.workContext[0]) { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("module capability rejected")) + } + if err := h.authority.declarationError; err != nil { + verdict := status.Convert(err) + refusal := connect.NewError(connect.Code(verdict.Code()), errors.New(verdict.Message())) + for _, detail := range verdict.Proto().Details { + wireDetail, err := connect.NewErrorDetail(detail) + if err != nil { + return nil, err + } + refusal.AddDetail(wireDetail) + } + return nil, refusal + } + return connect.NewResponse(&v1.ModuleDeclareAuditEventTypesResponse{}), nil + })) + mux.HandleFunc("/", h.gateway.serve) + // Resolved gateway URLs can include a path and a trailing slash. + server := httptest.NewServer(http.StripPrefix("/host", mux)) + t.Cleanup(server.Close) + client, err := NewGatewayAuthority(Seams{ + Gateway: staticGateway{url: server.URL + "/host/", client: server.Client()}, + InternalToken: testInternalToken, + }, moduleCredentials) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + test(t, h, client) + }) + } +} + +func auditDeclarations() []*ModuleAuditEventTypeDeclaration { + return []*ModuleAuditEventTypeDeclaration{ + { + Type: "example.item.changed", Description: "An item changed", + Visibility: ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT, + Fields: []*ModuleAuditFieldDeclaration{ + {Name: "item_id", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID}, + {Name: "state", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_ENUM, Values: []string{"created", "updated"}}, + {Name: "contact", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING, Pii: true}, + }, + }, + { + Type: "example.item.removed", Description: "An item was removed", + Visibility: ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL, + Fields: []*ModuleAuditFieldDeclaration{{Name: "item_id", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID}}, + }, + } +} + +func assertAuditCall(t *testing.T, call authorityCall, want *v1.ModuleDeclareAuditEventTypesRequest, moduleToken string) { + t.Helper() + if call.method != auditDeclarationProcedure || !proto.Equal(call.declaration, want) { + t.Fatalf("declaration changed: method=%q, got=%v, want=%v", call.method, call.declaration, want) + } + if len(call.internalToken) != 1 || call.internalToken[0] != testInternalToken || len(call.workContext) != 1 || call.workContext[0] != moduleToken || len(call.authorization) != 0 || len(call.moduleSecret) != 0 { + t.Fatal("declaration must carry only the perimeter token and current module capability") + } +} + +func TestAuditDeclarationPreservesSchemaAndCachesModuleProof(t *testing.T) { + forAuditTransports(t, func(t *testing.T, h host, client *Client) { + input := &v1.ModuleDeclareAuditEventTypesRequest{Prefix: moduleCredentials.Prefix, Types: auditDeclarations()} + want := proto.Clone(input).(*v1.ModuleDeclareAuditEventTypesRequest) + for range 2 { + if err := client.DeclareAuditEventTypes(t.Context(), input.Types); err != nil { + t.Fatal(err) + } + } + if !proto.Equal(input, want) { + t.Fatal("declaration mutated the caller's schema") + } + calls := h.authority.snapshot() + if len(calls) != 2 { + t.Fatalf("authority calls=%d, want 2", len(calls)) + } + for _, call := range calls { + assertAuditCall(t, call, want, "module.token1") + } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 1 || calls[0] != hostWorkContextPath { + t.Fatalf("broker calls=%v, want one cached module mint", calls) + } + h.assertNoStrayPaths(t) + }) +} + +func TestAuditDeclarationRefreshPreservesSchema(t *testing.T) { + forAuditTransports(t, func(t *testing.T, h host, client *Client) { + if _, err := client.ModuleWorkContext(t.Context()); err != nil { + t.Fatal(err) + } + h.gateway.revoke("module.token1") + input := &v1.ModuleDeclareAuditEventTypesRequest{Prefix: moduleCredentials.Prefix, Types: auditDeclarations()} + want := proto.Clone(input).(*v1.ModuleDeclareAuditEventTypesRequest) + if err := client.DeclareAuditEventTypes(t.Context(), input.Types); err != nil { + t.Fatal(err) + } + calls := h.authority.snapshot() + if len(calls) != 2 { + t.Fatalf("authority calls=%d, want one rejection and one retry", len(calls)) + } + assertAuditCall(t, calls[0], want, "module.token1") + assertAuditCall(t, calls[1], want, "module.token2") + if !proto.Equal(input, want) { + t.Fatal("refresh mutated the caller's schema") + } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 2 { + t.Fatalf("broker calls=%v, want initial mint and one refresh", calls) + } + h.assertNoStrayPaths(t) + }) +} + +func TestAuditDeclarationPreservesHostRefusals(t *testing.T) { + for _, code := range []codes.Code{codes.PermissionDenied, codes.InvalidArgument, codes.FailedPrecondition, codes.Unavailable, codes.Unauthenticated} { + t.Run(code.String(), func(t *testing.T) { + forAuditTransports(t, func(t *testing.T, h host, client *Client) { + detail := &errdetails.ErrorInfo{Reason: "DECLARATION_REFUSED", Domain: "audit.example", Metadata: map[string]string{"type": "example.item.changed"}} + verdict, err := status.New(code, "host declaration refusal").WithDetails(detail) + if err != nil { + t.Fatal(err) + } + h.authority.declarationError = verdict.Err() + err = client.DeclareAuditEventTypes(t.Context(), auditDeclarations()) + var got *connect.Error + if !errors.As(err, &got) || got.Code() != connect.Code(code) || got.Message() != verdict.Message() || len(got.Details()) != 1 { + t.Fatalf("lost host refusal: %v", err) + } + gotDetail, err := got.Details()[0].Value() + if err != nil || !proto.Equal(gotDetail, detail) { + t.Fatalf("lost host error detail: %v, %v", gotDetail, err) + } + wantCalls := 1 + if code == codes.Unauthenticated { + wantCalls = 2 // one refresh, then surface the repeated rejection + } + if calls := h.authority.snapshot(); len(calls) != wantCalls { + t.Fatalf("authority calls=%d, want %d", len(calls), wantCalls) + } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != wantCalls { + t.Fatalf("broker calls=%v, want %d", calls, wantCalls) + } + h.assertNoStrayPaths(t) + }) + }) + } +} + +func TestAuditDeclarationStopsAtBrokerRefusal(t *testing.T) { + forAuditTransports(t, func(t *testing.T, h host, client *Client) { + h.gateway.refuse[hostWorkContextPath] = refusal{http.StatusUnauthorized, "module identity refused"} + err := client.DeclareAuditEventTypes(t.Context(), auditDeclarations()) + var brokerError *BrokerError + if !errors.Is(err, ErrInvalidCredentials) || !errors.As(err, &brokerError) || brokerError.StatusCode != http.StatusUnauthorized || brokerError.Path != hostWorkContextPath { + t.Fatalf("lost broker refusal: %v", err) + } + if len(h.authority.snapshot()) != 0 { + t.Fatal("declaration reached authority without a module capability") + } + h.assertNoStrayPaths(t) + }) +} + +func TestAuditDeclarationNeedsAuthorityBeforeMinting(t *testing.T) { h := newHost(t) - client := h.client(t, moduleCredentials) - types := []*ModuleAuditEventTypeDeclaration{{Type: "example.record.changed", Description: "Record changed", Visibility: ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT, Fields: []*ModuleAuditFieldDeclaration{{Name: "record_id", Kind: ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID}}}} - if err := client.DeclareAuditEventTypes(t.Context(), types); err != nil { + seams := h.seams() + seams.Authority = Authority{} + client, err := New(seams, moduleCredentials) + if err != nil { t.Fatal(err) } - calls := h.authority.snapshot() - if len(calls) != 1 { - t.Fatalf("calls=%d", len(calls)) + t.Cleanup(func() { _ = client.Close() }) + if err := client.DeclareAuditEventTypes(t.Context(), auditDeclarations()); !errors.Is(err, ErrNoAuthority) { + t.Fatalf("error=%v, want ErrNoAuthority", err) } - call := calls[0] - if call.declaration.GetPrefix() != moduleCredentials.Prefix || call.declaration.GetTypes()[0].GetType() != types[0].Type { - t.Fatal("declaration owner or type changed") - } - if len(call.internalToken) != 1 || call.internalToken[0] != testInternalToken || len(call.workContext) != 1 || call.workContext[0] != "module.token1" || len(call.authorization) != 0 { - t.Fatal("wrong declaration authority") + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 0 || len(h.authority.snapshot()) != 0 { + t.Fatal("mint-only declaration must fail before any host request") } h.assertNoStrayPaths(t) } + +func TestAuditDeclarationDoesNotFallBackWhenBridgeIsAbsent(t *testing.T) { + gateway := newFakeGateway(t) // broker routes only; authority bridge returns 404 + client, err := NewGatewayAuthority(Seams{Gateway: gateway, InternalToken: testInternalToken}, moduleCredentials) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := client.DeclareAuditEventTypes(t.Context(), auditDeclarations()); connect.CodeOf(err) != connect.CodeUnimplemented { + t.Fatalf("missing bridge error=%v, want unimplemented", err) + } + calls, stray, _, _ := gateway.snapshot() + if len(calls) != 1 || calls[0] != hostWorkContextPath || len(stray) != 1 || stray[0] != "/modules/_authority"+auditDeclarationProcedure { + t.Fatalf("unexpected retry or fallback: broker=%v, other=%v", calls, stray) + } +} diff --git a/moduleauthority/gateway_test.go b/moduleauthority/gateway_test.go index 3be183c..013f9d1 100644 --- a/moduleauthority/gateway_test.go +++ b/moduleauthority/gateway_test.go @@ -59,10 +59,16 @@ func TestGatewayAuthorityExchangesThroughOnlyTheGateway(t *testing.T) { } func TestGatewayAuthorityRejectsAmbiguousTransport(t *testing.T) { - _, err := NewGatewayAuthority(Seams{Authority: Authority{Address: "direct.invalid:443"}}, moduleCredentials) + // Every seam is otherwise valid: a missing token or gateway must not make + // this test pass when the explicit transport-selection guard is removed. + h := newHost(t) + _, err := NewGatewayAuthority(h.seams(), moduleCredentials) if !errors.Is(err, ErrInvalidSeams) { t.Fatalf("got %v, want invalid seams", err) } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 0 || len(h.authority.snapshot()) != 0 { + t.Fatal("ambiguous transport must fail before any request") + } } func TestGatewayAuthorityDoesNotFollowRedirects(t *testing.T) { diff --git a/moduleauthority/host_fake_test.go b/moduleauthority/host_fake_test.go index 12c22ef..6646267 100644 --- a/moduleauthority/host_fake_test.go +++ b/moduleauthority/host_fake_test.go @@ -250,6 +250,7 @@ func (g *fakeGateway) snapshot() (calls, stray []string, bodies map[string][]map // authorityCall is one call the fake authority endpoint received. type authorityCall struct { declaration *v1.ModuleDeclareAuditEventTypesRequest + moduleSecret []string installation *v1.ModuleCurrentInstallationRequest authorization []string artifact *v1.ModuleExecutableArtifactRequest @@ -279,6 +280,8 @@ var servedOnAuthority = map[string]bool{ type fakeAuthority struct { gateway *fakeGateway address string + // declarationError is the host's admission verdict, configured before a call. + declarationError error mu sync.Mutex calls []authorityCall @@ -305,7 +308,7 @@ func newFakeAuthority(t *testing.T, gateway *fakeGateway, creds credentials.Tran func (a *fakeAuthority) handle(_ any, stream grpc.ServerStream) error { method, _ := grpc.MethodFromServerStream(stream) md, _ := metadata.FromIncomingContext(stream.Context()) - call := authorityCall{authorization: md.Get("authorization"), method: method, internalToken: md.Get("x-codefly-internal-token"), workContext: md.Get(codefly.WorkContextHeaderName)} + call := authorityCall{authorization: md.Get("authorization"), moduleSecret: md.Get("x-codefly-module-secret"), method: method, internalToken: md.Get("x-codefly-internal-token"), workContext: md.Get(codefly.WorkContextHeaderName)} // Each procedure's own request message: decoding NotifyOrgAdmins as an // exchange request would read its fields as the wrong ones. request := &v1.ModuleExchangeDelegatedOperationAudienceRequest{} @@ -354,6 +357,9 @@ func (a *fakeAuthority) handle(_ any, stream grpc.ServerStream) error { return status.Error(codes.Unauthenticated, "module work context is not a valid capability") } if call.declaration != nil { + if a.declarationError != nil { + return a.declarationError + } return stream.SendMsg(&v1.ModuleDeclareAuditEventTypesResponse{}) } if call.installation != nil {