diff --git a/AGENTS.md b/AGENTS.md index 869a397..c208b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,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/CHANGELOG.md b/CHANGELOG.md index fb7962e..1e3ec3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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. + ## Unreleased — module audit emission - Added `moduleauthority.Client.EmitAuditEvent`, preserving the complete event, diff --git a/README.md b/README.md index 648e6d2..74b2517 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. @@ -295,3 +296,53 @@ 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 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 e69ae8e..2b01c97 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 golang.org/x/tools v0.52.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.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect ) diff --git a/moduleauthority/audit_declarations.go b/moduleauthority/audit_declarations.go new file mode 100644 index 0000000..4391f6e --- /dev/null +++ b/moduleauthority/audit_declarations.go @@ -0,0 +1,27 @@ +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 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}) + if err != nil { + return fmt.Errorf("module authority: declare audit event types: %w", err) + } + return nil +} diff --git a/moduleauthority/audit_declarations_test.go b/moduleauthority/audit_declarations_test.go new file mode 100644 index 0000000..cb5f8e6 --- /dev/null +++ b/moduleauthority/audit_declarations_test.go @@ -0,0 +1,249 @@ +package moduleauthority + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "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) + seams := h.seams() + seams.Authority = Authority{} + client, err := New(seams, moduleCredentials) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := client.DeclareAuditEventTypes(t.Context(), auditDeclarations()); !errors.Is(err, ErrNoAuthority) { + t.Fatalf("error=%v, want ErrNoAuthority", err) + } + 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/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 new file mode 100644 index 0000000..aa03c25 --- /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. 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; +// 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..013f9d1 --- /dev/null +++ b/moduleauthority/gateway_test.go @@ -0,0 +1,96 @@ +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) { + // 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) { + 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 d685602..49a1c8e 100644 --- a/moduleauthority/host_fake_test.go +++ b/moduleauthority/host_fake_test.go @@ -250,6 +250,8 @@ 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 audit *v1.ModuleEmitAuditEventRequest installation *v1.ModuleCurrentInstallationRequest authorization []string @@ -266,6 +268,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.ModuleCapabilitiesServiceEmitAuditEventProcedure: true, accountsv1connect.ModuleCapabilitiesServiceGetCurrentInstallationProcedure: true, accountsv1connect.ModuleCapabilitiesServiceApproveExecutableArtifactProcedure: true, @@ -280,6 +283,8 @@ var servedOnAuthority = map[string]bool{ type fakeAuthority struct { gateway *fakeGateway address string + // declarationError is the host admission verdict, configured before a call. + declarationError error mu sync.Mutex calls []authorityCall @@ -306,12 +311,15 @@ 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{moduleSecret: md.Get("x-codefly-module-secret"), authorization: md.Get("authorization"), 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{} var recvErr error switch method { + case accountsv1connect.ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure: + call.declaration = &v1.ModuleDeclareAuditEventTypesRequest{} + recvErr = stream.RecvMsg(call.declaration) case accountsv1connect.ModuleCapabilitiesServiceEmitAuditEventProcedure: call.audit = &v1.ModuleEmitAuditEventRequest{} recvErr = stream.RecvMsg(call.audit) @@ -354,6 +362,12 @@ 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 { + if a.declarationError != nil { + return a.declarationError + } + return stream.SendMsg(&v1.ModuleDeclareAuditEventTypesResponse{}) + } if call.audit != nil { switch call.audit.GetIdempotencyKey() { case "actor-unresolved": 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 +)