diff --git a/.claude/skills/refresh-generated-api/SKILL.md b/.claude/skills/refresh-generated-api/SKILL.md index e66cb05..a13a8d0 100644 --- a/.claude/skills/refresh-generated-api/SKILL.md +++ b/.claude/skills/refresh-generated-api/SKILL.md @@ -103,7 +103,6 @@ overwrite. ## Scope -Wiring this into module-saas-starter's release, so a saas tag publishes a -matching SDK tag, is tracked in the solutions EPIC (obin-ai/lodestar#53, item -4). Until it lands, this walk is manual — which is a gap in the tooling, not a -step to get good at performing. +Release generation belongs to module-saas-starter and this SDK: a saas tag +must publish a matching SDK contract. Until release automation covers both, +this walk is manual — a gap in the owning tools, not a consumer responsibility. diff --git a/AGENTS.md b/AGENTS.md index e919edd..869a397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,9 @@ gofmt -l . | grep -v '^gen/' # expect no output `go test ./...` is load-bearing beyond the unit tests: `internal/apiboundary` type-checks the entire public surface against the boundary rule below, and -`internal/agentcontext` checks these context files. There is no lint config — +`internal/agentcontext` checks these context files. Keep `golang.org/x/tools` +compatible with the active Go toolchain: its importer must understand Go's +export data for the boundary gate to run. There is no lint config — `golangci-lint` being installed on your machine does not make it a gate here. The same workflow's `gen-drift` job regenerates `gen/` from the ref in diff --git a/CHANGELOG.md b/CHANGELOG.md index 81965c5..fb7962e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — module audit emission + +- Added `moduleauthority.Client.EmitAuditEvent`, preserving the complete event, + caller-selected idempotency key and host error codes through the existing + authenticated authority transport. Uncertain effects are not retried. +- Kept main's complete generated contract and existing artifact, installation + and delegation APIs. Receipt lookup and runtime-boundary attestation remain + deferred until their host contract is integrated. Module version stays 0.1.0. +- Updated the API boundary importer for Go 1.27.2 export data. + ## Unreleased — current installation identity - Added `moduleauthority.Client.GetCurrentInstallation` with independent module diff --git a/README.md b/README.md index a95b60e..648e6d2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ The SDK surfaces are: op, err := authority.MintModuleOperationContext(ctx, installedBindingID) // op.Token goes in x-codefly-work-context on the call to op.Audience. ``` + `EmitAuditEvent(ctx, AuditEvent)` sends a registered event through the same + module-authenticated authority seam. The host checks the module's grant, + tenant, actor, solution and registered field schema. The event's actor may be + the module's own principal or a `system:` label; a subject actor must + belong to the tenant. An empty tenant requires the host's cross-tenant grant. + + ```go + // Persist the complete event and key in the producer's outbox before sending. + event := moduleauthority.AuditEvent{ + Tenant: tenantID, EventType: "example.changed", Actor: "system:worker", + Solution: "example", EntryID: entryID, IdempotencyKey: outboxEventID, + Fields: map[string]any{"count": float64(3)}, + } + if err := authority.EmitAuditEvent(ctx, event); err != nil { + return err // Keep the outbox entry and its original key for recovery. + } + // The host acknowledged the event; the producer may now settle its outbox row. + ``` + + The host deduplicates by `(tenant, event_type, idempotency_key)`. Persist and + reuse the **complete original event and key** across retries and restarts; + never reuse a key for another intent. The contract in `SOURCE.txt` acknowledges + a duplicate key without comparing its payload. An empty key disables + deduplication, and some registered types require a key. Field values use the + protobuf Struct representation, so numbers are represented as doubles. + + A nil error acknowledges the host's durable audit write or an existing key, + not completion of outbound webhook delivery. After a timeout or transport + error the write may already have committed. The SDK does not own an outbox, + store receipts, or automatically retry an uncertain effect; only rejected + module authentication is refreshed and retried once. Host error codes remain + available through `connect.CodeOf(err)`. Receipt lookup and runtime-boundary + attestation await an integrated host contract and are not exposed here. + - **`settings/`** — the schema-agnostic typed-settings library every module and product depends on instead of vendoring a copy. It has two parts: @@ -202,8 +236,9 @@ the correct module path. **Never hand-edit `gen/` or sed the module path** — t protobuf file descriptors embed length-prefixed package strings and a text rewrite corrupts them (panics at `init()`). Always regenerate. -> Wiring this regen into module-saas-starter's release (so a saas tag publishes a -> matching SDK tag) is tracked in the solutions EPIC (obin-ai/lodestar#53, item 4). +Regeneration and matching SDK releases are owned by `module-saas-starter` and +this repository. Until release automation covers both, follow the +`refresh-generated-api` procedure in `.claude/skills/`. ## Consuming diff --git a/go.mod b/go.mod index dad3056..e69ae8e 100644 --- a/go.mod +++ b/go.mod @@ -7,17 +7,17 @@ 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.52.0 google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 ) require ( - golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.59.0 // indirect - golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect + 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/go.sum b/go.sum index e5832c6..e14c18f 100644 --- a/go.sum +++ b/go.sum @@ -34,18 +34,18 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -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/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= 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 new file mode 100644 index 0000000..491eadd --- /dev/null +++ b/moduleauthority/audit.go @@ -0,0 +1,56 @@ +package moduleauthority + +import ( + "context" + "errors" + "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" + "google.golang.org/protobuf/types/known/structpb" +) + +var ErrInvalidAuditEvent = errors.New("module authority: incomplete or invalid audit event") + +// AuditEvent is the complete intent presented to the host audit spine. Tenant +// may be empty only for an authorized system event. Actor and solution scope are +// checked by the host; naming them is not itself proof of authority. +type AuditEvent struct { + Tenant, EventType, Actor, Solution, EntryID string + Fields map[string]any + // A durable relay must persist this key and complete event before sending, + // and reuse them after a restart or uncertain acknowledgement. The host + // deduplicates by (tenant, event type, key), not by comparing intent. Never + // reuse a key for a different event. Empty disables deduplication. + IdempotencyKey string +} + +func auditRequest(event AuditEvent) (*v1.ModuleEmitAuditEventRequest, error) { + if event.EventType == "" || event.Actor == "" || event.Solution == "" { + return nil, ErrInvalidAuditEvent + } + fields, err := structpb.NewStruct(event.Fields) + if err != nil { + return nil, fmt.Errorf("%w: unsupported field value", ErrInvalidAuditEvent) + } + return &v1.ModuleEmitAuditEventRequest{Tenant: event.Tenant, EventType: event.EventType, + Actor: event.Actor, Solution: event.Solution, EntryId: event.EntryID, + Fields: fields, IdempotencyKey: event.IdempotencyKey}, nil +} + +// EmitAuditEvent asks the host to commit a registered event as this module. +// A nil error acknowledges the host's durable write or a previously used key; +// it does not mean that downstream webhook delivery has completed. Host error +// codes are preserved. Only rejected authentication is refreshed once; other +// errors leave the outcome uncertain and are never automatically retried. +func (c *Client) EmitAuditEvent(ctx context.Context, event AuditEvent) error { + request, err := auditRequest(event) + if err != nil { + return err + } + _, err = callAsModule(ctx, c, accountsv1connect.ModuleCapabilitiesServiceClient.EmitAuditEvent, request) + if err != nil { + return fmt.Errorf("module authority: emit audit event: %w", err) + } + return nil +} diff --git a/moduleauthority/audit_test.go b/moduleauthority/audit_test.go new file mode 100644 index 0000000..059ae18 --- /dev/null +++ b/moduleauthority/audit_test.go @@ -0,0 +1,159 @@ +package moduleauthority + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "connectrpc.com/connect" +) + +func TestAuditEmissionUsesModuleAuthority(t *testing.T) { + h := newHost(t) + client := h.client(t, moduleCredentials) + event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner, + Solution: "example", EntryID: "entry", Fields: map[string]any{"count": float64(3)}, IdempotencyKey: "effect-1"} + if err := client.EmitAuditEvent(t.Context(), event); err != nil { + t.Fatal(err) + } + calls := h.authority.snapshot() + if len(calls) != 1 { + t.Fatalf("calls=%d", len(calls)) + } + call := calls[0] + if call.method != "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent" { + t.Fatal(call.method) + } + 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 authority carriers") + } + e := call.audit + if e.GetTenant() != event.Tenant || e.GetActor() != event.Actor || e.GetEventType() != event.EventType || e.GetSolution() != event.Solution || e.GetEntryId() != event.EntryID || e.GetIdempotencyKey() != event.IdempotencyKey || !reflect.DeepEqual(e.GetFields().AsMap(), event.Fields) { + t.Fatal("audit intent changed in transport") + } + for _, attempt := range []struct { + key string + code connect.Code + }{{"actor-unresolved", connect.CodeFailedPrecondition}, {"unavailable", connect.CodeUnavailable}, {"deadline", connect.CodeDeadlineExceeded}, {"internal", connect.CodeInternal}} { + event.IdempotencyKey = attempt.key + before := len(h.authority.snapshot()) + if err := client.EmitAuditEvent(t.Context(), event); connect.CodeOf(err) != attempt.code { + t.Fatal(err) + } + if len(h.authority.snapshot()) != before+1 { + t.Fatal("effect error was retried") + } + } + h.assertNoStrayPaths(t) +} + +func TestAuditInvalidIntentDoesNotReachHost(t *testing.T) { + h := newHost(t) + client := h.client(t, moduleCredentials) + for _, event := range []AuditEvent{ + {}, {EventType: "example.changed", Solution: "example"}, + {EventType: "example.changed", Actor: testOwner, Solution: "example", Fields: map[string]any{"bad": make(chan int)}}, + } { + if err := client.EmitAuditEvent(t.Context(), event); !errors.Is(err, ErrInvalidAuditEvent) { + t.Fatal(err) + } + } + if len(h.authority.snapshot()) != 0 { + t.Fatal("invalid intent reached host") + } +} + +// Recovery state belongs to the producer. Recreating an SDK client must not +// replace its persisted key, invent a new event, or replay an uncertain emit. +func TestAuditEmissionPreservesIntentAcrossClientRestart(t *testing.T) { + h := newHost(t) + event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner, + Solution: "example", EntryID: "entry", Fields: map[string]any{"count": float64(3)}, IdempotencyKey: "unavailable"} + for range 2 { + client := h.client(t, moduleCredentials) + if err := client.EmitAuditEvent(t.Context(), event); connect.CodeOf(err) != connect.CodeUnavailable { + t.Fatalf("uncertain emit: %v", err) + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + } + calls := h.authority.snapshot() + if len(calls) != 2 { + t.Fatalf("got %d attempts for two explicit emits; uncertain effects must not be retried", len(calls)) + } + for _, call := range calls { + if call.method != "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent" || + call.audit.GetTenant() != event.Tenant || call.audit.GetEventType() != event.EventType || + call.audit.GetActor() != event.Actor || call.audit.GetSolution() != event.Solution || + call.audit.GetEntryId() != event.EntryID || call.audit.GetIdempotencyKey() != event.IdempotencyKey || + !reflect.DeepEqual(call.audit.GetFields().AsMap(), event.Fields) { + t.Fatal("restarting the client changed the caller's audit intent") + } + } + h.assertNoStrayPaths(t) +} + +func TestAuditEmissionRefreshesOnlyRejectedModuleAuthentication(t *testing.T) { + for _, scenario := range []struct { + name string + rejectAll bool + badToken bool + wantCode connect.Code + wantCalls int + }{ + {name: "expired capability", wantCalls: 2}, + {name: "revoked module", rejectAll: true, wantCode: connect.CodeUnauthenticated, wantCalls: 2}, + {name: "invalid perimeter", badToken: true, wantCode: connect.CodePermissionDenied, wantCalls: 1}, + } { + t.Run(scenario.name, func(t *testing.T) { + h := newHost(t) + client := h.client(t, moduleCredentials) + if _, err := client.ModuleWorkContext(t.Context()); err != nil { + t.Fatal(err) + } + h.gateway.revoke("module.token1") + h.gateway.mu.Lock() + h.gateway.rejectAll = scenario.rejectAll + h.gateway.mu.Unlock() + if scenario.badToken { + client.authority.token = "invalid-perimeter-token" + } + event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner, + Solution: "example", IdempotencyKey: "stable-key"} + err := client.EmitAuditEvent(t.Context(), event) + if scenario.wantCode == 0 && err != nil || scenario.wantCode != 0 && connect.CodeOf(err) != scenario.wantCode { + t.Fatalf("emit error = %v, want code %v", err, scenario.wantCode) + } + calls := h.authority.snapshot() + if len(calls) != scenario.wantCalls { + t.Fatalf("got %d calls, want %d", len(calls), scenario.wantCalls) + } + for i, call := range calls { + if len(call.workContext) != 1 || call.workContext[0] != fmt.Sprintf("module.token%d", i+1) || call.audit.GetIdempotencyKey() != event.IdempotencyKey { + t.Fatal("authentication refresh changed the intent or reused the rejected credential") + } + } + h.assertNoStrayPaths(t) + }) + } +} + +func TestAuditEmissionWithoutAuthorityDoesNotMint(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() }) + err = client.EmitAuditEvent(t.Context(), AuditEvent{EventType: "example.changed", Actor: testOwner, Solution: "example"}) + if !errors.Is(err, ErrNoAuthority) { + t.Fatalf("got %v, want ErrNoAuthority", err) + } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 0 || len(h.authority.snapshot()) != 0 { + t.Fatal("missing authority seam still contacted the host") + } +} diff --git a/moduleauthority/host_fake_test.go b/moduleauthority/host_fake_test.go index 4d54fb2..d685602 100644 --- a/moduleauthority/host_fake_test.go +++ b/moduleauthority/host_fake_test.go @@ -20,6 +20,7 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" @@ -249,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 { + audit *v1.ModuleEmitAuditEventRequest installation *v1.ModuleCurrentInstallationRequest authorization []string artifact *v1.ModuleExecutableArtifactRequest @@ -264,6 +266,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.ModuleCapabilitiesServiceEmitAuditEventProcedure: true, accountsv1connect.ModuleCapabilitiesServiceGetCurrentInstallationProcedure: true, accountsv1connect.ModuleCapabilitiesServiceApproveExecutableArtifactProcedure: true, accountsv1connect.ModuleCapabilitiesServiceAuthorizeExecutableArtifactProcedure: true, @@ -309,6 +312,9 @@ func (a *fakeAuthority) handle(_ any, stream grpc.ServerStream) error { request := &v1.ModuleExchangeDelegatedOperationAudienceRequest{} var recvErr error switch method { + case accountsv1connect.ModuleCapabilitiesServiceEmitAuditEventProcedure: + call.audit = &v1.ModuleEmitAuditEventRequest{} + recvErr = stream.RecvMsg(call.audit) case accountsv1connect.ModuleCapabilitiesServiceGetCurrentInstallationProcedure: call.installation = &v1.ModuleCurrentInstallationRequest{} recvErr = stream.RecvMsg(call.installation) @@ -348,6 +354,19 @@ 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.audit != nil { + switch call.audit.GetIdempotencyKey() { + case "actor-unresolved": + return status.Error(codes.FailedPrecondition, "audit actor unresolved") + case "unavailable": + return status.Error(codes.Unavailable, "audit storage unavailable") + case "deadline": + return status.Error(codes.DeadlineExceeded, "audit deadline exceeded") + case "internal": + return status.Error(codes.Internal, "audit commit failed") + } + return stream.SendMsg(&emptypb.Empty{}) + } if call.installation != nil { if call.installation.ParentWorkContextToken == "denied.token" { return status.Error(codes.PermissionDenied, "current authority denied")