Skip to content
Closed
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
23 changes: 23 additions & 0 deletions module/MODULE_INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,26 @@ receipts. New consumers require the supported authority response and refuse a
missing or unknown version; old servers reject the new request field. Adopting
this response does not itself migrate an existing consumer, validate software
compatibility or qualify an installed rollout.

## Recovering an audit emission

A module calls `ModuleCapabilitiesService.LookupAuditEvent` with the complete
original `ModuleEmitAuditEventRequest`, including a nonempty idempotency key,
under its current module Work Context. The host rechecks the same tenant,
registered event scope and actor rules as emission. This is not an alternative
human audit-query permission.

A nonempty `event_id` names the committed event for that exact intent. An empty
ID means no receipt was visible at the read; it does not prove that nothing
happened and does not authorize an effect retry. A changed intent under the key,
or a historical key-only reservation without recorded intent, returns
`FAILED_PRECONDITION`. The host does not backfill historical intent from a retry.
Lookup does not create an event or reserve a key. Revoking the module's grant
also revokes receipt access.

Keyed emissions now bind actor, tenant, resource, payload, impersonation and
client attribution alongside the event identity in the audit transaction.
Attempt timestamps, generated IDs, IP addresses and catalog versions do not
change the intent. Apply migration19 before starting a binary that uses the new
binding; producers must treat an unverifiable historical key as reconciliation
work rather than silently choosing another key and duplicating the effect.
5 changes: 5 additions & 0 deletions module/configurations/local/gateway.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Comma-separated exact IPs or CIDRs allowed to supply X-Forwarded-For and
# X-Real-IP. Empty means the gateway trusts no forwarding headers.
TRUSTED_PROXY_CIDRS=

# Explicit opt-in for registered /v1/<prefix> routes accepting signed Work
# Contexts without a bearer. Enable only targets that independently enforce
# Work Context scopes and current authority. Empty array keeps bearer-only access.
WORK_CONTEXT_MODULE_PREFIXES=[]
6 changes: 4 additions & 2 deletions module/services/accounts/AUTHZ_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`.

Inventory: **246 RPCs** across **35 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors.
Inventory: **248 RPCs** across **35 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors.

The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor.

Expand Down Expand Up @@ -115,6 +115,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit
| `/saas.accounts.v1.ModuleCapabilitiesService/ListReadableSourceCollections` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | List source collections the verified viewer may currently read. |
| `/saas.accounts.v1.ModuleCapabilitiesService/ListSubjectVisibility` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | List the subjects whose rows a viewer may read in a tenant. |
| `/saas.accounts.v1.ModuleCapabilitiesService/ListSubscriptions` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | List the calling principal's live event subscriptions. |
| `/saas.accounts.v1.ModuleCapabilitiesService/LookupAuditEvent` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Recover a committed audit event identity for matching intent under current emission authority. |
| `/saas.accounts.v1.ModuleCapabilitiesService/MintModuleOperationContext` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | SUCCESS: saas.module.operation_context_minted | FORBIDDEN / INTERNAL | SECRET → SECRET | Issue a composed module, with no person present, a Work Context for one of its installed operation audiences. |
| `/saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | SUCCESS: saas.module.registration_minted | FORBIDDEN / INTERNAL | SECRET → SECRET | Issue a composed module the signed credential it registers its gateway REST prefix with. |
| `/saas.accounts.v1.ModuleCapabilitiesService/MintModuleWorkContext` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | SUCCESS: saas.module.work_context_minted | FORBIDDEN / INTERNAL | SECRET → SECRET | Issue a composed module the Work Context its service principal calls this surface with. |
Expand All @@ -129,6 +130,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit
| `/saas.accounts.v1.ModuleCapabilitiesService/RequestApproval` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Open a pending approval whose resume job the module claims. |
| `/saas.accounts.v1.ModuleCapabilitiesService/Subscribe` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Create or re-affirm a durable event subscription for the caller. |
| `/saas.accounts.v1.ModuleCapabilitiesService/Unsubscribe` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Revoke one of the caller's own event subscriptions. |
| `/saas.accounts.v1.ModuleCapabilitiesService/VerifyWorkContextRuntimeBoundary` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | SECRET → CONFIDENTIAL | Attest current capability membership in an active host-owned runtime boundary. |
| `/saas.accounts.v1.NotificationService/DeleteNotification` | unary | `DELETE /v1/notifications/{id}` | `auth` | exposure=AUTHENTICATED; tenant=USER | — | — | — | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Delete one of the caller's notifications. |
| `/saas.accounts.v1.NotificationService/GetUnreadCount` | unary | `GET /v1/notifications/unread-count` | `auth` | exposure=AUTHENTICATED; tenant=USER | — | — | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Count the caller's unread notifications. |
| `/saas.accounts.v1.NotificationService/ListNotifications` | unary | `GET /v1/notifications` | `auth` | exposure=AUTHENTICATED; tenant=USER | — | — | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List the caller's notifications. |
Expand Down Expand Up @@ -258,7 +260,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit
## Tier totals

- `auth`: 43
- `internal`: 49
- `internal`: 51
- `mfa`: 4
- `org_admin`: 56
- `org_member`: 49
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package adapters

import (
"accounts/pkg/business"
catalogv1 "accounts/pkg/gen/saas/catalog/v1"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"os"
"sort"
"testing"
)

// The catalog also carries permissions admitted by the module composer, whose
// namespace-qualified logical IDs are wider than built-in descriptor scopes.
func TestServiceCatalogContributedPermissionVocabulary(t *testing.T) {
raw, err := os.ReadFile("../../../generated/service-catalog.json")
require.NoError(t, err)
baseline := &catalogv1.ServiceCatalog{}
require.NoError(t, protojson.Unmarshal(raw, baseline))
for _, tc := range []struct {
resource, action string
valid bool
}{
{"example.rows", "read", true},
{"example-suite.row_items", "update-item", true},
{"example..rows", "read", false},
{"Example.rows", "read", false},
{"example.rows", "read/any", false},
{"example.rows", "read:", false},
} {
t.Run(tc.resource+":"+tc.action, func(t *testing.T) {
catalog := proto.Clone(baseline).(*catalogv1.ServiceCatalog)
catalog.Permissions = append(catalog.Permissions, &catalogv1.PermissionDefinition{Permission: tc.resource + ":" + tc.action, Resource: tc.resource, Action: tc.action, Description: "Example contributed permission"})
sort.Slice(catalog.Permissions, func(i, j int) bool { return catalog.Permissions[i].Permission < catalog.Permissions[j].Permission })
err := business.ValidateServiceCatalog(catalog)
if tc.valid {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, "not canonical")
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,7 @@ func (h *moduleCapabilitiesConnectHandler) ListSubscriptions(ctx context.Context
func (h *moduleCapabilitiesConnectHandler) ReplayEvents(ctx context.Context, req *connect.Request[gen.ModuleReplayEventsRequest]) (*connect.Response[gen.ModuleReplayEventsResponse], error) {
return unary(ctx, req, h.inner.ReplayEvents)
}

func (h *moduleCapabilitiesConnectHandler) LookupAuditEvent(ctx context.Context, req *connect.Request[gen.ModuleEmitAuditEventRequest]) (*connect.Response[gen.ModuleLookupAuditEventResponse], error) {
return unary(ctx, req, h.inner.LookupAuditEvent)
}
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,19 @@ func moduleApprovalProto(a *business.ApprovalRequest) *gen.ModuleApproval {
CreatedAt: timestamppb.New(a.CreatedAt),
}
}

func (s *ModuleCapabilitiesServer) LookupAuditEvent(ctx context.Context, req *gen.ModuleEmitAuditEventRequest) (*gen.ModuleLookupAuditEventResponse, error) {
if err := Validate(req); err != nil {
return nil, err
}
caller, err := moduleCaller(ctx)
if err != nil {
return nil, err
}
eventID, err := service.ModuleLookupAuditEvent(ctx, caller,
req.GetTenant(), req.GetEventType(), req.GetActor(), req.GetSolution(), req.GetEntryId(), req.GetIdempotencyKey(), req.GetFields())
if err != nil {
return nil, err
}
return &gen.ModuleLookupAuditEventResponse{EventId: eventID}, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,16 @@ func TestFetchDatasourceBlob_StreamsAuthorizedBlobEndToEnd(t *testing.T) {
t.Fatal("streamed frames carried no content type")
}
}

// Receipt knowledge is not authority: even a fully specified lookup must pass
// module authentication before reaching the service or any stored audit binding.
func TestAuditReceiptLookupRequiresModuleAuthentication(t *testing.T) {
server := &ModuleCapabilitiesServer{}
_, err := server.LookupAuditEvent(context.Background(), &gen.ModuleEmitAuditEventRequest{
Tenant: blobStreamOrgID, EventType: "example.changed", Actor: blobStreamCallerID,
Solution: "example", EntryId: "entry", IdempotencyKey: "known-effect-key",
})
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("unauthenticated lookup: %v", err)
}
}
53 changes: 53 additions & 0 deletions module/services/accounts/code/pkg/adapters/refresh_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package adapters

import (
"context"
"errors"
"fmt"
"testing"

"accounts/pkg/auth"
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

type refreshOutcomeMinter struct {
auth.JWTMinter
err error
}

func (m refreshOutcomeMinter) VerifyRefresh(context.Context, string) (*auth.TokenPair, error) {
return nil, m.err
}

// Exercise the actual business and RPC path: rejected credentials must tell
// clients to sign in, while infrastructure failures must remain retryable.
func TestRefreshRejectionIsUnauthenticated(t *testing.T) {
previous := service
t.Cleanup(func() { service = previous })
for _, tc := range []struct {
name string
err error
want codes.Code
}{
{"revoked", auth.ErrRefreshRevoked, codes.Unauthenticated},
{"reused", auth.ErrRefreshReuse, codes.Unauthenticated},
{"wrapped", fmt.Errorf("rotation: %w", auth.ErrRefreshRevoked), codes.Unauthenticated},
{"policy", auth.RejectRefresh(auth.RefreshRejectionIdleTimeout), codes.Unauthenticated},
{"storage unavailable", errors.New("storage unavailable"), codes.Unknown},
} {
t.Run(tc.name, func(t *testing.T) {
service = &business.Service{}
service.SetJWTMinter(refreshOutcomeMinter{err: tc.err})
_, err := (&AuthServer{}).RefreshToken(context.Background(), &gen.RefreshTokenRequest{RefreshToken: "test-refresh-token"})
if status.Code(err) != tc.want {
t.Fatalf("refresh status=%s; want %s", status.Code(err), tc.want)
}
if tc.want == codes.Unauthenticated && status.Convert(err).Message() != "session refresh rejected" {
t.Fatal("refresh rejection must not distinguish token existence or rejection reason")
}
})
}
}
8 changes: 7 additions & 1 deletion module/services/accounts/code/pkg/adapters/rpcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,13 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ
if err := Validate(req); err != nil {
return nil, err
}
return service.RefreshToken(ctx, req)
response, err := service.RefreshToken(ctx, req)
if errors.Is(err, auth.ErrRefreshRevoked) || errors.Is(err, auth.ErrRefreshReuse) {
// Terminal session rejection must reach REST as 401, not an Unknown
// error (500) that clients correctly treat as a transient outage.
return nil, status.Error(codes.Unauthenticated, "session refresh rejected")
}
return response, err
}

func (s *AuthServer) SwitchOrganization(ctx context.Context, req *gen.SwitchOrganizationRequest) (*gen.SwitchOrganizationResponse, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package adapters

import (
"context"

"accounts/pkg/auth"
gen "accounts/pkg/gen/saas/accounts/v1"
"connectrpc.com/connect"
"github.com/codefly-dev/sdk-go/workcontext"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

func (s *ModuleCapabilitiesServer) VerifyWorkContextRuntimeBoundary(ctx context.Context, req *gen.VerifyWorkContextRuntimeBoundaryRequest) (*gen.VerifyWorkContextRuntimeBoundaryResponse, error) {
if err := Validate(req); err != nil {
return nil, err
}
if err := requireInternalCredential(ctx); err != nil {
return nil, err
}
md, _ := metadata.FromIncomingContext(ctx)
if len(md.Get(workcontext.WorkContextHeaderName)) != 1 {
return nil, status.Error(codes.Unauthenticated, "one module Work Context required")
}
authority := WorkContextSingleton()
if service == nil || authority == nil || authority.verifier == nil || authority.configureErr != nil || authority.authority == nil {
return nil, status.Error(codes.Unavailable, "work context authority unavailable")
}
caller, err := moduleCaller(ctx)
if err != nil {
return nil, err
}
token, err := workcontext.ParseWorkContextToken(req.ForwardedWorkContextToken)
if err != nil {
return nil, status.Error(codes.PermissionDenied, "invalid forwarded Work Context")
}
claims, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer})
if err != nil || claims.GetTenantId() == "" || claims.GetOwnerPrincipalId() == "" || claims.GetTaskId() == "" {
return nil, status.Error(codes.PermissionDenied, "invalid forwarded Work Context")
}
if len(claims.ActorChain) > 0 && authority.journal == nil {
return nil, status.Error(codes.Unavailable, "delegation journal unavailable")
}
ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.OwnerPrincipalId, claims.TenantId)
if _, err := authority.requireCurrentAuthority(ctx, claims.TenantId, claims); err != nil {
return nil, err
}
if err := service.VerifyModuleRuntimeBoundary(ctx, caller, claims.TenantId, claims.Audience, claims.TaskId); err != nil {
return nil, err
}
// Recheck authority after the registry read, as on the placed-record oracle.
if _, err := authority.requireCurrentAuthority(ctx, claims.TenantId, claims); err != nil {
return nil, err
}
return &gen.VerifyWorkContextRuntimeBoundaryResponse{TenantId: claims.TenantId, BoundaryId: claims.TaskId}, nil
}

func (h *moduleCapabilitiesConnectHandler) VerifyWorkContextRuntimeBoundary(ctx context.Context, req *connect.Request[gen.VerifyWorkContextRuntimeBoundaryRequest]) (*connect.Response[gen.VerifyWorkContextRuntimeBoundaryResponse], error) {
ctx = connectCtx(ctx, req.Header())
md, _ := metadata.FromIncomingContext(ctx)
md = md.Copy()
md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...)
out, err := h.inner.VerifyWorkContextRuntimeBoundary(metadata.NewIncomingContext(ctx, md), req.Msg)
if err != nil {
return nil, translateGRPCError(err)
}
return connect.NewResponse(out), nil
}
Loading