Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
`<gateway BaseURL>/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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
)
27 changes: 27 additions & 0 deletions moduleauthority/audit_declarations.go
Original file line number Diff line number Diff line change
@@ -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
}
249 changes: 249 additions & 0 deletions moduleauthority/audit_declarations_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions moduleauthority/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading