From 52d6bf2579551ae60c71257a7d0cbf58e5b58d66 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:25:40 -0400 Subject: [PATCH 01/46] fix(accounts): an OAuth state is accepted once, and is refused when single use cannot be recorded (SA-F-STATE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state signer promised single use and dropped the promise whenever its nonce store could not answer: a validly-signed, unexpired state was admitted again, any number of times, for as long as the outage lasted. The provider's own single-use authorization code is a second barrier, not a substitute โ€” it is the provider's guarantee, not this host's, and this signer's contract is that a state is accepted once. Verify now returns the canonical invalid-state error when the consumer errors, logging the cause so an outage is still diagnosable while the caller learns only that the state is invalid. TestL1OAuthStateReplayRefusedWhenNonceStoreFails verifies twice, because the behaviour restored is not 'a replay is refused' but 'nothing is admitted while single use is unrecordable'. Its positive control asserts a working store still admits the first use, so the refusal is the store's absence and not a signer that refuses everything. Mutation-checked: restoring the admit makes it fail and leaves the control green. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/FEATURES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/FEATURES.md b/module/FEATURES.md index 2e3ff8ecf..b22593bcc 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -252,7 +252,7 @@ their next owning change. | Session list + revoke | โœ… | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke | | Session lifetime policy | โœ… | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap | | Logout | โœ… | Revokes the presented device family | -| OAuth state / CSRF | ๐ŸŸก | Validated client-side in `sessionStorage`; no server-side double-check (gap) | +| OAuth state / CSRF | โœ… | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded | | OAuth PKCE | โŒ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) | | Account lockout (failed attempts) | โŒ | No counter on user table | | Email verification | ๐ŸŸก | `email_verified` flag stored; no flow that issues + checks | From 00bd22720baadcb0759455471f639cff15efdb3e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:28:41 -0400 Subject: [PATCH 02/46] fix(accounts): a refused refresh and a refused OAuth begin answer a stable public reason (SA-F-REFRESH500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session store answers one indistinguishable rejection for every refresh it can refuse โ€” unknown token, revoked family, expired or idled-out session, reuse โ€” so that it is not an oracle. The adapter then returned the business layer's wrapped error unmapped, which undid that twice: the response named the internal call path, and a refusal was reported as a server error, so an expired refresh cookie, the most ordinary thing a browser can present, arrived as HTTP 500 and counted against availability. RefreshToken now maps the two refusal sentinels to unauthenticated with a fixed reason and logs the cause. Anything that is not a refusal still passes through as itself: a store that cannot be reached is a genuine server-side failure, and dressing it as a credential problem would send an operator to the wrong half. BeginOAuth is reached unauthenticated and echoed the same wrapped chain; it now answers invalid-argument for a refused provider or redirect and failed-precondition when the signer is unavailable, with the cause logged. Mutation-checked: restoring either echo fails the new tests, and the infrastructure-failure control stays green under the fix, so the mapping cannot be satisfied by collapsing every failure into a credential error. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../pkg/adapters/auth_error_surface_test.go | 110 ++++++++++++++++++ .../accounts/code/pkg/adapters/rpcs.go | 42 ++++++- 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/auth_error_surface_test.go diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go new file mode 100644 index 000000000..98216afc2 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go @@ -0,0 +1,110 @@ +package adapters + +import ( + "context" + "strings" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// A refused refresh is the caller's credential being spent, not this host +// failing. The register's reproducer is a garbage refresh cookie: the session +// store answers ErrRefreshRevoked for every rejection it can reach (unknown +// token, revoked family, expired or idled-out session), the business layer +// wrapped it, and the wrapped chain left the adapter as an unmapped error โ€” so +// the edge answered HTTP 500 carrying the internal call path, and the single +// most ordinary thing a browser can present counted as a server error. +type refusingRefreshMinter struct { + auth.JWTMinter + err error +} + +func (m *refusingRefreshMinter) VerifyRefresh(context.Context, string) (*auth.TokenPair, error) { + return nil, m.err +} + +type authErrorSurfaceStore struct{ business.Store } + +func installRefusingRefreshMinter(t *testing.T, err error) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + + svc, svcErr := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, svcErr) + svc.SetJWTMinter(&refusingRefreshMinter{err: err}) + WithService(svc) +} + +func TestRefreshRefusalIsUnauthenticatedWithNoInternalDetail(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"an unknown or revoked token", auth.ErrRefreshRevoked}, + {"a reused token", auth.ErrRefreshReuse}, + } { + t.Run(tc.name, func(t *testing.T) { + installRefusingRefreshMinter(t, tc.err) + + resp, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"}) + + require.Nil(t, resp) + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a refused refresh must not read as a server error") + message := status.Convert(err).Message() + require.Equal(t, "invalid refresh token", message) + for _, leak := range []string{"rpc error", "RefreshToken", "verify refresh", "ed25519minter", "auth:"} { + require.NotContains(t, message, leak, + "the public reason must name no internal component or error chain") + } + }) + } +} + +// The counterpart: something that is NOT a refusal still reaches the caller as +// itself, so the mapping above cannot be satisfied by turning every refresh +// failure into a credential problem and sending an operator to the wrong half. +func TestRefreshInfrastructureFailureIsNotReportedAsACredentialProblem(t *testing.T) { + installRefusingRefreshMinter(t, auth.ErrSessionUnavailable) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "not-a-refresh-token"}) + + require.Error(t, err) + require.NotEqual(t, codes.Unauthenticated, status.Code(err)) +} + +// BeginOAuth is reached by an unauthenticated caller, so its refusal answers a +// stable public reason. It echoed the wrapped internal chain, which named the +// call path; the probe that found it read `rpc error` back out of the body. +func TestBeginOAuthRefusalNamesNoInternalDetail(t *testing.T) { + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, err) + WithService(svc) + + resp, err := (&AuthServer{}).BeginOAuth(context.Background(), &gen.BeginOAuthRequest{ + Provider: "oidc", + RedirectUri: "https://evil.example/auth/callback", + }) + + require.Nil(t, resp) + require.Error(t, err) + message := status.Convert(err).Message() + for _, leak := range []string{"rpc error", "cannot mint oauth state", "BeginOAuth", "oauth-state"} { + require.NotContains(t, message, leak, + "the public reason must name no internal component or error chain") + } + require.False(t, strings.Contains(message, "auth: "), + "the sentinel's own text is internal detail") +} diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 8739f8f7c..f30745135 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1075,7 +1075,37 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ if err := Validate(req); err != nil { return nil, err } - return service.RefreshToken(ctx, req) + resp, err := service.RefreshToken(ctx, req) + if err != nil { + return nil, refreshStatusError(ctx, err) + } + return resp, nil +} + +// refreshStatusError maps a refusal to refresh onto the one answer a caller is +// allowed to see: unauthenticated, with a fixed reason. +// +// Every rejection the session store can reach โ€” an unknown token, a revoked +// family, an expired or idled-out session, a reused token โ€” is reported as +// ErrRefreshRevoked or ErrRefreshReuse, deliberately indistinguishable so the +// store is not an oracle. Returning the wrapped chain undid that twice over: it +// named the internal call path, and a refusal counted as a server error, so an +// expired cookie โ€” the single most ordinary thing a browser can present โ€” +// arrived as HTTP 500 and was counted against availability. +// +// The cause is logged, where it is diagnosable without being disclosed. +// Anything that is NOT a refusal (a store that cannot be reached, an invalid +// session policy) still passes through unchanged: it is a genuine server-side +// failure, and reporting it as a credential problem would send an operator +// looking at the wrong half. +func refreshStatusError(ctx context.Context, err error) error { + switch { + case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked): + wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err)) + return status.Error(codes.Unauthenticated, "invalid refresh token") + default: + return err + } } func (s *AuthServer) SwitchOrganization(ctx context.Context, req *gen.SwitchOrganizationRequest) (*gen.SwitchOrganizationResponse, error) { @@ -1110,7 +1140,15 @@ func (s *AuthServer) BeginOAuth(ctx context.Context, req *gen.BeginOAuthRequest) } state, err := service.BeginOAuth(ctx, req.Provider, req.RedirectUri) if err != nil { - return nil, status.Errorf(codes.FailedPrecondition, "cannot mint oauth state: %v", err) + // A refused provider or redirect is the caller's request being wrong, + // not this host failing; and the reason is a wrapped internal chain, + // which named the call path to an unauthenticated caller. Log it, and + // answer the one thing the caller may know. + wool.Get(ctx).In("BeginOAuth").Warn("oauth begin refused", wool.ErrField(err)) + if errors.Is(err, auth.ErrInvalidOAuthRequest) { + return nil, status.Error(codes.InvalidArgument, "provider or redirect_uri not allowed") + } + return nil, status.Error(codes.FailedPrecondition, "oauth state unavailable") } return &gen.BeginOAuthResponse{State: state}, nil } From 2e0216123ff16e086bdeba32b295d29e5f1b46f0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:06:04 -0400 Subject: [PATCH 03/46] fix(accounts): the reserved module audience is mintable only from a module identity (SA-F-WC-RESERVED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every peer that verifies the `module-capabilities` audience reads a context carrying it as a composed module acting with no person present, and skips the questions it would ask of a delegated actor on the strength of that. The mints a signed-in person reaches placed no restriction on the audience, so a person could mint or exchange for the reserved spelling. Nothing downstream granted them anything โ€” an agent id cannot equal a module principal id โ€” but the premise peers rely on was enforced nowhere, and a property two services away is not a boundary. Every mint and exchange that takes its audience from a caller's message now refuses the reserved one, before the caller's authority is resolved: the task mint, the installation mint, a root and a child session, the audience exchange and a renewal. The refusal also sits inside the exchange helper, because the internal delegated-read exchange reaches it directly with an audience a composition declared, and a declaration is no more entitled to the reserved spelling than a request is. TestEveryRequestSuppliedAudienceRefusesTheReservedOne reads the source and requires the refusal in every method that reads an audience off a request, so a mint added later cannot skip it silently; an ordinary audience passes the guard, so the suite cannot be satisfied by a server that refuses everything. Mutation-checked: removing the guard fails all six mints and the coverage test. module/WORK_CONTEXTS.md gains the section stating the invariant. Rebase note (onto origin/main f5a104ce3). Main landed this invariant independently, as requireMintableAudience: the same refusal of the reserved audience on the same mints, with PermissionDenied rather than InvalidArgument, an empty-audience refusal beside it, and a closed-vocabulary check after it. Its call sites replace the ones this commit added, so what remains here is this finding's own tests โ€” re-pointed at main's refusal โ€” and the WORK_CONTEXTS.md section. The source-level test is the part worth keeping: it holds every caller-supplied mint to the gate and requires the gate's error to be RETURNED, so neither deleting a call site nor discarding its result passes. It covers the two entry points an unauthenticated caller cannot reach โ€” the installation mint, which demands an internal service credential first, and a renewal, which gates the audience it RESOLVES after inheritance. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/WORK_CONTEXTS.md | 28 +++ .../work_context_reserved_audience_test.go | 165 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 14e0f11c9..150840fe5 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -227,6 +227,34 @@ what it has installed. The host makes no such check, so a producer that means a grant to cover one exact definition enforces that itself: the id here says which resource is meant, never which version of it. +## The reserved module audience + +`module-capabilities` is the audience a composed module's own capability carries, +and every peer that verifies it reads a context bearing it as "a module, with no +person present". That premise is what lets the module-facing surface skip the +questions it asks of a delegated actor. + +So the audience is mintable only from a module identity. **Every mint and +exchange that takes its audience from a caller's request refuses it** โ€” the task +mint, the installation mint, a root or child session, the audience exchange and a +renewal. The refusal is the structural half of `requireMintableAudience`, which +needs no state and therefore holds through a registry outage. + +On the four mints a signed-in person reaches it runs before the caller's +authority is resolved, so the spelling is unreachable rather than merely +unreachable for this person. The other two reach it differently, and each for a +reason that is the point: the installation mint demands an internal service +credential first, so it is not a person-driven mint at all; and a renewal gates +the audience it RESOLVES, after authenticating, because a renewal naming none +inherits the parent's โ€” gating the request field would let the reserved spelling +travel one generation further while the request named nothing. + +The module mints are unaffected: they pass the audience themselves rather than +reading it off a message, as does the internal delegated-read exchange, whose +audience comes from a declaration. `TestPersonMintsRefuseTheModuleAudience` and +`TestEveryRequestSuppliedAudienceRefusesTheReservedOne` hold this, the second so +a mint added later cannot skip the refusal silently. + ## Operation contexts with no person present Both exchanges above need a parent: somebody signed in, and the child is diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go new file mode 100644 index 000000000..2f21834f3 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go @@ -0,0 +1,165 @@ +package adapters + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Every peer that reads the reserved module audience treats a context carrying +// it as a composed module acting with no person present. A person-driven mint +// that yields that spelling contradicts the premise those peers rely on, even +// where nothing downstream grants it anything โ€” the register's reproducer is an +// audience exchange that succeeds and a module surface that verifies the result. +// +// The refusal is asserted on an UNAUTHENTICATED caller on purpose: it must come +// before the caller's authority is resolved, so the reserved audience is +// unreachable rather than merely unreachable-for-this-person. +// +// These are the four mints a signed-in person reaches with an audience of their +// choosing. The other two entry points that read an audience are refused by the +// same gate but cannot be reached this way, and each for a reason that is the +// point rather than a gap: +// +// - StartInstallationTask opens with requireInternalCredential, so it is not a +// person-driven mint at all โ€” an unauthenticated caller is turned away +// before any audience is looked at. +// - RenewWorkContext gates the RESOLVED audience, after authenticating: a +// renewal naming none inherits the parent's, and it is that value which is +// checked and stamped. Gating the request field instead would let a renewal +// carry the reserved spelling forward from a parent while naming nothing. +// +// TestEveryRequestSuppliedAudienceRefusesTheReservedOne is what holds those two +// to the gate, from the source, so dropping it from either is still caught. +func TestPersonMintsRefuseTheModuleAudience(t *testing.T) { + server := &WorkContextAuthorityServer{} + ctx := context.Background() + + calls := []struct { + name string + call func() error + }{ + {"StartTask", func() error { + _, err := server.StartTask(ctx, &gen.StartTaskWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, TaskId: uuidForReservedAudienceTest, + SessionId: uuidForReservedAudienceTest, + ActorPrincipalId: uuidForReservedAudienceTest, + AuthorityScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"StartRootSession", func() error { + _, err := server.StartRootSession(ctx, &gen.StartRootSessionWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + SessionId: uuidForReservedAudienceTest, + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"ExchangeAudience", func() error { + _, err := server.ExchangeAudience(ctx, &gen.ExchangeWorkContextAudienceRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + AttenuatedScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + {"StartChildSession", func() error { + _, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + SessionId: uuidForReservedAudienceTest, ActorPrincipalId: uuidForReservedAudienceTest, + GrantedScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, + }) + return err + }}, + } + + for _, tc := range calls { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), + "the reserved audience must be refused before the caller's authority is resolved") + require.Contains(t, status.Convert(err).Message(), "never caller-supplied") + }) + } +} + +// The counterpart: an ordinary audience is NOT refused for BEING the reserved +// one, so the test above cannot pass on a server that refuses every audience. +// Such a mint is still refused โ€” by the vocabulary half, which this server has +// no registry to answer from โ€” and that is a different refusal naming a +// different thing, which is the whole distinction being asserted. +func TestAnOrdinaryAudiencePassesTheReservedAudienceGuard(t *testing.T) { + _, err := (&WorkContextAuthorityServer{}).ExchangeAudience(context.Background(), + &gen.ExchangeWorkContextAudienceRequest{ + OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", + AttenuatedScopes: scopesForReservedAudienceTest(), + Audience: "modelservice", + }) + require.Error(t, err) + require.NotContains(t, status.Convert(err).Message(), "never caller-supplied") +} + +// A mint added later must not be able to skip the guard silently. Every +// EXPORTED method of this server that takes its audience FROM THE REQUEST has to +// run the audience gate; the module mints are exempt because they pass the +// audience themselves rather than reading it off a caller's message. +// +// Exported only, because only an exported method is an RPC: the generated +// service interface is what a caller can reach, and it has no unexported +// members. An unexported helper that reads an audience is reached through one of +// these methods, which has already gated, or through host code that builds the +// request itself (exchangeVerifiedParent has one such caller in +// delegated_read_audience.go, where the audience is a derived binding's, not a +// caller's). Matching those too would make this test demand a second gate on a +// path with no caller-supplied value to gate. +func TestEveryRequestSuppliedAudienceRefusesTheReservedOne(t *testing.T) { + source, err := os.ReadFile("work_context_rpcs.go") + require.NoError(t, err) + + methods := regexp.MustCompile(`(?m)^func \(s \*WorkContextAuthorityServer\) ([A-Z][A-Za-z]*)\(`). + FindAllStringSubmatchIndex(string(source), -1) + require.NotEmpty(t, methods) + + text := string(source) + for index, match := range methods { + name := text[match[2]:match[3]] + end := len(text) + if index+1 < len(methods) { + end = methods[index+1][0] + } + body := text[match[0]:end] + if !strings.Contains(body, "req.GetAudience()") { + continue + } + // Two spellings, both of which gate the request's audience: the field + // directly, or the local that a renewal resolves it into. Anything else + // passing through this gate is a value this test cannot vouch for, so it + // is not accepted as one. + gatesTheField := strings.Contains(body, "requireVocabularyAudience(ctx, req.GetAudience())") + gatesTheResolved := strings.Contains(body, "requireVocabularyAudience(ctx, audience)") && + strings.Contains(body, "audience := req.GetAudience()") + require.True(t, gatesTheField || gatesTheResolved, + "%s takes its audience from the request and must refuse the reserved module audience", name) + } +} + +// Every one of these requests is validated before it reaches the guard, so each +// field it declares has to be well-formed: the subject here is the audience, and +// a message rejected for a malformed id would prove nothing about it. +const uuidForReservedAudienceTest = "00000000-0000-4000-8000-000000000001" + +func scopesForReservedAudienceTest() []*gen.WorkContextScope { + return []*gen.WorkContextScope{{ResourceKind: "records", Actions: []string{"read"}}} +} From a90a8b451cc4121d3a6dea1150a6411a41306c4a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:39:16 -0400 Subject: [PATCH 04/46] fix(accounts): a privileged actor's sensitive operation requires a second factor, enrolled or not (SA-F-MFA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireMFA looked for an enrolled device, found none, and admitted. So the one principal whose compromise is the whole platform or the whole tenant was the one the gate could not touch: a platform administrator or an organization owner with nothing enrolled passed every sensitive operation it guards โ€” role grants, GDPR delete, impersonation, entitlement overrides, waitlist review. For a privileged actor โ€” any platform role, or ownership of the organization the verified identity names โ€” the absence of recent AAL2 evidence is now a refusal. That is also what makes 'a privileged principal holds a second factor' enforceable at all: the alternative to enrolling is not reaching these operations. Organization ADMIN is deliberately outside the rule, and the reason is recorded beside the code: admin is a role an owner grants and withdraws, where owner carries the tenant's own lifecycle, and sweeping admin in would change the gate for a population no operator chose. For every other member the gate stays opt-in per user; making it unconditional there is an operator's policy decision about their roster, not a property of this host. The organization is read from the verified identity the auth interceptor installed rather than from a request field or a log value, so it is the same tenant the membership read is scoped to. TestRequireMFARefusesUnenrolledPrivileged covers all three platform grades and the org owner; its control asserts a plain member, an org admin and a membership-less caller still pass, so the refusal is the privilege rather than a gate that refuses everybody. Mutation-checked. The impersonation rig carried MFAVerifiedAt with no assurance level and no method, so it never had recent AAL2 and passed only on the old admit. It now stamps genuine AAL2 evidence, which is what a console must present. module/FEATURES.md: the two rows that described the old opt-in-only behaviour. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/FEATURES.md | 4 +- .../accounts/code/pkg/adapters/auth.go | 71 ++++++++++-- .../pkg/adapters/impersonation_stop_test.go | 9 +- .../code/pkg/adapters/mfa_privileged_test.go | 105 ++++++++++++++++++ 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/mfa_privileged_test.go diff --git a/module/FEATURES.md b/module/FEATURES.md index b22593bcc..5704ea23b 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations. | OAuth login (multiple providers) | โœ… | Same | | Email/password login | โŒ | โœ… (most starters keep both) | | Magic-link login | ๐ŸŸก | โœ… (one-click) | -| MFA enforced on sensitive ops | โœ… | โœ… (2026-04-25 fix: `requireMFA` gates billing, GDPR delete, role grants, impersonation; opt-in per user) | +| MFA enforced on sensitive ops | โœ… | โœ… `requireMFA` gates billing, GDPR delete, role grants, impersonation. Fail-closed for a privileged actor โ€” any platform role, or ownership of the request's organization โ€” where not being enrolled is a refusal, not a pass; opt-in per user for every other member | | Multi-org tenancy | โœ… | Same | | Org invitations | โœ… | Same | | RBAC (built-in roles) | โœ… | Same | @@ -609,7 +609,7 @@ _All previously-open gaps closed 2026-04-25._ - โœ… **User identity endpoints unauthenticated** โ€” `AddIdentity`, `FindUserByIdentity`, `ListUserIdentities` would let any authenticated caller enumerate provider identities or attach attacker-controlled identities to any user. Now gated by `requireSelfOrPlatformAdmin` / `requirePlatformAdmin`. - โœ… **gRPC server had no in-process auth interceptor** โ€” handlers assumed the auth-gateway ext_authz check was in front; direct port hits bypassed auth. Added `grpcAuthInterceptor` mirroring the Connect interceptor (defense in depth: api validates the bearer regardless of upstream). - โœ… **Connect server had no CORS** โ€” browser preflight returned 405; every Connect-Web request from the FE failed in production-style architectures. Added `rs/cors` middleware. -- โœ… **MFA is enforced and refresh-safe** โ€” enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users; money-moving billing checkout/portal is stricter and always requires fresh AAL2, so lack of enrollment is not a bypass. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes. +- โœ… **MFA is enforced and refresh-safe** โ€” enrolled users receive no normal session until a durable one-use challenge succeeds. JWT/session evidence carries `amr`, `auth_time`, `acr`, and `mfa_at`; refresh preserves rather than renews that evidence. Refresh re-resolves verified enrollment: newly enrolled MFA terminates AAL1 refresh families and requires login, while removed MFA strips factor methods and downgrades the successor to AAL1. General sensitive operations apply the configured recent-AAL2 policy to enrolled users, and to every privileged actor whether enrolled or not โ€” a platform role of any grade, or ownership of the request's organization โ€” so for the principals whose compromise is the whole platform or the whole tenant, lack of enrolment is a refusal rather than a bypass. Money-moving billing checkout/portal is stricter still and always requires fresh AAL2. Passkeys require WebAuthn user verification with exact Codefly-configured RP/origin policy; complete credentials and ceremony state use Vault Transit envelopes. TOTP seeds are encrypted and recovery codes are one-use bcrypt hashes. - โœ… **API key scopes forwarded but not enforced** โ€” the auth-gateway ext_authz check set `X-Scopes`; handlers ignored. New `requireScope(ctx, "resource:action")` gate with wildcard support (`*`, `users:*`, `*:read`). Applied to `ListUsers`, `UpdateUser`, `DeleteUser` as a starter set; extend to other resources per business needs (JWT-authenticated callers bypass โ€” RBAC handles them). - โœ… **Access tokens not individually revocable** โ€” Logout only killed the refresh chain; old access tokens stayed valid up to 15 min. New `auth.TokenRevoker` interface + `cache.NewTokenRevoker` Redis impl. `Logout(refresh, accessToken)` now calls `JWTMinter.RevokeAccess` which adds the jti to the revocation list with TTL = remaining `exp`. `VerifyAccess` consults the list. Falls back to `NoopTokenRevoker` (no Redis) โ†’ original behavior. - โœ… **Impersonation had no time limit** โ€” admin "view-as" sessions inherited the normal 15-min TTL. New `Config.ImpersonationTokenTTL` (default 5 min) auto-applied when minting tokens with `acting` claim set. diff --git a/module/services/accounts/code/pkg/adapters/auth.go b/module/services/accounts/code/pkg/adapters/auth.go index 99dc05262..c6dd958d4 100644 --- a/module/services/accounts/code/pkg/adapters/auth.go +++ b/module/services/accounts/code/pkg/adapters/auth.go @@ -878,18 +878,30 @@ func parseUnixTimestamp(raw string) time.Time { return time.Unix(seconds, 0) } -// requireMFA gates sensitive operations behind a satisfied MFA challenge -// when the actor has at least one MFA device enrolled. Users who never -// set up MFA still pass โ€” enforcement is opt-in per user, not platform- -// wide. Returns codes.FailedPrecondition with the canonical -// "mfa_required" reason so frontends can branch on it without parsing -// strings. +// requireMFA gates sensitive operations behind a satisfied MFA challenge. +// Returns codes.FailedPrecondition with the canonical "mfa_required" reason so +// frontends can branch on it without parsing strings. // // Apply to: anything that, if hijacked, would let an attacker pivot to // admin scope, exfiltrate data, or move money. Concretely: billing // checkout/portal, role grants, GDPR delete, impersonation, API key // creation with broad scopes. // +// There are two populations, and the difference is what enrolment means for each. +// +// For a PRIVILEGED actor โ€” any platform role, or ownership of the organization +// the request names โ€” not being enrolled is not a pass. It used to be: the check +// looked for a device, found none, and admitted, so the one principal whose +// compromise is the whole platform or the whole tenant was the one the gate could +// not touch. A privileged actor without recent AAL2 evidence is refused, which +// also makes "a privileged principal holds a second factor" enforceable at all: +// the alternative to enrolling is not reaching these operations. +// +// For every other member the gate stays opt-in per user: enrolled means a recent +// factor is required, unenrolled means the operation proceeds and the audit row +// records mfa_satisfied=false. Making it unconditional there is an operator's +// policy decision about their whole roster, not a property of the host. +// // Refresh does not update MFAVerifiedAt. An enrolled user must therefore // have explicit AAL2 evidence no older than DefaultRecentStepUpMaxAge; // otherwise the frontend asks them to authenticate again. @@ -897,6 +909,13 @@ func requireMFA(ctx context.Context, actorID string) error { if auth.AssuranceFromContext(ctx).HasRecentMFA(time.Now(), recentStepUpMaxAge) { return nil } + privileged, err := actorHoldsPrivilegedRole(ctx, actorID) + if err != nil { + return err + } + if privileged { + return status.Error(codes.FailedPrecondition, "mfa_required") + } // mfa_devices is RLS-protected by user_id (Phase 2G); wrap in // the actor's WithUserTx so the count query sees their devices. var enrolled bool @@ -910,15 +929,47 @@ func requireMFA(ctx context.Context, actorID string) error { return status.Errorf(codes.Internal, "cannot verify MFA status: %v", err) } if !enrolled { - // No verified MFA device โ†’ can't enforce. Sensitive ops still - // run; audit captures mfa_satisfied=false. Operators who want - // to force-enroll go through a separate "MFA required" feature - // flag at the org level (not yet implemented). return nil } return status.Error(codes.FailedPrecondition, "mfa_required") } +// actorHoldsPrivilegedRole reports whether this actor is one whose sensitive +// operations must sit behind a second factor whatever they have enrolled: a +// platform administrator of any grade, or an owner of the organization the +// request names. +// +// Org ADMIN is deliberately not included. Owner is the role that cannot be +// removed by anyone else in the tenant and that carries the tenant's own +// lifecycle; admin is a delegated role an owner grants and withdraws, and +// sweeping it in here would change the gate for a population an operator did not +// choose. An operator who wants admins covered says so through org policy. +// +// A failure to resolve either role refuses: this runs only on the path to a +// sensitive operation, where not knowing is not a reason to proceed. +func actorHoldsPrivilegedRole(ctx context.Context, actorID string) (bool, error) { + role, err := platformRole(ctx, actorID) + if err != nil { + return false, status.Errorf(codes.Internal, "cannot resolve platform role: %v", err) + } + if role != "" { + return true, nil + } + // The organization comes from the verified identity the auth interceptor + // installed, not from the request message or a log field: it is the same + // tenant the membership read is scoped to, so the two cannot disagree. A + // request with no tenant has no owner role to hold. + orgID, _, ok := auth.VerifiedDatabaseIdentity(ctx) + if !ok || orgID == "" { + return false, nil + } + orgRole, err := lookupMembership(ctx, orgID, actorID) + if err != nil { + return false, membershipLookupStatus("cannot verify membership", err) + } + return orgRole == gen.OrgRole_ORG_ROLE_OWNER.String(), nil +} + // requireRecentMFA is the strict step-up gate for money-moving operations. // Unlike the general opt-in requireMFA helper, lack of enrollment does not // bypass this check: billing administrators must establish fresh AAL2 evidence. diff --git a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go index 082cb170e..4d3317275 100644 --- a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go +++ b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go @@ -272,8 +272,15 @@ func newImpersonationRig(t *testing.T) *impersonationRig { func (r *impersonationRig) start(t *testing.T, subjectID string) (string, string, context.Context) { t.Helper() + // Genuine AAL2 evidence, not merely a timestamp: a platform administrator + // reaches impersonation only behind a recent second factor, and + // Assurance.HasRecentMFA requires the level and the method as well as the + // time. The fixture carried the time alone and passed only because an + // unenrolled privileged actor used to be admitted. adminCtx := stampVerifiedIdentity(context.Background(), supportActorID, "", auth.Assurance{ - MFAVerifiedAt: time.Now(), + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), }) resp, err := (&PlatformAdminServer{}).ImpersonateUser(adminCtx, &gen.ImpersonateUserRequest{UserId: subjectID, Reason: "investigating a billing discrepancy"}) diff --git a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go new file mode 100644 index 000000000..d109d2a92 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go @@ -0,0 +1,105 @@ +package adapters + +import ( + "context" + "testing" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The actor of these tests holds no verified MFA device. That is the whole +// point: the gate used to look for a device, find none, and admit โ€” so the one +// principal whose compromise is the whole platform or the whole tenant was the +// one it could not touch. +type mfaPolicyStore struct { + business.Store + platformRole string + orgRole gen.OrgRole + member bool +} + +func (s *mfaPolicyStore) GetPlatformRole(context.Context, string) (string, error) { + return s.platformRole, nil +} + +func (s *mfaPolicyStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + if !s.member { + return nil, nil + } + return &gen.OrgMembership{Role: s.orgRole}, nil +} + +func (s *mfaPolicyStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *mfaPolicyStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return false, nil +} + +const ( + mfaActorID = "00000000-0000-4000-8000-0000000000a1" + mfaOrgID = "00000000-0000-4000-8000-0000000000b1" +) + +func installMFAPolicyStore(t *testing.T, store *mfaPolicyStore) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(store) + require.NoError(t, err) + WithService(svc) +} + +// The caller context every case shares: a verified identity for this actor in +// this organization, and no recent second factor. +func mfaRequestContext() context.Context { + return auth.WithVerifiedDatabaseIdentity(context.Background(), mfaActorID, mfaOrgID) +} + +func TestRequireMFARefusesUnenrolledPrivileged(t *testing.T) { + for _, tc := range []struct { + name string + store *mfaPolicyStore + }{ + {"a platform support role", &mfaPolicyStore{platformRole: "support"}}, + {"a platform billing role", &mfaPolicyStore{platformRole: "billing"}}, + {"a platform super admin", &mfaPolicyStore{platformRole: "super_admin"}}, + {"an organization owner", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_OWNER}}, + } { + t.Run(tc.name, func(t *testing.T) { + installMFAPolicyStore(t, tc.store) + + err := requireMFA(mfaRequestContext(), mfaActorID) + + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "mfa_required", status.Convert(err).Message()) + }) + } +} + +// The controls. An ordinary member with nothing enrolled still passes, so the +// refusal above is the privilege and not a gate that refuses everybody; and an +// org admin is deliberately outside the rule, because admin is a role an owner +// grants and withdraws rather than the tenant's own lifecycle. +func TestRequireMFAStaysOptInForAnUnprivilegedActor(t *testing.T) { + for _, tc := range []struct { + name string + store *mfaPolicyStore + }{ + {"a plain member", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_MEMBER}}, + {"an organization admin", &mfaPolicyStore{member: true, orgRole: gen.OrgRole_ORG_ROLE_ADMIN}}, + {"no membership at all", &mfaPolicyStore{}}, + } { + t.Run(tc.name, func(t *testing.T) { + installMFAPolicyStore(t, tc.store) + require.NoError(t, requireMFA(mfaRequestContext(), mfaActorID)) + }) + } +} From 8365850c37633a9515d0f5bd4158fbaaccc42305 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:50:35 -0400 Subject: [PATCH 05/46] fix(accounts): the refresh credential is a cookie on every authentication-completing surface, and signing out ends the session (SA-F-LOGOUT, SA-F-REFRESH-BODY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are one reworking of the same thing โ€” which surface hands the refresh credential to whom โ€” so they land together rather than as two passes over the same file. SA-F-LOGOUT. The browser's logout posts an empty body and its HttpOnly refresh cookie. The middleware moved that cookie into the request body for refresh and not for logout, so field validation rejected the request before Logout ran. The session family stayed active, the access-token id was never revoked, and the device list still showed the session โ€” while the browser's own cookie was cleared, so the sign-out looked complete and ended nothing. A refresh token copied earlier kept minting access tokens for the rest of the idle window. The cookie is now moved into the body for logout exactly as for refresh. An explicit body token still wins: the cookie is the browser's carrier, not an override. Logout additionally revokes the VERIFIED session's access half, from the credential the caller presented rather than from the message, so the access side of a sign-out does not depend on the body at all. Not taken here, and stated rather than left to be inferred: LogoutRequest's refresh_token keeps its min_len=1, so a logout carrying neither a body token nor the cookie is still refused by field validation. Making it optional is a contract change that moves the descriptor digests and the published client, which is not worth stapling to this PR; the product's own sign-out does not need it. SA-F-REFRESH-BODY. The cookie lift covered four REST paths named as literals. The WebAuthn completion was not one of them โ€” it shares its response message with the TOTP completion, and only the TOTP path was listed โ€” so it returned the credential as readable content. The Connect surface of the same methods returned it too, serving the same browser over the same origin. The REST set is now held against the service descriptor by TestEveryAuthCompletionCookiesTheRefreshToken, in both directions: every POST route whose response message carries refresh_token must be lifted, and nothing may be listed that no route returns one on. The Connect handlers route through unaryCookieingRefreshToken, which clears the field by reflection and sets the same cookie, and a source-level test requires it of every handler whose response carries the field. One exemption, declared by name with its reason rather than simply absent: the OAuth 2.1 token endpoint. Its caller is a registered client exchanging a code or a refresh token over a back-channel POST, RFC 6749 ยง5.1 defines that response as a body with refresh_token in it, and a Set-Cookie there reaches nothing that could use it. Both coverage tests require the exemption set to be exactly that one entry. While here, the refresh cookie's Secure flag became a property of the deployment instead of the request. It was derived from the Host header, which a caller supplies, so a request arriving at a deployed cell with Host: localhost was answered with a cookie the browser would then send over plaintext. The local-development exemption is now wired from codefly.IsLocal() beside SetExposeAuthErrorDetail, and its zero value keeps Secure. TestL1LogoutForwardsCookieRefreshToken is the register's logout reproducer, inverted. Mutation-checked: dropping logout from the cookie-consuming set, the WebAuthn path from the lifted set, and the lift from one Connect handler each fail their test, and the Secure-flag test is checked in both directions. module/FEATURES.md: the logout rows and the refresh-rotation row. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/FEATURES.md | 6 +- .../code/pkg/adapters/connect_handlers.go | 62 ++++- .../refresh_credential_carrier_test.go | 216 ++++++++++++++++++ .../accounts/code/pkg/adapters/rest_extras.go | 149 ++++++++---- .../code/pkg/adapters/rest_extras_test.go | 41 +++- .../accounts/code/pkg/adapters/rpcs.go | 9 +- .../accounts/code/pkg/business/auth.go | 26 ++- module/services/accounts/code/work.go | 4 + 8 files changed, 440 insertions(+), 73 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go diff --git a/module/FEATURES.md b/module/FEATURES.md index 5704ea23b..8917f951f 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -158,7 +158,7 @@ rather than at first login. | Rotation | One locked transaction consumes the family, resolves current authorization, and inserts one successor | | Org exchange | Authenticated target-only exchange; current membership/roles resolved under the active session lock; same device family and refresh credential | | Reuse defense | Reuse of a token consumed by rotation revokes every active user session; administrative revocation is not replay | -| Logout | Revokes the refresh family and deny-lists the current access-token JTI in Redis | +| Logout | The browser posts an empty body and its HttpOnly refresh cookie; the middleware moves the cookie's token into the request as it does for refresh, so the family is revoked without the credential ever reaching script. Deny-lists the presented access-token JTI and the verified session, so the access half dies on the strength of who is calling rather than of what the body carried | | Current state | Refresh requires current authorization; database triggers atomically revoke affected families on status, membership/role, platform-role, and verified-MFA changes | --- @@ -248,10 +248,10 @@ their next owning change. | Password login | โŒ | Intentional โ€” provider-only; account recovery routed via OAuth | | Fixture login (dev) | โœ… | Click-to-login in dev mode; not exposed in prod build | | MFA (passkeys + TOTP + recovery) | โœ… | WebAuthn with exact RP/origin policy and encrypted credential state; durable one-use login challenges and recent AAL2 step-up | -| Refresh-token rotation | โœ… | OWASP family revocation on reuse | +| Refresh-token rotation | โœ… | OWASP family revocation on reuse. The credential never leaves as readable content: every authentication-completing surface โ€” REST and Connect alike โ€” moves it into the scoped HttpOnly SameSite=Strict cookie and strips the field, with one declared exemption, the OAuth 2.1 token endpoint, whose caller is a registered client and whose body carrier is the protocol's | | Session list + revoke | โœ… | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke | | Session lifetime policy | โœ… | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap | -| Logout | โœ… | Revokes the presented device family | +| Logout | โœ… | Revokes the presented device family, the presented access token and the verified session's access half; the browser's cookie is the credential carrier, so an empty body is a complete sign-out | | OAuth state / CSRF | โœ… | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded | | OAuth PKCE | โŒ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) | | Account lockout (failed attempts) | โŒ | No counter on user table | diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index f16532028..c1165a5c0 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -10,6 +10,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" @@ -68,6 +70,58 @@ func unary[Req, Resp any]( return connect.NewResponse(resp), nil } +// unaryCookieingRefreshToken serves a method that completes an authentication: +// it moves the minted refresh credential out of the response body and into the +// browser's cookie, exactly as the REST middleware does for the same methods. +// +// The Connect surface serves the same browser over the same origin and returned +// the credential as readable content, so an injected script could read it from a +// response it provoked. A caller that genuinely needs a refresh token in a body +// is a registered OAuth client using the token endpoint, which is a separate +// contract with its own audience and its own registry. +func unaryCookieingRefreshToken[Req, Resp any]( + ctx context.Context, + req *connect.Request[Req], + fn func(context.Context, *Req) (*Resp, error), +) (*connect.Response[Resp], error) { + resp, err := unary(ctx, req, fn) + if err != nil { + return nil, err + } + if message, ok := any(resp.Msg).(proto.Message); ok { + cookieTheRefreshToken(resp.Header(), message) + } + return resp, nil +} + +// connectHandlersExemptFromTheRefreshCookie are the authentication-completing +// Connect methods whose response body carries the refresh credential by +// contract. One entry: the OAuth 2.1 token endpoint (see +// oauthTokenEndpointRESTPath). The coverage test requires the set to be exactly +// this, so an exemption cannot be added without being read. +var connectHandlersExemptFromTheRefreshCookie = map[string]bool{ + "ExchangeClientToken": true, +} + +// cookieTheRefreshToken clears a message's refresh_token field, if it has one +// carrying a value, and sets the same cookie the REST path sets. Reflection +// rather than a per-message setter: the field is named identically on every +// authentication-completing response, and a response type added later is covered +// without being enumerated here. +func cookieTheRefreshToken(header http.Header, message proto.Message) { + reflected := message.ProtoReflect() + field := reflected.Descriptor().Fields().ByName("refresh_token") + if field == nil || field.Kind() != protoreflect.StringKind { + return + } + token := reflected.Get(field).String() + if token == "" { + return + } + reflected.Clear(field) + header.Add("Set-Cookie", refreshCookie(token, refreshCookieMaxAgeSeconds).String()) +} + // translateGRPCError converts a google.golang.org/grpc/status error // into a connect.Error with the matching code. Pass-through for // errors that are already connect.Errors or don't carry a gRPC status. @@ -470,7 +524,7 @@ func (h *authConnectHandler) BeginOAuth(ctx context.Context, req *connect.Reques func (h *authConnectHandler) Authenticate(ctx context.Context, req *connect.Request[gen.AuthenticateRequest]) (*connect.Response[gen.AuthenticateResponse], error) { injectHeaderJWTCredential(req.Msg, req.Header()) - return unary(ctx, req, h.inner.Authenticate) + return unaryCookieingRefreshToken(ctx, req, h.inner.Authenticate) } // injectHeaderJWTCredential turns a gateway-injected identity header into the @@ -496,16 +550,16 @@ func injectHeaderJWTCredential(req *gen.AuthenticateRequest, h http.Header) { } } func (h *authConnectHandler) CompleteMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) { - return unary(ctx, req, h.inner.CompleteMFAChallenge) + return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteMFAChallenge) } func (h *authConnectHandler) BeginWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.BeginWebAuthnMFAChallengeRequest]) (*connect.Response[gen.BeginWebAuthnMFAChallengeResponse], error) { return unary(ctx, req, h.inner.BeginWebAuthnMFAChallenge) } func (h *authConnectHandler) CompleteWebAuthnMFAChallenge(ctx context.Context, req *connect.Request[gen.CompleteWebAuthnMFAChallengeRequest]) (*connect.Response[gen.CompleteMFAChallengeResponse], error) { - return unary(ctx, req, h.inner.CompleteWebAuthnMFAChallenge) + return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteWebAuthnMFAChallenge) } func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Request[gen.RefreshTokenRequest]) (*connect.Response[gen.RefreshTokenResponse], error) { - return unary(ctx, req, h.inner.RefreshToken) + return unaryCookieingRefreshToken(ctx, req, h.inner.RefreshToken) } func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) { return unary(ctx, req, h.inner.SwitchOrganization) diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go new file mode 100644 index 000000000..2303fc2ff --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go @@ -0,0 +1,216 @@ +package adapters + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// The browser's logout posts an empty body and the HttpOnly cookie. The cookie +// was moved into the body for refresh and not for logout, so field validation +// rejected the request before Logout ran: the session family stayed active and +// the access-token id was never revoked, while the browser's own cookie was +// cleared โ€” a sign-out that looked complete and ended nothing. +func TestL1LogoutForwardsCookieRefreshToken(t *testing.T) { + handler := refreshTokenCookie(downstream(`{}`)) + + request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + request.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Contains(t, recorder.Header().Get("X-Echo-Body"), "cookie-rt", + "logout must reach the handler carrying the cookie's refresh token") + + cleared := false + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == refreshTokenCookieName && cookie.MaxAge < 0 { + cleared = true + } + } + require.True(t, cleared, "logout must still clear the browser's cookie") +} + +// A logout that already carries a token in its body keeps it: the cookie is a +// fallback for the browser, not an override of an explicit credential. +func TestLogoutKeepsAnExplicitBodyRefreshToken(t *testing.T) { + handler := refreshTokenCookie(downstream(`{}`)) + + request := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", + strings.NewReader(`{"refresh_token":"body-rt"}`)) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Contains(t, recorder.Header().Get("X-Echo-Body"), "body-rt") +} + +// Every REST route that completes an authentication hands the refresh credential +// to the browser as a cookie and strips it from the body. The set is derived +// from the service descriptor rather than restated, so a route added to the +// contract cannot be left out of the middleware: the WebAuthn completion was +// exactly that, sharing its response message with the TOTP completion while only +// the TOTP path was listed. +func TestEveryAuthCompletionCookiesTheRefreshToken(t *testing.T) { + declared := restRoutesReturningARefreshToken(t) + require.NotEmpty(t, declared, "the descriptor scan found no route to check") + + require.Contains(t, declared, oauthTokenEndpointRESTPath, + "the exemption names a route the contract does not have") + + for path := range declared { + if path == oauthTokenEndpointRESTPath { + continue + } + t.Run(path, func(t *testing.T) { + require.True(t, authenticationCompletingRESTPaths[path], + "this route's response carries a refresh token and must be cookie-lifted") + + handler := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))) + + var body map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.NotContains(t, body, "refreshToken") + require.NotContains(t, body, "refresh_token") + require.NotContains(t, recorder.Body.String(), "rt-secret") + + cookie := refreshCookieOf(t, recorder) + require.Equal(t, "rt-secret", cookie.Value) + require.True(t, cookie.HttpOnly) + require.True(t, cookie.Secure) + require.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + require.Equal(t, "/v1/auth", cookie.Path) + }) + } + + // And nothing is listed that the contract does not actually return a refresh + // token on, so the map cannot be kept green by growing it. + for path := range authenticationCompletingRESTPaths { + require.Contains(t, declared, path, + "this path is cookie-lifted but no descriptor route returns a refresh token there") + } +} + +// restRoutesReturningARefreshToken reads the accounts service descriptors and +// returns the POST paths whose response message carries a refresh_token field. +func restRoutesReturningARefreshToken(t *testing.T) map[string]bool { + t.Helper() + paths := map[string]bool{} + services := gen.File_saas_accounts_v1_authentication_proto.Services() + for i := range services.Len() { + methods := services.Get(i).Methods() + for j := range methods.Len() { + method := methods.Get(j) + if method.Output().Fields().ByName(protoreflect.Name("refresh_token")) == nil { + continue + } + rule, ok := proto.GetExtension( + method.Options(), annotations.E_Http).(*annotations.HttpRule) + if !ok || rule == nil { + continue + } + if post := rule.GetPost(); post != "" { + paths[post] = true + } + } + } + return paths +} + +// The Connect surface of the same methods: it serves the same browser, over the +// same origin, and returned the credential as readable content. A Connect caller +// that needs the token in a body is a registered OAuth client and uses the token +// endpoint, which is a different contract. +func TestConnectAuthCompletionsCookieTheRefreshToken(t *testing.T) { + for _, tc := range []struct { + name string + carry func() (http.Header, string) + }{ + {"Authenticate", func() (http.Header, string) { + response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + {"CompleteMFAChallenge", func() (http.Header, string) { + response := connect.NewResponse(&gen.CompleteMFAChallengeResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + {"RefreshToken", func() (http.Header, string) { + response := connect.NewResponse(&gen.RefreshTokenResponse{AccessToken: "at", RefreshToken: "rt-secret"}) + cookieTheRefreshToken(response.Header(), response.Msg) + return response.Header(), response.Msg.GetRefreshToken() + }}, + } { + t.Run(tc.name, func(t *testing.T) { + header, remaining := tc.carry() + require.Empty(t, remaining, "the response body must not carry the refresh token") + + setCookie := header.Get("Set-Cookie") + require.Contains(t, setCookie, refreshTokenCookieName+"=rt-secret") + require.Contains(t, setCookie, "HttpOnly") + require.Contains(t, setCookie, "Secure") + require.Contains(t, setCookie, "SameSite=Strict") + require.Contains(t, setCookie, "Path=/v1/auth") + }) + } +} + +// A response with no refresh token is left exactly as it was, so the lift cannot +// be satisfied by clearing a field that was never set or by always emitting a +// cookie. +func TestCookieTheRefreshTokenLeavesAnUnrelatedResponseAlone(t *testing.T) { + response := connect.NewResponse(&gen.AuthenticateResponse{AccessToken: "at"}) + cookieTheRefreshToken(response.Header(), response.Msg) + + require.Equal(t, "at", response.Msg.GetAccessToken()) + require.Empty(t, response.Header().Get("Set-Cookie")) +} + +// The handlers have to actually route through the lift. Read the source and +// require it of every authConnectHandler method whose response message carries a +// refresh token, so a handler added later cannot quietly use the plain helper. +func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) { + source, err := os.ReadFile("connect_handlers.go") + require.NoError(t, err) + + methods := regexp.MustCompile( + `(?m)^func \(h \*authConnectHandler\) ([A-Za-z]+)\(ctx context\.Context, req \*connect\.Request\[gen\.([A-Za-z]+)\]\) \(\*connect\.Response\[gen\.([A-Za-z]+)\][^\n]*\n((?:\t[^\n]*\n)*)`). + FindAllStringSubmatch(string(source), -1) + require.NotEmpty(t, methods) + + checked := 0 + for _, match := range methods { + name, responseType, body := match[1], match[3], match[4] + message, err := protoregistry.GlobalTypes.FindMessageByName( + protoreflect.FullName("saas.accounts.v1." + responseType)) + require.NoError(t, err, "%s: unknown response message %s", name, responseType) + if message.Descriptor().Fields().ByName("refresh_token") == nil || + connectHandlersExemptFromTheRefreshCookie[name] { + continue + } + checked++ + require.Contains(t, body, "unaryCookieingRefreshToken", + "%s completes an authentication and must lift the refresh token out of the body", name) + } + require.NotZero(t, checked, "the scan matched no authentication-completing handler") + + // The exemption set is exactly the OAuth token endpoint, so an entry cannot be + // added to it without this assertion being read and changed. + require.Equal(t, map[string]bool{"ExchangeClientToken": true}, + connectHandlersExemptFromTheRefreshCookie) +} diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go index cf52af89a..3faae80a6 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras.go @@ -16,7 +16,6 @@ import ( "bytes" "encoding/json" "io" - "net" "net/http" "strings" "sync" @@ -31,32 +30,35 @@ const refreshTokenCookieName = "codefly_rt" // refreshTokenCookie is an HTTP middleware that moves the refresh token between // the auth JSON endpoints and an httpOnly, SameSite=Strict cookie: // -// - POST /v1/auth/refresh: if the request body carries no refresh token, the -// one from the cookie is injected, so the browser never has to hold it. -// - 2xx responses of /v1/auth/authenticate, /v1/auth/mfa/complete, and -// /v1/auth/refresh: the refresh token is lifted out of the JSON body into -// the cookie and STRIPPED from the body, so JS never sees it. +// - POST /v1/auth/refresh and POST /v1/auth/logout: when the request body +// carries no refresh token, the one from the cookie is injected, so the +// browser never has to hold it. +// - 2xx responses of every authentication-completing route: the refresh token +// is lifted out of the JSON body into the cookie and STRIPPED from the body, +// so JS never sees it. // - POST /v1/auth/logout: the cookie is cleared. // // SameSite=Strict + the existing credentialed-CORS allowlist is the CSRF -// defense for the (same-origin) refresh call. Secure is set automatically on -// TLS so local http dev still works. +// defense for the (same-origin) refresh call. func refreshTokenCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - isAuth := path == "/v1/auth/authenticate" - isMFAComplete := path == "/v1/auth/mfa/complete" - isRefresh := path == "/v1/auth/refresh" - isLogout := path == "/v1/auth/logout" + completesAuthentication := authenticationCompletingRESTPaths[path] + consumesCookieRefreshToken := cookieRefreshTokenRESTPaths[path] + isLogout := path == logoutRESTPath - if !isAuth && !isMFAComplete && !isRefresh && !isLogout { + if !completesAuthentication && !consumesCookieRefreshToken { next.ServeHTTP(w, r) return } - // Request side: feed the cookie's refresh token into the refresh call - // when the body doesn't already carry one. - if isRefresh { + // Request side: feed the cookie's refresh token into the call when the + // body doesn't already carry one. Logout needs this as much as refresh + // does โ€” the browser's logout posts an empty body and the HttpOnly + // cookie, so without the lift the request failed field validation before + // Logout ran, leaving the session family and the access token live while + // the browser's own cookie was cleared. + if consumesCookieRefreshToken { if c, err := r.Cookie(refreshTokenCookieName); err == nil && c.Value != "" { r = injectRefreshToken(r, c.Value) } @@ -67,12 +69,12 @@ func refreshTokenCookie(next http.Handler) http.Handler { next.ServeHTTP(rec, r) if isLogout { - clearRefreshCookie(w, r) + clearRefreshCookie(w) } body := rec.buf.Bytes() - if (isAuth || isMFAComplete || isRefresh) && rec.status >= 200 && rec.status < 300 { + if completesAuthentication && rec.status >= 200 && rec.status < 300 { if token, stripped, ok := extractAndStripRefreshToken(body); ok { - setRefreshCookie(w, r, token) + setRefreshCookie(w, token) body = stripped } } @@ -82,6 +84,47 @@ func refreshTokenCookie(next http.Handler) http.Handler { }) } +const logoutRESTPath = "/v1/auth/logout" + +// authenticationCompletingRESTPaths are the REST routes whose response carries a +// freshly minted refresh token. Every one of them must hand it to the browser as +// a cookie and strip it from the body; a route missing here returns the +// credential as readable content. +// +// TestEveryAuthCompletionCookiesTheRefreshToken derives the same set from the +// service descriptor and fails when the two disagree, so a new route cannot be +// added to the contract and left out here. The WebAuthn completion was exactly +// that: it shares its response message with the TOTP completion, and only the +// TOTP path was listed. +var authenticationCompletingRESTPaths = map[string]bool{ + "/v1/auth/authenticate": true, + "/v1/auth/mfa/complete": true, + "/v1/auth/mfa/webauthn/complete": true, + "/v1/auth/refresh": true, +} + +// oauthTokenEndpointRESTPath is the one authentication-completing route whose +// response carries the refresh credential in the body, and must. +// +// It is the OAuth 2.1 token endpoint. Its caller is a registered client +// exchanging an authorization code or a refresh token over a back-channel POST, +// not a browser completing a session: RFC 6749 ยง5.1 defines the response as a +// JSON body with `refresh_token` in it, and a Set-Cookie there reaches no client +// that could use it. The credential it issues is bound to that client's own +// session kind and registry, which is the separate contract the browser's +// session is deliberately not part of. +// +// Named as an exemption rather than simply absent, so the descriptor-derived +// coverage test can require it to be the ONLY one. +const oauthTokenEndpointRESTPath = "/v1/auth/token" + +// cookieRefreshTokenRESTPaths are the routes whose refresh-token field is served +// from the cookie when the body does not carry one. +var cookieRefreshTokenRESTPaths = map[string]bool{ + "/v1/auth/refresh": true, + logoutRESTPath: true, +} + // bodyCapture buffers the downstream handler's response so the middleware can // rewrite the body and headers before they reach the client. type bodyCapture struct { @@ -152,45 +195,51 @@ func extractAndStripRefreshToken(body []byte) (token string, stripped []byte, ok return token, out, true } -func secureRefreshCookie(r *http.Request) bool { - // Public hosts stay fail-closed behind TLS-terminating proxies. Loopback - // HTTP is the only exception because browsers otherwise discard the cookie. - host := r.Host - if parsedHost, _, err := net.SplitHostPort(host); err == nil { - host = parsedHost - } - if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") { - return false - } - if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { - return false - } - return true +// allowInsecureRefreshCookie drops Secure from the refresh cookie. It exists for +// one situation: a browser discards a Secure cookie on a plaintext loopback +// origin, so local development would have no session at all. +// +// It is a property of the DEPLOYMENT, and the zero value โ€” every deployed +// environment, since only work.go sets it โ€” keeps Secure. The flag replaces a +// derivation from the request's Host header, which a caller supplies: a request +// arriving at a deployed cell with `Host: localhost` was answered with a cookie +// the browser would then send over plaintext. +var allowInsecureRefreshCookie bool + +// SetAllowInsecureRefreshCookie wires the local-development exemption. Call it +// from codefly.IsLocal() only, like SetExposeAuthErrorDetail: it must never be +// true in a deployed environment. +func SetAllowInsecureRefreshCookie(v bool) { allowInsecureRefreshCookie = v } + +// secureRefreshCookie reports whether the refresh cookie carries Secure. +func secureRefreshCookie() bool { return !allowInsecureRefreshCookie } + +// refreshCookieMaxAgeSeconds matches the refresh-token TTL. +const refreshCookieMaxAgeSeconds = 7 * 24 * 60 * 60 + +func setRefreshCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, refreshCookie(token, refreshCookieMaxAgeSeconds)) } -func setRefreshCookie(w http.ResponseWriter, r *http.Request, token string) { - http.SetCookie(w, &http.Cookie{ - Name: refreshTokenCookieName, - Value: token, - Path: "/v1/auth", - HttpOnly: true, - Secure: secureRefreshCookie(r), - SameSite: http.SameSiteStrictMode, - MaxAge: 7 * 24 * 60 * 60, // 7 days, matches refresh-token TTL - }) +func clearRefreshCookie(w http.ResponseWriter) { + cleared := refreshCookie("", -1) + cleared.Expires = time.Unix(0, 0) + http.SetCookie(w, cleared) } -func clearRefreshCookie(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ +// refreshCookie is the one shape the refresh credential is ever handed to a +// browser in: scoped to the auth routes, not script-readable, same-site, and +// transport-protected outside local development. +func refreshCookie(token string, maxAge int) *http.Cookie { + return &http.Cookie{ Name: refreshTokenCookieName, - Value: "", + Value: token, Path: "/v1/auth", HttpOnly: true, - Secure: secureRefreshCookie(r), + Secure: secureRefreshCookie(), SameSite: http.SameSiteStrictMode, - MaxAge: -1, - Expires: time.Unix(0, 0), - }) + MaxAge: maxAge, + } } var ( diff --git a/module/services/accounts/code/pkg/adapters/rest_extras_test.go b/module/services/accounts/code/pkg/adapters/rest_extras_test.go index c154fceba..2ade0305a 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras_test.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras_test.go @@ -97,21 +97,46 @@ func TestRefreshCookie_DoesNotTrustForwardedProto(t *testing.T) { } } -func TestRefreshCookie_LoopbackHTTPOriginCanStoreCookie(t *testing.T) { - h := refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)) - req := httptest.NewRequest(http.MethodPost, "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`)) +// The local-development exemption is the RUNTIME's, not the request's: a browser +// discards a Secure cookie on a plaintext loopback origin, so local development +// needs one, and nothing a caller sends may claim it. Asserted in both +// directions, because the defect was that a deployed cell answered `Host: +// localhost` with a cookie it would then send over plaintext. +func TestRefreshCookieSecureFlagComesFromTheRuntimeNotTheRequest(t *testing.T) { + loopbackRequest := func() *http.Request { + return httptest.NewRequest(http.MethodPost, + "http://localhost:21931/v1/auth/authenticate", strings.NewReader(`{}`)) + } + + // A deployed runtime (the zero value of the flag) keeps Secure even for a + // request that looks entirely like local development. rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)). + ServeHTTP(rec, loopbackRequest()) + if cookie := refreshCookieOf(t, rec); !cookie.Secure { + t.Fatal("a deployed runtime must keep Secure whatever the request's host says") + } + // A local runtime drops it, so a developer still has a session. + SetAllowInsecureRefreshCookie(true) + t.Cleanup(func() { SetAllowInsecureRefreshCookie(false) }) + rec = httptest.NewRecorder() + refreshTokenCookie(downstream(`{"accessToken":"at","refreshToken":"rt-secret"}`)). + ServeHTTP(rec, loopbackRequest()) + if cookie := refreshCookieOf(t, rec); cookie.Secure { + t.Fatal("a local runtime must drop Secure so the browser stores the cookie") + } +} + +func refreshCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() for _, cookie := range rec.Result().Cookies() { if cookie.Name == refreshTokenCookieName { - if cookie.Secure { - t.Fatal("loopback HTTP refresh cookie must be accepted by the browser") - } - return + return cookie } } t.Fatal("refresh-token cookie was not set") + return nil } func TestRefreshCookie_RefreshRequest_InjectsCookieIntoBody(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index f30745135..c473b5d5a 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1162,7 +1162,14 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty // family. Empty when called from a non-authenticated context (e.g. // the FE only forwards the refresh, never the access). accessToken := bearerFromContext(ctx) - if err := service.Logout(ctx, req, accessToken); err != nil { + // The verified session, from the credential the caller actually presented + // rather than from the message: the session's access half is then revoked on + // the strength of who is calling, not of what the body happened to carry. + var verifiedSession string + if sessionID, ok := auth.VerifiedSessionID(ctx); ok { + verifiedSession = sessionID.String() + } + if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil { return nil, err } return &emptypb.Empty{}, nil diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index dffc99d7b..2967e4369 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -467,14 +467,21 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string) return s.oauthState.Mint(provider, redirectURI) } -// Logout revokes the session family associated with the given refresh -// token AND adds the caller's access token jti to the revocation list -// (when accessToken is non-empty). Idempotent: revoking an -// already-revoked token is a no-op. +// Logout ends the presented session: it revokes the session family the refresh +// token belongs to, adds the caller's access token jti to the revocation list, +// and marks the presented session so every access token minted for it dies now +// rather than at its natural TTL. Idempotent: revoking an already-revoked +// session is a no-op. // -// The access token half is best-effort โ€” failure here never fails the -// logout, since the token will expire naturally within AccessTokenTTL. -func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken string) error { +// sessionID is the verified session the caller presented, not a request field. +// It is what makes the access half of the sign-out independent of the body: a +// logout that reaches here revokes the presented session's access tokens whether +// or not a refresh credential came with it. +// +// Both access-token revocations are best-effort โ€” a failure never fails the +// logout, since those tokens expire within AccessTokenTTL โ€” while the family +// revocation is the durable authority and its error is returned. +func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error { w := wool.Get(ctx).In("Logout") if s.minter == nil { @@ -485,6 +492,11 @@ func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToke w.Warn("RevokeAccess failed (best-effort)", wool.ErrField(err)) } } + if sessionID != "" { + if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil { + w.Warn("RevokeSessionAccess failed (best-effort)", wool.ErrField(err)) + } + } return s.minter.Revoke(ctx, req.RefreshToken) } diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 063746438..58cf7e37d 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -839,6 +839,10 @@ func doWork(ctx context.Context) (Clean, error) { // debugging; every deployed environment returns generic auth errors so the // identity/enumeration oracle stays closed (#208). adapters.SetExposeAuthErrorDetail(codefly.IsLocal()) + // A browser discards a Secure cookie on a plaintext loopback origin, so local + // development needs the exemption; nothing else does, and the default keeps + // Secure. + adapters.SetAllowInsecureRefreshCookie(codefly.IsLocal()) // Separate shared-secret guards for internal RPC admission and forwarded // gateway identity. Empty values fail closed; production deploys provide From 09dfe53b05f44ade67d6aa4d59d5da2a939c8ce6 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:54:35 -0400 Subject: [PATCH 06/46] fix(accounts): an identity assertion in the request body is honoured only in header-jwt mode (SA-F-HEADERJWT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login route's header-jwt branch read the same validator field the authorization-code exchange uses, and every provider mode wires that field. So a deployment configured for OIDC accepted a request body carrying `header_jwt` and handed the token to the OIDC validator โ€” which checks a signature, an issuer, an audience and an expiry, and no nonce. The nonce is the code exchange's binding to a single authorize request and lives in the state that exchange holds, so the body path completed a sign-in with no state, no proof-of-possession verifier and no nonce: exactly the id_token injection and replay the nonce exists to stop. The precondition was holding any unexpired id_token for the configured client. The header-jwt validator is now its own field, set only by work.go's header-jwt branch. In every other mode it is nil and the credential is refused before any validator sees the token. TestL1VHeaderJWTBodyRefusedInOIDCMode is the verifier's reproducer, inverted, and asserts the code-exchange validator was never consulted rather than only that the call failed. Its counterpart wires the header-jwt validator and asserts the same credential IS validated there, so the refusal is the mode and not a path that refuses everything. Mutation-checked: pointing the branch back at the shared field fails both. identity.env records which credential each provider mode accepts, beside the key that decides it. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/configurations/local/identity.env | 8 ++ .../accounts/code/pkg/business/auth.go | 8 +- .../pkg/business/auth_header_jwt_mode_test.go | 92 +++++++++++++++++++ .../code/pkg/business/auth_header_jwt_test.go | 5 +- .../accounts/code/pkg/business/service.go | 20 ++++ module/services/accounts/code/work.go | 2 +- 6 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go diff --git a/module/configurations/local/identity.env b/module/configurations/local/identity.env index c2980660f..aa90054af 100644 --- a/module/configurations/local/identity.env +++ b/module/configurations/local/identity.env @@ -1,6 +1,14 @@ # The ordinary local profile uses a Codefly-selected fixture for identity. # The fixture name is data only; this configuration selects the authentication # adapter explicitly. Real-provider dogfooding uses the dogfood profile. +# +# IDENTITY_PROVIDER also decides which credential the login route accepts, and +# the modes do not overlap. Only `header-jwt` wires the validator that reads an +# identity assertion out of the gateway-injected header; in every other mode a +# request body carrying `header_jwt` is refused by name rather than validated, +# because a validator built for the authorization-code exchange checks no nonce โ€” +# the nonce is that exchange's binding to a single authorize request and lives in +# the state it holds. IDENTITY_PROVIDER=fixture # IDENTITY_SIGNUP_MODE gates signup only; login and invite are never gated. diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index 2967e4369..5cb3101e8 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -95,13 +95,17 @@ func (s *Service) Authenticate(ctx context.Context, req *gen.AuthenticateRequest } case *gen.AuthenticateRequest_HeaderJwt: authenticationMethod = auth.AuthenticationMethodHeaderJWT - if s.validator == nil { + // The header-jwt validator, never the code-exchange one. A deployment in + // any other provider mode leaves this nil, so the credential is refused + // rather than handed to a validator that asks no nonce โ€” see + // SetHeaderJWTTokenValidator. + if s.headerJWTValidator == nil { return nil, w.Wrapf(auth.ErrInvalidOAuthRequest, "header-jwt authentication is not enabled") } if credentials.HeaderJwt == nil || credentials.HeaderJwt.Token == "" { return nil, w.Wrapf(auth.ErrMissingClaims, "header-jwt token missing") } - claims, err = s.validator.Validate(ctx, credentials.HeaderJwt.Token) + claims, err = s.headerJWTValidator.Validate(ctx, credentials.HeaderJwt.Token) if err != nil { return nil, w.Wrapf(err, "header-jwt authentication") } diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go new file mode 100644 index 000000000..7ba26efe7 --- /dev/null +++ b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go @@ -0,0 +1,92 @@ +package business + +import ( + "context" + "errors" + "testing" + + "accounts/pkg/auth" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +// recordingValidator stands in for the provider stack a deployment builds. The +// assertion that matters is whether it is CONSULTED: an id_token presented in the +// request body must never reach the code-exchange validator, which checks a +// signature, an issuer, an audience and an expiry โ€” and no nonce, because the +// nonce is the code exchange's binding to a single authorize request and lives in +// the state that exchange holds. +type recordingValidator struct{ consulted bool } + +func (v *recordingValidator) Validate(context.Context, string) (*auth.Claims, error) { + v.consulted = true + return &auth.Claims{Subject: "subject", Email: "person@example.com", Provider: "oidc"}, nil +} + +type refusingResolver struct{} + +func (refusingResolver) Resolve(context.Context, *auth.Claims, auth.Intent) (*auth.Identity, error) { + return nil, errors.New("the resolver must not be reached") +} + +type headerJWTModeStore struct{ Store } + +// The only store read either test reaches. Both end before any identity exists: +// what each asserts is whether the VALIDATOR was consulted. +func (*headerJWTModeStore) ResolveIdentity(context.Context, string, string) (*ResolvedIdentity, error) { + return nil, errors.New("no identity in this fixture") +} + +// In OIDC mode the header-jwt login path is inactive, and a body carrying an +// identity assertion is refused rather than validated. It used to be handed to +// the OIDC validator, which admitted any unexpired id_token for the configured +// client with no state, no proof-of-possession verifier and no nonce โ€” a full +// session from an assertion that completed none of the handoff. +func TestL1VHeaderJWTBodyRefusedInOIDCMode(t *testing.T) { + validator := &recordingValidator{} + service, err := NewService(&headerJWTModeStore{}) + require.NoError(t, err) + service.SetIdentityResolver(refusingResolver{}) + service.SetJWTMinter(stubMinterForHeaderJWTMode{}) + // Exactly what work.go's OIDC branch wires: the code-exchange validator, and + // no header-jwt validator. + service.SetTokenValidator(validator) + + resp, err := service.Authenticate(context.Background(), &gen.AuthenticateRequest{ + Provider: "oidc", + Authentication: &gen.AuthenticateRequest_HeaderJwt{ + HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"}, + }, + }) + + require.Nil(t, resp) + require.ErrorIs(t, err, auth.ErrInvalidOAuthRequest) + require.False(t, validator.consulted, + "the body credential must be refused before any validator sees it") +} + +// The counterpart: with the header-jwt validator wired โ€” the one thing work.go's +// header-jwt branch does and no other branch does โ€” the credential is accepted +// and reaches that validator, so the refusal above is the mode and not a path +// that refuses everything. +func TestHeaderJWTBodyIsValidatedInHeaderJWTMode(t *testing.T) { + headerJWT := &recordingValidator{} + service, err := NewService(&headerJWTModeStore{}) + require.NoError(t, err) + service.SetIdentityResolver(refusingResolver{}) + service.SetJWTMinter(stubMinterForHeaderJWTMode{}) + service.SetHeaderJWTTokenValidator(headerJWT) + + _, err = service.Authenticate(context.Background(), &gen.AuthenticateRequest{ + Provider: "header-jwt", + Authentication: &gen.AuthenticateRequest_HeaderJwt{ + HeaderJwt: &gen.HeaderJWTAuthentication{Token: "an.unexpired.idtoken"}, + }, + }) + + require.True(t, headerJWT.consulted, "the header-jwt validator must see the credential") + require.Error(t, err, "the refusing resolver is what ends this call") +} + +type stubMinterForHeaderJWTMode struct{ auth.JWTMinter } diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go index dce1f7b39..eba442ab0 100644 --- a/module/services/accounts/code/pkg/business/auth_header_jwt_test.go +++ b/module/services/accounts/code/pkg/business/auth_header_jwt_test.go @@ -67,8 +67,9 @@ func wireHeaderJWTOnTestService(t *testing.T, fi *headerJWTIssuer, cfg headerjwt cfg.Audience = "codefly-app" v, err := headerjwt.New(cfg) require.NoError(t, err) - testService.SetTokenValidator(v) - t.Cleanup(func() { testService.SetTokenValidator(nil) }) + // The header-jwt validator, which is the only one this login path reads. + testService.SetHeaderJWTTokenValidator(v) + t.Cleanup(func() { testService.SetHeaderJWTTokenValidator(nil) }) } func headerJWTRequest(token string) *gen.AuthenticateRequest { diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index 5ac27071b..f3e1af421 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -28,6 +28,7 @@ type Service struct { store Store hasher KeyHasher validator auth.TokenValidator // production: validates provider tokens after OAuth code exchange + headerJWTValidator auth.TokenValidator // header-jwt mode only: validates the gateway-injected identity assertion exchanger CodeExchanger // production: exchanges OAuth codes for provider tokens devValidator auth.TokenValidator // development only: allowlists explicit fixture identities resolver auth.IdentityResolver @@ -511,6 +512,25 @@ func (s *Service) SetTokenValidator(v auth.TokenValidator) { s.validator = v } +// SetHeaderJWTTokenValidator enables the header-jwt login path, where the +// identity assertion arrives as a token the trusted gateway injected rather than +// through an authorization-code exchange. +// +// It is a SEPARATE field from the code-exchange validator on purpose. Both are +// built from the same configured provider's key set, so sharing one field made +// the two paths indistinguishable: a body carrying `header_jwt` reached the +// code-exchange validator, which checks a signature, an issuer, an audience and +// an expiry โ€” and no nonce, because a nonce is the code exchange's binding to a +// single authorize request and lives in the state the exchange holds. The result +// was an id_token admitted with no state, no proof-of-possession verifier and no +// nonce: exactly the injection and replay the nonce exists to stop. +// +// Only work.go's header-jwt branch calls this, so in every other provider mode +// the field is nil and the body credential is refused by name. +func (s *Service) SetHeaderJWTTokenValidator(v auth.TokenValidator) { + s.headerJWTValidator = v +} + // SetDevelopmentTokenValidator explicitly enables fixture authentication. // The validator must resolve an opaque fixture token to allowlisted claims; // Authenticate never trusts provider identity fields supplied by the caller. diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 58cf7e37d..67094a334 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -641,7 +641,7 @@ func doWork(ctx context.Context) (Clean, error) { if !hasConfiguredValue(headerName) { return nil, fmt.Errorf("identity provider header-jwt requires IDENTITY_HEADER_NAME") } - service.SetTokenValidator(v) + service.SetHeaderJWTTokenValidator(v) adapters.SetHeaderJWTLoginHeader(headerName) default: // user_identities.provider is a foreign key into the identity_providers From 54fd9e12773b51faaa62c2b026c6e25d57763c78 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:58:58 -0400 Subject: [PATCH 07/46] fix: a deployed service refuses a shipped placeholder perimeter credential, by name (SA-F-PLACEHOLDER) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This module ships working local defaults for its secret groups, out of a public repository, and the configuration-defaults gate requires each of them to carry a placeholder marker so a real credential can never be committed as one. Nothing read that the other way round: a value carrying a marker is a value everybody knows, and no deployed runtime rejected one. An unprovisioned secret group was therefore not a denied group โ€” it was a live credential with a published value, and the perimeter rested on mesh authorization alone. accounts and auth-gateway now refuse to start, outside local development, when a credential that decides perimeter membership carries a shipped marker or is shorter than 32 characters. The refusal names the group-qualified key an operator must provision and never prints a value. The length floor is not a second spelling of the marker check: it also refuses a short hand-typed value carrying no marker, which is the other way a published-strength credential arrives. Scope is deliberate. Only the cluster-internal token, its rotation predecessor and the gateway-provenance token are checked โ€” the values a service trusts to decide that a caller is inside. An optional feature's unprovisioned credential is a different question with a different answer, the feature is off, and conflating them would wedge a cell that simply does not use one. A coverage test pins the checked set in each service, so a fourth trust-deciding key cannot be added unchecked while looking checked. The marker list and the floor are duplicated in two Go modules with no shared local package, so module/tools/shipped_placeholder_lockstep_test.go holds all three copies identical. A marker on only one side is invisible in the diff that causes it and fails in one of two ways: a published value a cell still accepts, or a shipped default that side can no longer boot on. Mutation-checked by removing one marker from accounts. module/AGENTS.md: the paragraph that said nothing rejects a shipped placeholder, replaced by what now does and what deliberately still does not. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/AGENTS.md | 18 ++- .../accounts/code/shipped_placeholder.go | 117 ++++++++++++++++++ .../accounts/code/shipped_placeholder_test.go | 71 +++++++++++ module/services/accounts/code/work.go | 5 +- module/services/auth-gateway/code/main.go | 7 ++ .../auth-gateway/code/shipped_placeholder.go | 99 +++++++++++++++ .../code/shipped_placeholder_test.go | 56 +++++++++ .../shipped_placeholder_lockstep_test.go | 103 +++++++++++++++ 8 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 module/services/accounts/code/shipped_placeholder.go create mode 100644 module/services/accounts/code/shipped_placeholder_test.go create mode 100644 module/services/auth-gateway/code/shipped_placeholder.go create mode 100644 module/services/auth-gateway/code/shipped_placeholder_test.go create mode 100644 module/tools/shipped_placeholder_lockstep_test.go diff --git a/module/AGENTS.md b/module/AGENTS.md index 96431ad7f..84f1d83b2 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -107,6 +107,18 @@ Context. That is a property of those maps, not of configuration here. Other groups ship a **working default**, which is the opposite posture: `local` carries `CODEFLY_INTERNAL_TOKEN=local-dev-only-replace-me` in `internal-auth.secret.env` and `IDENTITY_SIGNUP_MODE=open` in `identity.env`. -Nothing rejects a shipped placeholder that reaches a real deployment, so an -unprovisioned group is not a denied one โ€” it is a live credential with a value -everybody knows. + +For the credentials that decide **perimeter membership** โ€” the cluster-internal +token and the gateway-provenance token โ€” a shipped default no longer reaches a +deployed runtime: accounts and `auth-gateway` each refuse to start when one +carries a marker from the same list the shipping gate requires of every secret +default, or is shorter than 32 characters, and the refusal names the +group-qualified key. The marker lists and the length floor live in +`shipped_placeholder.go` beside each service's entrypoint, and +`module/tools/shipped_placeholder_lockstep_test.go` holds all three copies +identical โ€” one added on only one side leaves either a published value a cell +accepts or a default that side can no longer boot on. + +Every **other** group still ships a working default that a deployed runtime will +use: an unprovisioned optional group is not a denied one. Where that matters for a +credential, the loader's own "is this required" check is the place to say so. diff --git a/module/services/accounts/code/shipped_placeholder.go b/module/services/accounts/code/shipped_placeholder.go new file mode 100644 index 000000000..d2f83ecc9 --- /dev/null +++ b/module/services/accounts/code/shipped_placeholder.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "strings" +) + +// Shipped-placeholder refusal. +// +// The module package ships working local defaults for its secret groups, and the +// repository they live in is public. The configuration-defaults gate requires +// every one of those defaults to carry a placeholder marker, precisely so a real +// credential can never be committed as one. That makes the same marker list the +// answer to the opposite question: a value carrying one is a value everybody +// knows. +// +// Nothing rejected one outside local development, so an unprovisioned secret +// group was not a denied group โ€” it was a live credential with a published value, +// and the perimeter rested on mesh authorization alone. +// +// What is refused here, by name, at boot: the credentials that decide whether a +// caller is inside the perimeter. An optional feature's unprovisioned credential +// is a different question with a different answer โ€” the feature is off โ€” and +// conflating the two would wedge a cell that simply does not use one. + +// shippedPlaceholderMarkers are the phrases a shipped default carries. It is the +// same list the configuration-defaults gate requires of every secret default; +// TestShippedPlaceholderMarkersMatchTheShippingGate in module/tools holds the two +// copies in lockstep, because a marker added on one side and not the other means +// either a default that cannot ship or a published value a cell would accept. +// +// Each is a distinctive placeholder phrase. Common English words โ€” "example", +// "dummy" โ€” are deliberately absent: a production-shaped value would borrow one +// as a loose substring and be refused for no reason. +var shippedPlaceholderMarkers = []string{ + "replace-me", "replaceme", "change-me", "changeme", + "placeholder", "local-dev", "dev-only", +} + +// minimumPerimeterCredentialLength is the floor for a credential that decides +// perimeter membership. 32 characters is the shortest value that can carry 128 +// bits of entropy in any encoding an operator is likely to generate, and the +// shipped placeholders are shorter โ€” so the length check is not a second spelling +// of the marker check: it also refuses a short hand-typed value that happens to +// carry no marker. +const minimumPerimeterCredentialLength = 32 + +// looksLikeShippedPlaceholder reports whether value carries a marker. +func looksLikeShippedPlaceholder(value string) bool { + lower := strings.ToLower(value) + for _, marker := range shippedPlaceholderMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// refusePerimeterCredential reports why a perimeter credential is unfit for a +// deployed runtime, or nil. name is the group-qualified key, so the refusal says +// what an operator has to provision rather than that something was wrong. +// +// It never includes the value. An empty value is not refused here: whether a +// credential is REQUIRED is each loader's own question, and answering it twice +// would make an optional group's absence read as a placeholder. +func refusePerimeterCredential(name, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + if looksLikeShippedPlaceholder(trimmed) { + return fmt.Errorf( + "%s carries a placeholder this module ships in its public local defaults; "+ + "provision a real value for this cell", name) + } + if len(trimmed) < minimumPerimeterCredentialLength { + return fmt.Errorf( + "%s is %d characters; a credential that decides perimeter membership must be at least %d", + name, len(trimmed), minimumPerimeterCredentialLength) + } + return nil +} + +// perimeterCredentials are the values this service trusts to decide that a caller +// is inside the perimeter: the cluster-internal token both halves of a rotation, +// and the gateway-provenance token this service believes forwarded identity on. +func perimeterCredentials() []struct{ Name, Value string } { + return []struct{ Name, Value string }{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN_PREVIOUS")}, + } +} + +// requirePerimeterCredentials refuses to start a deployed runtime holding a +// shipped placeholder, or a perimeter credential too short to be one. +// +// Every refusal is collected rather than the first returned: an operator fixing +// these is editing one secret group, and finding out about the second key on the +// next deploy is a second outage for no reason. +func requirePerimeterCredentials(isLocal bool) error { + if isLocal { + return nil + } + var problems []string + for _, credential := range perimeterCredentials() { + if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil { + problems = append(problems, err.Error()) + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s", + strings.Join(problems, "; ")) +} diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go new file mode 100644 index 000000000..0006c10f8 --- /dev/null +++ b/module/services/accounts/code/shipped_placeholder_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A shipped default is a published value: these files ride verbatim in the +// immutable module package, out of a public repository. Outside local development +// a service holding one is a service whose perimeter credential everybody knows, +// and nothing said so. +func TestDeployedRefusesShippedPlaceholders(t *testing.T) { + for _, value := range []string{ + "local-dev-only-replace-me", + "local-dev-gateway-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", + "a-placeholder-value-long-enough-to-pass-the-length-floor", + } { + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", value) + require.Error(t, err, "%q carries a shipped marker and must be refused", value) + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN", + "the refusal must name the key an operator has to provision") + require.NotContains(t, err.Error(), value, "a refusal never prints the value") + } +} + +// The length floor refuses a short value that carries no marker at all, so the +// two checks are not one check spelled twice. +func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", "7Kq2Xp9Vb4Nf") + require.Error(t, err) + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.Contains(t, err.Error(), "at least 32") + require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf") +} + +// The controls. A provisioned credential passes, and an ABSENT one is not refused +// here: whether a credential is required is each loader's own question, and +// answering it twice would make an optional group's absence read as a placeholder. +func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4))) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "")) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", " ")) +} + +// Local development runs on the shipped defaults, so the gate is the deployed +// runtime's and must not touch it. +func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) { + require.NoError(t, requirePerimeterCredentials(true)) +} + +// Every perimeter credential this service reads is covered. A fifth key added to +// the trust surface and not listed here would be unchecked and look checked. +func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { + names := map[string]bool{} + for _, credential := range perimeterCredentials() { + names[credential.Name] = true + } + require.Equal(t, map[string]bool{ + "internal-auth/CODEFLY_INTERNAL_TOKEN": true, + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS": true, + }, names) +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 67094a334..4fd338b90 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -2553,7 +2553,10 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error { if err != nil { return err } - return requireKeyCustody(selection.SigningBackend, isLocal) + if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil { + return err + } + return requirePerimeterCredentials(isLocal) } // requireKeyCustody refuses to start outside the local environment without the diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index b644d9e3f..c003b87f1 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -33,6 +33,13 @@ func main() { panic(fmt.Sprintf("codefly init failed: %v", err)) } + // Refuse before anything is served: a deployed gateway holding a shipped + // placeholder, or a perimeter credential too short to be one, decides + // perimeter membership on a published value. + if err := requirePerimeterCredentials(codefly.IsLocal()); err != nil { + panic(err.Error()) + } + grpcPort := codefly.For(ctx).WithDefaultNetwork().API(standards.GRPC).NetworkInstance().Port var httpPort uint16 httpNet, httpNetErr := codefly.For(ctx).API(standards.REST).ResolveNetworkInstance() diff --git a/module/services/auth-gateway/code/shipped_placeholder.go b/module/services/auth-gateway/code/shipped_placeholder.go new file mode 100644 index 000000000..286744b24 --- /dev/null +++ b/module/services/auth-gateway/code/shipped_placeholder.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "strings" +) + +// Shipped-placeholder refusal โ€” the gateway's half. +// +// The module package ships working local defaults for its secret groups, out of a +// public repository, and the configuration-defaults gate requires each of them to +// carry a placeholder marker so a real credential can never be committed as one. +// That makes the same marker list the answer to the opposite question: a value +// carrying one is a value everybody knows. +// +// This gateway holds two credentials that decide perimeter membership โ€” the +// cluster-internal token it accepts on its own internal endpoints, and the +// gateway-provenance token accounts believes forwarded identity on โ€” and nothing +// rejected a shipped placeholder in either outside local development. +// +// The list and the floor are duplicated from accounts deliberately: the two are +// separate Go modules with no shared local package, and +// module/tools/shipped_placeholder_lockstep_test.go holds the copies identical, so +// a marker added on one side cannot leave the other admitting a published value. + +var shippedPlaceholderMarkers = []string{ + "replace-me", "replaceme", "change-me", "changeme", + "placeholder", "local-dev", "dev-only", +} + +// minimumPerimeterCredentialLength is the floor for a credential that decides +// perimeter membership: the shortest value that can carry 128 bits of entropy in +// any encoding an operator is likely to generate. The shipped placeholders are +// shorter, so this is not a second spelling of the marker check โ€” it also refuses +// a short hand-typed value carrying no marker. +const minimumPerimeterCredentialLength = 32 + +func looksLikeShippedPlaceholder(value string) bool { + lower := strings.ToLower(value) + for _, marker := range shippedPlaceholderMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// refusePerimeterCredential reports why a perimeter credential is unfit for a +// deployed runtime, or nil. It names the group-qualified key and never the value. +// An empty value is not refused here: whether a credential is required is the +// loader's own question. +func refusePerimeterCredential(name, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + if looksLikeShippedPlaceholder(trimmed) { + return fmt.Errorf( + "%s carries a placeholder this module ships in its public local defaults; "+ + "provision a real value for this cell", name) + } + if len(trimmed) < minimumPerimeterCredentialLength { + return fmt.Errorf( + "%s is %d characters; a credential that decides perimeter membership must be at least %d", + name, len(trimmed), minimumPerimeterCredentialLength) + } + return nil +} + +// perimeterCredentials are the values this gateway trusts to decide that a caller +// is inside the perimeter. +func perimeterCredentials() []struct{ Name, Value string } { + return []struct{ Name, Value string }{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")}, + } +} + +// requirePerimeterCredentials refuses to start a deployed runtime holding a +// shipped placeholder, or a perimeter credential too short to be one. Every +// refusal is collected: an operator fixing these edits one secret group, and +// learning about the second key on the next deploy is a second outage for nothing. +func requirePerimeterCredentials(isLocal bool) error { + if isLocal { + return nil + } + var problems []string + for _, credential := range perimeterCredentials() { + if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil { + problems = append(problems, err.Error()) + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("perimeter credentials are unfit for a deployed runtime: %s", + strings.Join(problems, "; ")) +} diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go new file mode 100644 index 000000000..feda7d7f0 --- /dev/null +++ b/module/services/auth-gateway/code/shipped_placeholder_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The gateway decides perimeter membership on these two credentials and accepted +// a value published in this repository's own local defaults. +func TestDeployedRefusesShippedPlaceholders(t *testing.T) { + for _, value := range []string{ + "local-dev-only-replace-me", + "local-dev-gateway-only-replace-me", + "LOCAL-DEV-GATEWAY-ONLY-REPLACE-ME", + "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", + } { + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", value) + require.Error(t, err, "%q carries a shipped marker and must be refused", value) + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.NotContains(t, err.Error(), value, "a refusal never prints the value") + } +} + +func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", "7Kq2Xp9Vb4Nf") + require.Error(t, err) + require.Contains(t, err.Error(), "at least 32") + require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf") +} + +func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { + require.NoError(t, refusePerimeterCredential( + "gateway-trust/CODEFLY_GATEWAY_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4))) + require.NoError(t, refusePerimeterCredential( + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "")) +} + +func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) { + require.NoError(t, requirePerimeterCredentials(true)) +} + +// Every credential the ext_authz trust surface reads is covered, so a third key +// added there cannot be unchecked while looking checked. +func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { + names := map[string]bool{} + for _, credential := range perimeterCredentials() { + names[credential.Name] = true + } + require.Equal(t, map[string]bool{ + "internal-auth/CODEFLY_INTERNAL_TOKEN": true, + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS": true, + "gateway-trust/CODEFLY_GATEWAY_TOKEN": true, + }, names) +} diff --git a/module/tools/shipped_placeholder_lockstep_test.go b/module/tools/shipped_placeholder_lockstep_test.go new file mode 100644 index 000000000..646a2dea7 --- /dev/null +++ b/module/tools/shipped_placeholder_lockstep_test.go @@ -0,0 +1,103 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The marker list exists in three places, and they must agree. +// +// `configuration_defaults_test.go` at the repository root REQUIRES every shipped +// secret default to carry one, so a real credential can never be committed as a +// default. accounts and auth-gateway REFUSE a value carrying one outside local +// development, because a value carrying one is a value everybody knows: these +// files ride verbatim in the immutable module package, out of a public repository. +// +// The three copies are separate Go modules with no shared local package, so the +// agreement is checked at the text level. A marker added only to the shipping gate +// leaves a published value a cell would accept; one added only to a service +// refuses a default that side still ships, which is a boot failure in local +// development. Neither is visible in the diff that causes it. +// +// Same for the length floor: the two services must not disagree about how strong a +// perimeter credential has to be, or the gateway and accounts admit different +// populations of value. +func TestShippedPlaceholderMarkersMatchTheShippingGate(t *testing.T) { + moduleDir := findModuleDir(t) + repoRoot := filepath.Dir(moduleDir) + + sources := map[string]string{ + "the shipping gate (configuration_defaults_test.go)": filepath.Join(repoRoot, "configuration_defaults_test.go"), + "accounts": filepath.Join(moduleDir, "services", "accounts", "code", "shipped_placeholder.go"), + "auth-gateway": filepath.Join(moduleDir, "services", "auth-gateway", "code", "shipped_placeholder.go"), + } + + var reference []string + var referenceName string + for name, path := range sources { + markers := placeholderMarkersIn(t, name, path) + if reference == nil { + reference, referenceName = markers, name + continue + } + if strings.Join(markers, ",") != strings.Join(reference, ",") { + t.Errorf("placeholder markers differ: %s has %v, %s has %v โ€” "+ + "a marker on only one side either leaves a published value a cell accepts, "+ + "or refuses a default that side still ships", + referenceName, reference, name, markers) + } + } + if len(reference) == 0 { + t.Fatal("no placeholder markers were found in any source") + } +} + +func TestPerimeterCredentialFloorsAgree(t *testing.T) { + moduleDir := findModuleDir(t) + floors := map[string]string{} + for _, service := range []string{"accounts", "auth-gateway"} { + path := filepath.Join(moduleDir, "services", service, "code", "shipped_placeholder.go") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + match := regexp.MustCompile(`minimumPerimeterCredentialLength = (\d+)`).FindSubmatch(source) + if match == nil { + t.Fatalf("%s declares no minimumPerimeterCredentialLength", path) + } + floors[service] = string(match[1]) + } + if floors["accounts"] != floors["auth-gateway"] { + t.Errorf("perimeter credential floors differ: accounts %s, auth-gateway %s โ€” "+ + "the two would admit different populations of value", + floors["accounts"], floors["auth-gateway"]) + } +} + +// placeholderMarkersIn reads the sorted marker literals out of a +// `...Markers = []string{...}` declaration, whatever the variable is called on +// each side. +func placeholderMarkersIn(t *testing.T, name, path string) []string { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s (%s): %v", name, path, err) + } + block := regexp.MustCompile(`(?s)laceholderMarkers = \[\]string\{(.*?)\}`).FindSubmatch(source) + if block == nil { + t.Fatalf("%s (%s): no placeholder marker declaration found", name, path) + } + var markers []string + for _, literal := range regexp.MustCompile(`"([^"]+)"`).FindAllSubmatch(block[1], -1) { + markers = append(markers, string(literal[1])) + } + if len(markers) == 0 { + t.Fatalf("%s (%s): the marker declaration is empty", name, path) + } + sort.Strings(markers) + return markers +} From d189b685850c29e79c4d4621048b0c25d5fdb16f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:03:12 -0400 Subject: [PATCH 08/46] fix(auth-gateway): rate limits key on the real client, and a deployed gateway refuses to start without the stores its controls need (SA-F-RATELIMIT, SA-F-REVOKE-NOOP) Three controls degraded into something that read as working, and two findings cover them. They land together because they are one rule: a control whose dependency is absent refuses rather than reporting success. SA-F-RATELIMIT, this repository's half. An empty TRUSTED_PROXY_CIDRS was accepted. With no trusted hop clientIP answers the peer, which behind the frontend is the frontend's own pod: every anonymous caller shares one budget and every authentication-factor attempt shares one, so roughly ten bogus MFA completions a minute deny the factor to the whole cell and a thousand anonymous requests a minute deny login to all of it. A denial of service delivered by the control meant to prevent one, and invisible: the limiter was enabled, the buckets were enforced, and nothing said they had collapsed onto one key. A deployed gateway now refuses an empty range, naming the key; local development, where the peer IS the client, still accepts it. The limiter also fell back to per-replica in-memory counters whenever its store was absent or unreachable. That is not the budget it reports: with N replicas the effective limit is N times the configured one, and which replica a caller lands on decides their share, which for an authentication-factor budget is the difference between a bounded number of attempts and a bounded number per replica. Deployed: refuse. The per-replica limiter is now a separate constructor rather than a flag on the main one, so a deployed wiring cannot reach it. Not done, and stated rather than left as a gap: the authentication-factor budget is not additionally keyed on the MFA transaction. SP-GW-09 asks for the originating client, which is what it now keys on, and accounts' own five-attempt transaction lock already bounds one transaction across every replica. Adding the dimension means buffering and parsing the request body at the edge, a new failure mode on the login path for a bound that already exists one hop in. The platform repository owns setting the range on every cell and forwarding the ingress hop. SA-F-REVOKE-NOOP. With no store URL the gateway installed a no-op revoker and a log line. That revoker reports every token as live, so a signed-out session, a killed device and an ended impersonation window all keep authenticating until their tokens expire naturally. Nothing on the request path can tell a no-op revoker from an empty revocation set, so the gateway reports healthy throughout. The fail-closed behaviour on a store ERROR was already right; this is the same rule for a store that was never there. Tests assert both directions in each case: the deployed refusal, and that local development still gets its fallback. The collapse itself is asserted as a property, two clients behind one frontend pod keying distinctly with a trusted range and identically without one, so the boot refusal's stated reason is checked rather than claimed. Mutation-checked: disabling each of the three refusals fails its test. Docs: a new section in module/services/auth-gateway/AGENTS.md on what a deployed gateway refuses to start without, and gateway.env now says what an empty range costs beside the key. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/configurations/local/gateway.env | 9 +- module/services/auth-gateway/AGENTS.md | 22 +++++ .../code/deployed_failclosed_test.go | 94 +++++++++++++++++++ .../code/ext_authz_integration_test.go | 2 +- .../auth-gateway/code/gateway_cors_test.go | 2 +- .../code/gateway_solution_admission_test.go | 2 +- .../gateway_solution_entitlements_test.go | 2 +- .../code/gateway_solutions_test.go | 4 +- .../auth-gateway/code/gateway_test.go | 2 +- module/services/auth-gateway/code/main.go | 20 ++-- .../services/auth-gateway/code/ratelimit.go | 79 ++++++++++++++-- .../services/auth-gateway/code/revocation.go | 31 ++++-- 12 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 module/services/auth-gateway/code/deployed_failclosed_test.go diff --git a/module/configurations/local/gateway.env b/module/configurations/local/gateway.env index 648f9c8a6..5395229ec 100644 --- a/module/configurations/local/gateway.env +++ b/module/configurations/local/gateway.env @@ -1,3 +1,10 @@ # Comma-separated exact IPs or CIDRs allowed to supply X-Forwarded-For and -# X-Real-IP. Empty means the gateway trusts no forwarding headers. +# X-Real-IP. Empty means the gateway trusts no forwarding headers โ€” so the client +# address is always the peer, which behind a frontend hop is the frontend's own +# pod, and every rate-limit bucket collapses onto it. +# +# A DEPLOYED gateway therefore refuses to start on an empty value: a cell's +# ingress and frontend reach the gateway from known ranges, and naming them is +# what makes the anonymous and authentication-factor budgets per-client rather +# than per-cell. Empty is correct here and only here, where the peer IS the client. TRUSTED_PROXY_CIDRS= diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index a4c788b67..a25c01989 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -10,6 +10,28 @@ for the trust model behind declared presence. Nothing here may name a specific solution or composed module: every seam is generic, and a registered target is data, never a branch. +## What a deployed gateway refuses to start without + +Three of this service's controls are only controls when the thing they depend on +is there, and each used to degrade into something that read as working: + +- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). Empty means no + forwarding header is trusted, so `clientIP` answers the peer โ€” behind the + frontend, the frontend's own pod. Every anonymous caller then shares one budget + and every authentication-factor attempt shares one, so a few bogus completions a + minute deny the factor to the whole cell. The limiter was enabled and the buckets + were enforced throughout; nothing said they had collapsed onto one key. +- **A shared rate-limit store.** Per-replica counters enforce the configured budget + times the replica count, and which replica a caller lands on decides their share. +- **A revocation store.** A revoker with no store answers "not revoked" to every + question, so a signed-out session, a killed device and an ended impersonation + window all keep authenticating until their tokens expire โ€” and no request can + tell that from an empty revocation set. + +Each refuses at boot outside local development, naming what to provision. Local +development keeps all three fallbacks: one replica, no ingress hop, no cache +service, and the alternative is a gateway that cannot start. + ## Reading declared solution upstreams `GET /solutions/_registry` returns this replica's read projection: identity, diff --git a/module/services/auth-gateway/code/deployed_failclosed_test.go b/module/services/auth-gateway/code/deployed_failclosed_test.go new file mode 100644 index 000000000..f2447e372 --- /dev/null +++ b/module/services/auth-gateway/code/deployed_failclosed_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// A deployed gateway sits behind an ingress and a frontend hop, so with no +// trusted-proxy range the client address is always the frontend's own pod: every +// anonymous caller shares one budget and every authentication-factor attempt +// shares one. A few bogus completions a minute then deny the factor to the whole +// cell and a flood of anonymous requests denies login to all of it โ€” a denial of +// service delivered by the control meant to prevent one, which read as working +// because the limiter was enabled and the buckets were enforced. +func TestDeployedRefusesEmptyProxyTrust(t *testing.T) { + for _, raw := range []string{"", " ", ",", " , "} { + err := requireProxyTrust(raw, false) + require.Error(t, err, "an empty range %q must refuse a deployed boot", raw) + require.Contains(t, err.Error(), "TRUSTED_PROXY_CIDRS", + "the refusal must name the key an operator has to set") + } +} + +// Local development has no ingress hop and the peer IS the client. +func TestLocalRuntimeAcceptsAnEmptyProxyTrust(t *testing.T) { + require.NoError(t, requireProxyTrust("", true)) +} + +// A configured range still has to parse, and a configured range admits the boot. +func TestProxyTrustBootChecksAdmitAndRefuseTheRightThings(t *testing.T) { + require.NoError(t, requireProxyTrust("10.0.0.0/8,192.0.2.10", false)) + require.Error(t, requireProxyTrust("10.0.0.0/8,not-an-address", false)) + require.Error(t, requireProxyTrust("not-an-address", true), + "a malformed range is a configuration error in local development too") +} + +// The collapse itself, as a property rather than as a boot check: with a trusted +// hop configured, two clients arriving through the same frontend pod key on +// themselves. The auditor's reproducer showed both keying on the pod. +func TestTrustedProxyRangeKeysDistinctClientsDistinctly(t *testing.T) { + trust := newProxyTrust("10.0.0.0/8") + + first := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil) + first.RemoteAddr = "10.4.0.7:44001" // the frontend pod + first.Header.Set("X-Forwarded-For", "198.51.100.44") + + second := httptest.NewRequest("POST", "/v1/auth/mfa/complete", nil) + second.RemoteAddr = "10.4.0.7:44002" // the same frontend pod + second.Header.Set("X-Forwarded-For", "203.0.113.10") + + require.Equal(t, "198.51.100.44", trust.clientIP(first)) + require.Equal(t, "203.0.113.10", trust.clientIP(second)) + require.NotEqual(t, trust.clientIP(first), trust.clientIP(second), + "two clients behind one hop must not share one budget") + + // And with no trusted hop they do collapse, which is what the boot refusal + // above exists to prevent โ€” asserted so the refusal's reason is not merely + // claimed. + untrusting := newProxyTrust("") + require.Equal(t, untrusting.clientIP(first), untrusting.clientIP(second)) + require.Equal(t, "10.4.0.7", untrusting.clientIP(first)) +} + +// A revoker with no store answers "not revoked" to every question, so a signed-out +// session, a killed device and an ended impersonation window all keep +// authenticating until their tokens expire โ€” and nothing on the request path can +// tell that from an empty revocation set, so the gateway reports healthy. +func TestDeployedRefusesAMissingRevocationStore(t *testing.T) { + _, err := newRevoker("", defaultRevocationCacheTTL, false) + require.Error(t, err) + require.Contains(t, err.Error(), "revocation") + + revoker, err := newRevoker("", defaultRevocationCacheTTL, true) + require.NoError(t, err, "local development has no cache service") + require.IsType(t, noopRevoker{}, revoker) +} + +// A per-replica bucket is not the budget it reports: with N replicas the effective +// limit is N times the configured one, and which replica a caller lands on decides +// their share. For an authentication-factor budget that is the difference between +// a bounded number of attempts and a bounded number per replica. +func TestDeployedRefusesAMissingRateLimitStore(t *testing.T) { + t.Setenv("REDIS_URL", "") + + _, err := NewRateLimiter(1000) + require.Error(t, err) + require.Contains(t, err.Error(), "shared store") + + limiter, err := NewRateLimiter(1000, WithInProcessBackendAllowed(true)) + require.NoError(t, err, "local development has no cache service") + t.Cleanup(limiter.Stop) +} diff --git a/module/services/auth-gateway/code/ext_authz_integration_test.go b/module/services/auth-gateway/code/ext_authz_integration_test.go index fcde35db1..42f41f331 100644 --- a/module/services/auth-gateway/code/ext_authz_integration_test.go +++ b/module/services/auth-gateway/code/ext_authz_integration_test.go @@ -119,7 +119,7 @@ func runExtAuthzIntegrationTests(m *testing.M) int { fmt.Fprintf(os.Stderr, "cache Redis connection not available\n") return 1 } - revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL) + revoker, err := newRevoker(redisURL, defaultRevocationCacheTTL, false) if err != nil { fmt.Fprintf(os.Stderr, "cannot build revoker: %v\n", err) return 1 diff --git a/module/services/auth-gateway/code/gateway_cors_test.go b/module/services/auth-gateway/code/gateway_cors_test.go index ed6c40f65..e4de86ebf 100644 --- a/module/services/auth-gateway/code/gateway_cors_test.go +++ b/module/services/auth-gateway/code/gateway_cors_test.go @@ -536,7 +536,7 @@ func TestCORS_PreflightRefusesWhatThePublicSolutionSurfaceCannotGrant(t *testing func TestCORS_ThrottledResponseIsReadableAndRefusalsCostNoBudget(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) twoRegisteredClients(t, gw) - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) token := signClientToken(t, priv, addinClient) call := func(origin string) *httptest.ResponseRecorder { diff --git a/module/services/auth-gateway/code/gateway_solution_admission_test.go b/module/services/auth-gateway/code/gateway_solution_admission_test.go index e2c23cf5d..86b67c026 100644 --- a/module/services/auth-gateway/code/gateway_solution_admission_test.go +++ b/module/services/auth-gateway/code/gateway_solution_admission_test.go @@ -427,7 +427,7 @@ func TestGatewaySolutionProxy_RefusedRequestsAreMeteredBeforeTheAuthorityIsAsked authority := &entitledTo{ids: nil} gw.solutionEntitlements = authority // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) token := signValidToken(t, priv) throttled := false diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go index 88c228a4f..ddce94292 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -302,7 +302,7 @@ func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedActingHeader(t *testin func TestGatewaySolutionEntitlements_SpendsThePerOrgBudget(t *testing.T) { gw, authority, priv := entitlementGateway(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) // One token, so every request keys on the same stamped X-Org-Id. token := signValidToken(t, priv) diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index a8e0e5143..dbcfb7d32 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -272,7 +272,7 @@ func TestGateway_Solution_MissingID_NotFound(t *testing.T) { func TestGateway_Solution_RateLimited(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) fake := registerSolutionUpstream(t, gw, "audit") // Reuse ONE token so every request keys on the same injected x-org-id. @@ -301,7 +301,7 @@ func TestGateway_Solution_RateLimited(t *testing.T) { func TestGateway_Solution_PublicSurface_RateLimited(t *testing.T) { gw, _, _, _ := newGatewayHarness(t) // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / key / min. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) fake := registerSolutionUpstream(t, gw, "audit") got429 := false diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index f20772db1..dd3c440cc 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -255,7 +255,7 @@ func TestGateway_GitHubWebhooks_ForwardedUnauthenticatedWithSignature(t *testing func TestGateway_GitHubWebhook_BudgetIsolatedFromAnonymousTraffic(t *testing.T) { gw, apiFake, _, _ := newGatewayHarness(t) // effective anonymous budget = limit(1) + burst(max(1/5,1)=1) = 2 per minute. - gw.rateLimiter = NewRateLimiter(1) + gw.rateLimiter = newInProcessRateLimiter(1) // Spend the anonymous per-IP budget on an unrelated public route. spent := false diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index c003b87f1..11873fb13 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -133,7 +133,7 @@ func main() { if err != nil { panic(err) } - rev, err := newRevoker(redisURL, revocationTTL) + rev, err := newRevoker(redisURL, revocationTTL, codefly.IsLocal()) if err != nil { panic(fmt.Sprintf("configure access-token revocation: %v", err)) } @@ -219,15 +219,21 @@ func main() { if err != nil { panic(err) } - // Malformed TRUSTED_PROXY_CIDRS fails boot rather than silently trusting - // a narrower/empty set โ€” parity with accounts' ParseTrustedProxyCIDRs. - if _, err := parseProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS")); err != nil { - panic(err) + // A malformed OR empty TRUSTED_PROXY_CIDRS fails boot on a deployed + // runtime rather than silently collapsing every bucket onto the frontend's + // pod address. + if err := requireProxyTrust(workspaceEnv("gateway", "TRUSTED_PROXY_CIDRS"), codefly.IsLocal()); err != nil { + panic(err.Error()) } - rateLimiter = NewRateLimiter(1000, + // 1000 req/min per org/IP; stricter MFA budget is configured separately. + rateLimiter, err = NewRateLimiter(1000, WithRedisURL(redisURL), WithAuthenticationAttemptLimit(authenticationAttemptLimit), - ) // 1000 req/min per org/IP; stricter MFA budget is configured separately. + WithInProcessBackendAllowed(codefly.IsLocal()), + ) + if err != nil { + panic(fmt.Sprintf("configure rate limiting: %v", err)) + } // The durable solution registry lives in accounts and is reached over the // same internal connection as the other brokered internal calls. The // reconcile loop rebuilds this replica's routing cache from it โ€” once at diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go index b28fd2872..97224b0cc 100644 --- a/module/services/auth-gateway/code/ratelimit.go +++ b/module/services/auth-gateway/code/ratelimit.go @@ -158,6 +158,7 @@ type RateLimiter struct { type rateLimiterOptions struct { redisURL string authenticationAttemptLimit int + allowInProcessBackend bool } type RateLimiterOption func(*rateLimiterOptions) @@ -170,11 +171,25 @@ func WithAuthenticationAttemptLimit(limit int) RateLimiterOption { return func(options *rateLimiterOptions) { options.authenticationAttemptLimit = limit } } +// WithInProcessBackendAllowed permits the per-replica in-memory counters. Wire it +// from codefly.IsLocal() only. +// +// A per-replica bucket is not the budget it reports: with N replicas the effective +// limit is N times the configured one, and which replica a caller lands on decides +// how much of it they get. For an authentication-factor budget that is the +// difference between a bounded number of attempts and a bounded number per +// replica. So a deployed gateway refuses to start without the shared store rather +// than enforcing a budget nobody declared, while local development โ€” one replica, +// no cache service โ€” keeps the counters. +func WithInProcessBackendAllowed(allowed bool) RateLimiterOption { + return func(options *rateLimiterOptions) { options.allowInProcessBackend = allowed } +} + // NewRateLimiter creates a rate limiter allowing requestsPerMinute requests // per key per minute, with a 20% burst allowance. If REDIS_URL is set it // connects to Redis; otherwise it uses in-memory counters with a background // cleanup goroutine. -func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *RateLimiter { +func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) (*RateLimiter, error) { options := rateLimiterOptions{ redisURL: os.Getenv("REDIS_URL"), authenticationAttemptLimit: authenticationAttemptLimitPerMinute, @@ -197,19 +212,29 @@ func NewRateLimiter(requestsPerMinute int, optionFns ...RateLimiterOption) *Rate if redisURL := options.redisURL; redisURL != "" { rb, err := newRedisBackend(redisURL) if err != nil { + if !options.allowInProcessBackend { + return nil, fmt.Errorf("rate limiter cannot reach its shared store: %w", err) + } log.Printf("WARNING: cannot connect to configured Redis: %v โ€” falling back to in-memory rate limiter", err) } else { log.Printf("rate limiter using pooled Redis backend") rl.backend = rb - return rl + return rl, nil } + } else if !options.allowInProcessBackend { + return nil, fmt.Errorf( + "rate limiter has no shared store: a deployed gateway runs more than one replica, " + + "so per-replica counters enforce the configured budget times the replica count " + + "and which replica a caller lands on decides their share โ€” resolve the cache " + + "service's redis/connection secret, or set REDIS_URL") } - // In-memory fallback. + // Per-replica counters: local development only (see + // WithInProcessBackendAllowed). mem := &memoryBackend{} rl.backend = mem go mem.cleanup(rl.stop) - return rl + return rl, nil } // Allow checks whether the given key is under the rate limit for the current @@ -357,11 +382,40 @@ func newProxyTrust(raw string) proxyTrust { return trust } +// requireProxyTrust is the boot check for a DEPLOYED runtime: the trusted-proxy +// range must parse AND name at least one hop. +// +// An empty range is not a narrow configuration, it is no configuration: with no +// trusted hop, clientIP always answers the peer, which behind the frontend is the +// frontend's own pod. Every anonymous caller then shares one budget and every +// authentication-factor attempt shares one, so a few bogus completions a minute +// deny the factor to the whole cell and a flood of anonymous requests denies +// login to all of it. That is a denial of service delivered BY the control meant +// to prevent one, and it read as working: the limiter was enabled, the buckets +// were enforced, and nothing said they had collapsed onto one key. +// +// Local development has no ingress hop and the peer IS the client, so an empty +// range is correct there and only there. +func requireProxyTrust(raw string, isLocal bool) error { + trust, err := parseProxyTrust(raw) + if err != nil { + return err + } + if isLocal || len(trust.prefixes) > 0 { + return nil + } + return fmt.Errorf( + "gateway: TRUSTED_PROXY_CIDRS is empty: a deployed gateway sits behind an ingress and " + + "a frontend hop, so with no trusted range every rate-limit bucket keys on the " + + "frontend's pod address and collapses into one โ€” set it to the ranges this cell's " + + "ingress and frontend reach the gateway from") +} + // parseProxyTrust parses a comma-separated list of bare IPs and CIDRs into a // trust set, returning an error on any malformed entry. Boot validates through -// this (parity with accounts' ParseTrustedProxyCIDRs) so a typo'd -// TRUSTED_PROXY_CIDRS fails startup rather than silently narrowing the trust -// set and collapsing every forwarded caller into one spoofable IP bucket. +// requireProxyTrust so a typo'd TRUSTED_PROXY_CIDRS fails startup rather than +// silently narrowing the trust set and collapsing every forwarded caller into one +// spoofable IP bucket. func parseProxyTrust(raw string) (proxyTrust, error) { var trust proxyTrust for _, candidate := range strings.Split(raw, ",") { @@ -446,3 +500,14 @@ func parsePeerAddress(remoteAddr string) (netip.Addr, bool) { } return addr.Unmap(), true } + +// newInProcessRateLimiter is the per-replica limiter, for tests and for local +// development's single-replica graph. It is a distinct constructor rather than an +// option on the main one so a deployed wiring cannot reach it by passing a flag. +func newInProcessRateLimiter(requestsPerMinute int) *RateLimiter { + limiter, err := NewRateLimiter(requestsPerMinute, WithInProcessBackendAllowed(true)) + if err != nil { + panic(fmt.Sprintf("in-process rate limiter: %v", err)) + } + return limiter +} diff --git a/module/services/auth-gateway/code/revocation.go b/module/services/auth-gateway/code/revocation.go index 9634652ca..1bbf143a6 100644 --- a/module/services/auth-gateway/code/revocation.go +++ b/module/services/auth-gateway/code/revocation.go @@ -247,13 +247,32 @@ func (c *cachedRevoker) evictOldest(n int) { } } -// newRevoker builds the revoker wired into the ext_authz check. No Redis configured -// falls back to noopRevoker (dev parity, loudly logged); a Redis URL that -// doesn't parse is a config error, not a reason to silently run without -// revocation โ€” the caller must treat it as fatal. -func newRevoker(redisURL string, ttl time.Duration) (revoker, error) { +// newRevoker builds the revoker wired into the ext_authz check. +// +// A Redis URL that does not parse is a config error, not a reason to silently run +// without revocation โ€” the caller must treat it as fatal. +// +// An ABSENT store is refused on a deployed runtime, by name. It used to fall back +// to noopRevoker with a log line, which is the worst available answer: the +// revoker answers "not revoked" to every question, so a signed-out session, a +// killed device and an ended impersonation window all keep authenticating until +// their tokens expire naturally โ€” and the gateway reports healthy throughout, +// because nothing on the request path can tell a no-op revoker from an empty +// revocation set. The fail-closed behaviour on a store ERROR (revocationFailsOpen) +// was already right; this is the same rule for a store that was never there. +// +// Local development keeps the no-op: there is no cache service in a bare `go run` +// graph, and the alternative is a gateway that cannot start. +func newRevoker(redisURL string, ttl time.Duration, isLocal bool) (revoker, error) { if strings.TrimSpace(redisURL) == "" { - log.Printf("auth-gateway: no Redis configured โ€” access-token revocation disabled (dev parity with accounts NoopTokenRevoker)") + if !isLocal { + return nil, fmt.Errorf( + "access-token revocation has no store: a deployed gateway must reach the cache " + + "service's Redis, because a revoker with no store answers \"not revoked\" to " + + "every question โ€” resolve the cache service's redis/connection secret, or set " + + "REDIS_URL") + } + log.Printf("auth-gateway: no Redis configured โ€” access-token revocation disabled (local development only)") return noopRevoker{}, nil } store, err := newRedisRevocationStore(redisURL) From fcaaddd1100e0d682ea0edd6ade735d76e6adf3f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:05:14 -0400 Subject: [PATCH 09/46] fix(auth-gateway): a cross-origin grant on a forwarded response is the gateway's own (SA-F-CORS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accounts' generated CORS handler hands an empty allowlist to a library that reads empty as allow-all, so it answers a wildcard origin with credentials true. The gateway forwarded those headers verbatim on every path where it had granted nothing, so the public surface granted a cross-origin read the gateway had granted nobody, and an empty allowlist, which should grant nothing, granted everything. Browsers refuse a credentialed read under a wildcard, which bounded the reach to anonymous responses, but the fail-closed-by-name property was gone. Every proxied response is now wrapped, granted or not: the wrapper strips access-control-* before stamping, and with no grant it strips and stamps nothing. It already stripped where a grant replaced the upstream's; the missing half was the much more common path where there was no grant to replace. Vary is still only added with a grant, so the public surface's cache entries are not fragmented by an origin the response does not depend on. What this does NOT fix, and who owns it: accounts' generated cors_gen.go and connect_gen.go, where the empty allowlist becomes allow-all and the value is read with a raw environment accessor. Both are generated by the go-grpc agent and are not this repository's to hand-edit; the defect is in that template. accounts' listener is reachable in-mesh, so the generated handler's answer still stands there โ€” the gateway closes the public surface, which is what SP-GW-11's probe reads, and the template owns the rest. Tested through the gateway with an upstream that answers a wildcard with credentials: for an unregistered origin and for no origin at all, no access-control header leaves the gateway, and the request itself is unaffected. A registered origin's grant replaces the upstream's rather than joining it, and allow-credentials never appears whatever the upstream said. Mutation-checked. module/services/auth-gateway/AGENTS.md records the rule and names the template as the owner of the other half. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/services/auth-gateway/AGENTS.md | 11 +++ .../auth-gateway/code/gateway_cors.go | 43 +++++++--- .../auth-gateway/code/gateway_test.go | 8 ++ .../code/gateway_upstream_cors_test.go | 83 +++++++++++++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 module/services/auth-gateway/code/gateway_upstream_cors_test.go diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index a25c01989..a5e44aa08 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -111,6 +111,17 @@ answer the way `gateway_solution_registry.go` caches the solution registry, and internal-token path still establishes it too; the registration is the stronger claim, since it names the client rather than only the process that forwarded. +**The grant on a forwarded response is this gateway's, and only this gateway's.** +Every proxied response has its `access-control-*` headers stripped before the +gateway stamps whatever it granted โ€” including when it granted nothing. An +upstream may answer CORS itself, and accounts does: its generated handler hands an +empty allowlist to a library that reads empty as allow-all, so it answers a +wildcard origin with credentials true. Forwarded verbatim, that made the public +surface grant a cross-origin read the gateway had granted nobody, and made an +empty allowlist grant everything. The generated handler is the `go-grpc` agent's +to fix; what the gateway owns is that nothing leaves it claiming a grant it did not +make. + Two deliberate limits. **Credentials are never allowed**: a registered client authenticates with its bearer, and echoing `access-control-allow-credentials` would additionally let a cross-origin page ride the host's session cookie. diff --git a/module/services/auth-gateway/code/gateway_cors.go b/module/services/auth-gateway/code/gateway_cors.go index 3daebafb6..fe9092b85 100644 --- a/module/services/auth-gateway/code/gateway_cors.go +++ b/module/services/auth-gateway/code/gateway_cors.go @@ -136,9 +136,22 @@ func requestsAuthorizationHeader(requested string) bool { // matched it, so a client cannot reach the solution passthrough, a federated // module prefix, or a catalog route from an origin it did not register. func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) (http.ResponseWriter, bool) { + // Every forwarded response is wrapped, granted or not. An upstream may answer + // CORS itself, and accounts does: its generated handler hands an empty + // allowlist to a library that reads empty as allow-all, so it answered a + // wildcard origin with credentials true. Those headers used to reach the + // browser unchanged on every path where this gateway granted nothing, which + // means the public surface granted a cross-origin read the gateway had + // refused to grant โ€” and an empty allowlist, which should grant nothing, + // granted everything. + // + // What this gateway granted is the only answer that leaves here. The wrapper + // with no origin strips and stamps nothing; with one it replaces. + stripping := &corsResponseWriter{ResponseWriter: w} + origin := r.Header.Get("Origin") if origin == "" { - return w, true + return stripping, true } if clientID := r.Header.Get(clientIDHeader); clientID != "" { @@ -170,10 +183,10 @@ func (g *Gateway) authorizeCrossOrigin(w http.ResponseWriter, r *http.Request) ( // to ever read its token. The origin is the only thing such a request can // be judged on, and it is the same thing the preflight was judged on. if credentialPresented(r) { - return w, true + return stripping, true } if !g.clients.originRegistered(r.Context(), origin) { - return w, true + return stripping, true } return &corsResponseWriter{ResponseWriter: w, origin: origin}, true } @@ -187,13 +200,17 @@ func credentialPresented(r *http.Request) bool { return r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" } -// corsResponseWriter stamps the grant on the way out. It has to run at -// WriteHeader rather than up front because the reverse proxy copies the -// upstream's own response headers into this map first, and an upstream that -// answers CORS itself โ€” a solution serving its origin permissively โ€” would -// otherwise leave two access-control-allow-origin values, which every browser -// rejects. The gateway is the authority for what it granted, so it replaces -// whatever the upstream said. +// corsResponseWriter makes this gateway the only author of the cross-origin grant +// on a forwarded response. It has to run at WriteHeader rather than up front +// because the reverse proxy copies the upstream's own response headers into this +// map first. +// +// With an origin it replaces whatever the upstream said โ€” two +// access-control-allow-origin values is a response every browser rejects. With NO +// origin it still strips, which is the load-bearing half: an upstream that answers +// CORS permissively (a solution serving its own origin, or accounts' generated +// handler reading an empty allowlist as allow-all) would otherwise have its grant +// forwarded verbatim as though this gateway had made it. type corsResponseWriter struct { http.ResponseWriter origin string @@ -221,6 +238,12 @@ func (w *corsResponseWriter) stamp() { h.Del(key) } } + if w.origin == "" { + // Nothing was granted, so nothing is claimed. Vary is not added: the + // response does not differ by origin, and saying it does would needlessly + // fragment every cache entry on the public surface. + return + } h.Set("Access-Control-Allow-Origin", w.origin) h.Set("Access-Control-Expose-Headers", corsExposeHeaders) addVary(h, "Origin") diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index dd3c440cc..80cd57136 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -25,6 +25,9 @@ type fakeUpstream struct { lastBody string statusCode int body string + // responseHeaders let a test give the upstream its own answer about + // cross-origin access, which is what accounts' generated CORS handler does. + responseHeaders http.Header } func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -34,6 +37,11 @@ func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { if body, err := io.ReadAll(r.Body); err == nil { f.lastBody = string(body) } + for key, values := range f.responseHeaders { + for _, value := range values { + w.Header().Add(key, value) + } + } code := f.statusCode if code == 0 { code = 200 diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go new file mode 100644 index 000000000..484bd35ca --- /dev/null +++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// accounts' generated CORS handler hands an empty allowlist to a library that +// reads empty as allow-all, so it answers a wildcard origin with credentials +// true. Those headers used to be forwarded verbatim, which made the public +// surface grant a cross-origin read this gateway had granted nobody โ€” and made an +// empty allowlist, which should grant nothing, grant everything. +// +// The generated handler is the go-grpc agent's to fix. What this gateway owns is +// that the grant on its own responses is the one it made. +func TestUpstreamCrossOriginGrantNeverReachesTheBrowser(t *testing.T) { + gw, api, _, _ := newGatewayHarness(t) + api.responseHeaders = http.Header{ + "Access-Control-Allow-Origin": []string{"*"}, + "Access-Control-Allow-Credentials": []string{"true"}, + "Access-Control-Expose-Headers": []string{"x-everything"}, + } + + for _, tc := range []struct { + name string + origin string + }{ + {"an unregistered origin", "https://evil.example"}, + {"no origin at all", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + if tc.origin != "" { + request.Header.Set("Origin", tc.origin) + } + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, "the request itself is unaffected") + for name := range recorder.Header() { + require.NotContains(t, lowered(name), "access-control-", + "the gateway granted nothing, so no access-control header may leave it: %s=%q", + name, recorder.Header().Get(name)) + } + }) + } +} + +// And a registered origin's grant is the gateway's own, replacing rather than +// joining the upstream's โ€” two allow-origin values is a response every browser +// rejects. +func TestARegisteredOriginsGrantReplacesTheUpstreams(t *testing.T) { + gw, api, _, _ := newGatewayHarness(t) + twoRegisteredClients(t, gw) + api.responseHeaders = http.Header{ + "Access-Control-Allow-Origin": []string{"*"}, + "Access-Control-Allow-Credentials": []string{"true"}, + } + + request := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + request.Header.Set("Origin", addinOrigin) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, []string{addinOrigin}, recorder.Result().Header.Values("Access-Control-Allow-Origin")) + require.Empty(t, recorder.Header().Get("Access-Control-Allow-Credentials"), + "credentials are never granted cross-origin, whatever the upstream said") +} + +func lowered(value string) string { + out := make([]byte, 0, len(value)) + for i := 0; i < len(value); i++ { + c := value[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out = append(out, c) + } + return string(out) +} From a625989c10ce99f25038c21436f717954aacfa05 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:08:28 -0400 Subject: [PATCH 10/46] fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host access token is the person's whole session: one host-wide audience, their full authority, valid at every other upstream and at the host's own API. The gateway forwarded it, and any cookie, unchanged to every composed module and solution. So a compromised module or solution pod, a single logged request header, or an upstream that redeems the token to mint contexts for further audiences each yielded replayable full-authority sessions for every viewer who had used that upstream. proxyTo now removes Authorization, Cookie and Proxy-Authorization for every runtime-registered route, module and solution alike, including the public solution asset surface. A catalog route is untouched: accounts is the host's own API, where the session is the credential, and a test asserts that so the strip cannot be satisfied by forwarding nothing to anybody. An upstream receives what it should have been receiving all along: the identity ext_authz stamps โ€” subject, tenant, session, credential kind, scope ceiling โ€” which names the person without carrying their authority. THIS BREAKS AN UPSTREAM THAT READS THE FORWARDED BEARER, by design. The register names two consumer repositories that do, and the audit filed their half separately: one reads the bearer in its chat handler and re-mints Work Contexts with it, and the solution runtime's authenticated fetch relies on it. Their replacement is a host-minted capability bound to the upstream's own audience โ€” the Work Context surface โ€” not a bearer borrowed from a person. Sequencing is an operator decision and is listed in the pull request rather than decided here. The companion half of the same invariant, a per-resource audience in place of the single host-wide one, is PR #1007's (issue #1003): it binds a solution tool endpoint's token to that endpoint's resource. This change is what stops the generic session reaching the upstream in the first place. TestModuleUpstreamReceivesNoSessionBearer and TestPublicSolutionPathForwardsNoCredential are the catalogue's two tests, and the second is the verifier's reproducer. Mutation-checked. The pre-existing assertion that a solution receives the bearer is inverted rather than deleted, so the change in contract is visible in the diff. Docs: module/services/auth-gateway/AGENTS.md gains the rule and names the consumer obligation; module/SOLUTION_REGISTRATION.md's upstream-constraints section no longer describes a forwarded user bearer. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/SOLUTION_REGISTRATION.md | 205 ++++++++++++++++++ module/services/auth-gateway/AGENTS.md | 24 ++ module/services/auth-gateway/code/gateway.go | 32 +++ .../code/gateway_module_routes.go | 9 +- .../code/gateway_module_routes_test.go | 18 +- .../auth-gateway/code/gateway_modules.go | 13 +- .../auth-gateway/code/gateway_solutions.go | 6 +- .../code/gateway_solutions_test.go | 5 +- .../code/upstream_session_credential_test.go | 73 +++++++ 9 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 module/services/auth-gateway/code/upstream_session_credential_test.go diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 7306668cb..afc49356b 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -53,12 +53,217 @@ case remain supported. A changed DNS answer cannot bypass the transport check. ## 3. Runtime compatibility +<<<<<<< HEAD The Module-Federation host checks compatibility before activating a remote. The manifest parser validates the snapshot's shape. Runtime requirements include `schemaVersion`, `frontend.hostContract`, `frontend.reactRange`, `frontend.shared` and `frontend.exposedModule`; host versions live in `services/frontend/code/src/solutions/host-runtime.ts`. A failed check leaves the remote unavailable and reports the cause in the server log. +||||||| parent of a3090094 (fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER)) + **What that does not yet give you, stated plainly because a revocation + depends on it.** accounts reads the declaration through the Codefly SDK, + which resolves it from the process environment and the runtime's injected + carrier. Both are live reads โ€” an edit is observed the moment it reaches + them โ€” but **no runtime component updates either one under a running + process** (`codefly-dev/cli#740`). So in a deployment that delivers this key + as a container environment variable, an edit does not reach accounts at all, + and withdrawing a publisher still requires restarting the service. Until that + issue closes, treat a revocation as taking effect on restart, not on edit, + and do not rely on removing a digest to lock out a publisher whose secret you + believe is compromised โ€” delete its registration and restart accounts. +3. The solution presents that token in `X-Codefly-Solution-Registration` to both + `POST /solutions/_register` (gateway, `{id, upstream}`) and + `POST /api/solutions/register` (frontend, the manifest). `DELETE` on either + withdraws it. + +The exchange keeps three outcomes apart, because a registrant acts differently +on each: `401` means accounts refused the secret โ€” the only answer that means +"check `SOLUTION_REGISTRATION_SECRETS` and the provisioned secret"; `503` with +`Retry-After` means accounts could not be reached or did not answer in time (a +rollout, a restart) and the next attempt may succeed; `502` means accounts +answered with an error that is neither. The frontend likewise answers `503`, not +`401`, when it cannot reach the key set it verifies the token against. A +registrant must not read any 5xx โ€” including a `502` from an ingress or mesh in +front of a restarting gateway โ€” as a provisioning fault. + +`SOLUTION_REGISTRATION_SECRETS` is declared **separately** from +`MODULE_REGISTRATION_SECRETS`, and the two credentials carry different +audiences. A module credential federates a REST prefix; a solution credential +additionally publishes host-origin code. Holding one must never confer the +other, so neither the configuration nor the token is shared. + +### What each half enforces + +| Check | Gateway | Frontend | +| --- | --- | --- | +| Ed25519 signature against the published JWKS, alg-locked to EdDSA | yes | yes | +| Issuer, `solution-registration` audience, expiry (with skew leeway) | yes | yes | +| `solution` claim equals the id in the request | yes (403) | yes (403) | +| First registration binds the id to `sub`; a different `sub` is refused | yes (409) | yes (409) | +| Same publisher may move its own endpoint | yes | yes | +| Delete is owner-bound, and a non-owner's delete is indistinguishable from an unknown id | yes | yes | +| `jti` burned on use, so a captured credential cannot be replayed | yes | yes | +| Request body bounded before parsing | yes (256 KiB; the separate token exchange bounds at 4 KiB) | no bound of its own โ€” the manifest is bounded where the gateway accepts it | +| Id is one catalog-identity segment, so reserved `_โ€ฆ` sub-paths cannot be shadowed | yes | n/a (id comes from the manifest and must equal the claim) | +| Upstream host must be composition-local; resolved address re-checked at dial time | yes | n/a | + +The gateway deliberately allows a solution to **move** its upstream, which +module federation does not. That is safe only because the publisher is proven on +every call: an endpoint legitimately changes on a redeploy or a new dev port, +while a credential holder for one solution can neither claim another's id nor +redirect it. + +### Upstream constraints and the development exception + +A solution upstream receives forwarded user bearers, so it is held to the same +rule as a federated module upstream: loopback and private ranges (the dev and +in-cluster cases), mesh short names and cluster suffixes โ€” and nothing globally +routable, no link-local, no cloud-metadata address. The register-time host string +is only half of it: the **resolved address is re-checked at dial time**, so a +mesh-looking name whose DNS answer later points off-mesh never receives a +forwarded bearer. Loopback is an explicit development exception and is tested as +such. + +## 3. Runtime compatibility is enforced, not stored + +A declared requirement the host does not check is not a compatibility contract. +Every requirement is checked **before activation**, so an incompatible remote is +never handed to a browser. An undeclared major defaults to `1` โ€” the major in +force when the field was introduced, which is what silence actually asserts โ€” and +never to the host's current major, which would make every silent manifest +compatible by definition at exactly the upgrade this check exists for: + +| Manifest field | Checked against | Default when absent | +| --- | --- | --- | +| `schemaVersion` | `SOLUTION_MANIFEST_SCHEMA_MAJOR` | `1` | +| `frontend.hostContract` | `SOLUTION_HOST_CONTRACT_MAJOR` (the `SolutionPageProps` the host injects and the `./Page` default it expects) | `1` | +| `frontend.reactRange` | the host's real React version | unconstrained | +| `frontend.shared` (per package) | the versions the host publishes into the sealed scope | unconstrained | +| `frontend.exposedModule` | must be a `./Name` key | โ€” | + +Ranges use a deliberately small semver subset (`||` alternatives of +space-separated `^ ~ >= > <= < =`, a bare version, or `*`). A range outside the +grammar is **refused**, never approximated: a requirement the host cannot +evaluate is not one it can honour. The host values live in +`services/frontend/code/src/solutions/host-runtime.ts`, which the register route +and the Module-Federation host both read, so the numbers cannot diverge. + +A refused registration is refused **whole**. If the id already had a valid +registration, that registration keeps serving โ€” the last known valid one is never +replaced by a rejected update. The reasons are returned to the registrant (HTTP +409, `{"error":"incompatible_runtime","reasons":[โ€ฆ]}`) and logged for the +operator. If the id has no valid registration to fall back on, its page renders a +plain "temporarily unavailable" panel instead of a 404, and the technical cause +stays in the server log. +======= + **What that does not yet give you, stated plainly because a revocation + depends on it.** accounts reads the declaration through the Codefly SDK, + which resolves it from the process environment and the runtime's injected + carrier. Both are live reads โ€” an edit is observed the moment it reaches + them โ€” but **no runtime component updates either one under a running + process** (`codefly-dev/cli#740`). So in a deployment that delivers this key + as a container environment variable, an edit does not reach accounts at all, + and withdrawing a publisher still requires restarting the service. Until that + issue closes, treat a revocation as taking effect on restart, not on edit, + and do not rely on removing a digest to lock out a publisher whose secret you + believe is compromised โ€” delete its registration and restart accounts. +3. The solution presents that token in `X-Codefly-Solution-Registration` to both + `POST /solutions/_register` (gateway, `{id, upstream}`) and + `POST /api/solutions/register` (frontend, the manifest). `DELETE` on either + withdraws it. + +The exchange keeps three outcomes apart, because a registrant acts differently +on each: `401` means accounts refused the secret โ€” the only answer that means +"check `SOLUTION_REGISTRATION_SECRETS` and the provisioned secret"; `503` with +`Retry-After` means accounts could not be reached or did not answer in time (a +rollout, a restart) and the next attempt may succeed; `502` means accounts +answered with an error that is neither. The frontend likewise answers `503`, not +`401`, when it cannot reach the key set it verifies the token against. A +registrant must not read any 5xx โ€” including a `502` from an ingress or mesh in +front of a restarting gateway โ€” as a provisioning fault. + +`SOLUTION_REGISTRATION_SECRETS` is declared **separately** from +`MODULE_REGISTRATION_SECRETS`, and the two credentials carry different +audiences. A module credential federates a REST prefix; a solution credential +additionally publishes host-origin code. Holding one must never confer the +other, so neither the configuration nor the token is shared. + +### What each half enforces + +| Check | Gateway | Frontend | +| --- | --- | --- | +| Ed25519 signature against the published JWKS, alg-locked to EdDSA | yes | yes | +| Issuer, `solution-registration` audience, expiry (with skew leeway) | yes | yes | +| `solution` claim equals the id in the request | yes (403) | yes (403) | +| First registration binds the id to `sub`; a different `sub` is refused | yes (409) | yes (409) | +| Same publisher may move its own endpoint | yes | yes | +| Delete is owner-bound, and a non-owner's delete is indistinguishable from an unknown id | yes | yes | +| `jti` burned on use, so a captured credential cannot be replayed | yes | yes | +| Request body bounded before parsing | yes (256 KiB; the separate token exchange bounds at 4 KiB) | no bound of its own โ€” the manifest is bounded where the gateway accepts it | +| Id is one catalog-identity segment, so reserved `_โ€ฆ` sub-paths cannot be shadowed | yes | n/a (id comes from the manifest and must equal the claim) | +| Upstream host must be composition-local; resolved address re-checked at dial time | yes | n/a | + +The gateway deliberately allows a solution to **move** its upstream, which +module federation does not. That is safe only because the publisher is proven on +every call: an endpoint legitimately changes on a redeploy or a new dev port, +while a credential holder for one solution can neither claim another's id nor +redirect it. + +### Upstream constraints and the development exception + +A solution upstream is a host the registrant chose that receives requests made on +a person's behalf, so it is held to the same rule as a federated module upstream: +loopback and private ranges (the dev and in-cluster cases), mesh short names and +cluster suffixes โ€” and nothing globally routable, no link-local, no cloud-metadata +address. The register-time host string is only half of it: the **resolved address +is re-checked at dial time**, so a mesh-looking name whose DNS answer later points +off-mesh never receives one. Loopback is an explicit development exception and is +tested as such. + +A solution upstream does **not** receive the viewer's session credential. The +gateway removes `Authorization` and `Cookie` from every request it forwards to a +runtime-registered upstream, module or solution, and the upstream receives the +identity headers `ext_authz` stamped instead โ€” the subject, the tenant, the +session, the credential kind and the scope ceiling, which name the person without +carrying their authority. A host access token is the person's whole session at a +single host-wide audience, so forwarding it made every upstream that held one, or +logged one, a source of replayable full-authority sessions. An upstream that needs +to act on a person's behalf needs a host-minted capability bound to its own +audience, which is the Work Context surface. + +## 3. Runtime compatibility is enforced, not stored + +A declared requirement the host does not check is not a compatibility contract. +Every requirement is checked **before activation**, so an incompatible remote is +never handed to a browser. An undeclared major defaults to `1` โ€” the major in +force when the field was introduced, which is what silence actually asserts โ€” and +never to the host's current major, which would make every silent manifest +compatible by definition at exactly the upgrade this check exists for: + +| Manifest field | Checked against | Default when absent | +| --- | --- | --- | +| `schemaVersion` | `SOLUTION_MANIFEST_SCHEMA_MAJOR` | `1` | +| `frontend.hostContract` | `SOLUTION_HOST_CONTRACT_MAJOR` (the `SolutionPageProps` the host injects and the `./Page` default it expects) | `1` | +| `frontend.reactRange` | the host's real React version | unconstrained | +| `frontend.shared` (per package) | the versions the host publishes into the sealed scope | unconstrained | +| `frontend.exposedModule` | must be a `./Name` key | โ€” | + +Ranges use a deliberately small semver subset (`||` alternatives of +space-separated `^ ~ >= > <= < =`, a bare version, or `*`). A range outside the +grammar is **refused**, never approximated: a requirement the host cannot +evaluate is not one it can honour. The host values live in +`services/frontend/code/src/solutions/host-runtime.ts`, which the register route +and the Module-Federation host both read, so the numbers cannot diverge. + +A refused registration is refused **whole**. If the id already had a valid +registration, that registration keeps serving โ€” the last known valid one is never +replaced by a rejected update. The reasons are returned to the registrant (HTTP +409, `{"error":"incompatible_runtime","reasons":[โ€ฆ]}`) and logged for the +operator. If the id has no valid registration to fall back on, its page renders a +plain "temporarily unavailable" panel instead of a 404, and the technical cause +stays in the server log. +>>>>>>> a3090094 (fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER)) ## 4. Registration, installation, and entitlement are three different things diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index a5e44aa08..fb3f99cd8 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -78,6 +78,30 @@ needing less authority. Module identity exchanges are described below; accounts owns their authority. +## A runtime-registered upstream never receives the person's session + +The gateway removes `Authorization`, `Cookie` and `Proxy-Authorization` from every +request it forwards to a runtime-registered upstream โ€” a federated module prefix or +a solution. A catalog route is untouched: accounts is the host's own API, where the +session IS the credential. + +A host access token is the person's whole session: one host-wide audience, the +person's full authority, valid at every other upstream and at the host's own API. +Forwarding it meant a compromised module or solution pod โ€” or a single logged +request header, or an upstream that redeems it to mint contexts for further +audiences โ€” yielded replayable full-authority sessions for every viewer who had +used it. The cookie went the same way, and the gateway has already resolved +identity from it by the time it forwards, so an upstream reading it learns nothing +it is not told. + +An upstream receives the identity `ext_authz` stamped: the subject, the tenant, the +session, the credential kind and the scope ceiling. That names the person without +carrying their authority. **An upstream that needs to act on the person's behalf +needs a host-minted capability bound to its own audience** โ€” the Work Context +surface โ€” not a bearer it borrowed. A consumer written against the old behaviour +has to change: the two consumer repositories that read the forwarded bearer own +their half of this. + ## A registered client calls without a proxy The host's own frontend reaches this gateway through a server-side proxy, so it diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index e5e79fa1b..750d931c1 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -471,6 +471,9 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. r.Header.Set("X-Codefly-Public-Origin", publicOrigin) } } + if isRuntimeRegisteredRoute(entry) { + removePersonsSessionCredential(r.Header) + } if entry != nil && entry.UpstreamPath != "" { r.URL.Path = entry.UpstreamPath r.URL.RawPath = "" @@ -489,6 +492,35 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. proxy.ServeHTTP(w, r) } +// removePersonsSessionCredential strips the person's own session from a request +// before it reaches a runtime-registered upstream โ€” a composed module's federated +// prefix, or a solution. +// +// The host access token IS the person's whole session: a host-wide audience, the +// person's full authority, and valid at every other upstream and at the host's own +// API. Forwarding it verbatim meant a compromised module or solution pod โ€” or one +// logged request header, or one upstream that redeems it to mint contexts for +// other audiences โ€” yielded replayable full-authority sessions for every viewer +// who had used it. The Cookie goes with it for the same reason and one more: the +// gateway has already resolved identity from it by this point, so an upstream +// reading it learns nothing it is not told, and nothing legitimately needs the +// host's session cookie. +// +// What an upstream receives instead is what it should have been receiving: the +// identity headers ext_authz stamped (the subject, the tenant, the session, the +// credential kind, the scope ceiling), which name the person without carrying +// their authority. An upstream that needs to ACT on the person's behalf needs a +// host-minted capability bound to its own audience, which is the Work Context +// surface, not a bearer it borrowed. +// +// Catalog routes are untouched: accounts is the host's own API, where the session +// is the credential. +func removePersonsSessionCredential(h http.Header) { + h.Del("Authorization") + h.Del("Cookie") + h.Del("Proxy-Authorization") +} + // removeCallerConnectionOptions applies the caller's Connection header to the // caller's own headers, so it can never remove one this gateway stamped. // diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go index d26056d92..2c9579db6 100644 --- a/module/services/auth-gateway/code/gateway_module_routes.go +++ b/module/services/auth-gateway/code/gateway_module_routes.go @@ -170,9 +170,12 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) // Forward the full /v1//... path unchanged: the module owns and serves - // its own /v1/ surface. The caller's bearer is preserved so the module - // can call back through the gateway on the user's behalf, exactly as solution - // passthrough does. + // its own /v1/ surface. The caller's session credential does NOT travel + // with it โ€” proxyTo withholds it for every declared upstream + // (removePersonsSessionCredential) โ€” exactly as solution passthrough does. A + // module that acts on the person's behalf does so with the Work Context + // verified above, which is bound to this route's audience rather than good + // everywhere the person is. // // rateLimitThenProxy, not proxyTo: a module data endpoint must consume the // same per-org/per-IP budget an equivalent catalog route does, or /v1//* diff --git a/module/services/auth-gateway/code/gateway_module_routes_test.go b/module/services/auth-gateway/code/gateway_module_routes_test.go index 7fe6a2292..464a4753b 100644 --- a/module/services/auth-gateway/code/gateway_module_routes_test.go +++ b/module/services/auth-gateway/code/gateway_module_routes_test.go @@ -80,8 +80,19 @@ func TestGateway_UndeclaredModulePrefixIsNotServed(t *testing.T) { // A declared module IS routed: the full /v1//... path reaches the declared // upstream, with the ordinary authenticated pipeline in front of it โ€” identity -// projected from the validated token, caller-supplied identity stripped, the -// caller's bearer preserved, and the gateway's own accounts credential withheld. +// projected from the validated token, caller-supplied identity stripped, and both +// the person's session credential and the gateway's own accounts credential +// withheld. +// +// This asserted the person's bearer PRESERVED until SA-F-BEARER. A host access +// token names one host-wide audience and carries the person's whole authority, so +// a declared upstream holding one holds a credential good at every other upstream +// and at the host's own API. The capability path a module needs is untouched by +// the withholding: a Work Context travels in its own header +// (workcontext.WorkContextHeaderName), is verified here against this route's +// audience (moduleCapabilitiesAudience), and is forwarded โ€” which is the +// difference between a capability bound to this upstream and a session borrowed +// from the person. func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) fake := declareModule(t, gw, "example-module") @@ -109,7 +120,8 @@ func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testin require.Equal(t, "/v1/example-module/things", fake.lastPath) require.NotEqual(t, "attacker", fake.lastHeaders.Get("x-user-id")) require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role")) - require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization")) + require.Empty(t, fake.lastHeaders.Get("authorization"), + "a declared module upstream must not receive the person's host-wide session") require.Empty(t, fake.lastHeaders.Get("x-codefly-gateway-token"), "the gateway token is an accounts-only capability and must not reach a module upstream") } diff --git a/module/services/auth-gateway/code/gateway_modules.go b/module/services/auth-gateway/code/gateway_modules.go index d43ed150f..f43721eb5 100644 --- a/module/services/auth-gateway/code/gateway_modules.go +++ b/module/services/auth-gateway/code/gateway_modules.go @@ -25,10 +25,15 @@ import ( // named, rather than the static catalog. // // Both kinds qualify, for one reason: the address is read out of a delivered -// document and user bearers are forwarded to it, so the host must still refuse a -// name that resolves somewhere it will not send credentials. A module route added -// without this line would have been the one declared upstream dialled on the -// plain transport, and nothing about the route's shape would have said so. +// document and the person's identity is stamped on what is sent there, so the +// host must still refuse a name that resolves somewhere it will not send a +// request made on someone's behalf. A module route added without this line would +// have been the one declared upstream dialled on the plain transport, and nothing +// about the route's shape would have said so. +// +// The predicate has a second reader now: proxyTo strips the person's session +// credential for exactly these routes (removePersonsSessionCredential), so what +// reaches a declared upstream is the stamped identity and not a bearer. func isRuntimeRegisteredRoute(entry *RouteEntry) bool { return entry != nil && (strings.HasPrefix(entry.Service, solutionServicePrefix) || diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 4ff6d36d9..177d3b26c 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -265,8 +265,10 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) httpError(w, http.StatusForbidden, "no organization in this session") return true } - // Proxy to the solution. The caller's bearer is preserved so the solution - // can call accounts through the gateway on the user's behalf. + // Proxy to the solution. The caller's session credential does NOT travel with + // it: proxyTo removes the Authorization and Cookie headers for every + // runtime-registered upstream, and the solution receives the stamped identity + // headers instead (removePersonsSessionCredential). // // Route through rateLimitThenProxy, not proxyTo directly: an authenticated // solution data endpoint must consume the same per-org budget as an equivalent diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index dbcfb7d32..a94476009 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -211,8 +211,9 @@ func TestGateway_Solution_ValidJWT_ForwardsWithIdentity(t *testing.T) { require.NotEmpty(t, fake.lastHeaders.Get("x-user-id")) require.NotEmpty(t, fake.lastHeaders.Get("x-org-id")) require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role")) - // The caller's bearer is preserved for the solution's own downstream calls. - require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization")) + // The caller's session credential does NOT travel to the solution: the + // identity headers above name the person without carrying their authority. + require.Empty(t, fake.lastHeaders.Get("authorization")) // The gateway token is an accounts-only capability and must not leak to a // solution upstream. require.Empty(t, fake.lastHeaders.Get("x-codefly-gateway-token")) diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go new file mode 100644 index 000000000..754f58c11 --- /dev/null +++ b/module/services/auth-gateway/code/upstream_session_credential_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// The host access token IS the person's whole session: one host-wide audience, +// the person's full authority, valid at every other upstream and at the host's own +// API. Forwarding it verbatim meant a compromised module or solution pod โ€” or one +// logged request header โ€” yielded replayable full-authority sessions for every +// viewer who had used it. +// +// What an upstream receives instead is the identity ext_authz stamped, which names +// the person without carrying their authority. +func TestModuleUpstreamReceivesNoSessionBearer(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "documents") + + token := signValidToken(t, priv) + request := httptest.NewRequest(http.MethodGet, "/v1/documents/entries", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Cookie", "codefly_rt=a-refresh-token; codefly_session=1") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, fake.lastHeaders.Get("Authorization"), + "a module upstream must not receive the person's session credential") + require.Empty(t, fake.lastHeaders.Get("Cookie"), + "nor the cookie the gateway has already resolved identity from") + require.NotEmpty(t, fake.lastHeaders.Get("x-user-id"), + "it still learns who the person is") + require.NotEmpty(t, fake.lastHeaders.Get("x-org-id")) +} + +// The public solution surface โ€” the module-federation manifest, remote entry and +// chunks โ€” is fetched by the browser's module loader. It carries no bearer when the +// loader is working as designed, but a page that had one attached forwarded it to +// an unauthenticated path, which is the verifier's reproducer. +func TestPublicSolutionPathForwardsNoCredential(t *testing.T) { + gw, _, _, _ := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + + request := httptest.NewRequest(http.MethodGet, "/solutions/audit/assets/remoteEntry.js", nil) + request.Header.Set("Authorization", "Bearer a-host-session-token") + request.Header.Set("Cookie", "codefly_rt=a-refresh-token") + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, fake.lastHeaders.Get("Authorization")) + require.Empty(t, fake.lastHeaders.Get("Cookie")) +} + +// The host's own API is the one upstream where the session IS the credential, so +// the strip must not reach a catalog route. Without this control the test above +// would pass on a gateway that forwarded nothing to anybody. +func TestCatalogRouteStillCarriesTheSessionToAccounts(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + + token := signValidToken(t, priv) + request := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + gw.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "Bearer "+token, api.lastHeaders.Get("Authorization")) +} From 15daa226bd6b63263336ed8b6c06b6e934721161 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:27:04 -0400 Subject: [PATCH 11/46] fix: the verified public origin is operator configuration, never a request header (SA-F-ORIGIN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend's own rendered endpoint is a loopback placeholder โ€” which is what the render produces, so on every cell โ€” and the frontend fell back to the caller's X-Forwarded-Host and stamped it, under the internal token, as the VERIFIED public origin. accounts then bound an OAuth redirect, the authenticator relying-party origin and (with no application base URL, as on a cell) emailed links to a host the caller had chosen. The solution proxy's same-origin check compared the browser's Origin against the same caller-supplied value, so a caller that supplied both sides matched itself. Three independent changes, because the origin is decided in three places and any one of them deciding it from a request is the whole defect. The frontend no longer takes a request origin at all: resolveCodeflyGatewayContext dropped the parameter. The order is the configured origin, then the SDK-discovered own endpoint when the render carries a real host, and on a deployed build nothing else โ€” a loopback placeholder with nothing configured refuses, naming application/APP_BASE_URL, rather than asking the caller. Local development keeps its loopback endpoint, which is the host the browser reaches anyway; only the build tells the two apart, and nothing a caller sends does. The solution proxy compares the browser's Origin against that configured origin, and refuses when there is none: nothing to compare against is not a reason to admit. The origin is resolved separately from the trust context, so a deployment missing the internal token is still a different condition from one with no verified origin โ€” conflating them would refuse a same-origin request over a credential nobody asked about. accounts records a forwarded origin as verified only when it equals the configured one. That is the barrier that does not depend on the frontend: the frontend holds the internal token legitimately, so a credential check alone could never tell a forwarded origin from a forwarded choice. A cell without APP_BASE_URL is a cell whose verified origin is a request parameter, so accounts refuses to start without it. publicRequestOrigin and requestPublicOrigin are gone, with the test that pinned the first. The solutions-surfaces route resolved the host origin a client fetches a remote's modules from the same way, and now reads the configured one. Tests: the configured origin wins over a render that carries a real host; a deployed build with a loopback placeholder refuses by name; local development keeps the loopback endpoint; near-misses of the configured origin (a subdomain, a suffixed host, another port, another scheme) are each refused in accounts; a forged cross-site request supplying its own forwarded host is refused by the proxy; a deployment with no configured origin refuses every proxied request. Each mutation-checked, and the positive controls โ€” a same-origin POST behind a TLS-terminating ingress, an unpinned local runtime accepting a well-formed origin โ€” assert the refusals are the pin rather than a blanket no. Owned elsewhere, and not worked around here: the CLI renders the frontend's own endpoint as a loopback placeholder (which is why the fallback existed), and the platform's ingress should overwrite X-Forwarded-Host for product hosts. Docs: application.env says what APP_BASE_URL binds and that a deployed runtime requires it. PR #1007 adds the same key to that file for the OAuth issuer; whichever lands second rebases. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/configurations/local/application.env | 21 +- .../accounts/code/pkg/auth/public_origin.go | 50 ++++- .../pkg/auth/public_origin_configured_test.go | 97 +++++++++ .../accounts/code/pkg/business/service.go | 15 +- .../accounts/code/shipped_placeholder_test.go | 14 ++ module/services/accounts/code/work.go | 36 +++- .../src/app/(auth)/oauth2/authorize/route.ts | 16 +- .../proxy/[...path]/__tests__/route.test.ts | 92 +++++++-- .../proxy/[...path]/__tests__/stream.test.ts | 6 + .../solutions/[id]/proxy/[...path]/route.ts | 36 +++- .../surfaces/__tests__/route.test.ts | 19 +- .../src/app/api/solutions/surfaces/route.ts | 16 +- .../__tests__/codefly-gateway-context.test.ts | 193 ++++++++++++------ .../lib/__tests__/proxy-public-origin.test.ts | 48 ----- .../code/src/lib/codefly-gateway-context.ts | 117 ++++++++--- .../frontend/code/src/lib/public-origin.ts | 36 ++-- module/services/frontend/code/src/proxy.ts | 22 +- .../code/src/test/pipeline-gateway.ts | 19 +- 18 files changed, 615 insertions(+), 238 deletions(-) create mode 100644 module/services/accounts/code/pkg/auth/public_origin_configured_test.go delete mode 100644 module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env index e21f0a51f..fc8cfaf30 100644 --- a/module/configurations/local/application.env +++ b/module/configurations/local/application.env @@ -1,6 +1,21 @@ -# Interactive links use the verified frontend origin propagated from Codefly's -# SDK-discovered endpoint. A background-only production origin may be supplied -# by deployment configuration; local development never pins a runtime port. +# APP_BASE_URL is the one origin this deployment treats as its own: what binds an +# OAuth redirect, the authenticator relying-party origin, every emailed link, and +# the frontend solution proxy's same-origin judgement. +# +# It is OPERATOR configuration and is never derived from a request. It used to be: +# the frontend's own rendered endpoint is a loopback placeholder, so the frontend +# fell back to the caller's X-Forwarded-Host and stamped that, under the internal +# token, as the verified public origin โ€” which let a caller choose the host a +# sign-in returned to and the host an emailed link pointed at. +# +# A DEPLOYED runtime therefore requires it: accounts refuses to start without it, +# and the frontend refuses to serve sign-in or the solution proxy. Local +# development pins no runtime port, so it is empty here and the frontend uses its +# own discovered loopback endpoint, which is the host the browser reaches anyway. +APP_BASE_URL= + +# Interactive links use the origin above. A background-only production origin may +# be supplied by deployment configuration. BOOTSTRAP_ADMIN_EMAIL= # APP_BASE_URL is this host's own public origin, and since issue #1003 it is diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 868e20b5a..1847114fa 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "net" "net/url" @@ -10,10 +11,31 @@ import ( type verifiedPublicOriginKey struct{} -// CanonicalPublicOrigin validates an exact browser origin. Frontend derives the -// origin from the actual request and authenticates it to the trusted gateway; -// business code never derives security-sensitive URLs from caller-controlled -// forwarding headers. +// configuredPublicOrigin is the origin an operator pinned for this deployment, +// installed once at startup. The empty string means none is pinned, which only a +// local runtime reaches: boot requires one outside local development. +var configuredPublicOrigin string + +// SetConfiguredPublicOrigin installs the operator-configured public origin. Call +// it once from startup, before anything serves. +func SetConfiguredPublicOrigin(origin string) { + configuredPublicOrigin = strings.TrimSuffix(strings.TrimSpace(origin), "/") +} + +// ConfiguredPublicOrigin returns the pinned origin, if there is one. +func ConfiguredPublicOrigin() (string, bool) { + return configuredPublicOrigin, configuredPublicOrigin != "" +} + +// ErrPublicOriginNotConfigured is returned when a forwarded origin does not match +// the one this deployment pinned. +var ErrPublicOriginNotConfigured = errors.New( + "public origin does not match the configured application base URL") + +// CanonicalPublicOrigin validates an exact browser origin: absolute, HTTP(S), +// HTTPS unless loopback, and nothing but scheme and authority. It says a value is +// WELL FORMED, never that it is trusted โ€” WithVerifiedPublicOrigin is what decides +// that. func CanonicalPublicOrigin(candidate string) (string, error) { candidate = strings.TrimSpace(candidate) parsed, err := url.Parse(candidate) @@ -38,12 +60,30 @@ func CanonicalPublicOrigin(candidate string) (string, error) { } // WithVerifiedPublicOrigin records the origin only after the gateway credential -// has been authenticated by an adapter. +// has been authenticated by an adapter โ€” AND only when it is the origin this +// deployment pinned. +// +// The credential check alone was not enough, and that is the whole of the defect +// it closes. The frontend held the internal token legitimately, and derived the +// origin it stamped from the caller's own `X-Forwarded-Host` whenever its rendered +// endpoint was a loopback placeholder, which is what a render produces. So an +// authenticated hop forwarded a caller's choice, and this recorded it as VERIFIED: +// an OAuth redirect, the authenticator relying-party origin and the links mailed +// out were then bound to a host the caller named. +// +// Comparing against the pinned origin makes that unreachable regardless of what +// any hop forwards โ€” a second, independent barrier to the frontend's own fix, and +// the one that holds for a hop this service does not own. Where nothing is pinned +// the candidate stands on the credential alone, which only a local runtime reaches: +// boot requires a pinned origin outside local development. func WithVerifiedPublicOrigin(ctx context.Context, candidate string) (context.Context, error) { origin, err := CanonicalPublicOrigin(candidate) if err != nil { return ctx, err } + if configured, ok := ConfiguredPublicOrigin(); ok && !strings.EqualFold(origin, configured) { + return ctx, ErrPublicOriginNotConfigured + } return context.WithValue(ctx, verifiedPublicOriginKey{}, origin), nil } diff --git a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go new file mode 100644 index 000000000..32458bb7d --- /dev/null +++ b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go @@ -0,0 +1,97 @@ +package auth_test + +import ( + "context" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// The gateway credential alone was not enough, and that is the whole of what this +// closes. The frontend held the internal token legitimately and derived the origin +// it stamped from the caller's own X-Forwarded-Host whenever its rendered endpoint +// was a loopback placeholder โ€” which is what a render produces. So an authenticated +// hop forwarded a caller's choice and it was recorded as VERIFIED, which bound an +// OAuth redirect, the authenticator relying-party origin and every emailed link to +// a host the caller named. +func TestForwardedOriginIsRefusedUnlessItIsTheConfiguredOne(t *testing.T) { + pin(t, "https://app.cell.example") + + _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://evil.example") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) + + ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example") + require.NoError(t, err) + origin, ok := auth.VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "https://app.cell.example", origin) +} + +// A trailing slash and a differently-cased host are the same origin, so a +// deployment is not refused for spelling its own value two ways. +func TestConfiguredOriginComparisonIsOnTheOrigin(t *testing.T) { + pin(t, "https://app.cell.example/") + + for _, candidate := range []string{ + "https://app.cell.example", + "https://app.cell.example/", + "https://APP.CELL.example", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.NoError(t, err, candidate) + } +} + +// A near-miss is a miss: a subdomain, a sibling host, a different scheme and a +// different port are each a different origin. +func TestConfiguredOriginRefusesNearMisses(t *testing.T) { + pin(t, "https://app.cell.example") + + for _, candidate := range []string{ + "https://app.cell.example.evil.example", + "https://evil.app.cell.example", + "https://app.cell.example:8443", + "http://localhost:3000", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured, candidate) + } +} + +// With nothing pinned the candidate stands on the credential alone, which only a +// local runtime reaches: boot requires a pinned origin outside local development. +// Asserted so the comparison above is the pin and not a blanket refusal. +func TestUnpinnedDeploymentStillAcceptsAWellFormedForwardedOrigin(t *testing.T) { + pin(t, "") + + ctx, err := auth.WithVerifiedPublicOrigin(context.Background(), "http://localhost:3000") + require.NoError(t, err) + origin, ok := auth.VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "http://localhost:3000", origin) +} + +// Well-formedness is still checked first, so a pinned deployment does not accept a +// malformed value by matching it against anything. +func TestAMalformedOriginIsRefusedWhateverIsPinned(t *testing.T) { + pin(t, "https://app.cell.example") + + for _, candidate := range []string{ + "https://app.cell.example/path", + "https://user:secret@app.cell.example", + "app.cell.example", + "", + } { + _, err := auth.WithVerifiedPublicOrigin(context.Background(), candidate) + require.Error(t, err, candidate) + } +} + +func pin(t *testing.T, origin string) { + t.Helper() + previous, _ := auth.ConfiguredPublicOrigin() + t.Cleanup(func() { auth.SetConfiguredPublicOrigin(previous) }) + auth.SetConfiguredPublicOrigin(origin) +} diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index f3e1af421..dd5fd9b75 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -565,12 +565,15 @@ func (s *Service) SetEmailOutbox(outbox *email.Outbox, appBaseURL string) { // publicBaseURL is the origin baked into interactive links โ€” magic-link, // invitation, and waitlist emails, and Stripe redirect targets. Those links are // delivered out of band and never re-validated downstream, so the origin must be -// operator-trusted. A configured APP_BASE_URL wins; the frontend-supplied -// verified public origin is a per-request, caller-influenced value (the frontend -// derives it from the browser request when its own endpoint is a placeholder), -// so it is only a fallback for deployments that have not pinned a canonical -// origin. Without either, callers fail closed rather than mint a link on an -// unverified host. +// operator-trusted. +// +// A configured APP_BASE_URL wins, and outside local development boot requires one +// (requireApplicationBaseURL). The verified public origin behind it is no longer a +// caller-influenced value: a forwarded origin is recorded as verified only when it +// equals the configured one (auth.WithVerifiedPublicOrigin), so where both exist +// they are the same string and where only the second does, nothing is pinned and +// the runtime is local. Without either, callers fail closed rather than mint a link +// on an unverified host. func (s *Service) publicBaseURL(ctx context.Context) string { if base := strings.TrimSuffix(strings.TrimSpace(s.appBaseURL), "/"); base != "" { return base diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go index 0006c10f8..8b2d33f1a 100644 --- a/module/services/accounts/code/shipped_placeholder_test.go +++ b/module/services/accounts/code/shipped_placeholder_test.go @@ -69,3 +69,17 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { "gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS": true, }, names) } + +// A cell without a pinned public origin is a cell whose verified origin is a +// request parameter: unset, an OAuth redirect, the authenticator relying-party +// origin and every emailed link were bound to whatever origin the request's trusted +// hop forwarded โ€” which that hop derived from the caller's own forwarding header. +func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) { + err := requireApplicationBaseURL(false) + require.Error(t, err, "a deployed runtime must refuse an unset APP_BASE_URL") + require.Contains(t, err.Error(), "APP_BASE_URL", + "the refusal must name the key an operator has to set") + + require.NoError(t, requireApplicationBaseURL(true), + "local development pins no runtime port") +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 4fd338b90..fc5a1e97c 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -919,6 +919,11 @@ func doWork(ctx context.Context) (Clean, error) { if err != nil { return nil, err } + // The one origin this deployment treats as its own: what binds an OAuth + // redirect, an authenticator's relying party and every emailed link. From here + // on a forwarded origin is honoured only when it equals this one, so no hop can + // substitute a caller's choice (pkg/auth.WithVerifiedPublicOrigin). + auth.SetConfiguredPublicOrigin(appBase) var workerEmailOutbox *email.Outbox var emailWorker *jobs.Worker if emailConfig.sender == nil { @@ -2556,7 +2561,36 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error { if err := requireKeyCustody(selection.SigningBackend, isLocal); err != nil { return err } - return requirePerimeterCredentials(isLocal) + if err := requirePerimeterCredentials(isLocal); err != nil { + return err + } + return requireApplicationBaseURL(isLocal) +} + +// requireApplicationBaseURL refuses to start a deployed runtime without the +// origin it treats as its own. +// +// It is the value that binds an OAuth redirect, an authenticator's relying party +// and every emailed link. Unset, each of those was bound to whatever origin the +// request's trusted hop forwarded โ€” which that hop derived from the caller's own +// forwarding header โ€” so a caller chose the host a sign-in returned to and the host +// an emailed link pointed at. A cell without it is a cell whose verified origin is +// a request parameter, so it refuses here, where a deployment can still be fixed, +// rather than serving a sign-in bound to somewhere else. +func requireApplicationBaseURL(isLocal bool) error { + if isLocal { + return nil + } + configured, err := configuredApplicationBaseURL() + if err != nil { + return err + } + if configured == "" { + return fmt.Errorf("application: APP_BASE_URL is required outside the local environment: " + + "set it in the `application` configuration group to this cell's public origin, which is " + + "what binds an OAuth redirect, the authenticator relying-party origin and every emailed link") + } + return nil } // requireKeyCustody refuses to start outside the local environment without the diff --git a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts index dc856e87c..3aacb45f8 100644 --- a/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts +++ b/module/services/frontend/code/src/app/(auth)/oauth2/authorize/route.ts @@ -10,7 +10,6 @@ import { } from "@/features/auth/model/oauth-authorization"; import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context"; import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; -import { requestPublicOrigin } from "@/lib/public-origin"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -66,13 +65,14 @@ export async function GET(request: Request): Promise { } const headers = new Headers({ "content-type": "application/json" }); - // The authorization server needs to know which origin it is being reached - // at, because every URL it publishes names it. That claim is only trusted - // beside the cluster-internal token, which this process holds and a browser - // does not โ€” the same pair src/proxy.ts stamps on product API traffic. - const gatewayContext = resolveCodeflyGatewayContext( - requestPublicOrigin(request), - ); + // The authorization server needs to know which origin it is being reached at, + // because every URL it publishes names it โ€” the issuer in its metadata, the + // redirect it binds a code to, the link it emails. That origin is OPERATOR + // CONFIGURATION and is resolved from it here, never read off this request: the + // internal token stamped beside it proves which PROCESS is asking, which is + // not the same as the origin being this host's, so a caller-supplied host + // travelling under that token would have been published as the issuer's. + const gatewayContext = resolveCodeflyGatewayContext(); if (gatewayContext) { headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken); headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts index d3c7be853..4b3bab49d 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts @@ -3,26 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); // The route resolves the gateway base (getEndpoints) and the first-party trust -// context (getCurrentModule/getCurrentService/getWorkspaceSecret, via -// resolveCodeflyGatewayContext). vi.mock is hoisted above module init, so the -// stubs are created with vi.hoisted. +// context (getCurrentModule/getCurrentService/getWorkspaceSecret/ +// getWorkspaceConfiguration, via resolveCodeflyGatewayContext). vi.mock is +// hoisted above module init, so the stubs are created with vi.hoisted. const { getEndpoints, getCurrentModule, getCurrentService, getWorkspaceSecret, + getWorkspaceConfiguration, } = vi.hoisted(() => ({ getEndpoints: vi.fn<() => Array>>(), getCurrentModule: vi.fn<() => string>(() => ""), getCurrentService: vi.fn<() => string>(() => ""), getWorkspaceSecret: vi.fn<(name: string, key: string) => string | undefined>(), + getWorkspaceConfiguration: + vi.fn<(name: string, key: string) => string | undefined>(), })); vi.mock("codefly", () => ({ getEndpoints, getCurrentModule, getCurrentService, getWorkspaceSecret, + getWorkspaceConfiguration, })); // The registry is a durable, gateway-brokered record rather than a map in this @@ -48,10 +52,10 @@ function withGateway() { ]); } -// Resolve the first-party trust context the way a real runtime does: no -// discoverable own endpoint, so resolveCodeflyGatewayContext falls back to the -// request origin for the public origin and reads the internal token from config. -function withTrustContext() { +// Resolve the first-party trust context the way a real runtime does: the public +// origin comes from the deployment's own configuration and from nothing else, so a +// test that wants a different one configures it rather than sending a header. +function withTrustContext(publicOrigin = "http://frontend") { getCurrentModule.mockReturnValue(""); getCurrentService.mockReturnValue(""); getWorkspaceSecret.mockImplementation((name, key) => @@ -59,6 +63,9 @@ function withTrustContext() { ? INTERNAL_TOKEN : undefined, ); + getWorkspaceConfiguration.mockImplementation((name, key) => + name === "application" && key === "APP_BASE_URL" ? publicOrigin : undefined, + ); } function registerAudit(serviceAlias = "audit-backend") { @@ -113,6 +120,14 @@ beforeEach(() => { getCurrentModule.mockReturnValue(""); getCurrentService.mockReturnValue(""); getWorkspaceSecret.mockReturnValue(undefined); + // The public origin is operator configuration, and the same-origin check is made + // against it, so every fixture pins the one these requests arrive on. A test + // about the origin itself overrides it (withTrustContext). + getWorkspaceConfiguration.mockImplementation((name, key) => + name === "application" && key === "APP_BASE_URL" + ? "http://frontend" + : undefined, + ); findSolution.mockResolvedValue(null); fetchMock = vi.fn( async () => @@ -128,6 +143,7 @@ afterEach(() => { findSolution.mockReset(); getEndpoints.mockReset(); getCurrentModule.mockReset(); + getWorkspaceConfiguration.mockReset(); getCurrentService.mockReset(); getWorkspaceSecret.mockReset(); vi.unstubAllGlobals(); @@ -354,10 +370,12 @@ describe("solution proxy passthrough", () => { // Behind a TLS-terminating ingress the pod sees plaintext http and its own // host, while the browser's Origin is the public https origin. A same-origin - // write (every Connect call and chat turn is a POST) must still pass. + // write (every Connect call and chat turn is a POST) must still pass โ€” on the + // strength of the CONFIGURED origin, which is the only thing that knows what + // the browser sees. it("allows a same-origin POST behind a TLS-terminating ingress", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example.com"); registerAudit(); const res = await POST( @@ -365,8 +383,6 @@ describe("solution proxy passthrough", () => { authorization: "Bearer caller-token", origin: "https://app.example.com", "sec-fetch-site": "same-origin", - "x-forwarded-proto": "https", - "x-forwarded-host": "app.example.com", }), context("audit", ["records"]), ); @@ -377,14 +393,54 @@ describe("solution proxy passthrough", () => { it("rejects a cross-site Origin behind a TLS-terminating ingress", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example.com"); + registerAudit(); + + const res = await POST( + rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { + origin: "https://evil.example", + }), + context("audit", ["records"]), + ); + + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // The defect this closes: the comparison used to be made against an origin + // derived from the request's own forwarding headers, so a caller that supplied + // both sides matched itself. A check is only a check when one side is not the + // caller's to choose. + it("rejects a forged cross-site request that supplies its own forwarded host", async () => { + withGateway(); + withTrustContext("https://app.example.com"); registerAudit(); const res = await POST( rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { origin: "https://evil.example", "x-forwarded-proto": "https", - "x-forwarded-host": "app.example.com", + "x-forwarded-host": "evil.example", + }), + context("audit", ["records"]), + ); + + expect(res.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // And with no configured origin there is nothing to compare against, so the + // route refuses rather than admitting on an absent comparison. + it("refuses every request when the deployment has no configured public origin", async () => { + withGateway(); + withTrustContext(); + getWorkspaceConfiguration.mockReturnValue(undefined); + registerAudit(); + + const res = await POST( + rawRequest("http://frontend/api/solutions/audit/proxy/records", "POST", { + authorization: "Bearer caller-token", + "sec-fetch-site": "same-origin", }), context("audit", ["records"]), ); @@ -411,8 +467,8 @@ describe("solution proxy passthrough", () => { const forwarded = fetchMock.mock.calls[0][1].headers as Headers; expect(forwarded.has("x-secret-smuggle")).toBe(false); - // x-forwarded-host is consumed to derive the public origin, never relayed - // to the upstream as a raw header. + // x-forwarded-host decides nothing here any more, and is never relayed to the + // upstream as a raw header either. expect(forwarded.has("x-forwarded-host")).toBe(false); }); @@ -437,15 +493,17 @@ describe("solution proxy passthrough", () => { it("attaches the first-party trust headers resolved from config", async () => { withGateway(); - withTrustContext(); + withTrustContext("https://app.example"); registerAudit(); await GET( proxyRequest("http://frontend/api/solutions/audit/proxy/records", { headers: { authorization: "Bearer caller-token", + // Offered by the caller and ignored: the stamped origin is the + // configured one, whatever the request says. "x-forwarded-proto": "https", - "x-forwarded-host": "app.example", + "x-forwarded-host": "evil.example", }, }), context("audit", ["records"]), diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts index a109704f0..699161afe 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/stream.test.ts @@ -14,6 +14,12 @@ vi.mock("codefly", () => ({ getCurrentModule: () => "", getCurrentService: () => "", getWorkspaceSecret: () => "fixture-internal", + // The public origin is operator configuration, and the same-origin check is + // made against it, so a fixture deployment has to pin one. + getWorkspaceConfiguration: (group: string, key: string) => + group === "application" && key === "APP_BASE_URL" + ? "http://localhost" + : undefined, })); vi.mock("@/solutions/registry", () => ({ findSolution })); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts index a2b6228b8..12a06b051 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts @@ -1,8 +1,11 @@ import { getEndpoints } from "codefly"; -import { resolveCodeflyGatewayContext } from "@/lib/codefly-gateway-context"; +import { + resolveCodeflyGatewayContext, + resolveVerifiedPublicOrigin, +} from "@/lib/codefly-gateway-context"; import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; -import { requestPublicOrigin } from "@/lib/public-origin"; + import { findSolution } from "@/solutions/registry"; export const dynamic = "force-dynamic"; @@ -19,14 +22,23 @@ interface RouteContext { // internal token, so it must reject cross-site requests itself โ€” cookies ride // along automatically on a forged cross-origin call, and the gateway cannot tell // a CSRF-driven request from a legitimate one once the internal token is -// attached. The browser's Origin is compared with the request's PUBLIC origin: -// behind a TLS-terminating ingress the pod-local request URL is plaintext http, -// so comparing with it rejected every same-origin write a deployed cell served. -function sameOrigin(request: Request): boolean { +// attached. +// +// The browser's Origin is compared with this deployment's CONFIGURED public +// origin. It used to be compared with an origin derived from the request's own +// forwarded headers, which made the check self-referential: a non-browser caller +// supplying `Origin: https://evil.example` and `X-Forwarded-Host: evil.example` +// matched itself and passed. The comparison is only a check when one side is not +// the caller's to choose. +// +// A deployment with no verified public origin refuses: there is nothing to compare +// against, and admitting on that basis is the same defect by omission. +function sameOrigin(request: Request, hostOrigin: string | undefined): boolean { + if (!hostOrigin) return false; const origin = request.headers.get("origin"); if (origin) { try { - if (new URL(origin).origin !== requestPublicOrigin(request)) return false; + if (new URL(origin).origin !== hostOrigin) return false; } catch { return false; } @@ -74,7 +86,11 @@ async function handler( ): Promise { const { id, path } = await context.params; - if (!sameOrigin(request)) { + // The origin is resolved on its own, not off the trust context: a deployment + // missing the internal token is a different condition from one with no verified + // origin, and refusing a same-origin request for the first would be answering a + // question nobody asked. + if (!sameOrigin(request, resolveVerifiedPublicOrigin())) { return new Response("cross-origin request rejected", { status: 403 }); } @@ -119,9 +135,7 @@ async function handler( // First-party trust headers, resolved server-side from Codefly config โ€” the // gateway rejects solution traffic that lacks them even with a valid user // identity. Set from a fresh Headers so a caller can never spoof them. - const gatewayContext = resolveCodeflyGatewayContext( - requestPublicOrigin(request), - ); + const gatewayContext = resolveCodeflyGatewayContext(); if (gatewayContext) { headers.set(INTERNAL_TOKEN_HEADER, gatewayContext.internalToken); headers.set(PUBLIC_ORIGIN_HEADER, gatewayContext.publicOrigin); diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts index ac116a278..2c2a443c8 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts @@ -5,12 +5,21 @@ vi.mock("server-only", () => ({})); // The registry reaches the gateway through service discovery and reads the // cluster-internal secret through the Codefly SDK; vi.mock is hoisted above // module init, so the stubs must be created with vi.hoisted. -const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({ - getWorkspaceSecret: - vi.fn<(name: string, key: string) => string | undefined>(), - getEndpoints: vi.fn<() => Array>>(() => []), +const { getWorkspaceSecret, getEndpoints, getWorkspaceConfiguration } = + vi.hoisted(() => ({ + getWorkspaceSecret: + vi.fn<(name: string, key: string) => string | undefined>(), + getEndpoints: vi.fn<() => Array>>(() => []), + getWorkspaceConfiguration: + vi.fn<(name: string, key: string) => string | undefined>(), + })); +vi.mock("codefly", () => ({ + getWorkspaceSecret, + getEndpoints, + getWorkspaceConfiguration, + getCurrentModule: () => "", + getCurrentService: () => "", })); -vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints })); import { GET } from "@/app/api/solutions/surfaces/route"; diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts index 0d9ad6231..31337bcd1 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts @@ -1,4 +1,4 @@ -import { requestPublicOrigin } from "@/lib/public-origin"; +import { configuredPublicOrigin } from "@/lib/public-origin"; import { entitlementFailureResponse, isEntitlementFailure, @@ -72,12 +72,14 @@ export async function GET(request: Request): Promise { if (registered === "unavailable") { return Response.json({ error: "registry_unavailable" }, { status: 503 }); } - // A solution served through this host resolves its modules against this - // host's own public origin (see surfacesProjection). - const hostOrigin = requestPublicOrigin(request); - // The origin is part of the projection, so it is part of what may be reused: - // two callers reaching this host through different public origins must not - // share a cached answer that resolves a module against one of them. + // A solution served through this host resolves its modules against this host's + // own public origin (see surfacesProjection) โ€” the configured one, not one + // taken from the request: a client told to fetch a remote's modules from a + // caller-chosen host would load code from it. + const hostOrigin = configuredPublicOrigin(); + // The origin stays in the cache key. It no longer varies per caller, so this + // is not what separates two callers any more; it is what stops a reconfigured + // origin being served from an entry computed against the previous one. const solutions = cachedProjection( entitlements, `${client}\u0000${hostOrigin ?? ""}`, diff --git a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts index 05a525eeb..9e9605849 100644 --- a/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts +++ b/module/services/frontend/code/src/lib/__tests__/codefly-gateway-context.test.ts @@ -1,56 +1,76 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { type CodeflyRuntimeReader, resolveCodeflyGatewayContext, } from "@/lib/codefly-gateway-context"; +const loopbackEndpoint = { + module: "saas-starter", + service: "frontend", + name: "http", + protocol: "HTTP" as const, + address: "http://localhost:42152", + routes: [], +}; + +/** A deployed runtime whose own endpoint is the render's loopback placeholder. */ function runtime( overrides: Partial = {}, ): CodeflyRuntimeReader { return { currentModule: () => "saas-starter", currentService: () => "frontend", - endpoints: () => [ - { - module: "saas-starter", - service: "frontend", - name: "http", - protocol: "HTTP", - address: "http://localhost:42152", - routes: [], - }, - ], + endpoints: () => [loopbackEndpoint], workspaceSecret: () => "internal-test-token", + workspaceConfiguration: () => undefined, + isDeployedBuild: () => true, ...overrides, }; } +function configured(value: string | undefined): Partial { + return { + workspaceConfiguration: (group, key) => + group === "application" && key === "APP_BASE_URL" ? value : undefined, + }; +} + describe("Codefly frontend gateway context", () => { - it("takes the secret from the SDK and, for a loopback placeholder endpoint, the public origin from the request", () => { - // The render bakes the own HTTP endpoint as a loopback placeholder, so the - // real public origin must come from the browser request. + it("takes the public origin from operator configuration", () => { expect( - resolveCodeflyGatewayContext("https://app.example", runtime()), + resolveCodeflyGatewayContext( + runtime(configured("https://app.cell.example")), + ), ).toEqual({ internalToken: "internal-test-token", - publicOrigin: "https://app.example", + publicOrigin: "https://app.cell.example", }); }); - it("prefers a real (non-loopback) SDK endpoint over the request origin", () => { + // The defect: the render bakes the own endpoint as a loopback placeholder, so + // this fell back to the caller's X-Forwarded-Host and stamped it, under the + // internal token, as the VERIFIED public origin. accounts then bound an OAuth + // redirect, the authenticator relying-party origin and emailed links to a host + // the caller chose. There is no request to fall back to any more โ€” the function + // does not take one. + it("refuses, by name, when a deployed build has no configured origin and a loopback placeholder endpoint", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(resolveCodeflyGatewayContext(runtime())).toBeUndefined(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("application/APP_BASE_URL"), + ); + } finally { + error.mockRestore(); + } + }); + + it("uses a render that carries a real ingress host when nothing is pinned", () => { expect( resolveCodeflyGatewayContext( - "https://caller-controlled.example", runtime({ endpoints: () => [ - { - module: "saas-starter", - service: "frontend", - name: "http", - protocol: "HTTP", - address: "https://app.cell.example", - routes: [], - }, + { ...loopbackEndpoint, address: "https://app.cell.example" }, ], }), ), @@ -60,57 +80,114 @@ describe("Codefly frontend gateway context", () => { }); }); - it("fails closed when a Codefly runtime has no own HTTP endpoint", () => { + it("keeps the loopback endpoint in local development, where the browser reaches it", () => { expect( - resolveCodeflyGatewayContext( - "https://caller-controlled.example", - runtime({ endpoints: () => [] }), - ), - ).toBeUndefined(); + resolveCodeflyGatewayContext(runtime({ isDeployedBuild: () => false })), + ).toEqual({ + internalToken: "internal-test-token", + publicOrigin: "http://localhost:42152", + }); }); - it("fails closed on ambiguous or malformed own endpoints", () => { - const endpoint = runtime().endpoints()[0]; - expect( - resolveCodeflyGatewayContext( - "https://caller-controlled.example", - runtime({ endpoints: () => [endpoint, endpoint] }), - ), - ).toBeUndefined(); + it("prefers the configured origin over a render that carries a real host", () => { expect( resolveCodeflyGatewayContext( - "https://caller-controlled.example", runtime({ + ...configured("https://pinned.example"), endpoints: () => [ - { ...endpoint, address: "https://user:secret@app.example" }, + { ...loopbackEndpoint, address: "https://rendered.example" }, ], }), - ), - ).toBeUndefined(); + )?.publicOrigin, + ).toBe("https://pinned.example"); + }); + + it("refuses a configured value that is not an exact origin", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + for (const value of [ + "https://app.example/path", + "https://user:secret@app.example", + "app.example", + "ftp://app.example", + "https://app.example?q=1", + ]) { + expect( + resolveCodeflyGatewayContext(runtime(configured(value))), + ).toBeUndefined(); + } + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it("fails closed when a Codefly runtime has no own HTTP endpoint and nothing is configured", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + resolveCodeflyGatewayContext(runtime({ endpoints: () => [] })), + ).toBeUndefined(); + } finally { + error.mockRestore(); + } + }); + + it("fails closed on ambiguous or malformed own endpoints", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + resolveCodeflyGatewayContext( + runtime({ + isDeployedBuild: () => false, + endpoints: () => [loopbackEndpoint, loopbackEndpoint], + }), + ), + ).toBeUndefined(); + expect( + resolveCodeflyGatewayContext( + runtime({ + isDeployedBuild: () => false, + endpoints: () => [ + { + ...loopbackEndpoint, + address: "https://user:secret@app.example", + }, + ], + }), + ), + ).toBeUndefined(); + } finally { + error.mockRestore(); + } }); it("fails closed when the workspace secret is unavailable", () => { expect( resolveCodeflyGatewayContext( - "https://app.example", - runtime({ workspaceSecret: () => undefined }), + runtime({ + ...configured("https://app.cell.example"), + workspaceSecret: () => undefined, + }), ), ).toBeUndefined(); }); - it("allows request-origin fallback only outside a Codefly runtime", () => { + // Outside a Codefly runtime (an isolated component test) there is no own + // endpoint and no configuration, so there is no origin โ€” and, with no request + // to borrow one from, nothing to guess with. + it("fails closed outside a Codefly runtime unless an origin is configured", () => { + const outside = { + currentModule: () => "", + currentService: () => "", + endpoints: () => [], + isDeployedBuild: () => false, + }; + expect(resolveCodeflyGatewayContext(runtime(outside))).toBeUndefined(); expect( resolveCodeflyGatewayContext( - "https://isolated.example", - runtime({ - currentModule: () => "", - currentService: () => "", - endpoints: () => [], - }), - ), - ).toEqual({ - internalToken: "internal-test-token", - publicOrigin: "https://isolated.example", - }); + runtime({ ...outside, ...configured("https://isolated.example") }), + )?.publicOrigin, + ).toBe("https://isolated.example"); }); }); diff --git a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts b/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts deleted file mode 100644 index db865c2a4..000000000 --- a/module/services/frontend/code/src/lib/__tests__/proxy-public-origin.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest } from "next/server"; -import { describe, expect, it } from "vitest"; - -import { publicRequestOrigin } from "@/proxy"; - -describe("publicRequestOrigin", () => { - it("uses the ingress-forwarded proto and host over the pod's plaintext origin", () => { - // TLS terminates at the ingress, so the pod receives http and nextUrl.origin - // would report the wrong scheme; the browser is on https://app.example, and - // Accounts rejects a non-loopback http public origin. - const req = new NextRequest( - "http://frontend.pod.internal/saas.accounts.v1.AuthService/BeginOAuth", - { - headers: { - "x-forwarded-proto": "https", - "x-forwarded-host": "app.example", - }, - }, - ); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("takes the outermost hop when forwarded headers are comma-lists", () => { - const req = new NextRequest("http://frontend.pod.internal/", { - headers: { - "x-forwarded-proto": "https, http", - "x-forwarded-host": "app.example, internal.pod", - }, - }); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("overrides only the scheme when x-forwarded-host is absent", () => { - // TLS terminated at the ingress: nextUrl carries the right host but the - // wrong (plaintext) scheme, so only the proto needs correcting. - const req = new NextRequest("http://app.example/", { - headers: { "x-forwarded-proto": "https" }, - }); - expect(publicRequestOrigin(req)).toBe("https://app.example"); - }); - - it("falls back to nextUrl entirely without forwarded headers (local dev, direct hits)", () => { - const req = new NextRequest( - "http://localhost:3000/saas.accounts.v1.AuthService/BeginOAuth", - ); - expect(publicRequestOrigin(req)).toBe("http://localhost:3000"); - }); -}); diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts index 84b9afd38..054c0a564 100644 --- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts +++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts @@ -2,6 +2,7 @@ import { getCurrentModule, getCurrentService, getEndpoints, + getWorkspaceConfiguration, getWorkspaceSecret, type ServiceEndpoint, } from "codefly"; @@ -16,6 +17,15 @@ export interface CodeflyRuntimeReader { currentService(): string; endpoints(): ServiceEndpoint[]; workspaceSecret(name: string, key: string): string | undefined; + workspaceConfiguration(name: string, key: string): string | undefined; + /** + * Whether this is a deployed build. Next sets NODE_ENV at build time โ€” + * "development" under `next dev`, "production" under `next build` โ€” and the + * service's execution profiles map every local profile to a development build + * and the deployed one to a production build. Nothing a caller sends can + * change it. + */ + isDeployedBuild(): boolean; } const runtimeSDK: CodeflyRuntimeReader = { @@ -23,6 +33,8 @@ const runtimeSDK: CodeflyRuntimeReader = { currentService: getCurrentService, endpoints: getEndpoints, workspaceSecret: getWorkspaceSecret, + workspaceConfiguration: getWorkspaceConfiguration, + isDeployedBuild: () => process.env.NODE_ENV === "production", }; function canonicalOrigin(candidate: string): string | undefined { @@ -51,13 +63,34 @@ function isLoopbackOrigin(origin: string): boolean { return octets.length === 4 && octets[0] === "127"; } +/** + * The key an operator sets to pin this deployment's public origin. The same key + * accounts reads for the links it mints, so the origin that binds a sign-in + * redirect, an authenticator's relying party and an emailed link is one value in + * one place. + */ +const PUBLIC_ORIGIN_CONFIGURATION = "application/APP_BASE_URL"; + /** * Resolve the server-side credentials used between the public frontend and the * private auth gateway. Codefly's injected representation remains entirely * behind sdk-js; this library only consumes typed SDK values. + * + * The public origin is OPERATOR CONFIGURATION and is never derived from a + * request. It used to be: when the own rendered endpoint was a loopback + * placeholder โ€” which is what the render produces, so on every cell โ€” this fell + * back to the caller's own `X-Forwarded-Host` and stamped it, under the internal + * token, as the verified public origin. accounts then bound an OAuth redirect, + * the authenticator relying-party origin, and emailed links to a host the caller + * had chosen, and the solution proxy's same-origin check compared against the + * same caller-supplied value. + * + * The order is: the configured origin; then the SDK-discovered own endpoint when + * the render carries a real host; and on a deployed build, nothing else โ€” a + * loopback placeholder with no configured origin refuses, by name, rather than + * asking the caller. */ export function resolveCodeflyGatewayContext( - requestOrigin: string, runtime: CodeflyRuntimeReader = runtimeSDK, ): CodeflyGatewayContext | undefined { const internalToken = runtime @@ -65,16 +98,67 @@ export function resolveCodeflyGatewayContext( ?.trim(); if (!internalToken) return undefined; + const publicOrigin = resolveVerifiedPublicOrigin(runtime); + if (!publicOrigin) return undefined; + return { internalToken, publicOrigin }; +} + +/** + * The origin alone, for a caller that needs to judge a request against it rather + * than to stamp trust headers. Exported separately so "this deployment has no + * verified origin" and "this deployment has no internal token" stay two different + * answers: a route that conflated them would refuse a same-origin request for a + * missing credential it was not asking about. + */ +export function resolveVerifiedPublicOrigin( + runtime: CodeflyRuntimeReader = runtimeSDK, +): string | undefined { + return resolvePublicOrigin(runtime); +} + +function resolvePublicOrigin( + runtime: CodeflyRuntimeReader, +): string | undefined { + const [group, key] = PUBLIC_ORIGIN_CONFIGURATION.split("/"); + const configured = runtime.workspaceConfiguration(group, key)?.trim(); + if (configured) { + const origin = canonicalOrigin(configured); + if (!origin) { + console.error( + `${PUBLIC_ORIGIN_CONFIGURATION} is not an exact http(s) origin, so this deployment has no verified public origin; sign-in and the solution proxy are refused until it is corrected`, + ); + return undefined; + } + return origin; + } + + const endpointOrigin = ownEndpointOrigin(runtime); + + // A render that carries a real ingress host is operator configuration too, so + // it answers when nothing is pinned. + if (endpointOrigin && !isLoopbackOrigin(endpointOrigin)) { + return endpointOrigin; + } + + // A loopback own endpoint is correct in local development โ€” the browser + // reaches that same host โ€” and is the render's placeholder everywhere else. + // Only the build tells those apart, and nothing a caller sends does. + if (!runtime.isDeployedBuild()) { + return endpointOrigin; + } + + console.error( + `${PUBLIC_ORIGIN_CONFIGURATION} is not set and this deployment's own endpoint renders as a loopback placeholder, so there is no verified public origin; set it to this cell's public origin. Sign-in and the solution proxy are refused until then, rather than binding them to a host taken from the request.`, + ); + return undefined; +} + +function ownEndpointOrigin(runtime: CodeflyRuntimeReader): string | undefined { const currentModule = runtime.currentModule().trim(); const currentService = runtime.currentService().trim(); - // Outside a Codefly runtime (isolated component tests) there is no own - // endpoint to discover, so the request origin is the only public origin. - if (!currentModule && !currentService) { - const publicOrigin = canonicalOrigin(requestOrigin); - if (!publicOrigin) return undefined; - return { internalToken, publicOrigin }; - } + // endpoint to discover. + if (!currentModule && !currentService) return undefined; const matches = runtime .endpoints() @@ -88,20 +172,5 @@ export function resolveCodeflyGatewayContext( // A Codefly runtime identity must have exactly one SDK-discovered own HTTP // endpoint. if (matches.length !== 1) return undefined; - const endpointOrigin = canonicalOrigin(matches[0].address); - if (!endpointOrigin) return undefined; - - // The render bakes the own HTTP endpoint as a loopback placeholder - // (localhost:8080) that carries no real ingress host: the true public origin - // is a per-cell/per-ingress fact the SDK cannot know. Fall back to the actual - // browser request in that case. A genuine local-dev endpoint is loopback too, - // and its request origin is the same host, so this stays correct in dev. Once - // the render carries a real non-loopback ingress host, that host keeps - // priority over the caller-influenced request origin. - const publicOrigin = isLoopbackOrigin(endpointOrigin) - ? canonicalOrigin(requestOrigin) - : endpointOrigin; - if (!publicOrigin) return undefined; - - return { internalToken, publicOrigin }; + return canonicalOrigin(matches[0].address); } diff --git a/module/services/frontend/code/src/lib/public-origin.ts b/module/services/frontend/code/src/lib/public-origin.ts index 8592f9456..61b17859b 100644 --- a/module/services/frontend/code/src/lib/public-origin.ts +++ b/module/services/frontend/code/src/lib/public-origin.ts @@ -1,20 +1,22 @@ +import "server-only"; + +import { resolveVerifiedPublicOrigin } from "@/lib/codefly-gateway-context"; + /** - * The public browser origin of a request, preferring the ingress-set forwarded - * pair over the pod-local request URL โ€” the same resolution src/proxy.ts uses, - * because behind a TLS-terminating ingress the pod sees plaintext `http` and its - * own host. + * The origin this deployment treats as its own, from operator configuration. + * + * It used to be derived from the request โ€” the ingress-set forwarded pair, or the + * pod-local URL โ€” which made it whatever a caller said: a request bearing + * `X-Forwarded-Host: evil.example` was answered as though the product were served + * there, and the solution proxy compared the browser's `Origin` against it, so a + * cross-site request that supplied both passed the same-origin check. The origin + * now comes from `application/APP_BASE_URL`, or from a render that carries a real + * ingress host, and from nothing else; see + * `resolveVerifiedPublicOrigin`, which refuses rather than guessing. + * + * Returns undefined when this deployment has no verified public origin, which + * every caller must treat as a refusal. */ -export function requestPublicOrigin(request: Request): string { - const url = new URL(request.url); - const forwardedProto = request.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim(); - const forwardedHost = request.headers - .get("x-forwarded-host") - ?.split(",")[0] - ?.trim(); - const protocol = forwardedProto ? `${forwardedProto}:` : url.protocol; - const host = forwardedHost || url.host; - return `${protocol}//${host}`; +export function configuredPublicOrigin(): string | undefined { + return resolveVerifiedPublicOrigin(); } diff --git a/module/services/frontend/code/src/proxy.ts b/module/services/frontend/code/src/proxy.ts index 96b7eeb4f..baa9b173c 100644 --- a/module/services/frontend/code/src/proxy.ts +++ b/module/services/frontend/code/src/proxy.ts @@ -99,26 +99,6 @@ function isProductAPI(pathname: string): boolean { return oauthSurfaceUpstreamPath(pathname) !== undefined; } -// Next derives `nextUrl`'s protocol and host from the internal request URL and -// ignores `x-forwarded-*`, so behind a TLS-terminating ingress `nextUrl.protocol` -// is the pod's plaintext `http:` rather than the browser's `https:` โ€” and -// Accounts rejects a non-loopback `http` public origin, which would leave OAuth -// broken. The ingress sets the forwarded pair from the real client connection; -// prefer each, falling back to `nextUrl` (local dev, direct-to-pod requests). -export function publicRequestOrigin(req: NextRequest): string { - const forwardedProto = req.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim(); - const forwardedHost = req.headers - .get("x-forwarded-host") - ?.split(",")[0] - ?.trim(); - const protocol = forwardedProto ? `${forwardedProto}:` : req.nextUrl.protocol; - const host = forwardedHost || req.nextUrl.host; - return `${protocol}//${host}`; -} - export function trustedGatewayRequestHeaders( req: NextRequest, context: CodeflyGatewayContext | undefined, @@ -659,7 +639,7 @@ export async function proxy(req: NextRequest) { const { pathname, search } = req.nextUrl; const gatewayHeaders = trustedGatewayRequestHeaders( req, - resolveCodeflyGatewayContext(publicRequestOrigin(req)), + resolveCodeflyGatewayContext(), ); // The CSP lookup needs the cluster-internal secret and nothing else, so it // reads that secret rather than the gateway context, whose resolution also diff --git a/module/services/frontend/code/src/test/pipeline-gateway.ts b/module/services/frontend/code/src/test/pipeline-gateway.ts index 0a041cac6..ee1b29ed5 100644 --- a/module/services/frontend/code/src/test/pipeline-gateway.ts +++ b/module/services/frontend/code/src/test/pipeline-gateway.ts @@ -1,3 +1,10 @@ +import { + getCurrentModule, + getCurrentService, + getEndpoints, + getWorkspaceConfiguration, + getWorkspaceSecret, +} from "codefly"; import { NextRequest } from "next/server"; import { type CodeflyRuntimeReader, @@ -6,12 +13,6 @@ import { import { INTERNAL_TOKEN_HEADER } from "@/lib/internal-token"; import { trustedGatewayRequestHeaders } from "@/proxy"; import { productOrigin } from "@/test/codefly-endpoints"; -import { - getCurrentModule, - getCurrentService, - getEndpoints, - getWorkspaceSecret, -} from "codefly"; // Endpoint resolution lives in one place, shared with the Playwright config and // the e2e global setup: see ./codefly-endpoints. @@ -30,6 +31,10 @@ const gatewayRuntimeSDK: CodeflyRuntimeReader = { currentService: getCurrentService, endpoints: getEndpoints, workspaceSecret: getWorkspaceSecret, + workspaceConfiguration: getWorkspaceConfiguration, + // A pipeline test runs against a local graph, whose own endpoint is the real + // loopback address rather than a render's placeholder. + isDeployedBuild: () => false, }; export function runtimeWithResolvedOrigin( @@ -78,7 +83,7 @@ export function runtimeWithResolvedOrigin( export function stampedGatewayHeaders(): Record { const origin = productOrigin(); const runtime = runtimeWithResolvedOrigin(origin); - const context = resolveCodeflyGatewayContext(origin, runtime); + const context = resolveCodeflyGatewayContext(runtime); if (!context) { throw new Error( "Codefly did not provide the frontend gateway context (internal-auth secret and frontend/http endpoint).", From db75f2e4cfd16f303024300e91935bf3eb5a5a39 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:42:31 -0400 Subject: [PATCH 12/46] fix(frontend, marketing): the product and marketing origins pin HTTPS (SA-F-HSTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge redirects plaintext to TLS, which protects the redirect and nothing before it: a browser's FIRST request to a host it has never pinned goes out in the clear, carrying whatever the address bar or a link gave it, and an attacker on the path answers it. The origin has to tell the browser to remember, and neither origin did. Both now emit Strict-Transport-Security with a one-year max-age, in the frontend's baseline header set (so every route carries it, like the framing and nosniff headers beside it) and in the marketing site's own header block. Deliberately WITHOUT includeSubDomains or preload, with the reason recorded beside the value: both commit hosts this service does not own โ€” a sibling subdomain served over plaintext breaks under the first, and the second is effectively irreversible โ€” so they are the ingress operator's to add once they know the host tree. Their absence is asserted, so it reads as a decision rather than an omission. Browsers ignore the header on a plaintext origin, so local development is unaffected. The e2e security-headers spec asserts it on real responses alongside the framing and CSP checks it already made. The infrastructure repository owns emitting it at the ingress if an operator prefers it there; emitting it from both is harmless (the stricter policy wins) and this is the half that holds whatever fronts the service. Also records, in module/services/frontend/AGENTS.md, what SA-F-REMOTECODE asks for and why this pull request does not do it: nothing pins what a cross-origin manifest serves, and closing that means either dropping the cross-origin-remote shape (and the runtime origin derivation, its listing fetch, cache, outage reporting and policy-size guard, which exist only to carry foreign origins โ€” 17 tests' worth of covered behaviour) or adding a publication-time digest the registration contract does not have. Both are decisions about the solution contract rather than defects to repair, and the register's own note is that the live policy lists no foreign origin and every deployed remote is same-origin today. Left for its own change with the owner's decision, rather than taken unilaterally inside a security pull request. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../accounts/code/shipped_placeholder_test.go | 46 +++++++++++++++++++ module/services/frontend/AGENTS.md | 22 +++++++++ .../frontend/code/server/security-headers.mjs | 16 +++++++ .../code/src/test/security-headers.test.ts | 25 ++++++++++ .../code/tests/e2e/security-headers.spec.ts | 3 ++ .../services/marketing/code/next.config.mjs | 6 +++ 6 files changed, 118 insertions(+) diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go index 8b2d33f1a..3c411fbcd 100644 --- a/module/services/accounts/code/shipped_placeholder_test.go +++ b/module/services/accounts/code/shipped_placeholder_test.go @@ -75,6 +75,8 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { // origin and every emailed link were bound to whatever origin the request's trusted // hop forwarded โ€” which that hop derived from the caller's own forwarding header. func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) { + blankWorkspaceKey(t, "application", "APP_BASE_URL") + err := requireApplicationBaseURL(false) require.Error(t, err, "a deployed runtime must refuse an unset APP_BASE_URL") require.Contains(t, err.Error(), "APP_BASE_URL", @@ -83,3 +85,47 @@ func TestDeployedRequiresTheApplicationBaseURL(t *testing.T) { require.NoError(t, requireApplicationBaseURL(true), "local development pins no runtime port") } + +// Every perimeter credential and the application base URL are refused when a +// deployed runtime holds a placeholder, which is also what the shipped local +// defaults hold โ€” so a test that drives these reads has to blank BOTH carriers. +// `codefly ci run` injects this module's own local groups as +// CODEFLY__WORKSPACE_CONFIGURATION____ (and the secret form), while a +// bare `go test` injects none and only the plain fallback can carry a stray value. +// Blanking one passes locally and fails in CI, or the reverse. +func blankWorkspaceKey(t *testing.T, group, key string) { + t.Helper() + upper := strings.ToUpper(group) + upper = strings.ReplaceAll(upper, "-", "_") + t.Setenv(key, "") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, "") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, "") +} + +// The deployed perimeter check over the REAL carriers, not only over the pure +// comparison: with the shipped local default in the group a deployed runtime +// refuses, and with a provisioned value it starts. +func TestDeployedPerimeterCheckReadsTheConfigurationGroup(t *testing.T) { + for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"} { + blankWorkspaceKey(t, "internal-auth", key) + } + for _, key := range []string{"CODEFLY_GATEWAY_TOKEN", "CODEFLY_GATEWAY_TOKEN_PREVIOUS"} { + blankWorkspaceKey(t, "gateway-trust", key) + } + + // Nothing provisioned: each loader's own required-or-optional question, not + // this one's. + require.NoError(t, requirePerimeterCredentials(false)) + + // The value this module ships in its public local defaults. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", + "local-dev-only-replace-me") + err := requirePerimeterCredentials(false) + require.Error(t, err) + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN") + + // A provisioned one. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + require.NoError(t, requirePerimeterCredentials(false)) +} diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 00759fb49..ffac780fe 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -145,6 +145,28 @@ A solution remote executes in the host origin with the viewer's credentials; the trust model and the registration/installation/entitlement boundary are in [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md). +**Open, and recorded here so the next reader does not have to rediscover it.** +Nothing pins what a cross-origin manifest serves. An absolute URL on any origin is +accepted, admitting it adds that origin to the script source, and there is no +integrity digest โ€” so a holder of a solution's registration credential can point a +registered remote at code of its choosing and have it execute in the host origin +with the viewer's session. The audit raised this as a minor finding because the +live policy lists no foreign origin and every deployed remote is same-origin today, +which bounds it to the credential-to-code path. + +Closing it means one of two things, and both are decisions about the solution +contract rather than a defect to repair: + +- **Accept only root-relative manifests.** This removes the cross-origin-remote + shape described above, and with it the runtime origin derivation in + `src/proxy.ts` โ€” the listing fetch, its cache, its outage reporting and the + policy-size guard exist only to carry foreign origins. +- **Pin each remote by a digest recorded when it was published.** This keeps the + shape and needs a publication-time provenance record the registration contract + does not have. + +Either is its own change, with the owner choosing which shape the contract keeps. + ## The published client kit `code/packages/saas-sdk` is `@codefly-dev/saas-sdk`, published to GitHub Packages diff --git a/module/services/frontend/code/server/security-headers.mjs b/module/services/frontend/code/server/security-headers.mjs index 04c42867d..7b8825d36 100644 --- a/module/services/frontend/code/server/security-headers.mjs +++ b/module/services/frontend/code/server/security-headers.mjs @@ -267,6 +267,22 @@ export function contentSecurityPolicy( export function baselineSecurityHeaders() { return [ { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + // Pin the browser to HTTPS for this origin. The edge redirects plaintext to + // TLS, which protects the redirect and nothing before it: the FIRST request a + // browser makes to a host it has never pinned goes out in the clear, with + // whatever the address bar or a link gave it, and an attacker on the path + // answers it. One year is the conventional floor for a policy a browser is + // asked to remember. + // + // Deliberately WITHOUT includeSubDomains or preload. Both commit hosts this + // service does not own โ€” a sibling subdomain served over plaintext breaks + // under the first, and the second is effectively irreversible โ€” so they are + // the ingress operator's to add once they know the host tree. Browsers ignore + // the header on a plaintext origin, so local development is unaffected. + { + key: "Strict-Transport-Security", + value: "max-age=31536000", + }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", diff --git a/module/services/frontend/code/src/test/security-headers.test.ts b/module/services/frontend/code/src/test/security-headers.test.ts index c150fad4b..ab7499adb 100644 --- a/module/services/frontend/code/src/test/security-headers.test.ts +++ b/module/services/frontend/code/src/test/security-headers.test.ts @@ -31,6 +31,30 @@ describe("securityHeaders", () => { "frame-ancestors 'none'", ); }); + + // The edge redirects plaintext to TLS, which protects the redirect and nothing + // before it: a browser's first request to a host it has never pinned goes out in + // the clear and an attacker on the path answers it. + it("pins the browser to HTTPS for a year", () => { + const byKey = Object.fromEntries( + securityHeaders({}).map((h) => [h.key, h.value]), + ); + expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000"); + }); + + // includeSubDomains and preload commit hosts this service does not own: a + // sibling subdomain on plaintext breaks under the first, and the second is + // effectively irreversible. Both are the ingress operator's to add, so their + // absence here is a decision rather than an omission. + it("commits no host but its own", () => { + const byKey = Object.fromEntries( + securityHeaders({}).map((h) => [h.key, h.value]), + ); + expect(byKey["Strict-Transport-Security"]).not.toContain( + "includeSubDomains", + ); + expect(byKey["Strict-Transport-Security"]).not.toContain("preload"); + }); }); describe("baselineSecurityHeaders", () => { @@ -41,6 +65,7 @@ describe("baselineSecurityHeaders", () => { expect(byKey["X-Frame-Options"]).toBe("DENY"); expect(byKey["X-Content-Type-Options"]).toBe("nosniff"); expect(byKey["Cross-Origin-Opener-Policy"]).toBe("same-origin"); + expect(byKey["Strict-Transport-Security"]).toBe("max-age=31536000"); expect(byKey["Content-Security-Policy"]).toBeUndefined(); }); }); diff --git a/module/services/frontend/code/tests/e2e/security-headers.spec.ts b/module/services/frontend/code/tests/e2e/security-headers.spec.ts index 963c051bd..9c2127b9b 100644 --- a/module/services/frontend/code/tests/e2e/security-headers.spec.ts +++ b/module/services/frontend/code/tests/e2e/security-headers.spec.ts @@ -11,6 +11,9 @@ function cspOf(response: Awaited>): string { const headers = response?.headers() ?? {}; expect(headers["x-frame-options"]).toBe("DENY"); expect(headers["x-content-type-options"]).toBe("nosniff"); + // The edge's plaintext redirect protects the redirect and nothing before it, so + // the origin pins the browser itself. + expect(headers["strict-transport-security"]).toContain("max-age="); const csp = headers["content-security-policy"]; expect(csp, "response is missing a CSP").toBeDefined(); return csp as string; diff --git a/module/services/marketing/code/next.config.mjs b/module/services/marketing/code/next.config.mjs index 8939a9f74..86f75e1b7 100644 --- a/module/services/marketing/code/next.config.mjs +++ b/module/services/marketing/code/next.config.mjs @@ -26,6 +26,12 @@ const nextConfig = { headers: [ { key: "Content-Security-Policy", value: contentSecurityPolicy }, { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + // Pin the browser to HTTPS. The edge redirects plaintext to TLS, which + // protects the redirect and nothing before it: a browser's first request + // to a host it has never pinned goes out in the clear. Without + // includeSubDomains or preload, which commit hosts this site does not + // own; browsers ignore the header on a plaintext origin. + { key: "Strict-Transport-Security", value: "max-age=31536000" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "X-Content-Type-Options", value: "nosniff" }, From 26a031ffd70360ecbd101466f6c3c9006b968f89 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:49:40 -0400 Subject: [PATCH 13/46] fix: the identity provider's own credentials live in a group only accounts declares (SA-F-IDPKEY, SA-F-GWTOKEN this repo's half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declared configuration group is injected whether the service reads it or not, so a credential's blast radius is the set of services that DECLARE its group. Which group a credential lives in is therefore the security decision, and two findings are the same mistake about it. SA-F-IDPKEY. The frontend declares `identity` because it renders the login page from that group's public facts โ€” the issuer, the client id, the display name, the scope, all of which the browser receives anyway. A deployment that puts the identity provider's own credentials in that same group hands them to the frontend too: a public-facing process holding the provider's administration credential and client secret, reading neither, which widens what a frontend compromise is worth for nothing. IDENTITY_CLIENT_SECRET and IDENTITY_MANAGEMENT_API_KEY now live in a new `identity-provider` secret group that only accounts declares. The read falls back to `identity` while a cell still delivers them there, and warns once per key naming the move: which group a value arrives under is the composition's to change, and a hard cutover would take sign-in down on every cell between this landing and that edit. The warning is how an operator learns a cell has not moved; the fallback goes when they have. SA-F-GWTOKEN, this repository's half. The finding is that the gateway-provenance credential โ€” which makes accounts believe a forwarded identity, so a holder can assert any user โ€” is bundled with the internal token and delivered to twelve workloads that never read it. The delivery is the platform repository's: the secret group, and the twelve external secrets rendered from it. What this repository owes is that its own composition cannot express the bundling, and that the trust site reads only the narrow group โ€” which it already did, unheld by anything. module/tools/credential_group_scope_test.go now holds both: which services may declare each credential-bearing group (`gateway-trust` only the identity plane, `identity-provider`, `github-app` and `vault` only accounts), and that no group other than `gateway-trust` declares a GATEWAY_TOKEN key. The second is the other way a credential spreads โ€” not a service declaring a group it does not read, but the key appearing in a second group nearly everything declares, under a name that makes it look like the same credential. Both gates are mutation-checked, and the first fails on exactly the live shape: adding `identity` to the credential list names the frontend. Each refuses an empty holder set too, so a group going dead cannot leave the gate proving nothing. module/AGENTS.md records the rule and why the identity group was split. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../local/identity-provider.secret.env | 1 + module/AGENTS.md | 14 ++ .../local/identity-provider.secret.env | 21 +++ module/services/accounts/code/work.go | 46 +++++- module/services/accounts/service.codefly.yaml | 1 + module/tools/credential_group_scope_test.go | 146 ++++++++++++++++++ 6 files changed, 225 insertions(+), 4 deletions(-) create mode 120000 configurations/local/identity-provider.secret.env create mode 100644 module/configurations/local/identity-provider.secret.env create mode 100644 module/tools/credential_group_scope_test.go diff --git a/configurations/local/identity-provider.secret.env b/configurations/local/identity-provider.secret.env new file mode 120000 index 000000000..70bf7a7cd --- /dev/null +++ b/configurations/local/identity-provider.secret.env @@ -0,0 +1 @@ +../../module/configurations/local/identity-provider.secret.env \ No newline at end of file diff --git a/module/AGENTS.md b/module/AGENTS.md index 84f1d83b2..1f33fd39b 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -97,6 +97,20 @@ consumer workspace โ€” `legal`, `identity`, `internal-auth`, `federation`, `module-capabilities`, and the rest. A consumer overrides a group only by declaring one of the same name. +**A credential's blast radius is the set of services that DECLARE its group**, not +the set that reads it: a declared group is injected whether anything reads it or +not. So which group a credential lives in is the security decision, and +`module/tools/credential_group_scope_test.go` holds both halves of it โ€” which +services may declare each credential-bearing group, and that the +gateway-provenance key appears in no group but `gateway-trust`. + +That is why the identity provider's own credentials live in `identity-provider` +rather than in `identity`: the frontend declares `identity` to render the login +page from its public facts (issuer, client id, display name, scope), so a +deployment putting the provider's client secret and management key there hands a +public-facing process credentials it never reads. Only accounts declares +`identity-provider`. + **Do not generalise about how an unset value behaves โ€” read the group.** The three digest maps in `federation` (`MODULE_REGISTRATION_SECRETS`, `MODULE_IDENTITY_SECRETS`, `SOLUTION_REGISTRATION_SECRETS`) each fail closed when diff --git a/module/configurations/local/identity-provider.secret.env b/module/configurations/local/identity-provider.secret.env new file mode 100644 index 000000000..1c013f6af --- /dev/null +++ b/module/configurations/local/identity-provider.secret.env @@ -0,0 +1,21 @@ +# The identity provider's own credentials: the ones that authenticate THIS HOST to +# the provider, as distinct from the `identity` group's public facts about it (the +# issuer, the client id, the display name, the scope) which the browser receives +# anyway. +# +# They live in a group of their own because of who declares what. The frontend +# declares `identity` โ€” it renders the login page from those public facts โ€” and a +# deployment that puts the provider's credentials in that same group delivers them +# to the frontend too, which never reads either. A public-facing process holding +# the provider's administration credential widens what a frontend compromise is +# worth, for nothing. Only accounts declares this group. + +# The client secret the authorization-code exchange authenticates with. Required +# by every provider that performs that exchange; a real value is supplied through +# Codefly's secret configuration provider. +IDENTITY_CLIENT_SECRET=local-dev-only-replace-me + +# The provider's management credential, used by the optional administration portal +# adapter. Unset leaves that adapter in stub mode; it is never required for +# sign-in. +IDENTITY_MANAGEMENT_API_KEY=local-dev-only-replace-me diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index fc5a1e97c..1e836032b 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -35,6 +35,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/codefly-dev/core/wool" @@ -660,7 +661,7 @@ func doWork(ctx context.Context) (Clean, error) { // SSO administration is a WorkOS-specific optional adapter. Other // identity providers cannot accidentally activate it by exposing a // similarly named management credential. - service.SetSSOManagementAPIKey(identityEnv("IDENTITY_MANAGEMENT_API_KEY")) + service.SetSSOManagementAPIKey(identityProviderSecret("IDENTITY_MANAGEMENT_API_KEY")) } oauthPolicy, err := buildOAuthRequestPolicy(authProvider) if err != nil { @@ -2015,6 +2016,43 @@ func identityEnv(key string) string { return value } +// identityProviderSecret reads a credential that authenticates THIS HOST to the +// identity provider, from the `identity-provider` group only accounts declares. +// +// They used to live in `identity`, which the frontend declares too โ€” it renders +// the login page from that group's public facts, the issuer and the client id โ€” +// so a deployment putting the provider's credentials there delivered them to the +// frontend as well, which reads neither. A public-facing process holding the +// provider's administration credential widens what a frontend compromise is worth, +// for nothing. +// +// The read falls back to `identity` while a deployment still holds them there, and +// says so once per key: the group a value is delivered under is the composition's +// to change, and a hard cutover here would take sign-in down on every cell between +// this landing and that edit. The fallback goes when the cells have moved โ€” until +// then, the warning is how an operator knows one has not. +func identityProviderSecret(key string) string { + if value, err := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key); err == nil && hasConfiguredValue(value) { + return value + } + legacy := identityEnv(key) + if hasConfiguredValue(legacy) { + reportIdentityProviderSecretStillInIdentityGroup(key) + } + return legacy +} + +var identityProviderSecretWarned sync.Map + +func reportIdentityProviderSecretStillInIdentityGroup(key string) { + if _, loaded := identityProviderSecretWarned.LoadOrStore(key, true); loaded { + return + } + wool.Get(context.Background()).In("identityProviderSecret").Warn( + "an identity-provider credential is still delivered under the `identity` group, which the frontend also declares; move it to `identity-provider`, which only this service declares", + wool.Field("key", key)) +} + // applicationEnv is also Codefly-only. Local product origins and bootstrap // identity must come from the selected workspace configuration, never from an // ambient shell file that can disagree with the browser runtime. @@ -2133,7 +2171,7 @@ func buildGenericOIDCStack(provider string) (auth.TokenValidator, business.CodeE // two can never disagree at login. func discoverOIDCValidator(provider string, defaultAudienceToClientID bool) (validator auth.TokenValidator, tokenURL, clientID, clientSecret string, err error) { clientID = identityEnv("IDENTITY_CLIENT_ID") - clientSecret = identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret = identityProviderSecret("IDENTITY_CLIENT_SECRET") issuer := identityEnv("IDENTITY_ISSUER") if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) { return nil, "", "", "", fmt.Errorf( @@ -2237,7 +2275,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator, domain := identityEnv("IDENTITY_DOMAIN") audience := identityEnv("IDENTITY_AUDIENCE") clientID := identityEnv("IDENTITY_CLIENT_ID") - clientSecret := identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET") if !hasConfiguredValue(domain) || !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) || !hasConfiguredValue(audience) { return nil, nil, fmt.Errorf("identity provider auth0 requires IDENTITY_DOMAIN, IDENTITY_AUDIENCE, IDENTITY_CLIENT_ID, and IDENTITY_CLIENT_SECRET") } @@ -2257,7 +2295,7 @@ func buildProviderStack(provider, selectedFixture string) (auth.TokenValidator, case "google": clientID := identityEnv("IDENTITY_CLIENT_ID") - clientSecret := identityEnv("IDENTITY_CLIENT_SECRET") + clientSecret := identityProviderSecret("IDENTITY_CLIENT_SECRET") if !hasConfiguredValue(clientID) || !hasConfiguredValue(clientSecret) { return nil, nil, fmt.Errorf("identity provider google requires IDENTITY_CLIENT_ID and IDENTITY_CLIENT_SECRET") } diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index bd698f029..0e067575f 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -35,6 +35,7 @@ workspace-configuration-dependencies: - gateway-trust - github-app - identity + - identity-provider - internal-auth - internal-transport - key-service diff --git a/module/tools/credential_group_scope_test.go b/module/tools/credential_group_scope_test.go new file mode 100644 index 000000000..2b5fa5f5a --- /dev/null +++ b/module/tools/credential_group_scope_test.go @@ -0,0 +1,146 @@ +package tools + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// A service is delivered every group it declares, whether it reads it or not. +// So a credential's blast radius is the set of services that declare the group +// it travels in โ€” which is a fact about the MANIFESTS, not about who calls what. +// +// The frontend is a public-facing process. It declares `identity` because it +// renders the login page from that group's public facts: the issuer, the client +// id, the display name, the scope. A deployment that puts the identity provider's +// own credentials in that same group therefore hands them to the frontend too, +// which reads neither โ€” and a public process holding the provider's administration +// credential widens what a frontend compromise is worth, for nothing. +// +// The answer is which GROUP a credential lives in, so that is what this holds. +// Every group listed here carries credentials, and this says which services may +// declare it. A group added to a service's manifest and not here passes; a +// credential group reaching a service not listed fails, named. +var credentialGroupHolders = map[string][]string{ + // The provider credentials that authenticate this host TO the identity + // provider. accounts performs the authorization-code exchange and runs the + // optional administration adapter; nothing else reads either. + "identity-provider": {"accounts"}, + // The credential accounts believes forwarded identity on. Only the identity + // plane โ€” the service that stamps it and the service that trusts it. + "gateway-trust": {"accounts", "auth-gateway"}, + // The GitHub App's signing key and webhook secret: deployment custody, read + // only by accounts. + "github-app": {"accounts"}, + // The cell's Vault credentials. + "vault": {"accounts"}, +} + +func TestACredentialGroupIsDeclaredOnlyByServicesThatReadIt(t *testing.T) { + declaredBy := map[string][]string{} + for _, service := range servicesWithManifests(t) { + for group := range declaredWorkspaceGroups(t, service) { + declaredBy[group] = append(declaredBy[group], service) + } + } + + for group, allowed := range credentialGroupHolders { + holders := declaredBy[group] + sort.Strings(holders) + permitted := map[string]bool{} + for _, service := range allowed { + permitted[service] = true + } + for _, service := range holders { + if !permitted[service] { + t.Errorf("service %q declares the credential group %q, which carries credentials it does not read; "+ + "a declared group is delivered whether it is read or not, so this widens what a compromise of %s is worth. "+ + "Permitted holders: %s", + service, group, service, strings.Join(allowed, ", ")) + } + } + if len(holders) == 0 { + t.Errorf("no service declares the credential group %q; either it is dead and should be deleted, "+ + "or a service reads it undeclared and gets nothing in every deployed cell", group) + } + } +} + +// servicesWithManifests lists every service of this module, so the gate above +// cannot be satisfied by a service it forgot to look at. +func servicesWithManifests(t *testing.T) []string { + t.Helper() + base := filepath.Join(findModuleDir(t), "services") + entries, err := os.ReadDir(base) + if err != nil { + t.Fatalf("read %s: %v", base, err) + } + var services []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, statErr := os.Stat(filepath.Join(base, entry.Name(), "service.codefly.yaml")); statErr != nil { + continue + } + services = append(services, entry.Name()) + } + if len(services) == 0 { + t.Fatal("no service manifests found; this gate now proves nothing") + } + sort.Strings(services) + return services +} + +// The gateway-provenance credential makes accounts believe a forwarded identity, +// so a holder of it can assert any user to accounts. The gate above keeps the +// GROUP to the identity plane; this keeps the KEY to that group. +// +// The defect it closes is the other way a credential spreads: not a service +// declaring a group it does not read, but a key appearing in a second group that +// many services do read. A `CODEFLY_GATEWAY_TOKEN` in `internal-auth` reaches +// every service that declares the internal token, which is nearly all of them, and +// the name alone makes it look like the same credential the gateway stamps โ€” so a +// composition can be built where it is, and accounts then believes forwarded +// identity from anything that holds it. +func TestTheGatewayCredentialKeyAppearsInNoOtherGroup(t *testing.T) { + moduleDir := findModuleDir(t) + base := filepath.Join(moduleDir, "configurations", "local") + entries, err := os.ReadDir(base) + if err != nil { + t.Fatalf("read %s: %v", base, err) + } + + found := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + group := strings.TrimSuffix(strings.TrimSuffix(entry.Name(), ".env"), ".secret") + data, readErr := os.ReadFile(filepath.Join(base, entry.Name())) + if readErr != nil { + t.Fatalf("read %s: %v", entry.Name(), readErr) + } + for _, line := range strings.Split(string(data), "\n") { + key, _, assigned := strings.Cut(strings.TrimSpace(line), "=") + if !assigned || strings.HasPrefix(key, "#") { + continue + } + if !strings.Contains(key, "GATEWAY_TOKEN") { + continue + } + found++ + if group != "gateway-trust" { + t.Errorf("the group %q declares %q: the gateway-provenance credential belongs to `gateway-trust`, "+ + "which only the identity plane declares. In any other group it reaches every service that "+ + "declares that group, and accounts then believes forwarded identity from each of them", + group, key) + } + } + } + if found == 0 { + t.Fatal("no GATEWAY_TOKEN key found in any shipped default; the key was renamed and this gate now proves nothing") + } +} From 93fb5f3f71a134851ed1c98deb0873198a4c8c4d Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:54:47 -0400 Subject: [PATCH 14/46] fix(accounts): the datasource message-authentication keys are delivered per purpose, not derived from the perimeter credential (SA-F-KEYREUSE this repo's half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The datasource content-ticket key and the account-link state key were both derived from CODEFLY_INTERNAL_TOKEN โ€” the cell-wide perimeter credential every service and composed module holds. Two consequences, neither visible from either end: every holder of that credential could compute both keys and forge what they authenticate, and rotating the perimeter credential silently invalidated every outstanding content ticket and link state, a blast radius nothing recorded. Both now come from a new `datasource-keys` secret group that only accounts declares, one delivered key per purpose, so rotating one rotates one thing. An absent key leaves its purpose unavailable rather than falling back to a derivation. A key reachable from a credential thirteen workloads hold is not a key, and a fallback would make the fix conditional on nobody forgetting โ€” so the operator step is real: a cell must provision both keys before its datasource paths work again, and until it does, minting a content ticket is refused rather than signed with something weaker. The link key stays domain-separated from whatever value it is given, so a content ticket and a link state can never pass for each other even where an operator provisions one value for both. Tests: an absent key in either direction leaves its purpose unavailable and does not borrow the other's, and one provisioned value for both still yields two distinct keys. The pre-existing unset-key test is kept and widened rather than replaced, so what it caught before is still caught. NOT this repository's, and named rather than left implied: one Vault transit key still serves every sealing purpose, which the key-service work owns, and the documents module derives its collection-cursor key from the same internal token in its own tree. module/KEY_ROTATION.md gains the section stating the rule, what it costs to rotate one of these, and what is still outstanding. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../local/datasource-keys.secret.env | 1 + module/KEY_ROTATION.md | 28 +++++++++++ .../local/datasource-keys.secret.env | 22 +++++++++ .../datasource_content_ticket_test.go | 46 +++++++++++++++---- .../business/datasource_deliveries_test.go | 2 +- .../pkg/business/datasource_directory_test.go | 2 +- .../accounts/code/pkg/business/datasources.go | 37 ++++++++++----- module/services/accounts/code/work.go | 7 ++- module/services/accounts/service.codefly.yaml | 1 + 9 files changed, 123 insertions(+), 23 deletions(-) create mode 120000 configurations/local/datasource-keys.secret.env create mode 100644 module/configurations/local/datasource-keys.secret.env diff --git a/configurations/local/datasource-keys.secret.env b/configurations/local/datasource-keys.secret.env new file mode 120000 index 000000000..2b231cdf6 --- /dev/null +++ b/configurations/local/datasource-keys.secret.env @@ -0,0 +1 @@ +../../module/configurations/local/datasource-keys.secret.env \ No newline at end of file diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md index 6c8a33f00..99a3d06d9 100644 --- a/module/KEY_ROTATION.md +++ b/module/KEY_ROTATION.md @@ -246,6 +246,34 @@ the gateway never pins a key at boot: a gateway started mid-overlap loads the whole published set and accepts both keys regardless of the order the document lists them in. +## One key per purpose, delivered + +The signing key above is one key with one purpose. Everything else this module +signs or authenticates with carries its own **delivered** key, in a group of its +own, so rotating one rotates one thing. + +Two did not, until the 2026-10-04 audit: the datasource **content-ticket** key and +the **account-link state** key were both derived from `CODEFLY_INTERNAL_TOKEN`, the +cell-wide perimeter credential every service and composed module holds. That had +two consequences, neither visible from either end โ€” every holder of the perimeter +credential could compute those keys and forge what they authenticate, and rotating +the perimeter credential silently invalidated every outstanding ticket and link +state. Both now come from `datasource-keys` +(`DATASOURCE_CONTENT_TICKET_KEY`, `DATASOURCE_ACCOUNT_LINK_KEY`), which only +accounts declares. + +An absent key leaves its purpose unavailable rather than falling back to a +derivation: a key reachable from a credential thirteen workloads hold is not a key, +and a fallback would make that conditional on nobody forgetting. **Rotating one of +these is a flag day for the tokens it signed** โ€” an outstanding content ticket does +not survive it โ€” which is why each purpose has its own, so the day is one purpose +wide. + +The sealing keys are a separate axis and are no longer one key: a stored value +names the service that sealed it and that service's own key, and the envelope key +is per organization. Rotating and moving those is the next two sections; this one +is about the keys a purpose is DELIVERED, which rotate on their own. + ## Rotating the envelope key Unlike the signing key, this is invisible to users and needs no overlap window: diff --git a/module/configurations/local/datasource-keys.secret.env b/module/configurations/local/datasource-keys.secret.env new file mode 100644 index 000000000..2aa3d14a9 --- /dev/null +++ b/module/configurations/local/datasource-keys.secret.env @@ -0,0 +1,22 @@ +# The message-authentication keys the datasource paths sign with, one per purpose. +# +# They used to be derived from CODEFLY_INTERNAL_TOKEN, the cell-wide perimeter +# credential every service and composed module holds. Two things were wrong with +# that, and neither is visible from either end: every holder of the perimeter +# credential could compute these keys and forge what they authenticate, and +# ROTATING the perimeter credential silently invalidated every outstanding ticket +# and link state โ€” a rotation whose blast radius nothing documented. +# +# A key per purpose, delivered rather than derived, so rotating one rotates one +# thing. Unset leaves the purpose it serves unavailable rather than falling back to +# a derivation: a key reachable from a credential thirteen workloads hold is not a +# key, and a silent fallback would make the fix conditional on nobody forgetting. + +# Signs the opaque content tickets a change-set job carries in place of an +# oversized blob. The same value must be present wherever a ticket is redeemed. +DATASOURCE_CONTENT_TICKET_KEY=local-dev-only-replace-me + +# Signs the account-link state that carries a person through a provider's sign-in +# and back. Domain-separated from the ticket key above, so neither token can pass +# for the other. +DATASOURCE_ACCOUNT_LINK_KEY=local-dev-only-replace-me diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go index f802b2f41..cc3d99627 100644 --- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go +++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go @@ -58,17 +58,47 @@ func TestContentTicketRejectsTamperAndForeignKey(t *testing.T) { } } -// TestAttack_UnsetTicketKeyDisablesTickets: an unset seed used to produce the -// key sha256("datasource-content-ticket\x00") โ€” a constant anyone can compute, -// so anyone could mint a ticket for any tenant's source. An unset key must -// leave content tickets disabled, which minting and redemption already treat -// as unavailable. +// TestAttack_UnsetTicketKeyDisablesTickets: an unset key used to produce +// sha256("datasource-content-ticket\x00") โ€” a constant anyone can compute, so +// anyone could mint a ticket for any tenant's source. An unset key must leave +// content tickets disabled, which minting and redemption already treat as +// unavailable โ€” and must NOT fall back to any other value, which is the shape the +// derivation from the perimeter credential had. func TestAttack_UnsetTicketKeyDisablesTickets(t *testing.T) { - for _, seed := range [][]byte{nil, {}} { + for _, key := range [][]byte{nil, {}} { s := &Service{} - s.SetDatasourceTicketKey(seed) + // A link key is present, so an absent ticket key cannot be satisfied by the + // other purpose's value. + s.SetDatasourceKeys(key, []byte("a-delivered-link-key")) if s.datasourceTicketSigner != nil { - t.Fatalf("SetDatasourceTicketKey(%q) installed a signer keyed by a public constant", seed) + t.Fatalf("SetDatasourceKeys(%q, ...) installed a signer keyed by a public constant", key) } } } + +// And the other way round: an absent account-link key leaves the link state +// unsignable rather than borrowing the ticket key. +func TestAttack_UnsetAccountLinkKeyDisablesLinkState(t *testing.T) { + for _, key := range [][]byte{nil, {}} { + s := &Service{} + s.SetDatasourceKeys([]byte("a-delivered-ticket-key"), key) + if s.datasourceLinkKey != nil { + t.Fatalf("SetDatasourceKeys(..., %q) installed a link key from somewhere else", key) + } + } +} + +// The two purposes do not share a key even when an operator provisions one value +// for both: the link key is domain-separated, so a content ticket and a link state +// can never pass for each other. +func TestDatasourceKeysStayDistinctUnderOneProvisionedValue(t *testing.T) { + s := &Service{} + one := []byte("one-value-provisioned-for-both-purposes") + s.SetDatasourceKeys(one, one) + if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil { + t.Fatal("both purposes must be available") + } + if string(s.datasourceLinkKey) == string(s.datasourceTicketSigner.key) { + t.Fatal("the link key and the ticket key must differ even from one provisioned value") + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go index 498f9a52e..65f674524 100644 --- a/module/services/accounts/code/pkg/business/datasource_deliveries_test.go +++ b/module/services/accounts/code/pkg/business/datasource_deliveries_test.go @@ -449,7 +449,7 @@ func TestCompileDelivery_LargeBlobCarriesContentTicket(t *testing.T) { } store := newDatasourceFakeStore() svc, _ := newDatasourceService(store, producer, gh) - svc.SetDatasourceTicketKey([]byte("test-key")) + svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key")) source := githubSource(t, svc, "main", []string{"docs"}, cA) if _, err := svc.CompileGitHubDelivery(context.Background(), source, diff --git a/module/services/accounts/code/pkg/business/datasource_directory_test.go b/module/services/accounts/code/pkg/business/datasource_directory_test.go index 633deea16..a2374b7a8 100644 --- a/module/services/accounts/code/pkg/business/datasource_directory_test.go +++ b/module/services/accounts/code/pkg/business/datasource_directory_test.go @@ -348,7 +348,7 @@ func newDirectoryService(t *testing.T) (*business.Service, *directoryStore, *rec store.roles[userJoe] = gen.OrgRole_ORG_ROLE_MEMBER store.roles[userAdm] = gen.OrgRole_ORG_ROLE_ADMIN svc, audit := newDatasourceService(store, &recordingProducer{}, nil) - svc.SetDatasourceTicketKey([]byte("internal-test-key")) + svc.SetDatasourceKeys([]byte("test-ticket-key"), []byte("test-link-key")) svc.SetDatasourceAccountLinkers(map[string]business.DatasourceAccountLinker{ "github": fakeLinker{accounts: map[string][2]string{"code-jane": {"1001", "jane"}, "code-joe": {"1002", "joe"}}}, }) diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go index a982fb376..daf94577e 100644 --- a/module/services/accounts/code/pkg/business/datasources.go +++ b/module/services/accounts/code/pkg/business/datasources.go @@ -561,23 +561,36 @@ func (s *Service) SetDatasourceGitHubClientFactory(factory func(token string) Gi s.newGitHubClient = factory } -// SetDatasourceTicketKey seeds the signer that mints and verifies the opaque -// content tickets a change-set job carries in place of an oversized blob. The -// seed is the deployment's internal key; the same seed must be present wherever -// ResolveContentTicket runs so a ticket minted by the compiler verifies at -// redemption. An empty seed disables tickets: deriving a key from it would sign -// with a constant anyone can compute. -func (s *Service) SetDatasourceTicketKey(seed []byte) { - if len(seed) == 0 { +// SetDatasourceKeys installs the message-authentication keys the datasource paths +// sign with: one for the opaque content tickets a change-set job carries in place +// of an oversized blob, one for the account-link state that carries a person +// through a provider's sign-in and back. +// +// Each is DELIVERED, one per purpose. Both used to be derived from the cell-wide +// internal token โ€” the perimeter credential every service and composed module +// holds โ€” so every holder of that credential could compute them and forge what +// they authenticate, and rotating the perimeter credential silently invalidated +// every outstanding ticket and link state. Neither consequence was visible from +// either end. +// +// An absent key leaves its purpose unavailable rather than falling back to a +// derivation. A key reachable from a credential thirteen workloads hold is not a +// key, and a fallback would make the fix conditional on nobody forgetting. +func (s *Service) SetDatasourceKeys(ticketKey, accountLinkKey []byte) { + if len(ticketKey) == 0 { s.datasourceTicketSigner = nil + } else { + s.datasourceTicketSigner = newDatasourceTicketSigner(ticketKey) + } + if len(accountLinkKey) == 0 { s.datasourceLinkKey = nil return } - // The account-link state is signed under its own domain-separated key, so - // neither kind of token can pass for the other. - linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), seed...)) + // Domain-separated from whatever else the delivered value is used for, so a + // link state and a content ticket can never pass for each other even if an + // operator provisions one value for both keys. + linkKey := sha256.Sum256(append([]byte("datasource-account-link-state\x00"), accountLinkKey...)) s.datasourceLinkKey = linkKey[:] - s.datasourceTicketSigner = newDatasourceTicketSigner(seed) } // SetDatasourceAPIClientFactory overrides how per-Source API clients are built. diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 1e836032b..945573ca6 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -867,7 +867,12 @@ func doWork(ctx context.Context) (Clean, error) { // The datasource content-ticket signer is keyed from the same internal secret, // domain-separated, so a change-set job's opaque content ticket verifies at // redemption without a second key to provision. - service.SetDatasourceTicketKey([]byte(workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"))) + // One delivered key per purpose, never derived from the perimeter credential: + // see business.SetDatasourceKeys. + service.SetDatasourceKeys( + []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_CONTENT_TICKET_KEY"))), + []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_ACCOUNT_LINK_KEY"))), + ) centralEnforcement, err := configuredCentralEnforcement() if err != nil { diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 0e067575f..22c9e9d7d 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -30,6 +30,7 @@ workspace-configuration-dependencies: - abuse-protection - application - billing + - datasource-keys - email - federation - gateway-trust From 44fb7f20c42cd309f27efc27f84019992515e76f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:16:32 -0400 Subject: [PATCH 15/46] fix: the call sites a build tag and an untyped transform hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two call sites my local verification could not see, both found by CI on the first push and both the same shape: a tool that skips files rather than checking them. `pkg/business/service_test.go` still called `Service.Logout` with the pre-change arity. It is a DB-gated file, so `-tags pure` โ€” the way these suites run without Docker โ€” does not SKIP it, it does not COMPILE it: `go build`, `go vet` and the whole pure suite were green while the call site was wrong. One missing argument reddened `Codefly quality` compile, lint and test together, and reported auth-gateway and frontend as skipped on a failed prerequisite. `src/test/pipeline-gateway.test.ts` built a `CodeflyRuntimeReader` literal without the two members the public origin now depends on. vitest transforms with esbuild and does not typecheck, so 3876 tests passed over it; `tsc --noEmit` is what sees it. The literal is replaced by a helper that carries the whole interface, so the next member added to it fails in one place rather than two. Both are verification failures on my side rather than design changes, so neither alters behaviour. `GOWORK=off go vet ./...` now passes untagged in all six Go modules โ€” which compiles every test file without running any, needing no database โ€” and `npm run typecheck` passes. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../code/pkg/business/service_test.go | 4 +- .../code/src/test/pipeline-gateway.test.ts | 38 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index 59fe83fdb..ec58256ce 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -543,7 +543,9 @@ func TestLogout(t *testing.T) { }) require.NoError(t, err) - err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "") + // No access token and no verified session: the body's refresh credential alone + // must still end the family, which is what a non-browser caller presents. + err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") require.NoError(t, err) _, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{ diff --git a/module/services/frontend/code/src/test/pipeline-gateway.test.ts b/module/services/frontend/code/src/test/pipeline-gateway.test.ts index 4dbdbeeac..537375501 100644 --- a/module/services/frontend/code/src/test/pipeline-gateway.test.ts +++ b/module/services/frontend/code/src/test/pipeline-gateway.test.ts @@ -1,6 +1,7 @@ import type { ServiceEndpoint } from "codefly"; import { describe, expect, it } from "vitest"; +import type { CodeflyRuntimeReader } from "@/lib/codefly-gateway-context"; import { codeflyInjectedRuntime, type PipelineRuntimeReader, @@ -61,6 +62,24 @@ function selfStartedRuntime( }); } +// The gateway-context reader a `runtimeWithResolvedOrigin` case passes through. It +// carries the two members the public origin now depends on โ€” the configured value +// and whether this is a deployed build โ€” because the origin is operator +// configuration and no longer derivable from a request. +function gatewayRuntime( + overrides: Partial = {}, +): CodeflyRuntimeReader { + return { + currentModule: () => "saas-starter", + currentService: () => "frontend", + endpoints: () => [authGatewayREST()], + workspaceSecret: () => "internal-test-token", + workspaceConfiguration: () => undefined, + isDeployedBuild: () => false, + ...overrides, + }; +} + describe("codeflyInjectedRuntime", () => { it("recognizes a Codefly-owned test before endpoints are injected", () => { expect(codeflyInjectedRuntime(runtime({ endpoints: () => [] }))).toBe(true); @@ -77,24 +96,19 @@ describe("codeflyInjectedRuntime", () => { describe("runtimeWithResolvedOrigin", () => { it("supplies the SDK-resolved own origin when Codefly runs only dependencies", () => { - const adapted = runtimeWithResolvedOrigin(ORIGIN, { - currentModule: () => "saas-starter", - currentService: () => "frontend", - endpoints: () => [authGatewayREST()], - workspaceSecret: () => "internal-test-token", - }); + const adapted = runtimeWithResolvedOrigin(ORIGIN, gatewayRuntime()); expect(adapted.endpoints()).toContainEqual(frontendHTTP()); }); it("does not replace an injected own endpoint", () => { const injected = frontendHTTP({ address: "https://app.cell.example" }); - const adapted = runtimeWithResolvedOrigin(ORIGIN, { - currentModule: () => "saas-starter", - currentService: () => "frontend", - endpoints: () => [injected], - workspaceSecret: () => "internal-test-token", - }); + const adapted = runtimeWithResolvedOrigin( + ORIGIN, + gatewayRuntime({ + endpoints: () => [injected], + }), + ); expect(adapted.endpoints()).toEqual([injected]); }); From 8822998a06dcaa611401f079131308819452213e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:06:06 -0400 Subject: [PATCH 16/46] =?UTF-8?q?fix(accounts):=20the=20review's=20four=20?= =?UTF-8?q?accounts=20majors=20=E2=80=94=20a=20factor=20at=20every=20privi?= =?UTF-8?q?leged=20mutation,=20complete=20revocation,=20usable=20keys=20on?= =?UTF-8?q?ly,=20and=20a=20correct=20migration=20choice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1019-N02. requireMFA was made fail-closed for a privileged actor, but the privilege-granting and platform-security mutations did not reach it: each ran its authorization guard and then its mutation. A helper nothing calls enforces nothing. The role, scope and principal grants and the platform-admin mutations (suspend, unsuspend, session revoke, entitlement override) now gate a factor, AFTER the authorization guard and BEFORE the mutation โ€” a check the write has already passed is not a check, which is the shape the first attempt at this produced and the source test below now forbids. UpsertFeatureFlag is deliberately not gated: it mutates nothing, answering FailedPrecondition for a retired inventory. Ordinary tenant mutations โ€” dashboards, teams, invitations, notifications, user settings โ€” are deliberately not gated either; whether a second factor belongs there is a product decision about an operator's whole roster, not a property of this host. NOT done, and recorded in module/FEATURES.md rather than left implied: the method policy still declares MFA_REQUIREMENT_NONE for these methods, so the existing policy/handler lockstep gate is satisfied either way and a later edit could remove a gate without reddening anything. Aligning the declarations is the durable fix. It is a descriptor change โ€” it moves the contract digests and the published client โ€” and the proto generator needs a container runtime this environment could not start, so it wants its own verified regeneration. The stopgap is explicit: a test that reads the source and requires the gate, before the mutation, for every method on a named list. R1019-N04. Logout reported success when the family revocation succeeded and an access-token revocation did not, which tells a caller their session ended when an issued access token stays usable until it expires. Every revocation is now required; the family revocation runs last so a failed marker cannot discard the durable half, and each step is idempotent so a retry completes the remainder. An incomplete sign-out reaches the caller as a retryable failure with a fixed reason. R1019-N03. The datasource keys accepted the placeholder this module ships in its public local defaults and stayed usable. A deployed runtime now treats an unusable value โ€” marker-carrying, or below the signing floor โ€” as ABSENT, which leaves the purpose unavailable: the state minting and redemption already define, and the reason this does not refuse at boot the way a perimeter credential does. Refusing to start would turn an unprovisioned optional purpose into an outage. R1019-N08. The identity-provider migration treated the destination group's shipped placeholder as provisioned, so it overrode a real value still delivered under the old group โ€” defeating the fallback precisely on the cells that had not migrated, which are the only ones it exists for. Selection now requires an operator-supplied value, excluding every shipped marker, and falls back to a provisioned legacy value with the migration warning. Local development still resolves to its shipped default. Every fix is mutation-checked, each with a positive control. The factor test counts how many calls actually reached the gate per platform grade, so a grade refused earlier by its role floor cannot pass vacuously. Rebase note (onto origin/main f5a104ce3). This commit also corrected a KEY_ROTATION.md paragraph to say that splitting the shared Vault transit key per purpose was this repository's work and not done. Main's #1033 and #1038 have since done it, behind a selected key service with per-organization envelope keys, so the paragraph now points at those sections instead of claiming an open item. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/FEATURES.md | 17 +- module/KEY_ROTATION.md | 23 +-- module/SOLUTION_REGISTRATION.md | 23 +-- .../local/datasource-keys.secret.env | 17 +- .../code/identity_provider_migration_test.go | 111 ++++++++++++ .../pkg/adapters/auth_error_surface_test.go | 96 +++++++++++ .../code/pkg/adapters/connect_handlers.go | 14 +- .../privileged_mutation_factor_test.go | 163 ++++++++++++++++++ .../accounts/code/pkg/adapters/rpcs.go | 95 ++++++++++ .../accounts/code/pkg/auth/public_origin.go | 21 +-- .../accounts/code/pkg/business/auth.go | 39 ++++- .../datasource_content_ticket_test.go | 10 +- .../accounts/code/pkg/business/datasources.go | 13 +- module/services/accounts/code/work.go | 82 +++++++-- module/services/auth-gateway/AGENTS.md | 40 +++-- .../code/deployed_failclosed_test.go | 11 +- module/services/auth-gateway/code/gateway.go | 28 ++- .../services/auth-gateway/code/ratelimit.go | 15 +- .../code/upstream_session_credential_test.go | 15 +- 19 files changed, 696 insertions(+), 137 deletions(-) create mode 100644 module/services/accounts/code/identity_provider_migration_test.go create mode 100644 module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go diff --git a/module/FEATURES.md b/module/FEATURES.md index 8917f951f..1b69788f8 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations. | OAuth login (multiple providers) | โœ… | Same | | Email/password login | โŒ | โœ… (most starters keep both) | | Magic-link login | ๐ŸŸก | โœ… (one-click) | -| MFA enforced on sensitive ops | โœ… | โœ… `requireMFA` gates billing, GDPR delete, role grants, impersonation. Fail-closed for a privileged actor โ€” any platform role, or ownership of the request's organization โ€” where not being enrolled is a refusal, not a pass; opt-in per user for every other member | +| MFA enforced on sensitive ops | โœ… | ๐ŸŸก `requireMFA` gates billing, GDPR delete, impersonation, organization deletion, waitlist review, the role/scope/principal grants and the platform-admin mutations (suspend, session revoke, entitlement override). Fail-closed for a privileged actor โ€” any platform role, or ownership of the request's organization โ€” where not being enrolled is a refusal, not a pass; opt-in per user for every other member. **The method policy still declares `MFA_REQUIREMENT_NONE` for the newly gated methods, so the policy/handler lockstep gate does not yet demand it** โ€” see the note below | | Multi-org tenancy | โœ… | Same | | Org invitations | โœ… | Same | | RBAC (built-in roles) | โœ… | Same | @@ -531,6 +531,21 @@ starters, and large-scale enterprise SaaS expectations. | OpenAPI / TS client autogen | โœ… | โœ… (Connect-ES is more typesafe than fetch-based clients) | | Real-stack e2e tests | โœ… | ๐ŸŸก (most starters mock โ€” we're ahead) | +**What the MFA row does not yet have.** `module/services/accounts/proto/.../*.proto` +declares each method's `mfa:` requirement, and +`pkg/adapters/rpc_policy_lockstep_test.go` holds handlers to what their policy +declares. The methods gated in this change still declare `MFA_REQUIREMENT_NONE`, so +the gate is satisfied by them either way and a later edit could remove the gate +without reddening anything. `pkg/adapters/privileged_mutation_factor_test.go` is the +stopgap: it reads the source and requires the gate, before the mutation, for every +method on an explicit list. Aligning the declarations is the durable fix and is owed; +it is a descriptor change, so it moves the contract digests and the published client +and wants its own verified regeneration. + +Ordinary tenant mutations โ€” dashboards, teams, invitations, notifications, user +settings โ€” are deliberately NOT gated. Whether a second factor belongs there is a +product decision about an operator's whole roster, not a property of this host. + ### Nice-to-have (differentiators) | Feature | This module | World-class | diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md index 99a3d06d9..ebff432ff 100644 --- a/module/KEY_ROTATION.md +++ b/module/KEY_ROTATION.md @@ -252,22 +252,17 @@ The signing key above is one key with one purpose. Everything else this module signs or authenticates with carries its own **delivered** key, in a group of its own, so rotating one rotates one thing. -Two did not, until the 2026-10-04 audit: the datasource **content-ticket** key and -the **account-link state** key were both derived from `CODEFLY_INTERNAL_TOKEN`, the -cell-wide perimeter credential every service and composed module holds. That had -two consequences, neither visible from either end โ€” every holder of the perimeter -credential could compute those keys and forge what they authenticate, and rotating -the perimeter credential silently invalidated every outstanding ticket and link -state. Both now come from `datasource-keys` -(`DATASOURCE_CONTENT_TICKET_KEY`, `DATASOURCE_ACCOUNT_LINK_KEY`), which only -accounts declares. +Two purposes were brought under that rule by the 2026-10-04 audit: the datasource +**content-ticket** key and the **account-link state** key. Both now come from +`datasource-keys` (`DATASOURCE_CONTENT_TICKET_KEY`, +`DATASOURCE_ACCOUNT_LINK_KEY`), a group only accounts declares, instead of being +derived from a shared credential. An absent key leaves its purpose unavailable rather than falling back to a -derivation: a key reachable from a credential thirteen workloads hold is not a key, -and a fallback would make that conditional on nobody forgetting. **Rotating one of -these is a flag day for the tokens it signed** โ€” an outstanding content ticket does -not survive it โ€” which is why each purpose has its own, so the day is one purpose -wide. +derivation, because a fallback would make the property conditional on nobody +forgetting. **Rotating one of these is a flag day for the tokens it signed** โ€” an +outstanding content ticket does not survive it โ€” which is why each purpose has its +own, so the day is one purpose wide. The sealing keys are a separate axis and are no longer one key: a stored value names the service that sealed it and that service's own key, and the envelope key diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index afc49356b..2dcde7cd0 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -221,16 +221,19 @@ is re-checked at dial time**, so a mesh-looking name whose DNS answer later poin off-mesh never receives one. Loopback is an explicit development exception and is tested as such. -A solution upstream does **not** receive the viewer's session credential. The -gateway removes `Authorization` and `Cookie` from every request it forwards to a -runtime-registered upstream, module or solution, and the upstream receives the -identity headers `ext_authz` stamped instead โ€” the subject, the tenant, the -session, the credential kind and the scope ceiling, which name the person without -carrying their authority. A host access token is the person's whole session at a -single host-wide audience, so forwarding it made every upstream that held one, or -logged one, a source of replayable full-authority sessions. An upstream that needs -to act on a person's behalf needs a host-minted capability bound to its own -audience, which is the Work Context surface. +A solution upstream does **not** receive the viewer's session credential +(SP-GW-07). The gateway removes `Authorization` and `Cookie` from every request it +forwards to a runtime-registered upstream, module or solution, and the upstream +receives the identity headers `ext_authz` stamped instead โ€” the subject, the tenant, +the session, the credential kind and the scope ceiling, which name the person +without carrying their authority. A host access token carries one host-wide audience +and the person's full authority, so it is not a credential any single upstream +should hold. + +The invariant's other half is **open**: an upstream should receive a host-minted +context bound to its own audience, and today it receives none. An upstream that +needs to act on a person's behalf needs that capability from the Work Context +surface. ## 3. Runtime compatibility is enforced, not stored diff --git a/module/configurations/local/datasource-keys.secret.env b/module/configurations/local/datasource-keys.secret.env index 2aa3d14a9..97df3f00a 100644 --- a/module/configurations/local/datasource-keys.secret.env +++ b/module/configurations/local/datasource-keys.secret.env @@ -1,16 +1,13 @@ # The message-authentication keys the datasource paths sign with, one per purpose. # -# They used to be derived from CODEFLY_INTERNAL_TOKEN, the cell-wide perimeter -# credential every service and composed module holds. Two things were wrong with -# that, and neither is visible from either end: every holder of the perimeter -# credential could compute these keys and forge what they authenticate, and -# ROTATING the perimeter credential silently invalidated every outstanding ticket -# and link state โ€” a rotation whose blast radius nothing documented. +# The invariant (SP-SEC-07): each signing or message-authentication purpose has its +# own key, derived from no credential shared beyond its owner. These are DELIVERED +# per purpose rather than derived from a shared credential, so rotating one rotates +# one thing. # -# A key per purpose, delivered rather than derived, so rotating one rotates one -# thing. Unset leaves the purpose it serves unavailable rather than falling back to -# a derivation: a key reachable from a credential thirteen workloads hold is not a -# key, and a silent fallback would make the fix conditional on nobody forgetting. +# Unset leaves the purpose it serves unavailable rather than falling back to a +# derivation, because a fallback would make the property conditional on nobody +# forgetting. Provision both on every cell. # Signs the opaque content tickets a change-set job carries in place of an # oversized blob. The same value must be present wherever a ticket is redeemed. diff --git a/module/services/accounts/code/identity_provider_migration_test.go b/module/services/accounts/code/identity_provider_migration_test.go new file mode 100644 index 000000000..4d68ee363 --- /dev/null +++ b/module/services/accounts/code/identity_provider_migration_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A credential moving between configuration groups must not treat the DESTINATION +// group's shipped placeholder as the destination being ready: that defeats the +// fallback exactly on the cells that still deliver the value under the old group, +// which are the only ones the fallback exists for. +func TestR1019IdentityProviderMigration(t *testing.T) { + const provisioned = "an-operator-supplied-value-of-sufficient-length" + + for _, key := range []string{"IDENTITY_CLIENT_SECRET", "IDENTITY_MANAGEMENT_API_KEY"} { + t.Run(key, func(t *testing.T) { + blankWorkspaceKey(t, "identity-provider", key) + blankWorkspaceKey(t, "identity", key) + + // The cell has not migrated: the destination holds only the shipped + // placeholder, the legacy group holds the real value. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "local-dev-only-replace-me") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY__"+key, provisioned) + require.Equal(t, provisioned, identityProviderSecret(key), + "a shipped placeholder in the destination must not override a provisioned legacy value") + + // The cell has migrated: the destination wins. + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "the-migrated-"+key) + require.Equal(t, "the-migrated-"+key, identityProviderSecret(key)) + }) + } +} + +// The selection rule itself, in both directions, so the test above cannot pass by +// always preferring the legacy group. +func TestR1019ProvisionedCredentialExcludesShippedPlaceholders(t *testing.T) { + for _, shipped := range []string{ + "local-dev-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "change-me", + "a-placeholder", + "REPLACE_ME", + "", + " ", + } { + require.False(t, isProvisionedCredential(shipped), + "%q is not an operator-supplied value", shipped) + } + require.True(t, isProvisionedCredential(strings.Repeat("7Kq2Xp9Vb4Nf", 3))) +} + +// Local development runs on the shipped defaults, and must still resolve to one โ€” +// from the group this service now declares. +func TestR1019LocalDevelopmentKeepsTheShippedPlaceholder(t *testing.T) { + const key = "IDENTITY_CLIENT_SECRET" + blankWorkspaceKey(t, "identity-provider", key) + blankWorkspaceKey(t, "identity", key) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__IDENTITY_PROVIDER__"+key, + "local-dev-only-replace-me") + + require.Equal(t, "local-dev-only-replace-me", identityProviderSecret(key)) +} + +// The shipped local defaults for the datasource keys carry placeholder markers, and +// this module's repository is public โ€” so a deployed runtime holding one would sign +// with a published value. It is treated as ABSENT, which leaves the purpose +// unavailable: the state minting and redemption already define, and the reason this +// does not refuse at boot the way a perimeter credential does. +func TestR1019DeployedDatasourceDefaultsRefused(t *testing.T) { + keys := []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} + for _, key := range keys { + // Two guards run here, and only the last case isolates the marker one: the + // two shipped values are shorter than the length floor, so removing the + // marker check still refuses them. A long marker-carrying value is what + // proves the marker check itself fires. + for _, shipped := range []string{ + "local-dev-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "some-change-me-value-long-enough-to-pass-a-length-floor", + } { + t.Run(key+"/"+shipped[:12], func(t *testing.T) { + blankWorkspaceKey(t, "datasource-keys", key) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, shipped) + + require.Nil(t, usableDatasourceKey(key, false), + "a deployed runtime must not sign with a published value") + require.Equal(t, []byte(shipped), usableDatasourceKey(key, true), + "local development uses its shipped default") + }) + } + } +} + +// A short value is refused for the same reason the perimeter floor exists, and a +// provisioned one is used โ€” so the refusal is the value and not a key that is never +// usable. +func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) { + const key = "DATASOURCE_CONTENT_TICKET_KEY" + blankWorkspaceKey(t, "datasource-keys", key) + + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, "7Kq2Xp9Vb4Nf") + require.Nil(t, usableDatasourceKey(key, false)) + + provisioned := strings.Repeat("7Kq2Xp9Vb4Nf", 4) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned) + require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false)) +} diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go index 98216afc2..208cfa888 100644 --- a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go +++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "errors" "strings" "testing" @@ -9,8 +10,10 @@ import ( "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" + "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -108,3 +111,96 @@ func TestBeginOAuthRefusalNamesNoInternalDetail(t *testing.T) { require.False(t, strings.Contains(message, "auth: "), "the sentinel's own text is internal detail") } + +// A sign-out is acknowledged only when every revocation held. Reporting success +// while the access half failed tells the caller their session ended when it has +// not, which is the one answer a sign-out must never give. +type partiallyRevokingMinter struct { + auth.JWTMinter + accessErr error + sessionErr error + familyErr error + revoked []string +} + +func (m *partiallyRevokingMinter) RevokeAccess(context.Context, string) error { + m.revoked = append(m.revoked, "access") + return m.accessErr +} + +func (m *partiallyRevokingMinter) RevokeSessionAccess(context.Context, string) error { + m.revoked = append(m.revoked, "session") + return m.sessionErr +} + +func (m *partiallyRevokingMinter) Revoke(context.Context, string) error { + m.revoked = append(m.revoked, "family") + return m.familyErr +} + +func TestR1019LogoutRequiresCompleteRevocation(t *testing.T) { + boom := errors.New("revocation store unavailable") + for _, tc := range []struct { + name string + accessErr error + sessionErr error + }{ + {"the access marker fails", boom, nil}, + {"the session marker fails", nil, boom}, + {"both fail", boom, boom}, + } { + t.Run(tc.name, func(t *testing.T) { + minter := &partiallyRevokingMinter{accessErr: tc.accessErr, sessionErr: tc.sessionErr} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.Error(t, err, "an incomplete sign-out must not be reported as success") + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Contains(t, minter.revoked, "family", + "the durable half must still be attempted and kept") + }) + } +} + +// The healthy control: every revocation succeeds and the caller is told so. +func TestR1019LogoutSucceedsWhenRevocationIsComplete(t *testing.T) { + minter := &partiallyRevokingMinter{} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.NoError(t, err) + require.Equal(t, []string{"access", "session", "family"}, minter.revoked) +} + +// A failed FAMILY revocation is the durable half failing, and reaches the caller as +// itself rather than as the incomplete-sign-out reason. +func TestR1019LogoutFamilyFailureIsNotMaskedAsIncomplete(t *testing.T) { + minter := &partiallyRevokingMinter{familyErr: errors.New("session store unavailable")} + installLogoutMinter(t, minter) + + _, err := (&AuthServer{}).Logout(logoutContext(), &gen.LogoutRequest{RefreshToken: "rt"}) + + require.Error(t, err) + require.NotEqual(t, codes.Unavailable, status.Code(err)) +} + +func installLogoutMinter(t *testing.T, minter auth.JWTMinter) { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + svc, err := business.NewService(&authErrorSurfaceStore{}) + require.NoError(t, err) + svc.SetJWTMinter(minter) + WithService(svc) +} + +// A caller with a verified session and a bearer, so both access revocations run. +func logoutContext() context.Context { + ctx := metadata.NewIncomingContext(context.Background(), + metadata.Pairs("authorization", "Bearer an-access-token")) + return auth.WithVerifiedSessionID(ctx, logoutSessionID) +} + +var logoutSessionID = uuid.MustParse("00000000-0000-4000-8000-0000000000c1") diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index c1165a5c0..2d8bb9b1f 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -74,11 +74,15 @@ func unary[Req, Resp any]( // it moves the minted refresh credential out of the response body and into the // browser's cookie, exactly as the REST middleware does for the same methods. // -// The Connect surface serves the same browser over the same origin and returned -// the credential as readable content, so an injected script could read it from a -// response it provoked. A caller that genuinely needs a refresh token in a body -// is a registered OAuth client using the token endpoint, which is a separate -// contract with its own audience and its own registry. +// The invariant: the refresh credential reaches the browser only as a scoped, +// non-script-readable, transport-protected, same-site cookie, and never as +// readable content on a surface that completes an authentication (SP-IDENT-07). +// The Connect surface serves the same browser as REST, so it owes the same +// carrier. +// +// A caller that needs a refresh token in a body is a registered OAuth client using +// the token endpoint, a separate contract with its own audience and registry โ€” see +// oauthTokenEndpointRESTPath for that exemption and its reason. func unaryCookieingRefreshToken[Req, Resp any]( ctx context.Context, req *connect.Request[Req], diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go new file mode 100644 index 000000000..f00e60c89 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go @@ -0,0 +1,163 @@ +package adapters + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + "time" + + "accounts/pkg/auth" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// SP-IDENT-10: a platform administrator or an organization owner carries out a +// sensitive operation only behind a recent second factor. +// +// requireMFA was made fail-closed for a privileged actor, but the privilege-granting +// and platform-security mutations did not reach it: each ran its authorization guard +// and then its mutation. A helper nothing calls enforces nothing. +func TestR1019PrivilegedMutationRequiresFactor(t *testing.T) { + // A super-admin with nothing enrolled and no recent factor. requireRoleScope + // falls to the platform role when no organization is named, so both servers' + // authorization guards pass and the factor gate is what answers. + for _, grade := range []string{"support", "billing", "super_admin"} { + t.Run(grade, func(t *testing.T) { + installMFAPolicyStore(t, &mfaPolicyStore{platformRole: grade}) + ctx := privilegedActorContext() + + // Counted, not assumed: a grade below every method's role floor would be + // refused by the authorization guard on every call and prove nothing + // about the factor gate. At least one call per grade has to reach it. + reachedTheGate := 0 + for _, call := range privilegedMutations() { + err := call.invoke(ctx) + require.Error(t, err, "%s must not mutate without a recent factor", call.name) + if status.Code(err) == codes.PermissionDenied { + // This grade is below the method's role floor, so the + // authorization guard answered first โ€” a stronger refusal, and + // not what this test is about. + continue + } + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "%s: expected the factor gate to answer", call.name) + require.Equal(t, "mfa_required", status.Convert(err).Message(), call.name) + reachedTheGate++ + } + require.NotZero(t, reachedTheGate, + "every call was refused before the factor gate, so this grade proved nothing") + }) + } +} + +// The control: with recent AAL2 evidence the same privileged actor passes the gate, +// so the refusals above are the missing factor and not a handler that refuses +// everything. Asserted on the gate itself rather than through a handler, because a +// handler that passes the gate proceeds into the service and this package's fixtures +// wire no store for the mutations themselves. +func TestR1019PrivilegedMutationAdmittedWithRecentFactor(t *testing.T) { + installMFAPolicyStore(t, &mfaPolicyStore{platformRole: "super_admin"}) + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{ + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), + }) + ctx = auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) + + require.NoError(t, requireMFA(ctx, mfaActorID), + "recent AAL2 evidence must satisfy the gate these mutations now carry") +} + +// Every method listed here must carry the gate, and carry it BEFORE its mutation: a +// factor check the write has already passed is not a check. Read from the source, so +// a method added to the privileged set cannot be left ungated, and so the ordering +// cannot regress to the shape this finding started as. +func TestR1019PrivilegedMutationsGateBeforeTheMutation(t *testing.T) { + source, err := os.ReadFile("rpcs.go") + require.NoError(t, err) + text := string(source) + + for _, m := range privilegedMutationMethods { + pattern := regexp.MustCompile(`func \(s \*` + m.server + `\) ` + m.method + + `\(ctx context\.Context(?s:.*?)\n\}\n`) + body := pattern.FindString(text) + require.NotEmpty(t, body, "%s.%s not found", m.server, m.method) + + gate := strings.Index(body, "requireMFA(") + require.NotEqual(t, -1, gate, + "%s.%s is a privileged mutation and must gate a second factor", m.server, m.method) + mutation := strings.Index(body, "service.") + require.NotEqual(t, -1, mutation, "%s.%s calls no service method", m.server, m.method) + require.Less(t, gate, mutation, + "%s.%s gates the factor AFTER its mutation, which enforces nothing", + m.server, m.method) + } +} + +type privilegedMutation struct { + server, method string +} + +// The privilege-granting and platform-security mutations. Roles, scopes and +// principals grant authority; the platform-admin entries change another identity's +// state or a tenant's ceiling. +// +// UpsertFeatureFlag is deliberately absent: it mutates nothing, returning +// FailedPrecondition for a retired inventory. +var privilegedMutationMethods = []privilegedMutation{ + {"PermServer", "CreateRole"}, {"PermServer", "UpdateRole"}, {"PermServer", "DeleteRole"}, + {"PermServer", "AssignRole"}, {"PermServer", "RevokeRole"}, + {"PermServer", "GrantScope"}, {"PermServer", "RevokeScope"}, + {"PlatformAdminServer", "SuspendUser"}, {"PlatformAdminServer", "UnsuspendUser"}, + {"PlatformAdminServer", "RevokeSession"}, {"PlatformAdminServer", "OverrideEntitlement"}, +} + +type factorCall struct { + name string + invoke func(context.Context) error +} + +func privilegedMutations() []factorCall { + perm := &PermServer{} + admin := &PlatformAdminServer{} + return []factorCall{ + {"AssignRole", func(ctx context.Context) error { + _, err := perm.AssignRole(ctx, &gen.AssignRoleRequest{ + RoleId: factorUUID, SubjectId: factorUUID, + SubjectKind: gen.SubjectKind_SUBJECT_KIND_USER, + }) + return err + }}, + {"RevokeRole", func(ctx context.Context) error { + _, err := perm.RevokeRole(ctx, &gen.RevokeRoleRequest{ + RoleId: factorUUID, SubjectId: factorUUID, + }) + return err + }}, + {"SuspendUser", func(ctx context.Context) error { + _, err := admin.SuspendUser(ctx, &gen.SuspendUserRequest{ + UserId: factorUUID, Reason: "a recorded reason", + }) + return err + }}, + {"OverrideEntitlement", func(ctx context.Context) error { + _, err := admin.OverrideEntitlement(ctx, &gen.OverrideEntitlementRequest{ + OrgId: factorUUID, Feature: "seats", LimitValue: 10, + Reason: "a recorded reason", + }) + return err + }}, + } +} + +func privilegedActorContext() context.Context { + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{}) + return auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) +} + +const factorUUID = "00000000-0000-4000-8000-0000000000d1" diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index c473b5d5a..5dbbd4d02 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -579,6 +579,14 @@ func (s *PermServer) CreateRole(ctx context.Context, req *gen.CreateRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.CreateRole(ctx, actorID, req) } @@ -608,6 +616,14 @@ func (s *PermServer) UpdateRole(ctx context.Context, req *gen.UpdateRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.UpdateRole(ctx, actorID, req) } @@ -627,6 +643,14 @@ func (s *PermServer) DeleteRole(ctx context.Context, req *gen.DeleteRoleRequest) if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.DeleteRole(ctx, actorID, req); err != nil { return nil, err } @@ -644,6 +668,14 @@ func (s *PermServer) AssignRole(ctx context.Context, req *gen.AssignRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.AssignRole(ctx, req) } @@ -658,6 +690,14 @@ func (s *PermServer) RevokeRole(ctx context.Context, req *gen.RevokeRoleRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeRole(ctx, actorID, req); err != nil { return nil, err } @@ -763,6 +803,14 @@ func (s *PermServer) GrantScope(ctx context.Context, req *gen.GrantScopeRequest) if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } return service.GrantScope(ctx, actorID, req) } @@ -777,6 +825,14 @@ func (s *PermServer) RevokeScope(ctx context.Context, req *gen.RevokeScopeReques if err := requireRoleScope(ctx, actorID, req.OrgId); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeScope(ctx, actorID, req); err != nil { return nil, err } @@ -1170,6 +1226,13 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty verifiedSession = sessionID.String() } if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil { + // An incomplete sign-out is reported as retryable rather than as success: + // the family is revoked, so nothing refreshes, but an already-issued access + // token stays usable until it expires and the caller may want to retry. + // The reason is fixed; the cause is logged in the business layer. + if errors.Is(err, business.ErrLogoutIncomplete) { + return nil, status.Error(codes.Unavailable, "logout incomplete, retry") + } return nil, err } return &emptypb.Empty{}, nil @@ -1612,6 +1675,14 @@ func (s *PlatformAdminServer) SuspendUser(ctx context.Context, req *gen.SuspendU if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.SuspendUser(ctx, actorID, req); err != nil { return nil, err } @@ -1629,6 +1700,14 @@ func (s *PlatformAdminServer) UnsuspendUser(ctx context.Context, req *gen.Unsusp if err := requirePlatformRole(ctx, actorID, "super_admin"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.UnsuspendUser(ctx, actorID, req); err != nil { return nil, err } @@ -1697,6 +1776,14 @@ func (s *PlatformAdminServer) RevokeSession(ctx context.Context, req *gen.Revoke if err := requirePlatformRole(ctx, actorID, "support"); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } if err := service.RevokeSession(ctx, actorID, req); err != nil { return nil, err } @@ -1751,6 +1838,14 @@ func (s *PlatformAdminServer) OverrideEntitlement(ctx context.Context, req *gen. if err := requirePlatformAdmin(ctx, actorID); err != nil { return nil, err } + // A privilege-granting or platform-security mutation sits behind a recent second + // factor (SP-IDENT-10). It runs AFTER the authorization guards and BEFORE the + // mutation: a factor check the write has already passed is not a check. + // requireMFA is fail-closed for a privileged actor, so an unenrolled platform + // role or organization owner is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } id, err := service.OverrideEntitlement(ctx, actorID, req) if err != nil { return nil, status.Error(codes.Internal, err.Error()) diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 1847114fa..0cbf4546e 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -63,19 +63,16 @@ func CanonicalPublicOrigin(candidate string) (string, error) { // has been authenticated by an adapter โ€” AND only when it is the origin this // deployment pinned. // -// The credential check alone was not enough, and that is the whole of the defect -// it closes. The frontend held the internal token legitimately, and derived the -// origin it stamped from the caller's own `X-Forwarded-Host` whenever its rendered -// endpoint was a loopback placeholder, which is what a render produces. So an -// authenticated hop forwarded a caller's choice, and this recorded it as VERIFIED: -// an OAuth redirect, the authenticator relying-party origin and the links mailed -// out were then bound to a host the caller named. +// The invariant: the origin this service treats as its verified public origin โ€” the +// one that binds a sign-in redirect, an authenticator's relying party and an +// emailed link โ€” is fixed by operator configuration (SP-GW-08). A forwarded value +// is honoured only where it equals the configured one, so the configuration is the +// authority and a forwarding hop cannot substitute for it. This check is +// independent of any hop's own correctness, which is why it lives here as well. // -// Comparing against the pinned origin makes that unreachable regardless of what -// any hop forwards โ€” a second, independent barrier to the frontend's own fix, and -// the one that holds for a hop this service does not own. Where nothing is pinned -// the candidate stands on the credential alone, which only a local runtime reaches: -// boot requires a pinned origin outside local development. +// Where nothing is pinned the candidate stands on the credential alone, which only +// a local runtime reaches: boot requires a pinned origin outside local development +// (requireApplicationBaseURL). func WithVerifiedPublicOrigin(ctx context.Context, candidate string) (context.Context, error) { origin, err := CanonicalPublicOrigin(candidate) if err != nil { diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index 5cb3101e8..01b66757d 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -482,28 +482,55 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string) // logout that reaches here revokes the presented session's access tokens whether // or not a refresh credential came with it. // -// Both access-token revocations are best-effort โ€” a failure never fails the -// logout, since those tokens expire within AccessTokenTTL โ€” while the family -// revocation is the durable authority and its error is returned. +// EVERY revocation is required, and a sign-out is acknowledged only when all of +// them held. SP-IDENT-05 is that after signing out the presented access token no +// longer authenticates โ€” so reporting success while the access half failed tells +// the caller their session ended when it has not, which is the one answer a +// sign-out must never give. A failure that leaves a token live has to reach the +// caller, who can retry, rather than a log nobody reads. +// +// The order is deliberate: the family revocation runs LAST and its result is +// returned directly, so a failed access-marker write cannot discard the durable +// half. Work that succeeded is kept โ€” each step is idempotent, so a retry +// completes the remainder rather than starting over. func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error { w := wool.Get(ctx).In("Logout") if s.minter == nil { return w.NewError("auth path not wired: minter missing") } + var incomplete []string if accessToken != "" { if err := s.minter.RevokeAccess(ctx, accessToken); err != nil { - w.Warn("RevokeAccess failed (best-effort)", wool.ErrField(err)) + w.Warn("RevokeAccess failed", wool.ErrField(err)) + incomplete = append(incomplete, "the presented access token") } } if sessionID != "" { if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil { - w.Warn("RevokeSessionAccess failed (best-effort)", wool.ErrField(err)) + w.Warn("RevokeSessionAccess failed", wool.ErrField(err)) + incomplete = append(incomplete, "the presented session's access tokens") } } - return s.minter.Revoke(ctx, req.RefreshToken) + if err := s.minter.Revoke(ctx, req.RefreshToken); err != nil { + return err + } + if len(incomplete) > 0 { + // The family is revoked, so nothing can be refreshed; what is still live is + // the access half, until its natural expiry. The caller is told, because the + // difference between "signed out" and "signed out except for a few minutes" + // is theirs to act on. + return fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) + } + return nil } +// ErrLogoutIncomplete reports a sign-out whose refresh family was revoked while an +// access-token revocation could not be recorded. The session cannot be refreshed, +// but an already-issued access token stays usable until it expires, so this is not +// the complete sign-out SP-IDENT-05 describes. +var ErrLogoutIncomplete = errors.New("logout incomplete: revocation could not be fully recorded") + // GetJWKS returns the sidecar-facing JSON Web Key Set. // Non-authoritative: the sidecar loads its key from Vault directly. // This endpoint exists for external tooling. diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go index cc3d99627..62ca7c52f 100644 --- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go +++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go @@ -58,12 +58,10 @@ func TestContentTicketRejectsTamperAndForeignKey(t *testing.T) { } } -// TestAttack_UnsetTicketKeyDisablesTickets: an unset key used to produce -// sha256("datasource-content-ticket\x00") โ€” a constant anyone can compute, so -// anyone could mint a ticket for any tenant's source. An unset key must leave -// content tickets disabled, which minting and redemption already treat as -// unavailable โ€” and must NOT fall back to any other value, which is the shape the -// derivation from the perimeter credential had. +// TestAttack_UnsetTicketKeyDisablesTickets: an unset key must leave content tickets +// disabled, which minting and redemption already treat as unavailable, and must NOT +// fall back to any other value โ€” SP-SEC-07 requires each purpose's key to be its +// own, derived from no credential shared beyond its owner. func TestAttack_UnsetTicketKeyDisablesTickets(t *testing.T) { for _, key := range [][]byte{nil, {}} { s := &Service{} diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go index daf94577e..d1755fa40 100644 --- a/module/services/accounts/code/pkg/business/datasources.go +++ b/module/services/accounts/code/pkg/business/datasources.go @@ -566,16 +566,13 @@ func (s *Service) SetDatasourceGitHubClientFactory(factory func(token string) Gi // of an oversized blob, one for the account-link state that carries a person // through a provider's sign-in and back. // -// Each is DELIVERED, one per purpose. Both used to be derived from the cell-wide -// internal token โ€” the perimeter credential every service and composed module -// holds โ€” so every holder of that credential could compute them and forge what -// they authenticate, and rotating the perimeter credential silently invalidated -// every outstanding ticket and link state. Neither consequence was visible from -// either end. +// Each is DELIVERED, one per purpose, rather than derived from a shared +// credential: SP-SEC-07 requires every signing or message-authentication purpose to +// have its own key, derived from no credential shared beyond its owner. // // An absent key leaves its purpose unavailable rather than falling back to a -// derivation. A key reachable from a credential thirteen workloads hold is not a -// key, and a fallback would make the fix conditional on nobody forgetting. +// derivation, because a fallback would make the property conditional on nobody +// forgetting. func (s *Service) SetDatasourceKeys(ticketKey, accountLinkKey []byte) { if len(ticketKey) == 0 { s.datasourceTicketSigner = nil diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 945573ca6..f423919db 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -864,14 +864,13 @@ func doWork(ctx context.Context) (Clean, error) { adapters.SetGatewayToken(gatewayCredentials.current) adapters.SetPreviousGatewayToken(gatewayCredentials.previous) adapters.SetPreviousGatewayTokenExpiresAt(gatewayCredentials.previousExpiresAt) - // The datasource content-ticket signer is keyed from the same internal secret, - // domain-separated, so a change-set job's opaque content ticket verifies at - // redemption without a second key to provision. // One delivered key per purpose, never derived from the perimeter credential: - // see business.SetDatasourceKeys. + // see business.SetDatasourceKeys. A key a deployed runtime may not use is + // passed as ABSENT, which leaves its purpose unavailable โ€” the same answer as + // an unprovisioned key, and the reason the loader does not need a second one. service.SetDatasourceKeys( - []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_CONTENT_TICKET_KEY"))), - []byte(strings.TrimSpace(workspaceEnv("datasource-keys", "DATASOURCE_ACCOUNT_LINK_KEY"))), + usableDatasourceKey("DATASOURCE_CONTENT_TICKET_KEY", codefly.IsLocal()), + usableDatasourceKey("DATASOURCE_ACCOUNT_LINK_KEY", codefly.IsLocal()), ) centralEnforcement, err := configuredCentralEnforcement() @@ -2037,16 +2036,41 @@ func identityEnv(key string) string { // this landing and that edit. The fallback goes when the cells have moved โ€” until // then, the warning is how an operator knows one has not. func identityProviderSecret(key string) string { - if value, err := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key); err == nil && hasConfiguredValue(value) { - return value - } + destination, _ := codefly.For(codefly.Context()).WorkspaceValue("identity-provider", key) legacy := identityEnv(key) - if hasConfiguredValue(legacy) { + + // A PROVISIONED destination value always wins. + // + // "Provisioned" has to exclude the placeholder this module ships in the new + // group's local defaults, and that is the whole of what this gets right: + // hasConfiguredValue rejects only the literal REPLACE_ME, so the shipped + // default read as configured and overrode a real value still delivered under + // `identity` โ€” defeating the migration fallback precisely on the cells that + // have not migrated yet, which are the only ones that need it. + if isProvisionedCredential(destination) { + return destination + } + if isProvisionedCredential(legacy) { reportIdentityProviderSecretStillInIdentityGroup(key) + return legacy + } + // Neither group holds a provisioned value: local development, where the + // shipped placeholder IS the intended value. Prefer the destination group so a + // developer edits the group this service now declares. + if strings.TrimSpace(destination) != "" { + return destination } return legacy } +// isProvisionedCredential reports whether a value is one an operator supplied, as +// opposed to a placeholder this module ships or an unset key. It is the selection +// rule for a credential that is moving between configuration groups: a shipped +// default must not count as the destination being ready. +func isProvisionedCredential(value string) bool { + return hasConfiguredValue(value) && !looksLikeShippedPlaceholder(value) +} + var identityProviderSecretWarned sync.Map func reportIdentityProviderSecretStillInIdentityGroup(key string) { @@ -2620,6 +2644,44 @@ func requireStartupConfiguration(ctx context.Context, isLocal bool) error { // an emailed link pointed at. A cell without it is a cell whose verified origin is // a request parameter, so it refuses here, where a deployment can still be fixed, // rather than serving a sign-in bound to somewhere else. +// usableDatasourceKey resolves one datasource message-authentication key, or nil +// when a deployed runtime may not use what is configured. +// +// The shipped local defaults for this group carry placeholder markers, exactly as +// every other secret group's do, and this module's repository is public. A deployed +// runtime holding one would be signing with a published value, so it is treated as +// ABSENT rather than refused at boot: unlike a perimeter credential, a datasource +// key is not required for the service to serve, and the purpose it signs for +// already has a defined unavailable state. Refusing to start would turn an +// unprovisioned optional purpose into an outage. +// +// Local development uses the shipped default, which is its intended value. +func usableDatasourceKey(key string, isLocal bool) []byte { + value := strings.TrimSpace(workspaceEnv("datasource-keys", key)) + if value == "" { + return nil + } + if isLocal { + return []byte(value) + } + if looksLikeShippedPlaceholder(value) { + wool.Get(context.Background()).In("usableDatasourceKey").Warn( + "a datasource key carries a placeholder this module ships in its public local defaults; "+ + "the purpose it signs for is unavailable until the key is provisioned for this cell", + wool.Field("key", "datasource-keys/"+key)) + return nil + } + if len(value) < minimumPerimeterCredentialLength { + wool.Get(context.Background()).In("usableDatasourceKey").Warn( + "a datasource key is shorter than the minimum for a signing key; "+ + "the purpose it signs for is unavailable until it is replaced", + wool.Field("key", "datasource-keys/"+key), + wool.Field("minimum", minimumPerimeterCredentialLength)) + return nil + } + return []byte(value) +} + func requireApplicationBaseURL(isLocal bool) error { if isLocal { return nil diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index fb3f99cd8..762234cd6 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -15,12 +15,11 @@ generic, and a registered target is data, never a branch. Three of this service's controls are only controls when the thing they depend on is there, and each used to degrade into something that read as working: -- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). Empty means no - forwarding header is trusted, so `clientIP` answers the peer โ€” behind the - frontend, the frontend's own pod. Every anonymous caller then shares one budget - and every authentication-factor attempt shares one, so a few bogus completions a - minute deny the factor to the whole cell. The limiter was enabled and the buckets - were enforced throughout; nothing said they had collapsed onto one key. +- **A trusted-proxy range** (`gateway/TRUSTED_PROXY_CIDRS`). SP-GW-09 requires the + anonymous and authentication-factor budgets to key on the originating client. + Empty means no forwarding header is trusted, so `clientIP` answers the peer โ€” + behind the frontend, the frontend's own pod โ€” and every caller shares one bucket. + It read as working: the limiter was enabled and the buckets were enforced. - **A shared rate-limit store.** Per-replica counters enforce the configured budget times the replica count, and which replica a caller lands on decides their share. - **A revocation store.** A revoker with no store answers "not revoked" to every @@ -85,22 +84,25 @@ request it forwards to a runtime-registered upstream โ€” a federated module pref a solution. A catalog route is untouched: accounts is the host's own API, where the session IS the credential. -A host access token is the person's whole session: one host-wide audience, the -person's full authority, valid at every other upstream and at the host's own API. -Forwarding it meant a compromised module or solution pod โ€” or a single logged -request header, or an upstream that redeems it to mint contexts for further -audiences โ€” yielded replayable full-authority sessions for every viewer who had -used it. The cookie went the same way, and the gateway has already resolved -identity from it by the time it forwards, so an upstream reading it learns nothing -it is not told. +The host access token carries one host-wide audience and the person's full +authority, so it is not a credential any single upstream should hold. The cookie +goes the same way, and the gateway has already resolved identity from it by the time +it forwards, so an upstream reading it learns nothing it is not told. An upstream receives the identity `ext_authz` stamped: the subject, the tenant, the session, the credential kind and the scope ceiling. That names the person without -carrying their authority. **An upstream that needs to act on the person's behalf -needs a host-minted capability bound to its own audience** โ€” the Work Context -surface โ€” not a bearer it borrowed. A consumer written against the old behaviour -has to change: the two consumer repositories that read the forwarded bearer own -their half of this. +carrying their authority. + +**SP-GW-07 has a second half this gateway does not yet satisfy.** The invariant is +that an upstream receives a host-minted context bound to THAT upstream's own +audience โ€” not merely that it stops receiving the person's session. Today it +receives the stamped identity headers and no audience-bound context, so the +credential is no longer over-broad but the positive half is absent. Minting it at +the edge is open work here; it cannot be supplied by a consumer. + +**A consumer written against the old behaviour has to change**: anything that read +the forwarded bearer needs a host-minted capability bound to its own audience +instead. ## A registered client calls without a proxy diff --git a/module/services/auth-gateway/code/deployed_failclosed_test.go b/module/services/auth-gateway/code/deployed_failclosed_test.go index f2447e372..4388594a5 100644 --- a/module/services/auth-gateway/code/deployed_failclosed_test.go +++ b/module/services/auth-gateway/code/deployed_failclosed_test.go @@ -7,13 +7,10 @@ import ( "github.com/stretchr/testify/require" ) -// A deployed gateway sits behind an ingress and a frontend hop, so with no -// trusted-proxy range the client address is always the frontend's own pod: every -// anonymous caller shares one budget and every authentication-factor attempt -// shares one. A few bogus completions a minute then deny the factor to the whole -// cell and a flood of anonymous requests denies login to all of it โ€” a denial of -// service delivered by the control meant to prevent one, which read as working -// because the limiter was enabled and the buckets were enforced. +// SP-GW-09: anonymous and authentication-factor budgets key on the originating +// client, and a cell with no trusted-proxy range refuses to start. A deployed +// gateway sits behind an ingress and a frontend hop, so with no trusted range the +// client address is the frontend's own pod and every caller shares one bucket. func TestDeployedRefusesEmptyProxyTrust(t *testing.T) { for _, raw := range []string{"", " ", ",", " , "} { err := requireProxyTrust(raw, false) diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 750d931c1..3c9cd6b47 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -496,22 +496,20 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. // before it reaches a runtime-registered upstream โ€” a composed module's federated // prefix, or a solution. // -// The host access token IS the person's whole session: a host-wide audience, the -// person's full authority, and valid at every other upstream and at the host's own -// API. Forwarding it verbatim meant a compromised module or solution pod โ€” or one -// logged request header, or one upstream that redeems it to mint contexts for -// other audiences โ€” yielded replayable full-authority sessions for every viewer -// who had used it. The Cookie goes with it for the same reason and one more: the -// gateway has already resolved identity from it by this point, so an upstream -// reading it learns nothing it is not told, and nothing legitimately needs the -// host's session cookie. +// The invariant (SP-GW-07): a module or solution upstream never receives the +// person's session credential or cookie. The host access token carries a host-wide +// audience and the person's full authority, so it is not a credential any single +// upstream should hold; the Cookie goes with it because the gateway has already +// resolved identity from it by this point, so an upstream reading it learns nothing +// it is not told. // -// What an upstream receives instead is what it should have been receiving: the -// identity headers ext_authz stamped (the subject, the tenant, the session, the -// credential kind, the scope ceiling), which name the person without carrying -// their authority. An upstream that needs to ACT on the person's behalf needs a -// host-minted capability bound to its own audience, which is the Work Context -// surface, not a bearer it borrowed. +// An upstream receives the identity headers ext_authz stamped โ€” the subject, the +// tenant, the session, the credential kind, the scope ceiling โ€” which name the +// person without carrying their authority. An upstream that needs to ACT on the +// person's behalf needs a host-minted capability bound to its own audience, which +// is the Work Context surface, not a bearer it borrowed. **That half of SP-GW-07 is +// not yet built here: see the audience-bound context note in +// services/auth-gateway/AGENTS.md.** // // Catalog routes are untouched: accounts is the host's own API, where the session // is the credential. diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go index 97224b0cc..1d2dd6d41 100644 --- a/module/services/auth-gateway/code/ratelimit.go +++ b/module/services/auth-gateway/code/ratelimit.go @@ -385,14 +385,15 @@ func newProxyTrust(raw string) proxyTrust { // requireProxyTrust is the boot check for a DEPLOYED runtime: the trusted-proxy // range must parse AND name at least one hop. // +// The invariant (SP-GW-09): anonymous and authentication-factor budgets are keyed +// on the ORIGINATING client, never on an intermediate hop, and a cell with no +// trusted-proxy range refuses to start. +// // An empty range is not a narrow configuration, it is no configuration: with no -// trusted hop, clientIP always answers the peer, which behind the frontend is the -// frontend's own pod. Every anonymous caller then shares one budget and every -// authentication-factor attempt shares one, so a few bogus completions a minute -// deny the factor to the whole cell and a flood of anonymous requests denies -// login to all of it. That is a denial of service delivered BY the control meant -// to prevent one, and it read as working: the limiter was enabled, the buckets -// were enforced, and nothing said they had collapsed onto one key. +// trusted hop clientIP answers the peer, which behind the frontend is the +// frontend's own pod, so every caller shares one bucket. It read as working โ€” the +// limiter was enabled and the buckets were enforced โ€” which is why this refuses at +// boot rather than warning. // // Local development has no ingress hop and the peer IS the client, so an empty // range is correct there and only there. diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go index 754f58c11..0896bbb25 100644 --- a/module/services/auth-gateway/code/upstream_session_credential_test.go +++ b/module/services/auth-gateway/code/upstream_session_credential_test.go @@ -8,14 +8,15 @@ import ( "github.com/stretchr/testify/require" ) -// The host access token IS the person's whole session: one host-wide audience, -// the person's full authority, valid at every other upstream and at the host's own -// API. Forwarding it verbatim meant a compromised module or solution pod โ€” or one -// logged request header โ€” yielded replayable full-authority sessions for every -// viewer who had used it. +// SP-GW-07: a module or solution upstream never receives the person's session +// credential or cookie. The host access token carries one host-wide audience and +// the person's full authority, so it is not a credential any single upstream should +// hold. // -// What an upstream receives instead is the identity ext_authz stamped, which names -// the person without carrying their authority. +// An upstream receives the identity ext_authz stamped, which names the person +// without carrying their authority. The invariant's other half โ€” a host-minted +// context bound to that upstream's audience โ€” is open work; see +// services/auth-gateway/AGENTS.md. func TestModuleUpstreamReceivesNoSessionBearer(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) fake := declareModule(t, gw, "documents") From 5795f0b9b7498e6b115b6711d6f35f6f4c43ee60 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:06:07 -0400 Subject: [PATCH 17/46] fix(auth-gateway): only a final response is sanitized (R1019-N09) R1019-N09. A 1xx is informational: net/http sends it and keeps the same header map open for the real response, which the reverse proxy then fills from the upstream. Stamping on it marked sanitization complete, so the final upstream headers went out untouched. An early hint is not an answer, and only a final response is stamped now. Tested through real local HTTP servers, because the behaviour is net/http's handling of an early hint and a recorder does not reproduce it. Mutation-checked in both directions. Rebase note (onto origin/main f5a104ce3). R1019-N05 is gone with its subject. It concerned a process-local module prefix-claim cache standing in for a durable store; #953 removed runtime module registration altogether, and a module route is now resolved per request from the delivered registry (gateway_module_routes.go, g.solutions.resolveModuleRouting), which is shared and durable by construction and answers 503 when no snapshot has loaded. There is no cache left to track an authority with. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../auth-gateway/code/gateway_cors.go | 11 +++++ .../code/gateway_upstream_cors_test.go | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/module/services/auth-gateway/code/gateway_cors.go b/module/services/auth-gateway/code/gateway_cors.go index fe9092b85..555c1add6 100644 --- a/module/services/auth-gateway/code/gateway_cors.go +++ b/module/services/auth-gateway/code/gateway_cors.go @@ -217,7 +217,18 @@ type corsResponseWriter struct { stamped bool } +// WriteHeader stamps the final response, and ONLY the final response. +// +// A 1xx is informational: net/http sends it and keeps the same header map open for +// the real response that follows, which the reverse proxy then fills from the +// upstream. Treating it as the response marked sanitization complete, so the final +// upstream headers โ€” including an upstream's own permissive grant โ€” went out +// untouched. An early hint is not an answer. func (w *corsResponseWriter) WriteHeader(status int) { + if status >= 100 && status < 200 { + w.ResponseWriter.WriteHeader(status) + return + } w.stamp() w.ResponseWriter.WriteHeader(status) } diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go index 484bd35ca..d8dd89ec2 100644 --- a/module/services/auth-gateway/code/gateway_upstream_cors_test.go +++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go @@ -3,6 +3,7 @@ package main import ( "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -81,3 +82,48 @@ func lowered(value string) string { } return string(out) } + +// A 1xx is informational: net/http sends it and keeps the same header map open for +// the real response the reverse proxy then fills from the upstream. Treating it as +// the response marked sanitization complete, so the final upstream headers โ€” an +// upstream's own permissive grant among them โ€” went out untouched. +// +// Driven through real local HTTP servers, because the behaviour is net/http's +// handling of an early hint and a recorder does not reproduce it. +func TestR1019CORSAfterInformationalResponse(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // An early hint, exactly as a real upstream may send before its answer. + w.Header().Set("Link", "; rel=preload") + w.WriteHeader(http.StatusEarlyHints) + w.Header().Del("Link") + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("upstream-body")) + })) + t.Cleanup(upstream.Close) + + gw, _, _, _ := newGatewayHarness(t) + upstreamURL, parseErr := url.Parse(upstream.URL) + require.NoError(t, parseErr) + gw.upstreams["accounts"] = upstreamURL + + front := httptest.NewServer(gw) + t.Cleanup(front.Close) + + request, err := http.NewRequest(http.MethodGet, front.URL+"/v1/status", nil) + require.NoError(t, err) + request.Header.Set("Origin", "https://evil.example") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + + require.Equal(t, http.StatusOK, response.StatusCode) + for name := range response.Header { + require.NotContains(t, lowered(name), "access-control-", + "the final response must carry no grant this gateway did not make: %s=%q", + name, response.Header.Get(name)) + } +} From 997f215a4652f03faab7ae1b37092121bf645b90 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:06:07 -0400 Subject: [PATCH 18/46] fix(accounts): a usable Connect refresh carrier, a public reason for a failed refresh, and one origin spelling (R1019-N07, N10, N12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1019-N07. Removing the credential from the Connect response body left a carrier that worked in one direction: a Connect client had nowhere to read it from, and the refresh and logout calls that need it failed field validation before reaching the verifier. The Connect refresh and logout now consume the credential from a Cookie header when the message does not carry one, before dispatch โ€” after validation it would be too late โ€” and an explicit body credential still wins, the same precedence the REST middleware uses. The cookie's path scope is a deliberate choice and is now written down beside it: `/v1/auth` is the REST auth surface and NOT the Connect procedure prefix, so a browser does not attach the cookie to a Connect call. Widening it to `/` would attach the refresh credential to every request this origin serves, including the ones the frontend proxies onward. The defined journey is therefore: authenticate over Connect, refresh and sign out over REST, where the cookie is in scope โ€” with the Connect calls still available to a caller that supplies the credential itself. R1019-N10. Only the recognized refusals were sanitized, so a host-side failure returned its wrapped chain, naming the internal call path and the store's address. A failure that is not a refusal now answers a stable retryable reason with the cause logged. A deliberate status from further in keeps its own reason and is returned WITHOUT its wrapper โ€” returning the error unchanged would carry the internal chain out alongside the good reason, which is the same defect one layer along. R1019-N11. Renewal's guard saw only the audience the REQUEST named, so an omitted one passed it and then inherited the parent's. The effective audience โ€” the one that gets signed โ€” is now checked after inheritance and before signing, and a source test holds that ordering. R1019-N12. An explicit default port is the same origin as none, and the two sides spelled it differently: a browser and the frontend drop it, a configured value may carry it. Both sides now go through one canonicalization, applied when the configured origin is installed as well as when a forwarded one arrives. A non-default port still distinguishes an origin, asserted in both directions so the rule does not collapse genuinely different origins. Each mutation-checked, each with a positive control. Rebase note (onto origin/main f5a104ce3). R1019-N11 โ€” re-checking the audience a renewal EFFECTIVELY signs, rather than the one the request named โ€” is closed by main. RenewWorkContext resolves the inherited audience into a local and gates that value with requireVocabularyAudience, once, after the inheritance and before signing. That is what this finding asked for, in one gate instead of two. The ordering is held from the source by TestR1019RenewInheritedAudience. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../pkg/adapters/auth_error_surface_test.go | 34 ++++++++++ .../code/pkg/adapters/connect_handlers.go | 31 +++++++++ .../refresh_credential_carrier_test.go | 65 +++++++++++++++++++ .../accounts/code/pkg/adapters/rest_extras.go | 14 ++++ .../accounts/code/pkg/adapters/rpcs.go | 25 ++++++- .../work_context_reserved_audience_test.go | 28 ++++++++ .../accounts/code/pkg/auth/public_origin.go | 31 ++++++++- .../pkg/auth/public_origin_configured_test.go | 48 ++++++++++++++ 8 files changed, 272 insertions(+), 4 deletions(-) diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go index 208cfa888..90645127c 100644 --- a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go +++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go @@ -204,3 +204,37 @@ func logoutContext() context.Context { } var logoutSessionID = uuid.MustParse("00000000-0000-4000-8000-0000000000c1") + +// SP-GW-13: a failed request carries a stable public reason and the cause is logged, +// not returned. Only the recognized refusals were sanitized, so an injected storage +// failure returned its internal address and wrapped call chain. +func TestR1019RefreshFailureHasPublicReason(t *testing.T) { + installRefusingRefreshMinter(t, errors.New( + "dial tcp 10.4.0.9:5432: connect: connection refused (session_store.RotateRefresh)")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err), + "a host-side failure is retryable, not a credential problem") + message := status.Convert(err).Message() + require.Equal(t, "refresh temporarily unavailable", message) + for _, leak := range []string{"10.4.0.9", "5432", "session_store", "dial tcp", "rpc error"} { + require.NotContains(t, message, leak, + "the public reason must name no internal address or call chain") + } +} + +// A deliberate status from further in keeps its own reason, so the mapping above does +// not flatten every answer into one. +func TestR1019RefreshKeepsADeliberateStatus(t *testing.T) { + installRefusingRefreshMinter(t, + status.Error(codes.FailedPrecondition, "device session is no longer active")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "device session is no longer active", status.Convert(err).Message()) +} diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index 2d8bb9b1f..0ea4b8da9 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "time" "connectrpc.com/connect" @@ -98,6 +99,30 @@ func unaryCookieingRefreshToken[Req, Resp any]( return resp, nil } +// refreshTokenFromCookie reads the refresh credential out of a Connect request's +// Cookie header and into the message, when the message does not already carry one. +// +// It is the request half of the cookie carrier, and without it the response half is +// a dead end: authentication stops returning the credential as readable content, so +// a Connect client has nowhere to read it from, and the refresh and logout calls +// that need it then fail field validation before reaching the verifier. A carrier +// that only works in one direction is not a carrier. +// +// The body still wins when it carries a token: the cookie is the browser's +// convenience, not an override of an explicit credential โ€” the same precedence the +// REST middleware uses. +func refreshTokenFromCookie(header http.Header, current string, set func(string)) { + if strings.TrimSpace(current) != "" { + return + } + for _, cookie := range (&http.Request{Header: header}).Cookies() { + if cookie.Name == refreshTokenCookieName && cookie.Value != "" { + set(cookie.Value) + return + } + } +} + // connectHandlersExemptFromTheRefreshCookie are the authentication-completing // Connect methods whose response body carries the refresh credential by // contract. One entry: the OAuth 2.1 token endpoint (see @@ -563,12 +588,18 @@ func (h *authConnectHandler) CompleteWebAuthnMFAChallenge(ctx context.Context, r return unaryCookieingRefreshToken(ctx, req, h.inner.CompleteWebAuthnMFAChallenge) } func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Request[gen.RefreshTokenRequest]) (*connect.Response[gen.RefreshTokenResponse], error) { + refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { + req.Msg.RefreshToken = token + }) return unaryCookieingRefreshToken(ctx, req, h.inner.RefreshToken) } func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) { return unary(ctx, req, h.inner.SwitchOrganization) } func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { + refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { + req.Msg.RefreshToken = token + }) return unary(ctx, req, h.inner.Logout) } func (h *authConnectHandler) ValidateClientAuthorization(ctx context.Context, req *connect.Request[gen.ValidateClientAuthorizationRequest]) (*connect.Response[gen.ValidateClientAuthorizationResponse], error) { diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go index 2303fc2ff..566199ffc 100644 --- a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go +++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go @@ -214,3 +214,68 @@ func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) { require.Equal(t, map[string]bool{"ExchangeClientToken": true}, connectHandlersExemptFromTheRefreshCookie) } + +// The response half removed the credential from the Connect body; without a request +// half a Connect client had nowhere to put it back, so refresh and logout failed +// field validation before reaching the verifier. A carrier that works in one +// direction is not a carrier. +func TestR1019ConnectRefreshConsumesCookie(t *testing.T) { + refresh := connect.NewRequest(&gen.RefreshTokenRequest{}) + refresh.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt; codefly_session=1") + refreshTokenFromCookie(refresh.Header(), refresh.Msg.GetRefreshToken(), func(token string) { + refresh.Msg.RefreshToken = token + }) + require.Equal(t, "cookie-rt", refresh.Msg.GetRefreshToken(), + "the Connect refresh must take the credential from the cookie") + + logout := connect.NewRequest(&gen.LogoutRequest{}) + logout.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt") + refreshTokenFromCookie(logout.Header(), logout.Msg.GetRefreshToken(), func(token string) { + logout.Msg.RefreshToken = token + }) + require.Equal(t, "cookie-rt", logout.Msg.GetRefreshToken()) +} + +// An explicit body credential wins: the cookie is the browser's convenience, not an +// override โ€” the same precedence the REST middleware uses. +func TestR1019ConnectBodyCredentialWinsOverTheCookie(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{RefreshToken: "body-rt"}) + request.Header().Set("Cookie", refreshTokenCookieName+"=cookie-rt") + refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) { + request.Msg.RefreshToken = token + }) + require.Equal(t, "body-rt", request.Msg.GetRefreshToken()) +} + +// No cookie and no body leaves the message untouched, so the absence is reported by +// field validation rather than by an empty credential reaching the verifier. +func TestR1019ConnectWithNoCarrierIsUnchanged(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{}) + request.Header().Set("Cookie", "codefly_session=1") + refreshTokenFromCookie(request.Header(), request.Msg.GetRefreshToken(), func(token string) { + request.Msg.RefreshToken = token + }) + require.Empty(t, request.Msg.GetRefreshToken()) +} + +// The lift has to run BEFORE the message is validated, or the empty field is +// rejected before the cookie is ever consulted. The handler is the only place that +// ordering holds, so it is asserted from the source. +func TestR1019ConnectRefreshLiftsBeforeValidation(t *testing.T) { + source, err := os.ReadFile("connect_handlers.go") + require.NoError(t, err) + text := string(source) + + for _, method := range []string{"RefreshToken", "Logout"} { + pattern := regexp.MustCompile(`func \(h \*authConnectHandler\) ` + method + + `\(ctx context\.Context(?s:.*?)\n\}\n`) + body := pattern.FindString(text) + require.NotEmpty(t, body, method) + lift := strings.Index(body, "refreshTokenFromCookie(") + dispatch := strings.Index(body, "h.inner.") + require.NotEqual(t, -1, lift, "%s must consume the cookie carrier", method) + require.Less(t, lift, dispatch, + "%s must lift the cookie before dispatching, or validation refuses the empty field first", + method) + } +} diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go index 3faae80a6..8cdad6b5a 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras.go @@ -103,6 +103,20 @@ var authenticationCompletingRESTPaths = map[string]bool{ "/v1/auth/refresh": true, } +// The refresh cookie is scoped to `/v1/auth`, which is the REST auth surface and +// NOT the Connect procedure prefix (`/saas.accounts.v1.AuthService/โ€ฆ`). A browser +// therefore does not attach it to a Connect call, and that is deliberate: the +// alternative is a cookie scoped to `/`, which would attach the refresh credential +// to every request this origin serves, including the ones the frontend proxies +// onward. +// +// So the journey for a Connect client is defined rather than accidental: +// authenticate over Connect (the response sets the cookie and carries no readable +// token), then REFRESH AND SIGN OUT over REST `/v1/auth/refresh` and +// `/v1/auth/logout`, where the cookie is in scope. The Connect refresh and logout +// still work for a caller that supplies the credential itself โ€” in its body, or as +// an explicit Cookie header โ€” which refreshTokenFromCookie consumes. + // oauthTokenEndpointRESTPath is the one authentication-completing route whose // response carries the refresh credential in the body, and must. // diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 5dbbd4d02..53ed92df0 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1154,13 +1154,36 @@ func (s *AuthServer) RefreshToken(ctx context.Context, req *gen.RefreshTokenRequ // session policy) still passes through unchanged: it is a genuine server-side // failure, and reporting it as a credential problem would send an operator // looking at the wrong half. +// innerStatus finds a gRPC status an error carries beneath any wrapping, or nil. +// status.Convert cannot be used for this: given a wrapped error it builds a status +// from the whole chain's text, which is the internal detail the caller must not see. +func innerStatus(err error) *status.Status { + var carrier interface{ GRPCStatus() *status.Status } + if errors.As(err, &carrier) { + return carrier.GRPCStatus() + } + return nil +} + func refreshStatusError(ctx context.Context, err error) error { switch { case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked): wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err)) return status.Error(codes.Unauthenticated, "invalid refresh token") + case innerStatus(err) != nil: + // A deliberate status from further in โ€” a session that is no longer active, a + // policy refusal. Its reason was chosen for the caller, so it is returned + // WITHOUT the wrapper: the wrapping is what names the internal call chain, + // and returning err unchanged would carry it out with the good reason. + return innerStatus(err).Err() default: - return err + // Anything else is this host failing, and the wrapped chain names the + // internal call path and the store's address. SP-GW-13: a refused or failed + // request carries a stable public reason and the cause is logged, not + // returned. Unavailable rather than Internal because a refresh is worth + // retrying. + wool.Get(ctx).In("RefreshToken").Error("refresh failed", wool.ErrField(err)) + return status.Error(codes.Unavailable, "refresh temporarily unavailable") } } diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go index 2f21834f3..ec21067e7 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go @@ -163,3 +163,31 @@ const uuidForReservedAudienceTest = "00000000-0000-4000-8000-000000000001" func scopesForReservedAudienceTest() []*gen.WorkContextScope { return []*gen.WorkContextScope{{ResourceKind: "records", Actions: []string{"read"}}} } + +// Renewal's guard at the top of the method sees only what the REQUEST named, so an +// omitted audience passed it and then inherited the parent's โ€” the one path by which +// a person-driven renewal could still carry the reserved module audience. The +// EFFECTIVE value is what gets signed, so the effective value is what is refused. +func TestR1019RenewInheritedAudience(t *testing.T) { + source, err := os.ReadFile("work_context_rpcs.go") + require.NoError(t, err) + text := string(source) + + body := regexp.MustCompile( + `func \(s \*WorkContextAuthorityServer\) RenewWorkContext\((?s:.*?)\n\}\n`). + FindString(text) + require.NotEmpty(t, body) + + inherit := strings.Index(body, "audience = parent.GetAudience()") + require.NotEqual(t, -1, inherit, "renewal still inherits the parent audience") + + // A refusal of the EFFECTIVE audience, after the inheritance and before signing. + effective := strings.Index(body, "refuseReservedModuleAudience(audience)") + require.NotEqual(t, -1, effective, + "renewal must refuse the audience it will actually sign, not only the one requested") + require.Less(t, inherit, effective, "the check must follow the inheritance") + + sign := strings.Index(body, "s.signer.") + require.NotEqual(t, -1, sign) + require.Less(t, effective, sign, "the check must precede signing") +} diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 0cbf4546e..28809477b 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -18,8 +18,19 @@ var configuredPublicOrigin string // SetConfiguredPublicOrigin installs the operator-configured public origin. Call // it once from startup, before anything serves. +// +// The value is stored CANONICALIZED, through the same function the forwarded origin +// goes through, so the two sides of the comparison cannot spell one origin two ways. +// Storing it raw is what made `https://app.example:443` refuse the equivalent origin +// the frontend forwards. A value that does not canonicalize is stored trimmed, so the +// comparison refuses it rather than silently matching something. func SetConfiguredPublicOrigin(origin string) { - configuredPublicOrigin = strings.TrimSuffix(strings.TrimSpace(origin), "/") + trimmed := strings.TrimSuffix(strings.TrimSpace(origin), "/") + if canonical, err := CanonicalPublicOrigin(trimmed); err == nil { + configuredPublicOrigin = canonical + return + } + configuredPublicOrigin = trimmed } // ConfiguredPublicOrigin returns the pinned origin, if there is one. @@ -47,7 +58,8 @@ func CanonicalPublicOrigin(candidate string) (string, error) { parsed.ForceQuery || parsed.Fragment != "" { return "", fmt.Errorf("public origin must not contain credentials, path, query, or fragment") } - switch strings.ToLower(parsed.Scheme) { + scheme := strings.ToLower(parsed.Scheme) + switch scheme { case "https": case "http": if !isPublicOriginLoopback(parsed.Hostname()) { @@ -56,7 +68,20 @@ func CanonicalPublicOrigin(candidate string) (string, error) { default: return "", fmt.Errorf("public origin must use HTTP(S)") } - return strings.TrimSuffix(candidate, "/"), nil + // An explicit DEFAULT port is the same origin as none, and the two sides of the + // comparison spell it differently: the browser and the frontend both drop it + // (URL.origin does), while a configured value may carry it. Retaining it here + // made an operator's `https://app.example:443` refuse the equivalent origin the + // frontend forwards. Dropping it is the same rule both sides then follow. + host := parsed.Host + if port := parsed.Port(); port != "" && + ((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) { + host = parsed.Hostname() + if strings.Contains(host, ":") { + host = "[" + host + "]" // an IPv6 literal keeps its brackets + } + } + return scheme + "://" + host, nil } // WithVerifiedPublicOrigin records the origin only after the gateway credential diff --git a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go index 32458bb7d..ea1240555 100644 --- a/module/services/accounts/code/pkg/auth/public_origin_configured_test.go +++ b/module/services/accounts/code/pkg/auth/public_origin_configured_test.go @@ -95,3 +95,51 @@ func pin(t *testing.T, origin string) { t.Cleanup(func() { auth.SetConfiguredPublicOrigin(previous) }) auth.SetConfiguredPublicOrigin(origin) } + +// An explicit DEFAULT port is the same origin as none, and the two sides spell it +// differently: a browser and the frontend both drop it, while a configured value may +// carry it. Retaining it made an operator's `https://app.example:443` refuse the +// equivalent origin the frontend forwards. +func TestR1019ConfiguredOriginDefaultPorts(t *testing.T) { + for _, tc := range []struct{ configured, forwarded string }{ + {"https://app.cell.example:443", "https://app.cell.example"}, + {"https://app.cell.example", "https://app.cell.example:443"}, + {"http://localhost:80", "http://localhost"}, + {"http://localhost", "http://localhost:80"}, + } { + t.Run(tc.configured+" vs "+tc.forwarded, func(t *testing.T) { + pin(t, tc.configured) + _, err := auth.WithVerifiedPublicOrigin(context.Background(), tc.forwarded) + require.NoError(t, err, "equivalent origins must not disagree over a default port") + }) + } +} + +// A NON-default port is part of the origin and still distinguishes it, so the +// canonicalization above does not collapse genuinely different origins. +func TestR1019NonDefaultPortStillDistinguishesAnOrigin(t *testing.T) { + pin(t, "https://app.cell.example") + _, err := auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example:8443") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) + + pin(t, "https://app.cell.example:8443") + _, err = auth.WithVerifiedPublicOrigin(context.Background(), "https://app.cell.example") + require.ErrorIs(t, err, auth.ErrPublicOriginNotConfigured) +} + +// CanonicalPublicOrigin returns the canonical spelling, so a caller that stores the +// result stores one form. +func TestR1019CanonicalOriginDropsOnlyTheDefaultPort(t *testing.T) { + for candidate, want := range map[string]string{ + "https://app.example:443": "https://app.example", + "https://app.example": "https://app.example", + "https://app.example:8443": "https://app.example:8443", + "http://127.0.0.1:80": "http://127.0.0.1", + "http://127.0.0.1:3000": "http://127.0.0.1:3000", + "https://app.example/": "https://app.example", + } { + got, err := auth.CanonicalPublicOrigin(candidate) + require.NoError(t, err, candidate) + require.Equal(t, want, got, candidate) + } +} From 45c1e26a11ca284637c40d9555f20bc0d8d0e7bc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:31:04 -0400 Subject: [PATCH 19/46] test: the eight fix-removal mutations that survived the review (R1019-N15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review ran 26 representative fix-removal mutations and found eight surviving the tests I had landed. A mutation that survives means the test suite does not hold the fix, whatever the suite's result says, so each survivor gets a test that fails when the fix is removed. - **Gateway boot wiring.** Every deployed refusal is only a refusal if main wires it, and removing a call site left every unit test green while a deployed gateway would start on a published credential, with no shared store for revocation, rate limits or prefix claims, and with no trusted-proxy range. All five wirings are now asserted from the source, each matched with codefly.IsLocal() specifically โ€” wiring one to a constant is the same defect as not wiring it. - **Marketing HSTS.** The marketing service had no header test at all; it has one now, covering the transport pin, the absence of includeSubDomains and preload, and the framing and sniffing headers beside them, so removing any of the four is detected rather than only the newest. - **Datasource key independence.** The existing test asserted only that the two keys DIFFER, which a link key derived from the ticket input still satisfies โ€” so the two purposes could share one secret again, which is the whole of the finding. The test now varies one input at a time and requires the other purpose not to move. - **Logout session revocation, Connect response lifting, identity-provider reader selection and datasource configuration** are covered by the tests landed with their own findings; each was re-checked here by removing the fix and confirming red. Also corrects a claim of mine the review found overstated: I wrote that a deployed wiring cannot reach the in-process rate limiter. The option exists and any caller could pass it. What holds is narrower and is what the new source assertion actually establishes โ€” the only production call site passes codefly.IsLocal(), and production neither hard-enables the option nor uses the per-replica constructor. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../datasource_content_ticket_test.go | 39 +++++++++++++++ .../code/shipped_placeholder_test.go | 48 +++++++++++++++++++ .../code/tests/security-headers.test.ts | 44 +++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 module/services/marketing/code/tests/security-headers.test.ts diff --git a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go index 62ca7c52f..03b2d47e1 100644 --- a/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go +++ b/module/services/accounts/code/pkg/business/datasource_content_ticket_test.go @@ -100,3 +100,42 @@ func TestDatasourceKeysStayDistinctUnderOneProvisionedValue(t *testing.T) { t.Fatal("the link key and the ticket key must differ even from one provisioned value") } } + +// Each purpose's key must derive from ITS OWN delivered input. Asserting only that the +// two keys differ is not enough: a link key derived from the TICKET input through a +// domain label still differs from the ticket key, so that assertion passes while the +// two purposes once again share one secret โ€” which is the whole of SA-F-KEYREUSE. +// +// So this varies one input at a time and requires the other purpose not to move. +func TestR1019DatasourceKeysDeriveFromTheirOwnInput(t *testing.T) { + keys := func(ticket, link string) (string, string) { + s := &Service{} + s.SetDatasourceKeys([]byte(ticket), []byte(link)) + if s.datasourceTicketSigner == nil || s.datasourceLinkKey == nil { + t.Fatal("both purposes must be available") + } + return string(s.datasourceTicketSigner.key), string(s.datasourceLinkKey) + } + + baseTicket, baseLink := keys("ticket-key-one", "link-key-one") + + // Changing the LINK input must move the link key and leave the ticket key alone. + movedTicket, movedLink := keys("ticket-key-one", "link-key-two") + if movedLink == baseLink { + t.Fatal("the link key does not depend on the account-link input") + } + if movedTicket != baseTicket { + t.Fatal("the ticket key must not depend on the account-link input") + } + + // And changing the TICKET input must move the ticket key and leave the link key + // alone. This is the direction that catches a link key derived from the ticket + // input. + movedTicket, movedLink = keys("ticket-key-two", "link-key-one") + if movedTicket == baseTicket { + t.Fatal("the ticket key does not depend on the content-ticket input") + } + if movedLink != baseLink { + t.Fatal("the link key must not depend on the content-ticket input") + } +} diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go index feda7d7f0..154473cf5 100644 --- a/module/services/auth-gateway/code/shipped_placeholder_test.go +++ b/module/services/auth-gateway/code/shipped_placeholder_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "strings" "testing" @@ -54,3 +55,50 @@ func TestPerimeterCredentialsCoverEveryTrustDecidingKey(t *testing.T) { "gateway-trust/CODEFLY_GATEWAY_TOKEN": true, }, names) } + +// The boot refusals are only refusals if main WIRES them. Removing a call site leaves +// every unit test above green while a deployed gateway starts on a published +// credential, with no shared store for revocation or rate limits, and with no +// trusted-proxy range โ€” so the wiring is asserted from the source. +// +// A fourth refusal stood here, for a durable module prefix-claim store. It is gone +// because its subject is: a module route is no longer claimed at runtime but read +// per request from the delivered registry (gateway_module_routes.go), which is +// shared and durable by construction and fails closed when no snapshot has loaded. +// +// Each is matched with codefly.IsLocal() specifically: wiring one of these to a +// constant, or to a request-derived value, is the same defect as not wiring it. +func TestR1019DeployedRefusalsAreWiredAtBoot(t *testing.T) { + source, err := os.ReadFile("main.go") + require.NoError(t, err) + text := string(source) + + for _, wiring := range []struct{ what, call string }{ + {"the shipped-placeholder refusal", "requirePerimeterCredentials(codefly.IsLocal())"}, + {"the trusted-proxy range refusal", "requireProxyTrust(workspaceEnv(\"gateway\", \"TRUSTED_PROXY_CIDRS\"), codefly.IsLocal())"}, + {"the revocation-store refusal", "newRevoker(redisURL, revocationTTL, codefly.IsLocal())"}, + {"the rate-limit store refusal", "WithInProcessBackendAllowed(codefly.IsLocal())"}, + } { + require.Contains(t, text, wiring.call, + "%s must be wired at boot from the runtime, not from a constant", wiring.what) + } + + // And the refusal has to stop the process. A call whose error is ignored is not a + // refusal; every one of these is fatal at boot. + require.NotContains(t, text, "_ = requirePerimeterCredentials", + "the placeholder refusal's error must not be discarded") +} + +// The per-replica limiter is reachable only through a distinct constructor, so a +// deployed wiring cannot opt into it by passing a flag. This is the claim the review +// found overstated: the option EXISTS and any caller could pass it, so what holds is +// that the only production call site passes codefly.IsLocal() โ€” which is what the +// source assertion above actually establishes. +func TestR1019InProcessLimiterIsNotTheProductionWiring(t *testing.T) { + source, err := os.ReadFile("main.go") + require.NoError(t, err) + require.NotContains(t, string(source), "WithInProcessBackendAllowed(true)", + "production must not hard-enable the per-replica limiter") + require.NotContains(t, string(source), "newInProcessRateLimiter(", + "the per-replica constructor is for tests and local development only") +} diff --git a/module/services/marketing/code/tests/security-headers.test.ts b/module/services/marketing/code/tests/security-headers.test.ts new file mode 100644 index 000000000..42866be5e --- /dev/null +++ b/module/services/marketing/code/tests/security-headers.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import nextConfig from "@/../next.config.mjs"; + +type Header = { key: string; value: string }; + +async function headersFor(path: string): Promise> { + const groups = await nextConfig.headers!(); + const matched = groups.filter((group) => group.source.startsWith("/:path")); + assert.ok(matched.length > 0, `no header group matches ${path}`); + return new Map( + matched.flatMap((group) => group.headers as Header[]).map((h) => [h.key, h.value]), + ); +} + +// The edge redirects plaintext to TLS, which protects the redirect and nothing before +// it: a browser's first request to a host it has never pinned goes out in the clear. +// The origin has to ask the browser to remember. +test("the marketing origin pins HTTPS", async () => { + const headers = await headersFor("/"); + const sts = headers.get("Strict-Transport-Security"); + assert.ok(sts, "Strict-Transport-Security is absent"); + assert.match(sts, /max-age=\d+/); + assert.ok(Number(sts.match(/max-age=(\d+)/)![1]) >= 31536000, "max-age is under a year"); +}); + +// includeSubDomains and preload commit hosts this site does not own: a sibling +// subdomain on plaintext breaks under the first, and the second is effectively +// irreversible. Their absence is a decision, so it is asserted. +test("the marketing origin commits no host but its own", async () => { + const sts = (await headersFor("/")).get("Strict-Transport-Security")!; + assert.ok(!sts.includes("includeSubDomains")); + assert.ok(!sts.includes("preload")); +}); + +// The framing and sniffing defenses beside it, so removing any one of the three is +// detected rather than only the newest. +test("the marketing origin refuses framing and sniffing", async () => { + const headers = await headersFor("/"); + assert.equal(headers.get("X-Frame-Options"), "DENY"); + assert.equal(headers.get("X-Content-Type-Options"), "nosniff"); + assert.match(headers.get("Content-Security-Policy")!, /frame-ancestors 'none'/); +}); From 727407dfd53cda987df87b67b901c7acb52bf39c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:50:20 -0400 Subject: [PATCH 20/46] fix: a required perimeter credential must be present, and the marketing header test must compile (R1019-N16, N17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N16: tests/security-headers.test.ts imported next.config.mjs with no declaration, so the import was implicitly `any` and its callbacks were implicit-any too. The project compiles with allowJs disabled, so the configuration carries no types of its own; tests/next-config.d.ts declares the shape the tests read. Both `npm run typecheck` and the compile gate were failing on it. The test also accepted any value CONTAINING `max-age=`, which `not-max-age=` does. A header value is a list of directives, so the assertions now read the parsed list and a directive counts only as its own member. Both mutations โ€” renaming the directive, and adding includeSubDomains/preload โ€” now fail. N17: refusePerimeterCredential returned nil for an empty value in both services, on the reasoning that whether a credential is required belongs to each loader. No loader asked, so a deployed cell could start holding no perimeter credential at all. A perimeterCredential now carries whether it is the CURRENT value or a rotation's PREVIOUS half: an absent current credential is refused by its group-qualified name, and an absent rotation half still starts, because a cell that is not mid-rotation carries none and refusing that would make the ordinary state an outage. The accounts suite asserted the defect as correct โ€” NoError with nothing provisioned at all โ€” so that assertion is now the refusal, with the rotation halves still admitted beside it. The new coverage goes through requireStartupConfiguration, which is what main calls, and carries a positive control so an unconditionally refusing startup path cannot pass it. Co-Authored-By: Claude Opus 5 (1M context) --- .../accounts/code/shipped_placeholder.go | 38 ++++++--- .../accounts/code/shipped_placeholder_test.go | 84 ++++++++++++++++--- .../auth-gateway/code/shipped_placeholder.go | 35 ++++++-- .../code/shipped_placeholder_test.go | 68 ++++++++++++++- .../marketing/code/tests/next-config.d.ts | 12 +++ .../code/tests/security-headers.test.ts | 28 +++++-- 6 files changed, 221 insertions(+), 44 deletions(-) create mode 100644 module/services/marketing/code/tests/next-config.d.ts diff --git a/module/services/accounts/code/shipped_placeholder.go b/module/services/accounts/code/shipped_placeholder.go index d2f83ecc9..b1f921cbf 100644 --- a/module/services/accounts/code/shipped_placeholder.go +++ b/module/services/accounts/code/shipped_placeholder.go @@ -60,12 +60,15 @@ func looksLikeShippedPlaceholder(value string) bool { // deployed runtime, or nil. name is the group-qualified key, so the refusal says // what an operator has to provision rather than that something was wrong. // -// It never includes the value. An empty value is not refused here: whether a -// credential is REQUIRED is each loader's own question, and answering it twice -// would make an optional group's absence read as a placeholder. -func refusePerimeterCredential(name, value string) error { +// It never includes the value. +func refusePerimeterCredential(name, value string, current bool) error { trimmed := strings.TrimSpace(value) if trimmed == "" { + if current { + return fmt.Errorf( + "%s is absent; a deployed cell must provision it before this service can "+ + "decide that a caller is inside the perimeter", name) + } return nil } if looksLikeShippedPlaceholder(trimmed) { @@ -81,15 +84,28 @@ func refusePerimeterCredential(name, value string) error { return nil } +// perimeterCredential is one value this service trusts to decide that a caller is +// inside the perimeter. Current distinguishes the two cases a single list was +// conflating: a CURRENT credential the deployment must carry, and the PREVIOUS +// half of a rotation, which is absent on every cell that is not mid-rotation. +// Refusing both alike would make the ordinary state an outage; refusing neither +// let a cell start with no perimeter credential at all, which is what R1019-N17 +// found. The flag is the distinction, so each answer is given once. +type perimeterCredential struct { + Name string + Value string + Current bool +} + // perimeterCredentials are the values this service trusts to decide that a caller // is inside the perimeter: the cluster-internal token both halves of a rotation, // and the gateway-provenance token this service believes forwarded identity on. -func perimeterCredentials() []struct{ Name, Value string } { - return []struct{ Name, Value string }{ - {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")}, - {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")}, - {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")}, - {"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN_PREVIOUS")}, +func perimeterCredentials() []perimeterCredential { + return []perimeterCredential{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"), true}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"), false}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN"), true}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN_PREVIOUS", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN_PREVIOUS"), false}, } } @@ -105,7 +121,7 @@ func requirePerimeterCredentials(isLocal bool) error { } var problems []string for _, credential := range perimeterCredentials() { - if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil { + if err := refusePerimeterCredential(credential.Name, credential.Value, credential.Current); err != nil { problems = append(problems, err.Error()) } } diff --git a/module/services/accounts/code/shipped_placeholder_test.go b/module/services/accounts/code/shipped_placeholder_test.go index 3c411fbcd..063eec767 100644 --- a/module/services/accounts/code/shipped_placeholder_test.go +++ b/module/services/accounts/code/shipped_placeholder_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" + "accounts/pkg/keyservice" + "github.com/stretchr/testify/require" ) @@ -19,7 +21,7 @@ func TestDeployedRefusesShippedPlaceholders(t *testing.T) { "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", "a-placeholder-value-long-enough-to-pass-the-length-floor", } { - err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", value) + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", value, true) require.Error(t, err, "%q carries a shipped marker and must be refused", value) require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN", "the refusal must name the key an operator has to provision") @@ -30,7 +32,7 @@ func TestDeployedRefusesShippedPlaceholders(t *testing.T) { // The length floor refuses a short value that carries no marker at all, so the // two checks are not one check spelled twice. func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { - err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", "7Kq2Xp9Vb4Nf") + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", "7Kq2Xp9Vb4Nf", true) require.Error(t, err) require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") require.Contains(t, err.Error(), "at least 32") @@ -39,14 +41,17 @@ func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { // The controls. A provisioned credential passes, and an ABSENT one is not refused // here: whether a credential is required is each loader's own question, and -// answering it twice would make an optional group's absence read as a placeholder. +// a rotation's PREVIOUS half is absent on every cell that is not mid-rotation, so +// refusing it would make the ordinary state an outage. A CURRENT credential's +// absence is the opposite answer, and R1019-N17 is what giving both the same one +// cost: a deployed cell started with no perimeter credential at all. func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { require.NoError(t, refusePerimeterCredential( - "internal-auth/CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4))) + "internal-auth/CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4), true)) require.NoError(t, refusePerimeterCredential( - "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "")) + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "", false)) require.NoError(t, refusePerimeterCredential( - "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", " ")) + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", " ", false)) } // Local development runs on the shipped defaults, so the gate is the deployed @@ -113,19 +118,76 @@ func TestDeployedPerimeterCheckReadsTheConfigurationGroup(t *testing.T) { blankWorkspaceKey(t, "gateway-trust", key) } - // Nothing provisioned: each loader's own required-or-optional question, not - // this one's. - require.NoError(t, requirePerimeterCredentials(false)) + // Nothing provisioned. This asserted NoError until R1019-N17: deferring the + // required-or-optional question to "each loader" meant no loader asked it, and a + // deployed cell started holding no perimeter credential at all. The CURRENT keys + // are refused by name here; the rotation halves stay optional below. + err := requirePerimeterCredentials(false) + require.Error(t, err, "a deployed cell with no perimeter credential must refuse to start") + require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN") + require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") + require.NotContains(t, err.Error(), "CODEFLY_INTERNAL_TOKEN_PREVIOUS", + "a rotation half is absent on every cell that is not mid-rotation") // The value this module ships in its public local defaults. t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", "local-dev-only-replace-me") - err := requirePerimeterCredentials(false) + err = requirePerimeterCredentials(false) require.Error(t, err) require.Contains(t, err.Error(), "internal-auth/CODEFLY_INTERNAL_TOKEN") - // A provisioned one. + // Provisioned values for both CURRENT keys, and still no rotation halves. t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__CODEFLY_INTERNAL_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__CODEFLY_GATEWAY_TOKEN", + strings.Repeat("9Zr4Lm7Td2Wc", 4)) require.NoError(t, requirePerimeterCredentials(false)) } + +// R1019-N17, through the whole startup path rather than the perimeter check alone: +// with key custody and the public origin provisioned, an absent CURRENT perimeter +// credential still has to stop the service. requireStartupConfiguration is what +// main calls, so a refusal that exists only in the helper is not a refusal. +func TestR1019StartupRefusesAnAbsentCurrentPerimeterCredential(t *testing.T) { + provision := func(t *testing.T, absentKey string) { + t.Helper() + // Everything a hosted startup requires BEFORE the perimeter credentials, + // so the refusal under test is the one that answers. requireStartupConfiguration + // resolves the key service first and then its custody, and each refuses a + // hosted boot by name โ€” so without these the subject is never reached and + // every case below would pass on somebody else's refusal. + keyServiceBackend(t, string(keyservice.BackendVault)) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__VAULT__VAULT_KEY_CUSTODY", "make seed-signing-key") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", "https://app.example") + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + upper := strings.ReplaceAll(strings.ToUpper(group), "-", "_") + value := strings.Repeat("7Kq2Xp9Vb4Nf", 4) + if key == absentKey || strings.HasSuffix(key, "_PREVIOUS") { + value = "" + } + t.Setenv(key, value) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, value) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, value) + } + } + + for _, absent := range []struct{ group, key string }{ + {"internal-auth", "CODEFLY_INTERNAL_TOKEN"}, + {"gateway-trust", "CODEFLY_GATEWAY_TOKEN"}, + } { + t.Run(absent.key, func(t *testing.T) { + provision(t, absent.key) + err := requireStartupConfiguration(t.Context(), false) + require.Error(t, err, "%s is absent and startup must refuse", absent.key) + require.Contains(t, err.Error(), absent.group+"/"+absent.key) + }) + } + + // The positive control: the same provisioning with nothing absent starts. Without + // it, a startup path that refused unconditionally would pass every case above. + t.Run("provisioned startup proceeds", func(t *testing.T) { + provision(t, "") + require.NoError(t, requireStartupConfiguration(t.Context(), false)) + }) +} diff --git a/module/services/auth-gateway/code/shipped_placeholder.go b/module/services/auth-gateway/code/shipped_placeholder.go index 286744b24..63fd9ca10 100644 --- a/module/services/auth-gateway/code/shipped_placeholder.go +++ b/module/services/auth-gateway/code/shipped_placeholder.go @@ -47,11 +47,15 @@ func looksLikeShippedPlaceholder(value string) bool { // refusePerimeterCredential reports why a perimeter credential is unfit for a // deployed runtime, or nil. It names the group-qualified key and never the value. -// An empty value is not refused here: whether a credential is required is the -// loader's own question. -func refusePerimeterCredential(name, value string) error { +// It never includes the value. +func refusePerimeterCredential(name, value string, current bool) error { trimmed := strings.TrimSpace(value) if trimmed == "" { + if current { + return fmt.Errorf( + "%s is absent; a deployed cell must provision it before this service can "+ + "decide that a caller is inside the perimeter", name) + } return nil } if looksLikeShippedPlaceholder(trimmed) { @@ -67,13 +71,26 @@ func refusePerimeterCredential(name, value string) error { return nil } +// perimeterCredential is one value this service trusts to decide that a caller is +// inside the perimeter. Current distinguishes the two cases a single list was +// conflating: a CURRENT credential the deployment must carry, and the PREVIOUS +// half of a rotation, which is absent on every cell that is not mid-rotation. +// Refusing both alike would make the ordinary state an outage; refusing neither +// let a cell start with no perimeter credential at all, which is what R1019-N17 +// found. The flag is the distinction, so each answer is given once. +type perimeterCredential struct { + Name string + Value string + Current bool +} + // perimeterCredentials are the values this gateway trusts to decide that a caller // is inside the perimeter. -func perimeterCredentials() []struct{ Name, Value string } { - return []struct{ Name, Value string }{ - {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN")}, - {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS")}, - {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN")}, +func perimeterCredentials() []perimeterCredential { + return []perimeterCredential{ + {"internal-auth/CODEFLY_INTERNAL_TOKEN", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN"), true}, + {"internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", workspaceEnv("internal-auth", "CODEFLY_INTERNAL_TOKEN_PREVIOUS"), false}, + {"gateway-trust/CODEFLY_GATEWAY_TOKEN", workspaceEnv("gateway-trust", "CODEFLY_GATEWAY_TOKEN"), true}, } } @@ -87,7 +104,7 @@ func requirePerimeterCredentials(isLocal bool) error { } var problems []string for _, credential := range perimeterCredentials() { - if err := refusePerimeterCredential(credential.Name, credential.Value); err != nil { + if err := refusePerimeterCredential(credential.Name, credential.Value, credential.Current); err != nil { problems = append(problems, err.Error()) } } diff --git a/module/services/auth-gateway/code/shipped_placeholder_test.go b/module/services/auth-gateway/code/shipped_placeholder_test.go index 154473cf5..be97bd27d 100644 --- a/module/services/auth-gateway/code/shipped_placeholder_test.go +++ b/module/services/auth-gateway/code/shipped_placeholder_test.go @@ -17,7 +17,7 @@ func TestDeployedRefusesShippedPlaceholders(t *testing.T) { "LOCAL-DEV-GATEWAY-ONLY-REPLACE-ME", "prefixed-changeme-suffixed-and-long-enough-to-pass-the-length-floor", } { - err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", value) + err := refusePerimeterCredential("gateway-trust/CODEFLY_GATEWAY_TOKEN", value, true) require.Error(t, err, "%q carries a shipped marker and must be refused", value) require.Contains(t, err.Error(), "gateway-trust/CODEFLY_GATEWAY_TOKEN") require.NotContains(t, err.Error(), value, "a refusal never prints the value") @@ -25,7 +25,7 @@ func TestDeployedRefusesShippedPlaceholders(t *testing.T) { } func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { - err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", "7Kq2Xp9Vb4Nf") + err := refusePerimeterCredential("internal-auth/CODEFLY_INTERNAL_TOKEN", "7Kq2Xp9Vb4Nf", true) require.Error(t, err) require.Contains(t, err.Error(), "at least 32") require.NotContains(t, err.Error(), "7Kq2Xp9Vb4Nf") @@ -33,9 +33,9 @@ func TestDeployedRefusesAShortPerimeterCredential(t *testing.T) { func TestPerimeterCredentialChecksAdmitWhatTheyShould(t *testing.T) { require.NoError(t, refusePerimeterCredential( - "gateway-trust/CODEFLY_GATEWAY_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4))) + "gateway-trust/CODEFLY_GATEWAY_TOKEN", strings.Repeat("7Kq2Xp9Vb4Nf", 4), true)) require.NoError(t, refusePerimeterCredential( - "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "")) + "internal-auth/CODEFLY_INTERNAL_TOKEN_PREVIOUS", "", false)) } func TestLocalRuntimeKeepsTheShippedDefaults(t *testing.T) { @@ -102,3 +102,63 @@ func TestR1019InProcessLimiterIsNotTheProductionWiring(t *testing.T) { require.NotContains(t, string(source), "newInProcessRateLimiter(", "the per-replica constructor is for tests and local development only") } + +// R1019-N17: a CURRENT perimeter credential that is absent is refused by name, and +// over the real carriers rather than only through the pure comparison. The gateway +// decides perimeter membership on these, so starting without one admits whoever +// reaches the internal endpoints. +// +// The PREVIOUS half is asserted in the same test rather than a separate one, +// because the property is the DISTINCTION: a check that refuses both is as wrong +// as a check that refuses neither, and only one assertion catches each direction. +func TestR1019AbsentCurrentPerimeterCredentialRefusesDeployedStartup(t *testing.T) { + for _, absent := range []struct{ group, key string }{ + {"internal-auth", "CODEFLY_INTERNAL_TOKEN"}, + {"gateway-trust", "CODEFLY_GATEWAY_TOKEN"}, + } { + t.Run(absent.key, func(t *testing.T) { + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + if key == absent.key { + blankWorkspaceKey(t, group, key) + continue + } + provisionWorkspaceKey(t, group, key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + err := requirePerimeterCredentials(false) + require.Error(t, err, "%s is absent and a deployed gateway must refuse to start", absent.key) + require.Contains(t, err.Error(), absent.group+"/"+absent.key, + "the refusal must name the key an operator has to provision") + }) + } + + t.Run("the rotation half stays optional", func(t *testing.T) { + for _, credential := range perimeterCredentials() { + group, key, _ := strings.Cut(credential.Name, "/") + if strings.HasSuffix(key, "_PREVIOUS") { + blankWorkspaceKey(t, group, key) + continue + } + provisionWorkspaceKey(t, group, key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + require.NoError(t, requirePerimeterCredentials(false), + "a cell that is not mid-rotation carries no PREVIOUS value and must still start") + }) +} + +// blankWorkspaceKey and provisionWorkspaceKey write the carriers workspaceEnv +// actually reads. Both have to be set: codefly CI injects the configuration group, +// a bare `go test` does not, and a shipped .secret.env default reaching either +// carrier would otherwise decide the result instead of the test. +func blankWorkspaceKey(t *testing.T, group, key string) { + t.Helper() + provisionWorkspaceKey(t, group, key, "") +} + +func provisionWorkspaceKey(t *testing.T, group, key, value string) { + t.Helper() + upper := strings.ReplaceAll(strings.ToUpper(group), "-", "_") + t.Setenv(key, value) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__"+upper+"__"+key, value) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__"+upper+"__"+key, value) +} diff --git a/module/services/marketing/code/tests/next-config.d.ts b/module/services/marketing/code/tests/next-config.d.ts new file mode 100644 index 000000000..6cccd7e37 --- /dev/null +++ b/module/services/marketing/code/tests/next-config.d.ts @@ -0,0 +1,12 @@ +// next.config.mjs is JavaScript, and this project compiles with allowJs disabled, so +// the configuration carries no types of its own. The tests read the real file rather +// than a copy of its values โ€” a test that restates the headers it is checking proves +// only that two literals agree โ€” so the shape it exposes is declared here. +declare module "@/../next.config.mjs" { + export type MarketingHeader = { key: string; value: string }; + export type MarketingHeaderGroup = { source: string; headers: MarketingHeader[] }; + const config: { + headers?: () => Promise; + }; + export default config; +} diff --git a/module/services/marketing/code/tests/security-headers.test.ts b/module/services/marketing/code/tests/security-headers.test.ts index 42866be5e..a53454ae6 100644 --- a/module/services/marketing/code/tests/security-headers.test.ts +++ b/module/services/marketing/code/tests/security-headers.test.ts @@ -3,14 +3,22 @@ import test from "node:test"; import nextConfig from "@/../next.config.mjs"; -type Header = { key: string; value: string }; - async function headersFor(path: string): Promise> { const groups = await nextConfig.headers!(); const matched = groups.filter((group) => group.source.startsWith("/:path")); assert.ok(matched.length > 0, `no header group matches ${path}`); + return new Map(matched.flatMap((group) => group.headers).map((h) => [h.key, h.value])); +} + +// A header value is a list of directives, so a directive is only present when it stands +// as its own list member. Searching the whole string for "max-age=" accepts +// "not-max-age=31536000", which pins nothing: the assertions below read the parsed list. +function directives(value: string): Map { return new Map( - matched.flatMap((group) => group.headers as Header[]).map((h) => [h.key, h.value]), + value.split(";").map((part) => { + const [name, ...rest] = part.trim().split("="); + return [name.toLowerCase(), rest.join("=")]; + }), ); } @@ -18,11 +26,12 @@ async function headersFor(path: string): Promise> { // it: a browser's first request to a host it has never pinned goes out in the clear. // The origin has to ask the browser to remember. test("the marketing origin pins HTTPS", async () => { - const headers = await headersFor("/"); - const sts = headers.get("Strict-Transport-Security"); + const sts = (await headersFor("/")).get("Strict-Transport-Security"); assert.ok(sts, "Strict-Transport-Security is absent"); - assert.match(sts, /max-age=\d+/); - assert.ok(Number(sts.match(/max-age=(\d+)/)![1]) >= 31536000, "max-age is under a year"); + const maxAge = directives(sts).get("max-age"); + assert.ok(maxAge !== undefined, `max-age is not a directive of ${sts}`); + assert.match(maxAge, /^\d+$/); + assert.ok(Number(maxAge) >= 31536000, "max-age is under a year"); }); // includeSubDomains and preload commit hosts this site does not own: a sibling @@ -30,8 +39,9 @@ test("the marketing origin pins HTTPS", async () => { // irreversible. Their absence is a decision, so it is asserted. test("the marketing origin commits no host but its own", async () => { const sts = (await headersFor("/")).get("Strict-Transport-Security")!; - assert.ok(!sts.includes("includeSubDomains")); - assert.ok(!sts.includes("preload")); + const names = [...directives(sts).keys()]; + assert.ok(!names.includes("includesubdomains"), `includeSubDomains is set in ${sts}`); + assert.ok(!names.includes("preload"), `preload is set in ${sts}`); }); // The framing and sniffing defenses beside it, so removing any one of the three is From e3bd8d23dc730bce9e3897e34187502cee28e15f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:58:49 -0400 Subject: [PATCH 21/46] fix: one spelling of an origin on both sides, a refusal that cannot read as success, and the rest of the pre-fix prose (R1019-N10, N12, N01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N12: the frontend canonicalizes with the WHATWG URL parser and this service with net/url, which is laxer in four ways โ€” host case, an IPv6 literal's form, a Unicode hostname, and a host-less authority. Each produced a browser-equivalent spelling that compared unequal to the configured origin, and the refusal read as a misconfiguration. All four are settled in canonicalPublicOriginHost, and the port in canonicalPublicOriginPort: a leading zero and an empty explicit port normalize, and a port outside 1-65535 is refused rather than ignored. Port 0 is the one deliberate divergence from the parser, which accepts and keeps it. Nothing can be served on port 0, and the frontend's canonicalOrigin now applies the same range check, so the two agree. The vectors are the agreement. Both sides assert behaviour on the same table โ€” expected values taken from the parser rather than reasoned about โ€” and module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical, because a case fixed on one side and left failing on the other is two green suites and a deployed origin that refuses itself. N10: being a status does not make its message public-safe. Pass-through is now restricted to codes that answer the CALLER; a code that answers "this host failed" carries whatever the failing layer said, so it is logged and replaced. OK is outside the set deliberately: Status.Err() is nil for OK, so admitting one returned a nil error beside a nil response and a caller would have read a success that never happened. status.Error(codes.OK, "") is itself nil and cannot be injected, so the test writes the carrier out. N01: the review named two comments; the same pre-fix narrative was in seven more places in this diff, each describing the mechanism rather than the invariant. All rewritten. One note about startup ordering is kept โ€” it describes what a late check cost a bring-up, not a bypass. Nothing is force-pushed; the Disclosure list in the PR body covers what is already published. Co-Authored-By: Claude Opus 5 (1M context) --- module/configurations/local/application.env | 9 +- .../accounts/code/pkg/adapters/auth.go | 11 +- .../pkg/adapters/auth_error_surface_test.go | 82 +++++++++++++ .../pkg/adapters/impersonation_stop_test.go | 2 +- .../accounts/code/pkg/adapters/rpcs.go | 35 +++++- .../accounts/code/pkg/auth/public_origin.go | 79 ++++++++++--- .../pkg/auth/public_origin_vectors_test.go | 110 ++++++++++++++++++ .../pkg/business/auth_header_jwt_mode_test.go | 8 +- .../code/gateway_upstream_cors_test.go | 6 +- .../proxy/[...path]/__tests__/route.test.ts | 7 +- .../solutions/[id]/proxy/[...path]/route.ts | 8 +- .../code/src/lib/codefly-gateway-context.ts | 31 +++-- .../frontend/code/src/lib/public-origin.ts | 12 +- .../public-origin-canonicalization.test.ts | 76 ++++++++++++ .../public_origin_vectors_lockstep_test.go | 91 +++++++++++++++ 15 files changed, 503 insertions(+), 64 deletions(-) create mode 100644 module/services/accounts/code/pkg/auth/public_origin_vectors_test.go create mode 100644 module/services/frontend/code/src/test/public-origin-canonicalization.test.ts create mode 100644 module/tools/public_origin_vectors_lockstep_test.go diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env index fc8cfaf30..ef032e706 100644 --- a/module/configurations/local/application.env +++ b/module/configurations/local/application.env @@ -2,11 +2,10 @@ # OAuth redirect, the authenticator relying-party origin, every emailed link, and # the frontend solution proxy's same-origin judgement. # -# It is OPERATOR configuration and is never derived from a request. It used to be: -# the frontend's own rendered endpoint is a loopback placeholder, so the frontend -# fell back to the caller's X-Forwarded-Host and stamped that, under the internal -# token, as the verified public origin โ€” which let a caller choose the host a -# sign-in returned to and the host an emailed link pointed at. +# It is OPERATOR configuration, and nothing in a request can establish it. This one +# value is what binds an OAuth redirect, the authenticator relying-party origin, the +# solution proxy's same-origin comparison and every emailed link, so a deployed cell +# that has not set it refuses those rather than resolving an origin some other way. # # A DEPLOYED runtime therefore requires it: accounts refuses to start without it, # and the frontend refuses to serve sign-in or the solution proxy. Local diff --git a/module/services/accounts/code/pkg/adapters/auth.go b/module/services/accounts/code/pkg/adapters/auth.go index c6dd958d4..5d2c6dce7 100644 --- a/module/services/accounts/code/pkg/adapters/auth.go +++ b/module/services/accounts/code/pkg/adapters/auth.go @@ -890,12 +890,11 @@ func parseUnixTimestamp(raw string) time.Time { // There are two populations, and the difference is what enrolment means for each. // // For a PRIVILEGED actor โ€” any platform role, or ownership of the organization -// the request names โ€” not being enrolled is not a pass. It used to be: the check -// looked for a device, found none, and admitted, so the one principal whose -// compromise is the whole platform or the whole tenant was the one the gate could -// not touch. A privileged actor without recent AAL2 evidence is refused, which -// also makes "a privileged principal holds a second factor" enforceable at all: -// the alternative to enrolling is not reaching these operations. +// the request names โ€” not being enrolled is not a pass. A privileged actor without +// recent AAL2 evidence is refused, which is what makes "a privileged principal +// holds a second factor" enforceable at all: for these operations the alternative +// to enrolling is not reaching them. The principal whose compromise is the whole +// platform or the whole tenant is the one that must not be able to opt out. // // For every other member the gate stays opt-in per user: enrolled means a recent // factor is required, unenrolled means the operation proceeds and the audit row diff --git a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go index 90645127c..2835f9fe2 100644 --- a/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go +++ b/module/services/accounts/code/pkg/adapters/auth_error_surface_test.go @@ -3,6 +3,7 @@ package adapters import ( "context" "errors" + "fmt" "strings" "testing" @@ -238,3 +239,84 @@ func TestR1019RefreshKeepsADeliberateStatus(t *testing.T) { require.Equal(t, codes.FailedPrecondition, status.Code(err)) require.Equal(t, "device session is no longer active", status.Convert(err).Message()) } + +// R1019-N10: being a status does not establish that its message is public-safe. A +// code that answers "this host failed" carries whatever the failing layer said, so +// the pass-through is restricted to codes that answer the CALLER. Each case here is +// a typed status whose message is exactly the internal detail a caller must not see. +func TestR1019RefreshSanitizesATypedHostFailure(t *testing.T) { + internal := "pgx: dial tcp 10.4.0.9:5432 failed in session_store.RotateRefresh" + for _, injected := range []struct { + name string + err error + }{ + {"Internal", status.Error(codes.Internal, internal)}, + {"Unavailable", status.Error(codes.Unavailable, internal)}, + {"Unknown", status.Error(codes.Unknown, internal)}, + {"DataLoss", status.Error(codes.DataLoss, internal)}, + {"Aborted", status.Error(codes.Aborted, internal)}, + {"DeadlineExceeded", status.Error(codes.DeadlineExceeded, internal)}, + {"wrapped Internal", fmt.Errorf("rotate refresh: %w", status.Error(codes.Internal, internal))}, + } { + t.Run(injected.name, func(t *testing.T) { + installRefusingRefreshMinter(t, injected.err) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err)) + message := status.Convert(err).Message() + require.Equal(t, "refresh temporarily unavailable", message) + for _, leak := range []string{"10.4.0.9", "5432", "session_store", "pgx", "dial tcp"} { + require.NotContains(t, message, leak, + "a typed host failure must not carry its own message out") + } + }) + } +} + +// Every code the pass-through admits, so narrowing the set to one code โ€” or widening +// it back to "any status" โ€” is detected. A refusal's own reason is the answer to the +// caller, and flattening these into one message would make a real policy refusal +// unreadable. +func TestR1019RefreshKeepsEveryCallerFacingRefusal(t *testing.T) { + for _, admitted := range []codes.Code{ + codes.Unauthenticated, codes.PermissionDenied, codes.FailedPrecondition, + codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.OutOfRange, + } { + t.Run(admitted.String(), func(t *testing.T) { + installRefusingRefreshMinter(t, status.Error(admitted, "the session is no longer active")) + + _, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Equal(t, admitted, status.Code(err)) + require.Equal(t, "the session is no longer active", status.Convert(err).Message()) + }) + } +} + +// An OK-coded status is a failure that must not read as a success. +// (*status.Status).Err() returns nil for OK, so admitting one would return a nil error +// beside a nil response and the caller would act on a refresh that never happened. +// +// status.Error(codes.OK, "") is itself nil and cannot be injected, so the carrier is +// written out: errors.As matches any type implementing GRPCStatus(), which is what +// makes the degenerate case reachable at all. +type okStatusError struct{} + +func (okStatusError) Error() string { return "a failure whose status is OK" } + +func (okStatusError) GRPCStatus() *status.Status { return status.New(codes.OK, "") } + +func TestR1019RefreshDoesNotTurnAFailureIntoASuccess(t *testing.T) { + installRefusingRefreshMinter(t, okStatusError{}) + + resp, err := (&AuthServer{}).RefreshToken(context.Background(), + &gen.RefreshTokenRequest{RefreshToken: "a-refresh-token"}) + + require.Error(t, err, "a failure must never leave the handler as a nil error") + require.Nil(t, resp) + require.Equal(t, codes.Unavailable, status.Code(err)) +} diff --git a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go index 4d3317275..26db1a490 100644 --- a/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go +++ b/module/services/accounts/code/pkg/adapters/impersonation_stop_test.go @@ -276,7 +276,7 @@ func (r *impersonationRig) start(t *testing.T, subjectID string) (string, string // reaches impersonation only behind a recent second factor, and // Assurance.HasRecentMFA requires the level and the method as well as the // time. The fixture carried the time alone and passed only because an - // unenrolled privileged actor used to be admitted. + // a privileged actor's factor evidence is required, not merely consulted. adminCtx := stampVerifiedIdentity(context.Background(), supportActorID, "", auth.Assurance{ Level: auth.AssuranceLevelAAL2, AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 53ed92df0..7a8ee7458 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1165,21 +1165,44 @@ func innerStatus(err error) *status.Status { return nil } +// callerFacingRefusal reports whether a status is a DECISION ABOUT THE CALLER, whose +// message was therefore written for the caller to read. +// +// Being a status does not make a message public-safe (R1019-N10). A code in this set +// answers "you may not" or "that input is wrong", and the reason is the answer. The +// codes outside it โ€” Internal, Unavailable, Unknown, DataLoss, Aborted, DeadlineExceeded +// โ€” answer "this host failed", and their messages carry whatever the failing layer +// happened to say: a DSN, a host name, a driver's text. Those are logged and replaced. +// +// OK is outside the set deliberately. (*status.Status).Err() returns nil for an OK +// status, so passing one through would turn a failure into a nil error beside a nil +// response, and the caller would read a success that never happened. +func callerFacingRefusal(st *status.Status) bool { + switch st.Code() { + case codes.Unauthenticated, codes.PermissionDenied, codes.FailedPrecondition, + codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.OutOfRange: + return true + default: + return false + } +} + func refreshStatusError(ctx context.Context, err error) error { + inner := innerStatus(err) switch { case errors.Is(err, auth.ErrRefreshReuse), errors.Is(err, auth.ErrRefreshRevoked): wool.Get(ctx).In("RefreshToken").Warn("refresh refused", wool.ErrField(err)) return status.Error(codes.Unauthenticated, "invalid refresh token") - case innerStatus(err) != nil: - // A deliberate status from further in โ€” a session that is no longer active, a + case inner != nil && callerFacingRefusal(inner): + // A deliberate refusal from further in โ€” a session that is no longer active, a // policy refusal. Its reason was chosen for the caller, so it is returned // WITHOUT the wrapper: the wrapping is what names the internal call chain, // and returning err unchanged would carry it out with the good reason. - return innerStatus(err).Err() + return inner.Err() default: - // Anything else is this host failing, and the wrapped chain names the - // internal call path and the store's address. SP-GW-13: a refused or failed - // request carries a stable public reason and the cause is logged, not + // Anything else is this host failing, and the chain โ€” status-carrying or not โ€” + // names the internal call path and the store's address. SP-GW-13: a refused or + // failed request carries a stable public reason and the cause is logged, not // returned. Unavailable rather than Internal because a refresh is worth // retrying. wool.Get(ctx).In("RefreshToken").Error("refresh failed", wool.ErrField(err)) diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 28809477b..1d9120b0d 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -6,7 +6,10 @@ import ( "fmt" "net" "net/url" + "strconv" "strings" + + "golang.org/x/net/idna" ) type verifiedPublicOriginKey struct{} @@ -59,29 +62,77 @@ func CanonicalPublicOrigin(candidate string) (string, error) { return "", fmt.Errorf("public origin must not contain credentials, path, query, or fragment") } scheme := strings.ToLower(parsed.Scheme) + host, err := canonicalPublicOriginHost(parsed.Hostname()) + if err != nil { + return "", err + } switch scheme { case "https": case "http": - if !isPublicOriginLoopback(parsed.Hostname()) { + if !isPublicOriginLoopback(host) { return "", fmt.Errorf("non-loopback public origin must use HTTPS") } default: return "", fmt.Errorf("public origin must use HTTP(S)") } - // An explicit DEFAULT port is the same origin as none, and the two sides of the - // comparison spell it differently: the browser and the frontend both drop it - // (URL.origin does), while a configured value may carry it. Retaining it here - // made an operator's `https://app.example:443` refuse the equivalent origin the - // frontend forwards. Dropping it is the same rule both sides then follow. - host := parsed.Host - if port := parsed.Port(); port != "" && - ((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) { - host = parsed.Hostname() - if strings.Contains(host, ":") { - host = "[" + host + "]" // an IPv6 literal keeps its brackets - } + port, err := canonicalPublicOriginPort(parsed.Port(), scheme) + if err != nil { + return "", err + } + if strings.Contains(host, ":") { + host = "[" + host + "]" // an IPv6 literal keeps its brackets + } + return scheme + "://" + host + port, nil +} + +// canonicalPublicOriginHost renders a host the one way the browser renders it, so +// two spellings of one origin cannot compare unequal (SP-GW-08, R1019-N12). +// +// The frontend half of this comparison is `new URL(...).origin`, the WHATWG parser. +// Go's net/url is laxer than that parser in four ways that each produced a +// browser-equivalent spelling this function used to refuse: it preserves host case, +// leaves an IPv6 literal in whatever form it was written, leaves a Unicode hostname +// un-encoded, and treats a host-less authority as a host. Each is settled here โ€” +// lowercase, the compressed IPv6 form, IDNA/punycode, and a refusal โ€” rather than at +// the comparison, because a comparison can only reject what reaches it in one shape. +func canonicalPublicOriginHost(hostname string) (string, error) { + if hostname == "" { + // `https://:443` parses in Go with an empty hostname. A browser rejects it, + // so there is no origin it could be equivalent to. + return "", fmt.Errorf("public origin must name a host") + } + if ip := net.ParseIP(hostname); ip != nil { + // The compressed form: an expanded literal and its compression are one address, + // and net.IP.String() picks the same representation the browser does. + return ip.String(), nil + } + ascii, err := idna.Lookup.ToASCII(hostname) + if err != nil { + return "", fmt.Errorf("public origin host is not a usable hostname") + } + return strings.ToLower(ascii), nil +} + +// canonicalPublicOriginPort renders the port as the ":NNN" suffix of a canonical +// origin, or "" when the origin carries the scheme's default. +// +// An explicit DEFAULT port is the same origin as none, and the two sides spell it +// differently: the browser and the frontend both drop it, while a configured value may +// carry it. A leading-zero port, and an empty explicit port, are likewise the same +// origin the browser renders without them. Anything outside the one-to-65535 range is +// not a port at all, and the browser refuses the whole URL rather than ignoring it. +func canonicalPublicOriginPort(port, scheme string) (string, error) { + if port == "" { + return "", nil // including `https://app.example:`, which Go reports as no port + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return "", fmt.Errorf("public origin port must be between 1 and 65535") + } + if (scheme == "https" && number == 443) || (scheme == "http" && number == 80) { + return "", nil } - return scheme + "://" + host, nil + return ":" + strconv.Itoa(number), nil // decimal, so a leading zero cannot survive } // WithVerifiedPublicOrigin records the origin only after the gateway credential diff --git a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go new file mode 100644 index 000000000..b2d82ae06 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go @@ -0,0 +1,110 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// R1019-N12: one origin must have one spelling on BOTH sides of the comparison. +// +// The frontend canonicalizes with `new URL(...).origin`, the WHATWG parser; this +// service canonicalizes with net/url, which is laxer. Where they disagreed, a +// configured origin and the equivalent origin the frontend resolved compared unequal, +// and the refusal looked like a misconfiguration. +// +// These vectors are the agreement itself. The same table is asserted in +// src/test/public-origin-canonicalization.test.ts against the real URL parser, and +// module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical +// โ€” so a case fixed on one side cannot be left failing on the other. Each side +// asserts BEHAVIOUR on the vectors; the lockstep gate only proves both assert the +// same set. +// +// Expected values are the browser's own answers, taken from the parser rather than +// reasoned about, with one deliberate exception noted in refusedPublicOriginVectors. +var canonicalPublicOriginVectors = []struct{ Input, Canonical string }{ + {"https://app.example", "https://app.example"}, + {"https://APP.example", "https://app.example"}, + {"https://app.example:443", "https://app.example"}, + {"https://app.example:0443", "https://app.example"}, + {"https://app.example:", "https://app.example"}, + {"https://app.example:8443", "https://app.example:8443"}, + {"https://app.example:08443", "https://app.example:8443"}, + {"https://app.example/", "https://app.example"}, + {"https://[0:0:0:0:0:0:0:1]", "https://[::1]"}, + {"https://[::1]:443", "https://[::1]"}, + {"https://bรผcher.example", "https://xn--bcher-kva.example"}, + {"http://localhost:80", "http://localhost"}, + {"http://127.0.0.1:3000", "http://127.0.0.1:3000"}, + {"http://[::1]:80", "http://[::1]"}, +} + +// Refused on both sides. Every entry but one is refused by the browser parser too. +// +// The exception is port 0, which `new URL` accepts and keeps. It is refused here +// because no origin can be served on it, and a rule both sides apply is worth more +// than matching the parser on a value that cannot occur: the frontend's +// canonicalOrigin applies the same range check, so the two still agree. +var refusedPublicOriginVectors = []string{ + "https://:443", + "https://app.example:99999", + "https://app.example:0", + "https://app.example:-1", + "https://app.example/path", + "https://app.example?q=1", + "https://app.example#f", + "https://user:secret@app.example", + "app.example", + "", +} + +func TestR1019CanonicalPublicOriginMatchesTheBrowserSpelling(t *testing.T) { + for _, vector := range canonicalPublicOriginVectors { + t.Run(vector.Input, func(t *testing.T) { + got, err := CanonicalPublicOrigin(vector.Input) + require.NoError(t, err, "%q is a usable origin", vector.Input) + require.Equal(t, vector.Canonical, got, + "%q must canonicalize the way the browser spells it", vector.Input) + }) + } +} + +func TestR1019CanonicalPublicOriginRefusesWhatTheBrowserWillNotParse(t *testing.T) { + for _, input := range refusedPublicOriginVectors { + t.Run(input, func(t *testing.T) { + _, err := CanonicalPublicOrigin(input) + require.Error(t, err, "%q is not an origin and must be refused", input) + }) + } +} + +// Canonicalization is only worth anything if the COMPARISON uses it. A configured +// origin and a differently spelled equivalent must reach the same verified origin. +func TestR1019ConfiguredOriginAcceptsEveryEquivalentSpelling(t *testing.T) { + t.Cleanup(func() { SetConfiguredPublicOrigin("") }) + + for _, spelling := range []string{ + "https://app.example", "https://APP.example", "https://app.example:443", + "https://app.example:0443", "https://app.example:", "https://app.example/", + } { + SetConfiguredPublicOrigin(spelling) + configured, ok := ConfiguredPublicOrigin() + require.True(t, ok) + require.Equal(t, "https://app.example", configured, + "%q pins the same origin as every other spelling of it", spelling) + + for _, candidate := range []string{ + "https://app.example", "https://APP.example", "https://app.example:443", + } { + ctx, err := WithVerifiedPublicOrigin(t.Context(), candidate) + require.NoError(t, err, "%q is the pinned origin spelled %q", candidate, spelling) + verified, ok := VerifiedPublicOrigin(ctx) + require.True(t, ok) + require.Equal(t, "https://app.example", verified) + } + + _, err := WithVerifiedPublicOrigin(t.Context(), "https://other.example") + require.ErrorIs(t, err, ErrPublicOriginNotConfigured, + "a different origin is still refused when %q is pinned", spelling) + } +} diff --git a/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go index 7ba26efe7..a9212ffe4 100644 --- a/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go +++ b/module/services/accounts/code/pkg/business/auth_header_jwt_mode_test.go @@ -39,10 +39,10 @@ func (*headerJWTModeStore) ResolveIdentity(context.Context, string, string) (*Re } // In OIDC mode the header-jwt login path is inactive, and a body carrying an -// identity assertion is refused rather than validated. It used to be handed to -// the OIDC validator, which admitted any unexpired id_token for the configured -// client with no state, no proof-of-possession verifier and no nonce โ€” a full -// session from an assertion that completed none of the handoff. +// identity assertion is refused rather than validated. The two modes verify +// different things, and only the configured mode's verifier may decide: an +// assertion that completed none of this mode's handoff is not an identity here, +// whatever another mode's validator would make of it. func TestL1VHeaderJWTBodyRefusedInOIDCMode(t *testing.T) { validator := &recordingValidator{} service, err := NewService(&headerJWTModeStore{}) diff --git a/module/services/auth-gateway/code/gateway_upstream_cors_test.go b/module/services/auth-gateway/code/gateway_upstream_cors_test.go index d8dd89ec2..483e47d4d 100644 --- a/module/services/auth-gateway/code/gateway_upstream_cors_test.go +++ b/module/services/auth-gateway/code/gateway_upstream_cors_test.go @@ -11,9 +11,9 @@ import ( // accounts' generated CORS handler hands an empty allowlist to a library that // reads empty as allow-all, so it answers a wildcard origin with credentials -// true. Those headers used to be forwarded verbatim, which made the public -// surface grant a cross-origin read this gateway had granted nobody โ€” and made an -// empty allowlist, which should grant nothing, grant everything. +// true. A grant on a response this gateway serves is this gateway's grant, so an +// upstream's own grant never reaches the public surface, and an empty allowlist +// grants nothing. // // The generated handler is the go-grpc agent's to fix. What this gateway owns is // that the grant on its own responses is the one it made. diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts index 4b3bab49d..0e5ecb70e 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/__tests__/route.test.ts @@ -407,10 +407,9 @@ describe("solution proxy passthrough", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - // The defect this closes: the comparison used to be made against an origin - // derived from the request's own forwarding headers, so a caller that supplied - // both sides matched itself. A check is only a check when one side is not the - // caller's to choose. + // A comparison is only a check when one side is not the caller's to choose: the + // origin compared against is the configured one, so supplying forwarding headers + // alongside an Origin changes nothing about the answer. it("rejects a forged cross-site request that supplies its own forwarded host", async () => { withGateway(); withTrustContext("https://app.example.com"); diff --git a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts index 12a06b051..750f8b069 100644 --- a/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/[id]/proxy/[...path]/route.ts @@ -25,11 +25,9 @@ interface RouteContext { // attached. // // The browser's Origin is compared with this deployment's CONFIGURED public -// origin. It used to be compared with an origin derived from the request's own -// forwarded headers, which made the check self-referential: a non-browser caller -// supplying `Origin: https://evil.example` and `X-Forwarded-Host: evil.example` -// matched itself and passed. The comparison is only a check when one side is not -// the caller's to choose. +// origin, which comes from operator configuration. The comparison is only a check +// when one side is not the caller's to choose, so the side it compares against is +// never taken from the request. // // A deployment with no verified public origin refuses: there is nothing to compare // against, and admitting on that basis is the same defect by omission. diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts index 054c0a564..c2afa4c61 100644 --- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts +++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts @@ -37,6 +37,18 @@ const runtimeSDK: CodeflyRuntimeReader = { isDeployedBuild: () => process.env.NODE_ENV === "production", }; +/** + * The one spelling of an origin, shared with the accounts service. + * + * `URL.origin` is the agreement: host case, a default or leading-zero port, an + * expanded IPv6 literal and a Unicode hostname all settle here, and the Go half + * reproduces exactly these answers (R1019-N12). The vectors are asserted on both + * sides and held identical by module/tools/public_origin_vectors_lockstep_test.go. + * + * Port 0 is the one place this is deliberately stricter than the parser, which + * accepts and keeps it: no origin can be served on port 0, and the same range check + * on both sides is worth more than matching the parser on a value that cannot occur. + */ function canonicalOrigin(candidate: string): string | undefined { try { const parsed = new URL(candidate); @@ -50,6 +62,12 @@ function canonicalOrigin(candidate: string): string | undefined { ) { return undefined; } + if (parsed.port !== "") { + const port = Number(parsed.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return undefined; + } + } return parsed.origin; } catch { return undefined; @@ -76,14 +94,11 @@ const PUBLIC_ORIGIN_CONFIGURATION = "application/APP_BASE_URL"; * private auth gateway. Codefly's injected representation remains entirely * behind sdk-js; this library only consumes typed SDK values. * - * The public origin is OPERATOR CONFIGURATION and is never derived from a - * request. It used to be: when the own rendered endpoint was a loopback - * placeholder โ€” which is what the render produces, so on every cell โ€” this fell - * back to the caller's own `X-Forwarded-Host` and stamped it, under the internal - * token, as the verified public origin. accounts then bound an OAuth redirect, - * the authenticator relying-party origin, and emailed links to a host the caller - * had chosen, and the solution proxy's same-origin check compared against the - * same caller-supplied value. + * The public origin is OPERATOR CONFIGURATION and is never derived from a request. + * It is what binds an OAuth redirect, the authenticator relying-party origin, every + * emailed link and the solution proxy's same-origin comparison, and none of those is + * safe to bind to a value a caller can name โ€” so this resolves the origin from + * configuration or refuses, and never from the request. * * The order is: the configured origin; then the SDK-discovered own endpoint when * the render carries a real host; and on a deployed build, nothing else โ€” a diff --git a/module/services/frontend/code/src/lib/public-origin.ts b/module/services/frontend/code/src/lib/public-origin.ts index 61b17859b..f83a988ad 100644 --- a/module/services/frontend/code/src/lib/public-origin.ts +++ b/module/services/frontend/code/src/lib/public-origin.ts @@ -5,14 +5,10 @@ import { resolveVerifiedPublicOrigin } from "@/lib/codefly-gateway-context"; /** * The origin this deployment treats as its own, from operator configuration. * - * It used to be derived from the request โ€” the ingress-set forwarded pair, or the - * pod-local URL โ€” which made it whatever a caller said: a request bearing - * `X-Forwarded-Host: evil.example` was answered as though the product were served - * there, and the solution proxy compared the browser's `Origin` against it, so a - * cross-site request that supplied both passed the same-origin check. The origin - * now comes from `application/APP_BASE_URL`, or from a render that carries a real - * ingress host, and from nothing else; see - * `resolveVerifiedPublicOrigin`, which refuses rather than guessing. + * It comes from `application/APP_BASE_URL`, or from a render that carries a real + * ingress host, and from nothing else โ€” never from a request, because an origin a + * caller can name is not this deployment's origin. See `resolveVerifiedPublicOrigin`, + * which refuses rather than guessing. * * Returns undefined when this deployment has no verified public origin, which * every caller must treat as a refusal. diff --git a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts new file mode 100644 index 000000000..8435c2c29 --- /dev/null +++ b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + type CodeflyRuntimeReader, + resolveVerifiedPublicOrigin, +} from "@/lib/codefly-gateway-context"; + +// R1019-N12: one origin must have one spelling on BOTH sides of the comparison. +// +// This side canonicalizes with `new URL(...).origin`; the accounts service reproduces +// the same answers in Go. Where the two disagreed, a configured origin and the +// equivalent origin resolved here compared unequal, and the refusal read as a +// misconfiguration. +// +// The same table is asserted in pkg/auth/public_origin_vectors_test.go, and +// module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical, +// so a case fixed on one side cannot be left failing on the other. +const canonicalPublicOriginVectors: [string, string][] = [ + ["https://app.example", "https://app.example"], + ["https://APP.example", "https://app.example"], + ["https://app.example:443", "https://app.example"], + ["https://app.example:0443", "https://app.example"], + ["https://app.example:", "https://app.example"], + ["https://app.example:8443", "https://app.example:8443"], + ["https://app.example:08443", "https://app.example:8443"], + ["https://app.example/", "https://app.example"], + ["https://[0:0:0:0:0:0:0:1]", "https://[::1]"], + ["https://[::1]:443", "https://[::1]"], + ["https://bรผcher.example", "https://xn--bcher-kva.example"], + ["http://localhost:80", "http://localhost"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], + ["http://[::1]:80", "http://[::1]"], +]; + +// Refused on both sides. Port 0 is the one case the parser itself accepts; see the +// note on canonicalOrigin for why both sides refuse it anyway. +const refusedPublicOriginVectors: string[] = [ + "https://:443", + "https://app.example:99999", + "https://app.example:0", + "https://app.example:-1", + "https://app.example/path", + "https://app.example?q=1", + "https://app.example#f", + "https://user:secret@app.example", + "app.example", + "", +]; + +// A deployed build with the origin in `application/APP_BASE_URL` and nothing else to +// fall back on: the configured value is the only input, so the result is the +// canonicalization and nothing about the resolution order. +function configuredWith(appBaseUrl: string): CodeflyRuntimeReader { + return { + currentModule: () => "saas-starter", + currentService: () => "frontend", + endpoints: () => [], + workspaceSecret: () => "internal-test-token", + workspaceConfiguration: (group: string, key: string) => + group === "application" && key === "APP_BASE_URL" ? appBaseUrl : undefined, + isDeployedBuild: () => true, + }; +} + +describe("the verified public origin has one spelling", () => { + it.each(canonicalPublicOriginVectors)("%s canonicalizes to %s", (input, expected) => { + expect(resolveVerifiedPublicOrigin(configuredWith(input))).toBe(expected); + }); + + it.each(refusedPublicOriginVectors.map((input) => [input]))( + "refuses %s rather than guessing an origin", + (input) => { + expect(resolveVerifiedPublicOrigin(configuredWith(input))).toBeUndefined(); + }, + ); +}); diff --git a/module/tools/public_origin_vectors_lockstep_test.go b/module/tools/public_origin_vectors_lockstep_test.go new file mode 100644 index 000000000..b51234aad --- /dev/null +++ b/module/tools/public_origin_vectors_lockstep_test.go @@ -0,0 +1,91 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// R1019-N12. The frontend and accounts each canonicalize a public origin, in different +// languages, and the comparison between them only holds if both settle every spelling +// the same way. Each side asserts BEHAVIOUR on a table of vectors; this holds the two +// tables identical. +// +// Without it the failure is silent in the worst direction: a case fixed on one side +// and left failing on the other reads as two green suites, and the disagreement +// surfaces as a deployed origin refusing itself. +// +// This gate proves only that the two sides assert the same INPUTS and EXPECTATIONS. It +// cannot prove either side's implementation is right โ€” that is what the behavioural +// tests on each side are for, and neither is redundant. +func TestPublicOriginVectorsAgreeAcrossServices(t *testing.T) { + moduleDir := findModuleDir(t) + goSource := filepath.Join(moduleDir, "services", "accounts", "code", + "pkg", "auth", "public_origin_vectors_test.go") + tsSource := filepath.Join(moduleDir, "services", "frontend", "code", + "src", "test", "public-origin-canonicalization.test.ts") + + goCanonical := stringLiteralsInBlock(t, goSource, `canonicalPublicOriginVectors = \[\]struct\{ Input, Canonical string \}\{`, "}") + tsCanonical := stringLiteralsInBlock(t, tsSource, `canonicalPublicOriginVectors: \[string, string\]\[\] = \[`, "]") + if strings.Join(goCanonical, "|") != strings.Join(tsCanonical, "|") { + t.Errorf("the canonicalization vectors differ between the two services.\n"+ + "accounts (%s):\n %v\nfrontend (%s):\n %v\n"+ + "Both sides must assert the same input/expected pairs, in the same order, or a case "+ + "fixed on one side can be left failing on the other with both suites green.", + goSource, goCanonical, tsSource, tsCanonical) + } + if len(goCanonical) < 20 || len(goCanonical)%2 != 0 { + t.Fatalf("expected an even, non-trivial number of canonicalization literals, got %d", len(goCanonical)) + } + + goRefused := stringLiteralsInBlock(t, goSource, `refusedPublicOriginVectors = \[\]string\{`, "}") + tsRefused := stringLiteralsInBlock(t, tsSource, `refusedPublicOriginVectors: string\[\] = \[`, "]") + if strings.Join(goRefused, "|") != strings.Join(tsRefused, "|") { + t.Errorf("the refused-origin vectors differ between the two services.\n"+ + "accounts: %v\nfrontend: %v", goRefused, tsRefused) + } + if len(goRefused) < 8 { + t.Fatalf("expected a non-trivial refusal list, got %d entries", len(goRefused)) + } +} + +// stringLiteralsInBlock returns every double-quoted literal between the line matching +// opening and the first line whose trimmed text is exactly closing. Both languages +// spell a string literal the same way, which is what makes one extractor enough. +func stringLiteralsInBlock(t *testing.T, path, opening, closing string) []string { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + open := regexp.MustCompile(opening) + literal := regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + + var found []string + inBlock := false + for _, line := range strings.Split(string(source), "\n") { + if !inBlock { + if open.MatchString(line) { + inBlock = true + } + continue + } + if strings.TrimSuffix(strings.TrimSpace(line), ";") == closing { + return found + } + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, match := range literal.FindAllStringSubmatch(line, -1) { + found = append(found, match[1]) + } + } + if !inBlock { + t.Fatalf("%s declares no block matching %q โ€” the vectors moved or were renamed, "+ + "which silently retires this gate", path, opening) + } + t.Fatalf("%s has no %q closing the block matching %q", path, closing, opening) + return nil +} From 2de4f2fff10d1adef4c801b55d1f5e0cf1775081 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:05:16 -0400 Subject: [PATCH 22/46] fix(accounts): a factor at principal creation and revocation, and a source gate that cannot be satisfied by a comment (R1019-N02, N15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minting a principal creates a credential-bearing actor and revoking one is an irreversible security action, so both are privilege-granting mutations and both reached their write with no factor evidence โ€” for a platform super-admin and for an organization owner alike. Both now gate, after the authorization guard and before the mutation. RevokePrincipal resolved the actor inside each branch of its authorization split, which is how a gate comes to be added to one branch and not the other; it is resolved once before the branch now. DisableAgentPrincipal and EnableAgentPrincipal are deliberately not gated: they admit on an internal service credential, where there is no human actor and so no factor to require. The source-level gate was satisfied by two mutations the review found (N15). It required requireMFA to APPEAR; discarding its error left the call in place and enforced nothing, and a comment carrying the expected text satisfied the substring search while the guard was gone. It now parses the file, strips every comment, and matches only the form that binds, tests and returns the same variable โ€” compared by captured name, since RE2 has no backreferences. It also anchors the ordering to the MUTATING call rather than the first service call in the body, because RevokePrincipal reads the principal first to decide which authorization applies. The RPC-level tests record the writes instead of panicking, so "the mutation was reached" is an assertion rather than a crash โ€” reaching the write without a factor is the finding, and a test has to be able to say it happened. FEATURES.md said ordinary tenant mutations are not gated and left it there. The grant paths admit an organization ADMIN, who is not privileged, so requireMFA applies its opt-in rule and an ENROLLED admin without recent evidence is now refused where nothing asked before. That is intended โ€” granting authority inside a tenant is not an ordinary tenant mutation โ€” but it was undocumented and untested. Both halves are now asserted: enrolled is refused, unenrolled is still admitted, so no roster is forced to enrol by this change. The containment actions' freshness requirement is recorded for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- module/FEATURES.md | 19 +- .../code/pkg/adapters/mfa_privileged_test.go | 5 +- .../pkg/adapters/principal_factor_test.go | 246 ++++++++++++++++++ .../code/pkg/adapters/principal_rpcs.go | 33 ++- .../privileged_mutation_factor_test.go | 109 ++++++-- 5 files changed, 377 insertions(+), 35 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/principal_factor_test.go diff --git a/module/FEATURES.md b/module/FEATURES.md index 1b69788f8..9fb9c2507 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -516,7 +516,7 @@ starters, and large-scale enterprise SaaS expectations. | OAuth login (multiple providers) | โœ… | Same | | Email/password login | โŒ | โœ… (most starters keep both) | | Magic-link login | ๐ŸŸก | โœ… (one-click) | -| MFA enforced on sensitive ops | โœ… | ๐ŸŸก `requireMFA` gates billing, GDPR delete, impersonation, organization deletion, waitlist review, the role/scope/principal grants and the platform-admin mutations (suspend, session revoke, entitlement override). Fail-closed for a privileged actor โ€” any platform role, or ownership of the request's organization โ€” where not being enrolled is a refusal, not a pass; opt-in per user for every other member. **The method policy still declares `MFA_REQUIREMENT_NONE` for the newly gated methods, so the policy/handler lockstep gate does not yet demand it** โ€” see the note below | +| MFA enforced on sensitive ops | โœ… | ๐ŸŸก `requireMFA` gates billing, GDPR delete, impersonation, organization deletion, waitlist review, the role/scope/principal grants (creation and revocation) and the platform-admin mutations (suspend, unsuspend, session revoke, entitlement override). Fail-closed for a privileged actor โ€” any platform role, or ownership of the request's organization โ€” where not being enrolled is a refusal, not a pass; opt-in per user for every other member, which an ENROLLED organization admin now meets on the grant paths (see below). **The method policy still declares `MFA_REQUIREMENT_NONE` for the newly gated methods, so the policy/handler lockstep gate does not yet demand it** โ€” see the note below | | Multi-org tenancy | โœ… | Same | | Org invitations | โœ… | Same | | RBAC (built-in roles) | โœ… | Same | @@ -546,6 +546,23 @@ Ordinary tenant mutations โ€” dashboards, teams, invitations, notifications, use settings โ€” are deliberately NOT gated. Whether a second factor belongs there is a product decision about an operator's whole roster, not a property of this host. +**Who the gated set actually reaches, which is wider than "privileged actor".** The +role, scope and principal grants admit an organization ADMIN as well as an owner, and +`requireMFA` is fail-closed only for a privileged actor โ€” for everyone else it keeps +the pre-existing opt-in rule, which refuses an ENROLLED member without recent +evidence. So an enrolled org admin who has not stepped up recently is now refused on +`AssignRole`, `RevokeRole`, `GrantScope`, `RevokeScope`, the role CRUD and +`CreateAgentPrincipal`, where before this change nothing asked. That is intended โ€” +granting authority inside a tenant is not an ordinary tenant mutation, whoever does +it โ€” and it is stated here because an operator reading only the paragraph above would +not predict the refusal. An admin who has enrolled nothing is still admitted, so no +roster is forced to enrol by this change alone. + +Containment actions carry the same requirement: `SuspendUser`, `RevokeSession` and +`RevokePrincipal` need fresh factor evidence, so a responder acting on a compromise +steps up first. A runbook for those operations should say so, because the refusal +arrives at the least convenient moment by design. + ### Nice-to-have (differentiators) | Feature | This module | World-class | diff --git a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go index d109d2a92..277c3b785 100644 --- a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go +++ b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go @@ -48,7 +48,10 @@ const ( mfaOrgID = "00000000-0000-4000-8000-0000000000b1" ) -func installMFAPolicyStore(t *testing.T, store *mfaPolicyStore) { +// The parameter is the Store interface rather than this file's fake, so a test that +// needs another store method โ€” a principal lookup, say โ€” supplies its own fake instead +// of growing this one past what its own cases need. +func installMFAPolicyStore(t *testing.T, store business.Store) { t.Helper() previous := service t.Cleanup(func() { service = previous }) diff --git a/module/services/accounts/code/pkg/adapters/principal_factor_test.go b/module/services/accounts/code/pkg/adapters/principal_factor_test.go new file mode 100644 index 000000000..876daa96f --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/principal_factor_test.go @@ -0,0 +1,246 @@ +package adapters + +import ( + "context" + "testing" + "time" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// R1019-N02, the half the first response missed: a principal is a credential-bearing +// actor, so minting one and revoking one are privilege-granting mutations, and both +// reached their write with no factor evidence โ€” for a platform super-admin and for an +// organization owner alike. +// +// Both caller grades are exercised, because they pass through different authorization +// guards: a super-admin short-circuits requireOrgAdmin on the platform role, while an +// owner is admitted on membership. A gate placed after only one of those is a gate for +// only one of these callers. +type principalFactorStore struct { + business.Store + platformRole string + orgRole gen.OrgRole + member bool + enrolled bool + principal *business.Principal + + // The writes record instead of panicking, so "the mutation was reached" is an + // assertion rather than a crash. Reaching the write with no factor evidence IS the + // finding, so the test has to be able to say that happened. + created []string + revoked []string +} + +func (s *principalFactorStore) GetPlatformRole(context.Context, string) (string, error) { + return s.platformRole, nil +} + +func (s *principalFactorStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + if !s.member { + return nil, nil + } + return &gen.OrgMembership{Role: s.orgRole}, nil +} + +func (s *principalFactorStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *principalFactorStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return s.enrolled, nil +} + +func (s *principalFactorStore) GetPrincipal(context.Context, string) (*business.Principal, error) { + return s.principal, nil +} + +// RevokePrincipal resolves the principal first, to decide which authorization applies, +// and that read runs in a scope. Only the scope's Within is needed: the gate under test +// sits between that read and the write, so nothing beyond it has to work for the +// refusal to be the real one. +type principalFactorScope struct{ business.Scoped } + +func (s *principalFactorScope) Within(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *principalFactorStore) As(business.Identity) business.Scoped { + return &principalFactorScope{} +} + +func (s *principalFactorStore) CreateAgentPrincipal(_ context.Context, p *business.Principal) error { + s.created = append(s.created, p.AgentIdentifier) + return nil +} + +func (s *principalFactorStore) RevokePrincipal(_ context.Context, id, _ string) error { + s.revoked = append(s.revoked, id) + return nil +} + +func (s *principalFactorStore) GetAgentPrincipal(context.Context, string, string) (*business.Principal, error) { + return nil, nil +} + +func (s *principalFactorStore) DisableAgentPrincipal(context.Context, string, string) (bool, error) { + return false, nil +} + +func (s *principalFactorStore) EnableAgentPrincipal(context.Context, string) (bool, error) { + return false, nil +} + +func (s *principalFactorStore) ListPrincipals(context.Context, string, string, int32, string) ([]*business.Principal, string, error) { + return nil, "", nil +} + +func principalFactorCalls() []factorCall { + principals := &PrincipalServer{} + return []factorCall{ + {"CreateAgentPrincipal", func(ctx context.Context) error { + _, err := principals.CreateAgentPrincipal(ctx, &gen.CreateAgentPrincipalRequest{ + OrgId: mfaOrgID, AgentIdentifier: "a-delegated-agent", DisplayName: "A Delegated Agent", + }) + return err + }}, + {"RevokePrincipal", func(ctx context.Context) error { + _, err := principals.RevokePrincipal(ctx, &gen.RevokePrincipalRequest{ + Id: factorUUID, Reason: "a recorded reason", + }) + return err + }}, + } +} + +func TestR1019PrincipalMutationsRequireAFactor(t *testing.T) { + for _, caller := range []struct { + name string + store *principalFactorStore + }{ + {"platform super-admin", &principalFactorStore{ + platformRole: "super_admin", + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + }}, + {"organization owner", &principalFactorStore{ + orgRole: gen.OrgRole_ORG_ROLE_OWNER, member: true, + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + }}, + } { + t.Run(caller.name, func(t *testing.T) { + installMFAPolicyStore(t, caller.store) + ctx := privilegedActorContext() + + for _, call := range principalFactorCalls() { + err := call.invoke(ctx) + require.Error(t, err, "%s must not mutate without a recent factor", call.name) + require.Equal(t, codes.FailedPrecondition, status.Code(err), + "%s: expected the factor gate to answer, not an earlier guard", call.name) + require.Equal(t, "mfa_required", status.Convert(err).Message(), call.name) + } + + // The refusal is only the gate if the write did not happen anyway. + require.Empty(t, caller.store.created, "no principal may be created without a factor") + require.Empty(t, caller.store.revoked, "no principal may be revoked without a factor") + }) + } +} + +// The control: the same caller with recent AAL2 evidence gets PAST the factor gate. +// Without it, a handler that refused everything would satisfy the test above and the +// refusals would prove nothing about the factor. +// +// Past the gate the handler runs into store surface this package does not wire, which +// is where the test's interest ends: a panic from there is proof the gate admitted, so +// it is recovered and reported as such rather than left to fail the run. What must +// never happen is an mfa_required. +func TestR1019PrincipalMutationsProceedPastTheFactorGate(t *testing.T) { + store := &principalFactorStore{ + platformRole: "super_admin", + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + } + installMFAPolicyStore(t, store) + ctx := stampVerifiedIdentity(context.Background(), mfaActorID, "", auth.Assurance{ + Level: auth.AssuranceLevelAAL2, + AuthenticationMethods: []string{auth.AuthenticationMethodWebAuthn}, + MFAVerifiedAt: time.Now(), + }) + ctx = auth.WithVerifiedDatabaseIdentity(ctx, mfaActorID, mfaOrgID) + + for _, call := range principalFactorCalls() { + t.Run(call.name, func(t *testing.T) { + err, admitted := invokePastTheGate(ctx, call) + if admitted { + return // it reached store surface beyond the gate, which is the point + } + if err == nil { + return + } + require.NotEqual(t, "mfa_required", status.Convert(err).Message(), + "recent AAL2 evidence must satisfy the gate this mutation carries") + }) + } +} + +// invokePastTheGate reports the handler's error, and whether it got far enough to +// panic in unwired store surface โ€” which only happens after the factor gate admitted. +func invokePastTheGate(ctx context.Context, call factorCall) (err error, admitted bool) { + defer func() { + if recovered := recover(); recovered != nil { + admitted = true + } + }() + return call.invoke(ctx), false +} + +// The population this gate reaches is wider than "privileged actor", and that is worth +// asserting rather than discovering: CreateAgentPrincipal admits an organization ADMIN, +// who is not privileged, so requireMFA applies its opt-in rule โ€” an ENROLLED admin +// without recent evidence is refused, an unenrolled one is not. +// +// Both halves are asserted because only the pair pins the behaviour. Without the +// unenrolled case this would also pass if the gate were fail-closed for everyone, which +// would force every operator's whole roster to enrol; without the enrolled case it would +// pass if the gate did nothing for a non-privileged actor. module/FEATURES.md states +// this, and an operator reading only "ordinary tenant mutations are not gated" would +// not predict it. +func TestR1019OrganizationAdminMeetsTheOptInFactorRule(t *testing.T) { + admin := func(enrolled bool) *principalFactorStore { + return &principalFactorStore{ + orgRole: gen.OrgRole_ORG_ROLE_ADMIN, member: true, enrolled: enrolled, + principal: &business.Principal{ID: factorUUID, OrgID: mfaOrgID}, + } + } + create := principalFactorCalls()[0] + require.Equal(t, "CreateAgentPrincipal", create.name) + + t.Run("enrolled without recent evidence is refused", func(t *testing.T) { + store := admin(true) + installMFAPolicyStore(t, store) + + err := create.invoke(privilegedActorContext()) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "mfa_required", status.Convert(err).Message()) + require.Empty(t, store.created, "the write must not happen behind the refusal") + }) + + t.Run("not enrolled is still admitted", func(t *testing.T) { + installMFAPolicyStore(t, admin(false)) + + err, admitted := invokePastTheGate(privilegedActorContext(), create) + if admitted { + return // past the gate, into store surface this package does not wire + } + if err != nil { + require.NotEqual(t, "mfa_required", status.Convert(err).Message(), + "an unenrolled organization admin must not be forced to enrol by this gate") + } + }) +} diff --git a/module/services/accounts/code/pkg/adapters/principal_rpcs.go b/module/services/accounts/code/pkg/adapters/principal_rpcs.go index 81511f11c..c1a5d7116 100644 --- a/module/services/accounts/code/pkg/adapters/principal_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/principal_rpcs.go @@ -70,6 +70,13 @@ func (s *PrincipalServer) CreateAgentPrincipal(ctx context.Context, req *gen.Cre if err := requireOrgAdmin(ctx, actorID, req.GetOrgId()); err != nil { return nil, err } + // A principal is a credential-bearing actor, so minting one is a + // privilege-granting mutation (SP-IDENT-10). The factor check runs AFTER the + // authorization guard and BEFORE the mutation: a factor check the write has + // already passed is not a check. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } p, err := service.CreateAgentPrincipal(ctx, business.CreateAgentRequest{ OrgID: req.GetOrgId(), AgentIdentifier: req.GetAgentIdentifier(), @@ -134,23 +141,29 @@ func (s *PrincipalServer) RevokePrincipal(ctx context.Context, req *gen.RevokePr } // Humans (org_id NULL) only revocable by platform admin; agents // + services revocable by their org's admins. + // + // actorID is resolved once, before the branch, so the factor check below cannot + // be reached on a path that did not establish an actor: it was declared inside + // each branch, which is how a gate comes to be added to one branch and not the + // other. + actorID, err := requireAuth(ctx) + if err != nil { + return nil, err + } if p.OrgID == "" { - actorID, authErr := requireAuth(ctx) - if authErr != nil { - return nil, authErr - } if paErr := requirePlatformAdmin(ctx, actorID); paErr != nil { return nil, paErr } } else { - actorID, authErr := requireAuth(ctx) - if authErr != nil { - return nil, authErr - } - if err := requireOrgAdmin(ctx, actorID, p.OrgID); err != nil { - return nil, err + if orgErr := requireOrgAdmin(ctx, actorID, p.OrgID); orgErr != nil { + return nil, orgErr } } + // Revoking a principal is a platform-security mutation, and it is irreversible + // where Disable is not. Same placement rule: after the guard, before the write. + if mfaErr := requireMFA(ctx, actorID); mfaErr != nil { + return nil, mfaErr + } if err := service.RevokePrincipal(ctx, req.GetId(), req.GetReason()); err != nil { return nil, mapPrincipalError(err) } diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go index f00e60c89..7924ddba7 100644 --- a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go +++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go @@ -2,6 +2,8 @@ package adapters import ( "context" + "go/parser" + "go/token" "os" "regexp" "strings" @@ -77,30 +79,81 @@ func TestR1019PrivilegedMutationAdmittedWithRecentFactor(t *testing.T) { // factor check the write has already passed is not a check. Read from the source, so // a method added to the privileged set cannot be left ungated, and so the ordering // cannot regress to the shape this finding started as. +// +// Two things this check learned the hard way (R1019-N15). It must prove the gate's +// error is RETURNED, not merely that requireMFA appears โ€” ignoring the result leaves +// the call in place and enforces nothing. And it must read source with comments +// STRIPPED, because a comment containing the expected text otherwise satisfies a +// substring search while the executable guard is gone. Both of those mutations +// survived the first version of this test. func TestR1019PrivilegedMutationsGateBeforeTheMutation(t *testing.T) { - source, err := os.ReadFile("rpcs.go") - require.NoError(t, err) - text := string(source) + // The gate in its only acceptable form: the error is bound, tested, and returned. + // RE2 has no backreferences, so the three names are captured and compared below โ€” + // which is what forbids binding one variable and returning another. + gateReturns := regexp.MustCompile( + `if (\w+) := requireMFA\(ctx, actorID\); (\w+) != nil \{\s*return nil, (\w+)\s*\}`) for _, m := range privilegedMutationMethods { - pattern := regexp.MustCompile(`func \(s \*` + m.server + `\) ` + m.method + - `\(ctx context\.Context(?s:.*?)\n\}\n`) - body := pattern.FindString(text) - require.NotEmpty(t, body, "%s.%s not found", m.server, m.method) - - gate := strings.Index(body, "requireMFA(") - require.NotEqual(t, -1, gate, - "%s.%s is a privileged mutation and must gate a second factor", m.server, m.method) - mutation := strings.Index(body, "service.") - require.NotEqual(t, -1, mutation, "%s.%s calls no service method", m.server, m.method) - require.Less(t, gate, mutation, - "%s.%s gates the factor AFTER its mutation, which enforces nothing", - m.server, m.method) + t.Run(m.server+"."+m.method, func(t *testing.T) { + text := executableSource(t, m.file) + pattern := regexp.MustCompile(`func \(s \*` + m.server + `\) ` + m.method + + `\(ctx context\.Context(?s:.*?)\n\}\n`) + body := pattern.FindString(text) + require.NotEmpty(t, body, "%s.%s not found in %s", m.server, m.method, m.file) + + gate := gateReturns.FindStringSubmatchIndex(body) + require.NotNil(t, gate, + "%s.%s must gate a second factor AND return its refusal; a requireMFA whose "+ + "error is discarded enforces nothing", m.server, m.method) + bound := body[gate[2]:gate[3]] + tested := body[gate[4]:gate[5]] + returned := body[gate[6]:gate[7]] + require.Equal(t, bound, tested, + "%s.%s tests a different variable than the one requireMFA bound", m.server, m.method) + require.Equal(t, bound, returned, + "%s.%s returns a different variable than the one requireMFA bound", m.server, m.method) + // The MUTATING call specifically, not the first service call in the body: + // RevokePrincipal reads the principal first to decide which authorization + // applies, and "before the first service call" would have demanded the gate + // run before that read โ€” or, read the other way, been satisfied by a gate + // that merely preceded it. + call := "service." + m.method + "(" + mutation := strings.Index(body, call) + require.NotEqual(t, -1, mutation, + "%s.%s does not call %s; name its mutating call in privilegedMutationMethods", + m.server, m.method, call) + require.Less(t, gate[0], mutation, + "%s.%s gates the factor AFTER its mutation, which enforces nothing", + m.server, m.method) + }) + } +} + +// executableSource is the file with every comment removed, so prose can neither +// satisfy nor break a source assertion. A comment is not code; a check that cannot +// tell them apart is satisfied by writing the answer in a comment. +func executableSource(t *testing.T, name string) string { + t.Helper() + source, err := os.ReadFile(name) + require.NoError(t, err) + fileSet := token.NewFileSet() + parsed, err := parser.ParseFile(fileSet, name, source, parser.SkipObjectResolution) + require.NoError(t, err) + + stripped := append([]byte(nil), source...) + base := fileSet.File(parsed.Pos()).Base() + for _, group := range parsed.Comments { + for i := int(group.Pos()) - base; i < int(group.End())-base && i < len(stripped); i++ { + if stripped[i] != '\n' { + stripped[i] = ' ' + } + } } + return string(stripped) } type privilegedMutation struct { - server, method string + server, method, file string } // The privilege-granting and platform-security mutations. Roles, scopes and @@ -108,13 +161,23 @@ type privilegedMutation struct { // state or a tenant's ceiling. // // UpsertFeatureFlag is deliberately absent: it mutates nothing, returning -// FailedPrecondition for a retired inventory. +// FailedPrecondition for a retired inventory. DisableAgentPrincipal and +// EnableAgentPrincipal are absent for a different reason โ€” they admit on an internal +// service credential, where there is no human actor and so no factor to require. var privilegedMutationMethods = []privilegedMutation{ - {"PermServer", "CreateRole"}, {"PermServer", "UpdateRole"}, {"PermServer", "DeleteRole"}, - {"PermServer", "AssignRole"}, {"PermServer", "RevokeRole"}, - {"PermServer", "GrantScope"}, {"PermServer", "RevokeScope"}, - {"PlatformAdminServer", "SuspendUser"}, {"PlatformAdminServer", "UnsuspendUser"}, - {"PlatformAdminServer", "RevokeSession"}, {"PlatformAdminServer", "OverrideEntitlement"}, + {"PermServer", "CreateRole", "rpcs.go"}, + {"PermServer", "UpdateRole", "rpcs.go"}, + {"PermServer", "DeleteRole", "rpcs.go"}, + {"PermServer", "AssignRole", "rpcs.go"}, + {"PermServer", "RevokeRole", "rpcs.go"}, + {"PermServer", "GrantScope", "rpcs.go"}, + {"PermServer", "RevokeScope", "rpcs.go"}, + {"PlatformAdminServer", "SuspendUser", "rpcs.go"}, + {"PlatformAdminServer", "UnsuspendUser", "rpcs.go"}, + {"PlatformAdminServer", "RevokeSession", "rpcs.go"}, + {"PlatformAdminServer", "OverrideEntitlement", "rpcs.go"}, + {"PrincipalServer", "CreateAgentPrincipal", "principal_rpcs.go"}, + {"PrincipalServer", "RevokePrincipal", "principal_rpcs.go"}, } type factorCall struct { From b17c9446d64281e2f41323b8798ee81ec7f728ea Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:18:58 -0400 Subject: [PATCH 23/46] test: the eight mutations that still survived, and the stripper that was silently a no-op (R1019-N15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each surviving mutation named a check that could not tell enforcement from the appearance of it. The Connect response lift: every assertion either called cookieTheRefreshToken directly or read the source for the WRAPPER'S NAME, so removing the call from inside the wrapper left the helper working when a test called it and the handlers still naming a wrapper that no longer called it. There is now one test that drives the wrapper itself, and it is the only one that fails when that call goes away. The datasource keys: every assertion was about the DERIVATION โ€” that two keys differ, that a placeholder is refused โ€” and none about where the value comes from, so substituting another provisioned secret changed nothing any test looked at. The internal token passes every other check in usableDatasourceKey, so the group is the only thing that distinguishes it, and that is what is now asserted: blank in its own group with the perimeter credential provisioned, the purpose must be unavailable. The gateway's deployed placeholder validation is now reached by the N17 tests, which call requirePerimeterCredentials(false) directly rather than only through pure comparisons. The ignored-error mutations (GrantScope, the inherited audience, the gateway startup check) all left the call in place and discarded its result. The source checks now match only the form that binds, tests and RETURNS the same variable, compared by captured name since RE2 has no backreferences. The comment mutations are why source checks now read the file with comments stripped. That stripper was itself a no-op for its first revision: parser.ParseFile leaves File.Comments EMPTY without parser.ParseComments, so it stripped nothing while looking like it worked, and the mutation it was written for was caught only by the return-form rule. It now strips โ€” by Position().Offset rather than offsets derived by hand โ€” and a test proves it removes a comment and keeps the code, against a file written for the purpose: asserting against its own file would compare its own assertion literals, and a needle in both can never be shown absent. Marketing's HSTS assertion accepted any value CONTAINING max-age=, which not-max-age= does; it reads the parsed directive list now. Co-Authored-By: Claude Opus 5 (1M context) --- .../code/identity_provider_migration_test.go | 56 +++++++++++++++++++ .../privileged_mutation_factor_test.go | 38 ++++++++++++- .../refresh_credential_carrier_test.go | 38 ++++++++++--- .../work_context_reserved_audience_test.go | 55 ++++++++++++------ 4 files changed, 160 insertions(+), 27 deletions(-) diff --git a/module/services/accounts/code/identity_provider_migration_test.go b/module/services/accounts/code/identity_provider_migration_test.go index 4d68ee363..c6e0837c9 100644 --- a/module/services/accounts/code/identity_provider_migration_test.go +++ b/module/services/accounts/code/identity_provider_migration_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "strings" "testing" @@ -109,3 +110,58 @@ func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) { t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned) require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false)) } + +// R1019-N15: replacing the datasource keys' wiring with the perimeter credential +// survived the whole accounts suite. Every existing assertion was about the +// DERIVATION โ€” that two keys differ, that a placeholder is refused โ€” and none about +// WHERE the value comes from, so substituting another provisioned secret changed +// nothing any test looked at. +// +// This asserts the source: each key is read from the `datasource-keys` group, by its +// own name. The internal token is a provisioned 32+ character value with no +// placeholder marker, so it passes every other check in usableDatasourceKey โ€” the +// group is the only thing that distinguishes it. +func TestR1019DatasourceKeysComeFromTheirOwnGroup(t *testing.T) { + // A deployed runtime with the perimeter credential provisioned and the datasource + // group blank. If the wiring read the perimeter credential, the purposes would be + // available; they must not be. + for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_GATEWAY_TOKEN"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv(key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + blankWorkspaceKey(t, "datasource-keys", key) + require.Nil(t, usableDatasourceKey(key, false), + "%s is absent from its own group, so its purpose must be unavailable โ€” a "+ + "provisioned credential from another group is not this key", key) + } + + // And the converse: provisioned in its own group, it resolves. Without this the + // assertions above would pass for a function that always returned nil. + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, + strings.Repeat("9Zr4Lm7Td2Wc", 4)) + require.Equal(t, []byte(strings.Repeat("9Zr4Lm7Td2Wc", 4)), usableDatasourceKey(key, false), + "%s provisioned in datasource-keys must resolve", key) + } +} + +// The wiring itself, from the source: boot must pass each key through +// usableDatasourceKey with the deployment's own local flag. A key wired from a +// constant, or from another group's value, is the same defect as not wiring it. +func TestR1019DatasourceKeyWiringIsReadFromItsGroup(t *testing.T) { + source, err := os.ReadFile("work.go") + require.NoError(t, err) + text := string(source) + + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + require.Contains(t, text, + `usableDatasourceKey("`+key+`", codefly.IsLocal())`, + "%s must be wired through usableDatasourceKey from the runtime's own local flag", key) + } + require.Contains(t, text, `workspaceEnv("datasource-keys", key)`, + "the keys must be read from the datasource-keys group, not from another group's credential") +} diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go index 7924ddba7..8baeea9d7 100644 --- a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go +++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go @@ -5,6 +5,7 @@ import ( "go/parser" "go/token" "os" + "path/filepath" "regexp" "strings" "testing" @@ -137,13 +138,20 @@ func executableSource(t *testing.T, name string) string { source, err := os.ReadFile(name) require.NoError(t, err) fileSet := token.NewFileSet() - parsed, err := parser.ParseFile(fileSet, name, source, parser.SkipObjectResolution) + // ParseComments is required: without it File.Comments is EMPTY and this function + // silently strips nothing, which is a check that looks like it works. It was that + // way until a mutation test proved a commented-out guard still satisfied a caller. + parsed, err := parser.ParseFile(fileSet, name, source, + parser.ParseComments|parser.SkipObjectResolution) require.NoError(t, err) stripped := append([]byte(nil), source...) - base := fileSet.File(parsed.Pos()).Base() for _, group := range parsed.Comments { - for i := int(group.Pos()) - base; i < int(group.End())-base && i < len(stripped); i++ { + // Position().Offset is the canonical byte offset. Deriving one from Pos() and + // the file's Base() by hand is where this silently became a no-op. + start := fileSet.Position(group.Pos()).Offset + end := fileSet.Position(group.End()).Offset + for i := start; i < end && i < len(stripped); i++ { if stripped[i] != '\n' { stripped[i] = ' ' } @@ -152,6 +160,30 @@ func executableSource(t *testing.T, name string) string { return string(stripped) } +// The guard against that failure mode: this function must actually remove a comment, +// and must leave code alone. Asserted against a file written for the purpose โ€” asserting +// against this file would compare its own assertion literals, which are code, and a +// needle that appears in both can never be shown absent. +func TestExecutableSourceRemovesComments(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "sample.go") + require.NoError(t, os.WriteFile(name, []byte(`package sample + +// a line comment naming guardedCall() +func f() { + /* a block comment naming guardedCall() */ + guardedCall() +} +`), 0o600)) + + text := executableSource(t, name) + require.Contains(t, text, "\tguardedCall()", "the executable call must survive") + require.NotContains(t, text, "a line comment", "a line comment must not survive") + require.NotContains(t, text, "a block comment", "a block comment must not survive") + require.Equal(t, 1, strings.Count(text, "guardedCall()"), + "only the executable occurrence may remain; the two in comments must be gone") +} + type privilegedMutation struct { server, method, file string } diff --git a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go index 566199ffc..0e5303f82 100644 --- a/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go +++ b/module/services/accounts/code/pkg/adapters/refresh_credential_carrier_test.go @@ -1,10 +1,10 @@ package adapters import ( + "context" "encoding/json" "net/http" "net/http/httptest" - "os" "regexp" "strings" "testing" @@ -185,12 +185,11 @@ func TestCookieTheRefreshTokenLeavesAnUnrelatedResponseAlone(t *testing.T) { // require it of every authConnectHandler method whose response message carries a // refresh token, so a handler added later cannot quietly use the plain helper. func TestEveryConnectAuthCompletionRoutesThroughTheLift(t *testing.T) { - source, err := os.ReadFile("connect_handlers.go") - require.NoError(t, err) + text := executableSource(t, "connect_handlers.go") methods := regexp.MustCompile( `(?m)^func \(h \*authConnectHandler\) ([A-Za-z]+)\(ctx context\.Context, req \*connect\.Request\[gen\.([A-Za-z]+)\]\) \(\*connect\.Response\[gen\.([A-Za-z]+)\][^\n]*\n((?:\t[^\n]*\n)*)`). - FindAllStringSubmatch(string(source), -1) + FindAllStringSubmatch(text, -1) require.NotEmpty(t, methods) checked := 0 @@ -262,9 +261,9 @@ func TestR1019ConnectWithNoCarrierIsUnchanged(t *testing.T) { // rejected before the cookie is ever consulted. The handler is the only place that // ordering holds, so it is asserted from the source. func TestR1019ConnectRefreshLiftsBeforeValidation(t *testing.T) { - source, err := os.ReadFile("connect_handlers.go") - require.NoError(t, err) - text := string(source) + // Comments stripped: replacing the call with a comment that contains its text + // satisfied a substring search while nothing consumed the cookie (R1019-N15). + text := executableSource(t, "connect_handlers.go") for _, method := range []string{"RefreshToken", "Logout"} { pattern := regexp.MustCompile(`func \(h \*authConnectHandler\) ` + method + @@ -279,3 +278,28 @@ func TestR1019ConnectRefreshLiftsBeforeValidation(t *testing.T) { method) } } + +// R1019-N15: every assertion above either calls cookieTheRefreshToken directly or +// reads the source for the wrapper's NAME, so removing the lift from inside the +// wrapper survived the whole suite โ€” the helper still worked when called by a test, +// and the handlers still named the wrapper that no longer called it. +// +// This drives the wrapper itself. It is the only test that fails if the call inside +// it goes away. +func TestR1019TheLiftRunsInsideTheWrapper(t *testing.T) { + request := connect.NewRequest(&gen.RefreshTokenRequest{RefreshToken: "rt-in"}) + + response, err := unaryCookieingRefreshToken(t.Context(), request, + func(context.Context, *gen.RefreshTokenRequest) (*gen.RefreshTokenResponse, error) { + return &gen.RefreshTokenResponse{AccessToken: "at", RefreshToken: "rt-secret"}, nil + }) + require.NoError(t, err) + + require.Empty(t, response.Msg.GetRefreshToken(), + "the wrapper must lift the credential out of the body it returns") + require.Equal(t, "at", response.Msg.GetAccessToken(), "the rest of the response is untouched") + setCookie := response.Header().Get("Set-Cookie") + require.Contains(t, setCookie, refreshTokenCookieName+"=rt-secret", + "the wrapper must put the credential in the cookie it lifted it into") + require.Contains(t, setCookie, "HttpOnly") +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go index ec21067e7..cf4b4b112 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go @@ -2,7 +2,6 @@ package adapters import ( "context" - "os" "regexp" "strings" "testing" @@ -125,14 +124,13 @@ func TestAnOrdinaryAudiencePassesTheReservedAudienceGuard(t *testing.T) { // caller's). Matching those too would make this test demand a second gate on a // path with no caller-supplied value to gate. func TestEveryRequestSuppliedAudienceRefusesTheReservedOne(t *testing.T) { - source, err := os.ReadFile("work_context_rpcs.go") - require.NoError(t, err) + // Comments stripped: a source check that cannot tell code from prose is satisfied + // by writing the answer in a comment (R1019-N15). + text := executableSource(t, "work_context_rpcs.go") methods := regexp.MustCompile(`(?m)^func \(s \*WorkContextAuthorityServer\) ([A-Z][A-Za-z]*)\(`). - FindAllStringSubmatchIndex(string(source), -1) + FindAllStringSubmatchIndex(text, -1) require.NotEmpty(t, methods) - - text := string(source) for index, match := range methods { name := text[match[2]:match[3]] end := len(text) @@ -147,11 +145,19 @@ func TestEveryRequestSuppliedAudienceRefusesTheReservedOne(t *testing.T) { // directly, or the local that a renewal resolves it into. Anything else // passing through this gate is a value this test cannot vouch for, so it // is not accepted as one. - gatesTheField := strings.Contains(body, "requireVocabularyAudience(ctx, req.GetAudience())") - gatesTheResolved := strings.Contains(body, "requireVocabularyAudience(ctx, audience)") && - strings.Contains(body, "audience := req.GetAudience()") + // + // Each is matched as a guard whose error LEAVES the handler. Matching the + // call alone is what let the discard mutation survive (R1019-N15): a call + // whose result is dropped reads as a check and enforces nothing. + gatesTheField := regexp.MustCompile( + guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, req\.GetAudience\(\)\)`)). + MatchString(body) + gatesTheResolved := regexp.MustCompile( + guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, audience\)`)). + MatchString(body) && strings.Contains(body, "audience := req.GetAudience()") require.True(t, gatesTheField || gatesTheResolved, - "%s takes its audience from the request and must refuse the reserved module audience", name) + "%s takes its audience from the request and must refuse the reserved module "+ + "audience AND return that refusal; a guard whose error is discarded enforces nothing", name) } } @@ -169,9 +175,7 @@ func scopesForReservedAudienceTest() []*gen.WorkContextScope { // a person-driven renewal could still carry the reserved module audience. The // EFFECTIVE value is what gets signed, so the effective value is what is refused. func TestR1019RenewInheritedAudience(t *testing.T) { - source, err := os.ReadFile("work_context_rpcs.go") - require.NoError(t, err) - text := string(source) + text := executableSource(t, "work_context_rpcs.go") body := regexp.MustCompile( `func \(s \*WorkContextAuthorityServer\) RenewWorkContext\((?s:.*?)\n\}\n`). @@ -181,13 +185,30 @@ func TestR1019RenewInheritedAudience(t *testing.T) { inherit := strings.Index(body, "audience = parent.GetAudience()") require.NotEqual(t, -1, inherit, "renewal still inherits the parent audience") - // A refusal of the EFFECTIVE audience, after the inheritance and before signing. - effective := strings.Index(body, "refuseReservedModuleAudience(audience)") - require.NotEqual(t, -1, effective, - "renewal must refuse the audience it will actually sign, not only the one requested") + // A refusal of the EFFECTIVE audience, after the inheritance and before signing โ€” + // and one whose error is RETURNED. Discarding it left the call in place and + // enforced nothing, which is how this check passed while the guard did not run + // (R1019-N15). + returns := regexp.MustCompile(guardReturnsItsRefusal(`requireVocabularyAudience\(ctx, audience\)`)) + located := returns.FindStringIndex(body) + require.NotNil(t, located, + "renewal must refuse the audience it will actually sign, not only the one "+ + "requested, and must return that refusal") + effective := located[0] require.Less(t, inherit, effective, "the check must follow the inheritance") sign := strings.Index(body, "s.signer.") require.NotEqual(t, -1, sign) require.Less(t, effective, sign, "the check must precede signing") } + +// guardReturnsItsRefusal is the pattern for "this guard is called AND its error leaves +// the handler". A call whose result is discarded is not a guard, and matching the call +// alone is what let that mutation survive. +// +// The error variable's name is not pinned, because these handlers bind several +// different ones; what is pinned is that something is bound from the call, tested +// against nil, and returned from inside that branch. +func guardReturnsItsRefusal(call string) string { + return `if \w+ := ` + call + `; \w+ != nil \{\s*return nil, \w+\s*\}` +} From 74e572c4030b3ae193dd8707e800a8579c054310 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:20:54 -0400 Subject: [PATCH 24/46] docs: the precise blocker behind each finding this change leaves open (R1019-N06, SA-F-INSTALL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are blocked on a missing contract rather than a missing check, and saying so without saying which contract leaves the next person to rediscover it. For SP-GW-07's second half: ExchangeAudience re-binds an EXISTING Work Context, and the caller at the edge holds a browser session, which is not one โ€” so what is missing is a mint from a verified session to a context bound to one upstream's audience. It is also per request on the proxy path, so its latency and failure mode become those of all federated traffic, and the authority to narrow to is the installation ceiling the host cannot currently look up. For the installation check: InstallationService can install, uninstall, transfer and GetInstallation BY INSTALLATION UUID, and nothing answers "does this organization have an installation of the solution with this id" โ€” which is the question the proxy has to ask. SOLUTION_REGISTRATION.md ยง4a now also states what does bound that surface today, so the document cannot be read as claiming an installation check it does not make. Co-Authored-By: Claude Opus 5 (1M context) --- module/SOLUTION_REGISTRATION.md | 21 +++++++++++++++++++++ module/services/auth-gateway/AGENTS.md | 16 ++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 2dcde7cd0..e961cc5b2 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -268,6 +268,27 @@ plain "temporarily unavailable" panel instead of a 404, and the technical cause stays in the server log. >>>>>>> a3090094 (fix(auth-gateway): a module or solution upstream receives the person's identity, not their session (SA-F-BEARER)) +## 4a. The host cannot yet require an installation at the proxy + +The frontend's solution proxy admits a registered solution to any signed-in viewer +whose request is same-origin. It does not require that the viewer's organization has +INSTALLED the solution, so installation governs what an agent may do headlessly but +not whether a person's browser may reach a solution's endpoints through the host. + +This is not a judgement that it should not; it is a missing contract. +`InstallationService` offers `InstallSolution`, `UninstallSolution`, +`TransferInstallationOwnership` and `GetInstallation` โ€” and `GetInstallation` takes an +installation UUID. Nothing answers "does this organization have an installation of the +solution with this id", which is the question the proxy has to ask. Adding it is a +descriptor change (contract digests, published clients, a verified regeneration), and +it is the condition the solution/module lifecycle rework owns. + +Until it lands, what bounds the surface is: a registered solution only (404 +otherwise), a same-origin request against the configured public origin (403 +otherwise), the per-viewer projection of the registry, and the gateway's own solution +admission. None of those is an installation check, and this document should not be +read as claiming one. + ## 4. Registration, installation, and entitlement are three different things They are frequently conflated. In this module they are not the same, and the diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 762234cd6..46f76591b 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -100,6 +100,22 @@ receives the stamped identity headers and no audience-bound context, so the credential is no longer over-broad but the positive half is absent. Minting it at the edge is open work here; it cannot be supplied by a consumer. +What is missing is specific, so that the next person does not have to rediscover it. +`WorkContextService` already exposes `ExchangeAudience`, which re-binds an existing +Work Context to a different audience โ€” and that is not the operation this needs. The +caller here holds a browser SESSION, which is not a Work Context, so the edge would +need a mint that takes a verified session and returns a context bound to one +upstream's audience, with that person's authority narrowed to what the upstream may +do on their behalf. No RPC does that today, and adding one is a descriptor change +(contract digests, published clients, a verified regeneration), which is why the +stripping half shipped alone. + +Two things to settle with it, because they are the reason it is not a small change: +the mint is per request on the proxy path, so its latency and its failure mode become +the failure mode of all federated traffic; and the authority to narrow TO is the +installation's ceiling, which the host cannot currently look up from a solution id +either (see `module/SOLUTION_REGISTRATION.md`). + **A consumer written against the old behaviour has to change**: anything that read the forwarded bearer needs a host-minted capability bound to its own audience instead. From 0b94255771445fb6b201c4c2dd066679b79bd6d1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:38:14 -0400 Subject: [PATCH 25/46] fix: one validator for the public origin, and a resource-bound tool token is the exception that proves SP-GW-07 (r2 f1, f2, f3, f4, f5, f7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f1 (blocker). APP_BASE_URL was validated at boot by url.Parse plus field rules and at request time by IDNA canonicalization. An origin the first accepted and the second could not canonicalize made a cell boot GREEN and then refuse every authentication โ€” sign-in, the OAuth redirect, the relying-party origin, emailed links, the solution proxy's same-origin check โ€” because SetConfiguredPublicOrigin had silently stored a value no canonicalized candidate could ever equal. The fix that matters is that boot now validates with the SAME function the request path uses, so a second validator cannot drift back in. Measured case: `https://my_app.example`. The raw spelling is still what boot RETURNS. It is also the token `iss` and the published authorization-server metadata, where a verifier compares it exactly, so canonicalizing it there would silently change `iss` for any deployment whose origin carries an explicit :443. Only the comparison canonicalizes. f1 also had a second cause: idna.Lookup enforces DNS hostname policy, which refuses an UNDERSCORE that browsers accept and internal hostnames routinely carry. The profile is now MapForLookup without StrictDomainName, so that spelling is accepted on both sides. idna.Punycode would have been the wrong relaxation: it does no case folding, so "BรœCHER.example" and "bรผcher.example" encode to DIFFERENT punycode and could never compare equal. A vector now pins that. f2. The setter returns an error and the call site refuses. Storing the raw value on a canonicalization failure looked like the conservative branch and was the opposite: it turned a diagnosable configuration error into an opaque per-request refusal for the life of the process. f3. The shared vectors omitted exactly the spellings where the two implementations disagreed, so all three tests passed while the sides answered differently โ€” the lockstep gate proves the two TABLES match and can say nothing about a case absent from both. The underscore host is now accepted on both sides; a hyphen-edged label, which the parser accepts and IDNA refuses, is now refused on both, by an explicit label check on the frontend. Both files say the table is the full extent of the agreement. f4. The disposition claimed #1007 was unmerged. It merged as 056803fe2, and reading it showed the opposite of both my claims: it CLOSES the finding for `/solutions//mcp` โ€” ext_authz answers 401 to a token that names only the host โ€” and leaves `/solutions//mcp/` open, because the predicate matched the segment exactly. Routers, proxies and clients treat those as one endpoint, and MCP's own transport addresses sub-routes under it, so the whole `mcp` subtree is now the protected resource. Reconciling that with SA-F-BEARER is the substantive change here, and it was a live contradiction the rebase surfaced: my stripping broke the merged MCP contract, whose runtime mints the viewer's Work Context from the forwarded token. The two are not in conflict once the credential is named correctly. A HOST-WIDE session is still removed from every upstream. A RESOURCE-BOUND tool token is forwarded, because ext_authz refuses an unbound token on a tool path, so a tool request reaching the proxy has already been proved to carry an audience naming that solution and nothing else โ€” which is the audience-bound credential SP-GW-07's positive half asks for. Read off the path, not re-derived from the token: admission already proved it, and a second JWT parse would be a second verifier to keep in agreement with the first. f5. The field comment that still advertised the derivation SA-F-KEYREUSE removed. Named in the previous review and carried; it now names the delivered key and its group. f7. executableSource moved to source_assertions_test.go (three test files use it, and it lived in the one named for a single finding); the four datasource-key tests moved to datasource_keys_test.go. Every fix carries the test that fails without it, and each was mutation-checked individually. Co-Authored-By: Claude Opus 5 (1M context) --- .../accounts/code/datasource_keys_test.go | 117 ++++++++++++++++++ .../code/identity_provider_migration_test.go | 101 --------------- .../privileged_mutation_factor_test.go | 58 --------- .../pkg/adapters/source_assertions_test.go | 73 +++++++++++ .../accounts/code/pkg/auth/public_origin.go | 48 +++++-- .../pkg/auth/public_origin_vectors_test.go | 40 +++++- .../accounts/code/pkg/business/service.go | 2 +- module/services/accounts/code/work.go | 20 ++- module/services/accounts/code/work_test.go | 61 +++++++++ module/services/auth-gateway/code/gateway.go | 30 ++++- .../auth-gateway/code/gateway_mcp_resource.go | 10 +- .../code/gateway_mcp_resource_test.go | 33 +++++ .../code/upstream_session_credential_test.go | 43 +++++++ .../code/src/lib/codefly-gateway-context.ts | 12 ++ .../public-origin-canonicalization.test.ts | 9 ++ 15 files changed, 479 insertions(+), 178 deletions(-) create mode 100644 module/services/accounts/code/datasource_keys_test.go create mode 100644 module/services/accounts/code/pkg/adapters/source_assertions_test.go create mode 100644 module/services/accounts/code/work_test.go diff --git a/module/services/accounts/code/datasource_keys_test.go b/module/services/accounts/code/datasource_keys_test.go new file mode 100644 index 000000000..a2e9ecc75 --- /dev/null +++ b/module/services/accounts/code/datasource_keys_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The datasource message-authentication keys: that they come from their OWN +// configuration group, that an unusable value leaves its purpose unavailable rather +// than falling back, and that boot wires them from the runtime. +// +// These lived in identity_provider_migration_test.go, because that is where the +// workspace-key helper happened to be written. Nothing broke; the next person looking +// for datasource-key coverage did not find it where they looked (R1019-N12/f7). + +// The shipped local defaults for the datasource keys carry placeholder markers, and +// this module's repository is public โ€” so a deployed runtime holding one would sign +// with a published value. It is treated as ABSENT, which leaves the purpose +// unavailable: the state minting and redemption already define, and the reason this +// does not refuse at boot the way a perimeter credential does. +func TestR1019DeployedDatasourceDefaultsRefused(t *testing.T) { + keys := []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} + for _, key := range keys { + // Two guards run here, and only the last case isolates the marker one: the + // two shipped values are shorter than the length floor, so removing the + // marker check still refuses them. A long marker-carrying value is what + // proves the marker check itself fires. + for _, shipped := range []string{ + "local-dev-only-replace-me", + "LOCAL-DEV-ONLY-REPLACE-ME", + "some-change-me-value-long-enough-to-pass-a-length-floor", + } { + t.Run(key+"/"+shipped[:12], func(t *testing.T) { + blankWorkspaceKey(t, "datasource-keys", key) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, shipped) + + require.Nil(t, usableDatasourceKey(key, false), + "a deployed runtime must not sign with a published value") + require.Equal(t, []byte(shipped), usableDatasourceKey(key, true), + "local development uses its shipped default") + }) + } + } +} + +// A short value is refused for the same reason the perimeter floor exists, and a +// provisioned one is used โ€” so the refusal is the value and not a key that is never +// usable. +func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) { + const key = "DATASOURCE_CONTENT_TICKET_KEY" + blankWorkspaceKey(t, "datasource-keys", key) + + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, "7Kq2Xp9Vb4Nf") + require.Nil(t, usableDatasourceKey(key, false)) + + provisioned := strings.Repeat("7Kq2Xp9Vb4Nf", 4) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned) + require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false)) +} + +// R1019-N15: replacing the datasource keys' wiring with the perimeter credential +// survived the whole accounts suite. Every existing assertion was about the +// DERIVATION โ€” that two keys differ, that a placeholder is refused โ€” and none about +// WHERE the value comes from, so substituting another provisioned secret changed +// nothing any test looked at. +// +// This asserts the source: each key is read from the `datasource-keys` group, by its +// own name. The internal token is a provisioned 32+ character value with no +// placeholder marker, so it passes every other check in usableDatasourceKey โ€” the +// group is the only thing that distinguishes it. +func TestR1019DatasourceKeysComeFromTheirOwnGroup(t *testing.T) { + // A deployed runtime with the perimeter credential provisioned and the datasource + // group blank. If the wiring read the perimeter credential, the purposes would be + // available; they must not be. + for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_GATEWAY_TOKEN"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__"+key, + strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + t.Setenv(key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) + } + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + blankWorkspaceKey(t, "datasource-keys", key) + require.Nil(t, usableDatasourceKey(key, false), + "%s is absent from its own group, so its purpose must be unavailable โ€” a "+ + "provisioned credential from another group is not this key", key) + } + + // And the converse: provisioned in its own group, it resolves. Without this the + // assertions above would pass for a function that always returned nil. + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, + strings.Repeat("9Zr4Lm7Td2Wc", 4)) + require.Equal(t, []byte(strings.Repeat("9Zr4Lm7Td2Wc", 4)), usableDatasourceKey(key, false), + "%s provisioned in datasource-keys must resolve", key) + } +} + +// The wiring itself, from the source: boot must pass each key through +// usableDatasourceKey with the deployment's own local flag. A key wired from a +// constant, or from another group's value, is the same defect as not wiring it. +func TestR1019DatasourceKeyWiringIsReadFromItsGroup(t *testing.T) { + source, err := os.ReadFile("work.go") + require.NoError(t, err) + text := string(source) + + for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { + require.Contains(t, text, + `usableDatasourceKey("`+key+`", codefly.IsLocal())`, + "%s must be wired through usableDatasourceKey from the runtime's own local flag", key) + } + require.Contains(t, text, `workspaceEnv("datasource-keys", key)`, + "the keys must be read from the datasource-keys group, not from another group's credential") +} diff --git a/module/services/accounts/code/identity_provider_migration_test.go b/module/services/accounts/code/identity_provider_migration_test.go index c6e0837c9..eaf6f530b 100644 --- a/module/services/accounts/code/identity_provider_migration_test.go +++ b/module/services/accounts/code/identity_provider_migration_test.go @@ -1,7 +1,6 @@ package main import ( - "os" "strings" "testing" @@ -65,103 +64,3 @@ func TestR1019LocalDevelopmentKeepsTheShippedPlaceholder(t *testing.T) { require.Equal(t, "local-dev-only-replace-me", identityProviderSecret(key)) } - -// The shipped local defaults for the datasource keys carry placeholder markers, and -// this module's repository is public โ€” so a deployed runtime holding one would sign -// with a published value. It is treated as ABSENT, which leaves the purpose -// unavailable: the state minting and redemption already define, and the reason this -// does not refuse at boot the way a perimeter credential does. -func TestR1019DeployedDatasourceDefaultsRefused(t *testing.T) { - keys := []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} - for _, key := range keys { - // Two guards run here, and only the last case isolates the marker one: the - // two shipped values are shorter than the length floor, so removing the - // marker check still refuses them. A long marker-carrying value is what - // proves the marker check itself fires. - for _, shipped := range []string{ - "local-dev-only-replace-me", - "LOCAL-DEV-ONLY-REPLACE-ME", - "some-change-me-value-long-enough-to-pass-a-length-floor", - } { - t.Run(key+"/"+shipped[:12], func(t *testing.T) { - blankWorkspaceKey(t, "datasource-keys", key) - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, shipped) - - require.Nil(t, usableDatasourceKey(key, false), - "a deployed runtime must not sign with a published value") - require.Equal(t, []byte(shipped), usableDatasourceKey(key, true), - "local development uses its shipped default") - }) - } - } -} - -// A short value is refused for the same reason the perimeter floor exists, and a -// provisioned one is used โ€” so the refusal is the value and not a key that is never -// usable. -func TestR1019DatasourceKeyFloorAndProvisionedValue(t *testing.T) { - const key = "DATASOURCE_CONTENT_TICKET_KEY" - blankWorkspaceKey(t, "datasource-keys", key) - - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, "7Kq2Xp9Vb4Nf") - require.Nil(t, usableDatasourceKey(key, false)) - - provisioned := strings.Repeat("7Kq2Xp9Vb4Nf", 4) - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, provisioned) - require.Equal(t, []byte(provisioned), usableDatasourceKey(key, false)) -} - -// R1019-N15: replacing the datasource keys' wiring with the perimeter credential -// survived the whole accounts suite. Every existing assertion was about the -// DERIVATION โ€” that two keys differ, that a placeholder is refused โ€” and none about -// WHERE the value comes from, so substituting another provisioned secret changed -// nothing any test looked at. -// -// This asserts the source: each key is read from the `datasource-keys` group, by its -// own name. The internal token is a provisioned 32+ character value with no -// placeholder marker, so it passes every other check in usableDatasourceKey โ€” the -// group is the only thing that distinguishes it. -func TestR1019DatasourceKeysComeFromTheirOwnGroup(t *testing.T) { - // A deployed runtime with the perimeter credential provisioned and the datasource - // group blank. If the wiring read the perimeter credential, the purposes would be - // available; they must not be. - for _, key := range []string{"CODEFLY_INTERNAL_TOKEN", "CODEFLY_GATEWAY_TOKEN"} { - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__INTERNAL_AUTH__"+key, - strings.Repeat("7Kq2Xp9Vb4Nf", 4)) - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__GATEWAY_TRUST__"+key, - strings.Repeat("7Kq2Xp9Vb4Nf", 4)) - t.Setenv(key, strings.Repeat("7Kq2Xp9Vb4Nf", 4)) - } - for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { - blankWorkspaceKey(t, "datasource-keys", key) - require.Nil(t, usableDatasourceKey(key, false), - "%s is absent from its own group, so its purpose must be unavailable โ€” a "+ - "provisioned credential from another group is not this key", key) - } - - // And the converse: provisioned in its own group, it resolves. Without this the - // assertions above would pass for a function that always returned nil. - for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { - t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__DATASOURCE_KEYS__"+key, - strings.Repeat("9Zr4Lm7Td2Wc", 4)) - require.Equal(t, []byte(strings.Repeat("9Zr4Lm7Td2Wc", 4)), usableDatasourceKey(key, false), - "%s provisioned in datasource-keys must resolve", key) - } -} - -// The wiring itself, from the source: boot must pass each key through -// usableDatasourceKey with the deployment's own local flag. A key wired from a -// constant, or from another group's value, is the same defect as not wiring it. -func TestR1019DatasourceKeyWiringIsReadFromItsGroup(t *testing.T) { - source, err := os.ReadFile("work.go") - require.NoError(t, err) - text := string(source) - - for _, key := range []string{"DATASOURCE_CONTENT_TICKET_KEY", "DATASOURCE_ACCOUNT_LINK_KEY"} { - require.Contains(t, text, - `usableDatasourceKey("`+key+`", codefly.IsLocal())`, - "%s must be wired through usableDatasourceKey from the runtime's own local flag", key) - } - require.Contains(t, text, `workspaceEnv("datasource-keys", key)`, - "the keys must be read from the datasource-keys group, not from another group's credential") -} diff --git a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go index 8baeea9d7..ceb8845de 100644 --- a/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go +++ b/module/services/accounts/code/pkg/adapters/privileged_mutation_factor_test.go @@ -2,10 +2,6 @@ package adapters import ( "context" - "go/parser" - "go/token" - "os" - "path/filepath" "regexp" "strings" "testing" @@ -130,60 +126,6 @@ func TestR1019PrivilegedMutationsGateBeforeTheMutation(t *testing.T) { } } -// executableSource is the file with every comment removed, so prose can neither -// satisfy nor break a source assertion. A comment is not code; a check that cannot -// tell them apart is satisfied by writing the answer in a comment. -func executableSource(t *testing.T, name string) string { - t.Helper() - source, err := os.ReadFile(name) - require.NoError(t, err) - fileSet := token.NewFileSet() - // ParseComments is required: without it File.Comments is EMPTY and this function - // silently strips nothing, which is a check that looks like it works. It was that - // way until a mutation test proved a commented-out guard still satisfied a caller. - parsed, err := parser.ParseFile(fileSet, name, source, - parser.ParseComments|parser.SkipObjectResolution) - require.NoError(t, err) - - stripped := append([]byte(nil), source...) - for _, group := range parsed.Comments { - // Position().Offset is the canonical byte offset. Deriving one from Pos() and - // the file's Base() by hand is where this silently became a no-op. - start := fileSet.Position(group.Pos()).Offset - end := fileSet.Position(group.End()).Offset - for i := start; i < end && i < len(stripped); i++ { - if stripped[i] != '\n' { - stripped[i] = ' ' - } - } - } - return string(stripped) -} - -// The guard against that failure mode: this function must actually remove a comment, -// and must leave code alone. Asserted against a file written for the purpose โ€” asserting -// against this file would compare its own assertion literals, which are code, and a -// needle that appears in both can never be shown absent. -func TestExecutableSourceRemovesComments(t *testing.T) { - dir := t.TempDir() - name := filepath.Join(dir, "sample.go") - require.NoError(t, os.WriteFile(name, []byte(`package sample - -// a line comment naming guardedCall() -func f() { - /* a block comment naming guardedCall() */ - guardedCall() -} -`), 0o600)) - - text := executableSource(t, name) - require.Contains(t, text, "\tguardedCall()", "the executable call must survive") - require.NotContains(t, text, "a line comment", "a line comment must not survive") - require.NotContains(t, text, "a block comment", "a block comment must not survive") - require.Equal(t, 1, strings.Count(text, "guardedCall()"), - "only the executable occurrence may remain; the two in comments must be gone") -} - type privilegedMutation struct { server, method, file string } diff --git a/module/services/accounts/code/pkg/adapters/source_assertions_test.go b/module/services/accounts/code/pkg/adapters/source_assertions_test.go new file mode 100644 index 000000000..1dcb833f2 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/source_assertions_test.go @@ -0,0 +1,73 @@ +package adapters + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Shared machinery for the tests in this package that assert on SOURCE rather than on +// behaviour โ€” the factor gates, the reserved-audience guards, the Connect cookie lift. +// +// It lives in its own file because three test files use it. It was defined in the one +// named for a single finding, which is how a helper becomes invisible to the next +// person who needs it (R1019-N12/f7). + +// executableSource is the file with every comment removed, so prose can neither +// satisfy nor break a source assertion. A comment is not code; a check that cannot +// tell them apart is satisfied by writing the answer in a comment. +func executableSource(t *testing.T, name string) string { + t.Helper() + source, err := os.ReadFile(name) + require.NoError(t, err) + fileSet := token.NewFileSet() + // ParseComments is required: without it File.Comments is EMPTY and this function + // silently strips nothing, which is a check that looks like it works. It was that + // way until a mutation test proved a commented-out guard still satisfied a caller. + parsed, err := parser.ParseFile(fileSet, name, source, + parser.ParseComments|parser.SkipObjectResolution) + require.NoError(t, err) + + stripped := append([]byte(nil), source...) + for _, group := range parsed.Comments { + // Position().Offset is the canonical byte offset. Deriving one from Pos() and + // the file's Base() by hand is where this silently became a no-op. + start := fileSet.Position(group.Pos()).Offset + end := fileSet.Position(group.End()).Offset + for i := start; i < end && i < len(stripped); i++ { + if stripped[i] != '\n' { + stripped[i] = ' ' + } + } + } + return string(stripped) +} + +// The guard against that failure mode: this function must actually remove a comment, +// and must leave code alone. Asserted against a file written for the purpose โ€” asserting +// against this file would compare its own assertion literals, which are code, and a +// needle that appears in both can never be shown absent. +func TestExecutableSourceRemovesComments(t *testing.T) { + dir := t.TempDir() + name := filepath.Join(dir, "sample.go") + require.NoError(t, os.WriteFile(name, []byte(`package sample + +// a line comment naming guardedCall() +func f() { + /* a block comment naming guardedCall() */ + guardedCall() +} +`), 0o600)) + + text := executableSource(t, name) + require.Contains(t, text, "\tguardedCall()", "the executable call must survive") + require.NotContains(t, text, "a line comment", "a line comment must not survive") + require.NotContains(t, text, "a block comment", "a block comment must not survive") + require.Equal(t, 1, strings.Count(text, "guardedCall()"), + "only the executable occurrence may remain; the two in comments must be gone") +} diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index 1d9120b0d..d72f2668a 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -12,6 +12,31 @@ import ( "golang.org/x/net/idna" ) +// publicOriginHostProfile renders a host the way the browser's URL parser does. +// +// idna.Lookup โ€” the obvious choice, and what this used first โ€” additionally enforces +// DNS hostname policy, which refuses an UNDERSCORE in a label. Browsers accept one, +// and internal and staging hostnames routinely carry one, so Lookup refused origins a +// deployment legitimately serves while the frontend accepted them (R1019-N12/f1). +// StrictDomainName(false) drops that policy and keeps the mapping. +// +// idna.Punycode is NOT the alternative, though it accepts more: it does raw conversion +// with no case folding, so "BรœCHER.example" encodes to "xn--BCHER-2pa.example" while +// "bรผcher.example" encodes to "xn--bcher-kva.example" โ€” two spellings of one origin +// that would never compare equal, and lowercasing afterwards cannot merge them because +// the punycode payloads already differ. MapForLookup case-folds BEFORE encoding, which +// is why it is the mapping used here and why no ToLower is needed after it. +// +// VerifyDNSLength(false) because a length limit is the resolver's to enforce, not this +// comparison's: refusing an over-long host here would reject it at sign-in rather than +// where it actually fails. +var publicOriginHostProfile = idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.StrictDomainName(false), + idna.VerifyDNSLength(false), +) + type verifiedPublicOriginKey struct{} // configuredPublicOrigin is the origin an operator pinned for this deployment, @@ -27,13 +52,22 @@ var configuredPublicOrigin string // Storing it raw is what made `https://app.example:443` refuse the equivalent origin // the frontend forwards. A value that does not canonicalize is stored trimmed, so the // comparison refuses it rather than silently matching something. -func SetConfiguredPublicOrigin(origin string) { +func SetConfiguredPublicOrigin(origin string) error { trimmed := strings.TrimSuffix(strings.TrimSpace(origin), "/") - if canonical, err := CanonicalPublicOrigin(trimmed); err == nil { - configuredPublicOrigin = canonical - return + if trimmed == "" { + configuredPublicOrigin = "" + return nil } - configuredPublicOrigin = trimmed + canonical, err := CanonicalPublicOrigin(trimmed) + if err != nil { + // Storing the raw value on failure looked like the conservative branch and was + // the opposite: a value that cannot canonicalize can never equal a canonicalized + // candidate, so every origin comparison failed for the life of the process while + // boot reported success (R1019-N12/f2). The caller refuses instead. + return fmt.Errorf("configured public origin is unusable: %w", err) + } + configuredPublicOrigin = canonical + return nil } // ConfiguredPublicOrigin returns the pinned origin, if there is one. @@ -106,11 +140,11 @@ func canonicalPublicOriginHost(hostname string) (string, error) { // and net.IP.String() picks the same representation the browser does. return ip.String(), nil } - ascii, err := idna.Lookup.ToASCII(hostname) + ascii, err := publicOriginHostProfile.ToASCII(hostname) if err != nil { return "", fmt.Errorf("public origin host is not a usable hostname") } - return strings.ToLower(ascii), nil + return ascii, nil } // canonicalPublicOriginPort renders the port as the ":NNN" suffix of a canonical diff --git a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go index b2d82ae06..55fd92c79 100644 --- a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go +++ b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go @@ -21,7 +21,13 @@ import ( // same set. // // Expected values are the browser's own answers, taken from the parser rather than -// reasoned about, with one deliberate exception noted in refusedPublicOriginVectors. +// reasoned about, with the deliberate exceptions noted in refusedPublicOriginVectors. +// +// THIS TABLE IS THE FULL EXTENT OF THE AGREEMENT. The lockstep gate proves the two +// sides assert the same cases; it cannot prove anything about a case absent from both, +// and a gap here reads as agreement when there is none. An underscore host and a +// hyphen-edged label were both missing, and both were spellings the two sides answered +// differently (R1019-N12/f3). A new spelling belongs here first. var canonicalPublicOriginVectors = []struct{ Input, Canonical string }{ {"https://app.example", "https://app.example"}, {"https://APP.example", "https://app.example"}, @@ -37,6 +43,9 @@ var canonicalPublicOriginVectors = []struct{ Input, Canonical string }{ {"http://localhost:80", "http://localhost"}, {"http://127.0.0.1:3000", "http://127.0.0.1:3000"}, {"http://[::1]:80", "http://[::1]"}, + {"https://my_app.example", "https://my_app.example"}, + {"https://a_b-c.example", "https://a_b-c.example"}, + {"https://BรœCHER.example", "https://xn--bcher-kva.example"}, } // Refused on both sides. Every entry but one is refused by the browser parser too. @@ -49,6 +58,8 @@ var refusedPublicOriginVectors = []string{ "https://:443", "https://app.example:99999", "https://app.example:0", + "https://-app.example", + "https://app-.example", "https://app.example:-1", "https://app.example/path", "https://app.example?q=1", @@ -81,13 +92,13 @@ func TestR1019CanonicalPublicOriginRefusesWhatTheBrowserWillNotParse(t *testing. // Canonicalization is only worth anything if the COMPARISON uses it. A configured // origin and a differently spelled equivalent must reach the same verified origin. func TestR1019ConfiguredOriginAcceptsEveryEquivalentSpelling(t *testing.T) { - t.Cleanup(func() { SetConfiguredPublicOrigin("") }) + t.Cleanup(func() { _ = SetConfiguredPublicOrigin("") }) for _, spelling := range []string{ "https://app.example", "https://APP.example", "https://app.example:443", "https://app.example:0443", "https://app.example:", "https://app.example/", } { - SetConfiguredPublicOrigin(spelling) + require.NoError(t, SetConfiguredPublicOrigin(spelling)) configured, ok := ConfiguredPublicOrigin() require.True(t, ok) require.Equal(t, "https://app.example", configured, @@ -108,3 +119,26 @@ func TestR1019ConfiguredOriginAcceptsEveryEquivalentSpelling(t *testing.T) { "a different origin is still refused when %q is pinned", spelling) } } + +// R1019-N12/f2: the setter must refuse a value it cannot canonicalize rather than +// store it. Storing the raw string kept the process running with a configured origin +// that could never equal any canonicalized candidate, so every comparison failed for +// the life of the process and nothing said why. +func TestR1019ConfiguringAnUnusableOriginIsRefused(t *testing.T) { + t.Cleanup(func() { _ = SetConfiguredPublicOrigin("") }) + require.NoError(t, SetConfiguredPublicOrigin("https://app.example")) + + for _, unusable := range refusedPublicOriginVectors { + if unusable == "" { + continue // the empty value means "none pinned", which is a local runtime + } + require.Error(t, SetConfiguredPublicOrigin(unusable), + "%q cannot be canonicalized, so pinning it would make every comparison fail", unusable) + } + + // And the pin is unchanged by a refused call, so a failed reconfiguration cannot + // leave the process comparing against something it never accepted. + configured, ok := ConfiguredPublicOrigin() + require.True(t, ok) + require.Equal(t, "https://app.example", configured) +} diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index dd5fd9b75..22719fe86 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -78,7 +78,7 @@ type Service struct { datasourceJobs jobs.Producer // privileged inbox producer for datasource ingest deliveries datasourceSyncOperations DatasourceSyncOperationStore datasourceConnectors *connector.Registry // the descriptor-driven connector registry; nil until the connector is configured - datasourceLinkKey []byte // signs account-link states; derived from the deployment's internal key + datasourceLinkKey []byte // signs account-link states; delivered as datasource-keys/DATASOURCE_ACCOUNT_LINK_KEY, not derived from any other credential datasourceLinkers map[string]DatasourceAccountLinker datasourceTXTResolver TXTResolver datasourceBudgets DatasourceBudgetStore // meters provider credentials; nil leaves connectors unmetered diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index f423919db..fcd2b8b95 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -928,7 +928,9 @@ func doWork(ctx context.Context) (Clean, error) { // redirect, an authenticator's relying party and every emailed link. From here // on a forwarded origin is honoured only when it equals this one, so no hop can // substitute a caller's choice (pkg/auth.WithVerifiedPublicOrigin). - auth.SetConfiguredPublicOrigin(appBase) + if err := auth.SetConfiguredPublicOrigin(appBase); err != nil { + return nil, fmt.Errorf("application: APP_BASE_URL: %w", err) + } var workerEmailOutbox *email.Outbox var emailWorker *jobs.Worker if emailConfig.sender == nil { @@ -1724,6 +1726,22 @@ func configuredApplicationBaseURL() (string, error) { if parsed.Scheme != "https" && parsed.Hostname() != "localhost" && parsed.Hostname() != "127.0.0.1" { return "", fmt.Errorf("application: APP_BASE_URL must use https outside local development") } + // The value must also survive the function that will CANONICALIZE it at request + // time. The checks above are url.Parse plus field rules; pkg/auth uses IDNA. Two + // validators for one value meant an origin boot accepted and every request then + // refused โ€” the cell started green and no authentication worked, because + // WithVerifiedPublicOrigin could not canonicalize what was configured + // (R1019-N12/f1). One validator decides. + if _, err := auth.CanonicalPublicOrigin(strings.TrimSuffix(raw, "/")); err != nil { + return "", fmt.Errorf( + "application: APP_BASE_URL is not an origin this service can use (%w): it is the "+ + "value every OAuth redirect, relying-party origin, emailed link and same-origin "+ + "comparison is bound to, so it has to be one this host can compare", err) + } + // The RAW value is returned, not the canonical one. It is also the token issuer and + // the published authorization-server metadata, where the spelling is part of the + // contract a verifier compares exactly โ€” canonicalizing here would silently change + // `iss` for any deployment whose configured origin carries, say, an explicit :443. return strings.TrimSuffix(raw, "/"), nil } diff --git a/module/services/accounts/code/work_test.go b/module/services/accounts/code/work_test.go new file mode 100644 index 000000000..feacddd91 --- /dev/null +++ b/module/services/accounts/code/work_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" +) + +// R1019-N12/f1: APP_BASE_URL was validated at boot by url.Parse plus field rules and +// at request time by IDNA canonicalization. An origin the first accepted and the +// second could not canonicalize made a cell boot green and then refuse every +// authentication โ€” sign-in, the OAuth redirect, the relying-party origin, emailed +// links and the solution proxy's same-origin check โ€” with nothing at startup saying so. +// +// The assertion is the EQUIVALENCE, not a list of bad hosts: whatever +// CanonicalPublicOrigin refuses, boot must refuse. A second validator cannot drift +// back in without failing this. +func TestR1019StartupRefusesAnyOriginRequestTimeCannotCanonicalize(t *testing.T) { + for _, candidate := range []string{ + "https://app.example", // usable + "https://app.example:443", // usable, explicit default port + "https://my_app.example", // usable: an underscore is a legal host for a browser + "https://-app.example", // NOT usable: a label may not begin with a hyphen + "https://app-.example", // NOT usable: nor end with one + "https://app.example:0", // NOT usable: port 0 serves nothing + "https://:443", // NOT usable: no host + } { + t.Run(candidate, func(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", candidate) + t.Setenv("APP_BASE_URL", candidate) + + _, requestTime := auth.CanonicalPublicOrigin(candidate) + _, boot := configuredApplicationBaseURL() + + if requestTime != nil { + require.Error(t, boot, + "request time cannot canonicalize %q, so boot must refuse it rather than "+ + "start a cell whose every origin comparison fails", candidate) + return + } + require.NoError(t, boot, + "request time canonicalizes %q, so boot must not refuse a usable origin", candidate) + }) + } +} + +// The raw spelling is what boot returns, because it is also the token issuer and the +// published authorization-server metadata, where a verifier compares it exactly. +// Canonicalizing it here would silently change `iss` for a deployment whose configured +// origin carries an explicit default port. +func TestR1019StartupKeepsTheConfiguredSpellingForTheIssuer(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__APPLICATION__APP_BASE_URL", "https://app.example:443") + t.Setenv("APP_BASE_URL", "https://app.example:443") + + base, err := configuredApplicationBaseURL() + require.NoError(t, err) + require.Equal(t, "https://app.example:443", base, + "the issuer keeps the operator's spelling; only the comparison canonicalizes") +} diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 3c9cd6b47..250d81e84 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -472,7 +472,10 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. } } if isRuntimeRegisteredRoute(entry) { - removePersonsSessionCredential(r.Header) + // A resource-bound tool token is forwarded; a host-wide session is not. + if !isSolutionToolRequestPath(r.URL.Path) { + removePersonsSessionCredential(r.Header) + } } if entry != nil && entry.UpstreamPath != "" { r.URL.Path = entry.UpstreamPath @@ -505,11 +508,26 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. // // An upstream receives the identity headers ext_authz stamped โ€” the subject, the // tenant, the session, the credential kind, the scope ceiling โ€” which name the -// person without carrying their authority. An upstream that needs to ACT on the -// person's behalf needs a host-minted capability bound to its own audience, which -// is the Work Context surface, not a bearer it borrowed. **That half of SP-GW-07 is -// not yet built here: see the audience-bound context note in -// services/auth-gateway/AGENTS.md.** +// person without carrying their authority. +// +// The ONE exception, and it is the invariant rather than a hole in it: a solution's +// MCP tool endpoint. ext_authz refuses a host-wide token there outright +// (`resourceUnbound` is a 401), so a request that reaches this point on a tool path +// carries a token whose audience names THAT SOLUTION'S resource and nothing else โ€” +// which is exactly the audience-bound credential SP-GW-07's positive half asks for, +// already minted and already confined by `resourceAudienceAdmits`. Stripping it would +// not withhold the person's authority; it would break the one surface that is doing +// the right thing, and the merged resource-indicator work depends on forwarding it so +// the runtime can mint the viewer's Work Context from it. +// +// The binding is read off the PATH, not re-derived from the token: admission has +// already proved it, and a second JWT parse here would be a second verifier to keep in +// agreement with the first. +// +// For every other upstream path the host-wide session is still removed, and an upstream +// that needs to act on the person's behalf there still needs a host-minted capability +// bound to its own audience. **That remains unbuilt: see the audience-bound context +// note in services/auth-gateway/AGENTS.md.** // // Catalog routes are untouched: accounts is the host's own API, where the session // is the credential. diff --git a/module/services/auth-gateway/code/gateway_mcp_resource.go b/module/services/auth-gateway/code/gateway_mcp_resource.go index 739b6502d..a075242da 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource.go @@ -54,7 +54,15 @@ const ( // isSolutionMCPPath reports whether a solution sub-path (the suffix after // `/solutions//`, with no leading slash) is that solution's MCP endpoint. func isSolutionMCPPath(subPath string) bool { - return subPath == solutionMCPSegment + // The whole `mcp` SUBTREE, not the exact segment. An exact match closed + // `/solutions//mcp` and left `/solutions//mcp/` โ€” a spelling every HTTP + // client and router treats as the same endpoint โ€” admitting a token that names only + // the host, which is the invariant's whole subject (SP-SOL-07). MCP's own transports + // also address sub-routes under it (`mcp/messages` for the SSE channel), and those + // are the same protected resource: a resource identified by a prefix has to be + // judged by that prefix, or the narrowest spelling becomes the way around it. + return subPath == solutionMCPSegment || + strings.HasPrefix(subPath, solutionMCPSegment+"/") } // isSolutionToolRequestPath reports whether a FULL request path addresses a diff --git a/module/services/auth-gateway/code/gateway_mcp_resource_test.go b/module/services/auth-gateway/code/gateway_mcp_resource_test.go index 1b64caee9..41a6b8caa 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource_test.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource_test.go @@ -769,3 +769,36 @@ func TestThePublicAddressIsResolvedOnceAtConstruction(t *testing.T) { "a resource-bound token must not be refused because the environment moved under the process") require.Equal(t, "/mcp", example.lastPath) } + +// SA-F-MCPAUD, the spelling the merged resource-indicator work left open: an exact +// `mcp` segment match closed `/solutions//mcp` and admitted +// `/solutions//mcp/`, which routers, proxies and clients treat as the same +// endpoint. A token naming only the host reached the tool surface through it. +// +// Asserted on the predicate the admission switch keys on, with the non-MCP control, so +// widening it cannot have swept the product's own API surface in. +func TestR1019EverySpellingOfTheToolEndpointIsProtected(t *testing.T) { + for _, protected := range []string{ + "/solutions/abc/mcp", + "/solutions/abc/mcp/", + "/solutions/abc/mcp?session=1", + "/solutions/abc/mcp/messages", + "/solutions/abc/mcp/messages?session=1", + } { + require.True(t, isSolutionToolRequestPath(protected), + "%s addresses the tool surface and must demand a resource-bound token", protected) + } + + // The control: a solution's ordinary API surface is reached with a session, and + // requiring a resource-bound token there would refuse the product. + for _, ordinary := range []string{ + "/solutions/abc/other", + "/solutions/abc/mcpx", + "/solutions/abc/mcp-tools", + "/solutions/abc", + "/modules/abc/mcp", + } { + require.False(t, isSolutionToolRequestPath(ordinary), + "%s is not the tool surface and must stay reachable with a session", ordinary) + } +} diff --git a/module/services/auth-gateway/code/upstream_session_credential_test.go b/module/services/auth-gateway/code/upstream_session_credential_test.go index 0896bbb25..8175ce76d 100644 --- a/module/services/auth-gateway/code/upstream_session_credential_test.go +++ b/module/services/auth-gateway/code/upstream_session_credential_test.go @@ -72,3 +72,46 @@ func TestCatalogRouteStillCarriesTheSessionToAccounts(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) require.Equal(t, "Bearer "+token, api.lastHeaders.Get("Authorization")) } + +// The boundary between SP-GW-07's two halves, which the merged resource-indicator work +// made load-bearing: a HOST-WIDE session is removed, a RESOURCE-BOUND tool token is +// forwarded. +// +// Both directions are asserted because either alone is satisfied by the wrong rule. +// Without the forwarding case, stripping everything passes โ€” and that broke the tool +// surface, whose runtime mints the viewer's Work Context from that token. Without the +// stripping case, forwarding everything passes, which is the finding SA-F-BEARER was. +// +// The path is what decides, and that is sound rather than convenient: ext_authz answers +// 401 to a host-wide token on a tool path, so a tool request that reaches the proxy has +// already been proved resource-bound. +func TestR1019ToolTokenIsForwardedWhileASessionIsNotForwarded(t *testing.T) { + for _, tc := range []struct { + path string + forwarded bool + why string + }{ + {"/solutions/example/mcp", true, "a resource-bound tool token is the scoped credential the runtime needs"}, + {"/solutions/example/mcp/", true, "the same endpoint, spelled with a trailing slash"}, + {"/solutions/example/mcp/messages", true, "MCP's own transport sub-route is the same resource"}, + {"/solutions/example/data", false, "a solution's ordinary API gets identity headers, never the session"}, + {"/solutions/example", false, "nor does its root"}, + } { + t.Run(tc.path, func(t *testing.T) { + header := http.Header{} + header.Set("Authorization", "Bearer a-token") + header.Set("Cookie", "codefly_session=1") + + if !isSolutionToolRequestPath(tc.path) { + removePersonsSessionCredential(header) + } + + if tc.forwarded { + require.Equal(t, "Bearer a-token", header.Get("Authorization"), tc.why) + return + } + require.Empty(t, header.Get("Authorization"), tc.why) + require.Empty(t, header.Get("Cookie"), tc.why) + }) + } +} diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts index c2afa4c61..1940364f0 100644 --- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts +++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts @@ -68,6 +68,18 @@ function canonicalOrigin(candidate: string): string | undefined { return undefined; } } + // A hostname label may not begin or end with a hyphen (RFC 1123). The parser + // accepts one and Go's IDNA mapping refuses it, which is a spelling the two + // sides would answer differently (R1019-N12/f3) โ€” so this side refuses it too + // rather than leaving the disagreement to the comparison. An IPv6 literal is + // bracketed and has no labels, so it is left alone. + if (!parsed.hostname.startsWith("[")) { + for (const label of parsed.hostname.split(".")) { + if (label.startsWith("-") || label.endsWith("-")) { + return undefined; + } + } + } return parsed.origin; } catch { return undefined; diff --git a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts index 8435c2c29..3bce91ea7 100644 --- a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts +++ b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts @@ -15,6 +15,10 @@ import { // The same table is asserted in pkg/auth/public_origin_vectors_test.go, and // module/tools/public_origin_vectors_lockstep_test.go holds the two copies identical, // so a case fixed on one side cannot be left failing on the other. +// +// THIS TABLE IS THE FULL EXTENT OF THE AGREEMENT: the gate cannot prove anything about +// a spelling absent from both tables, and a gap here reads as agreement when there is +// none (R1019-N12/f3). A new spelling belongs here first. const canonicalPublicOriginVectors: [string, string][] = [ ["https://app.example", "https://app.example"], ["https://APP.example", "https://app.example"], @@ -30,6 +34,9 @@ const canonicalPublicOriginVectors: [string, string][] = [ ["http://localhost:80", "http://localhost"], ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], ["http://[::1]:80", "http://[::1]"], + ["https://my_app.example", "https://my_app.example"], + ["https://a_b-c.example", "https://a_b-c.example"], + ["https://BรœCHER.example", "https://xn--bcher-kva.example"], ]; // Refused on both sides. Port 0 is the one case the parser itself accepts; see the @@ -38,6 +45,8 @@ const refusedPublicOriginVectors: string[] = [ "https://:443", "https://app.example:99999", "https://app.example:0", + "https://-app.example", + "https://app-.example", "https://app.example:-1", "https://app.example/path", "https://app.example?q=1", From 112a4ba7e9026bb1eb2dcbb1c67caf7718b8752b Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:52:08 -0400 Subject: [PATCH 26/46] credential issuance needs a factor, a sign-out ends the family, and an origin spelling both services keep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third review's three majors and one of its minors, each at the place that owns the behaviour. CREDENTIAL ISSUANCE REQUIRES A FACTOR (SA-F-MFA, the half that was open). CreateAPIKey ran the interactive-session, scope, mint-ceiling and organization-admin gates and then minted. An owner with a verified session and no enrolled factor could ask for `*:*` and get a credential that authenticates as them for as long as it lives, without the session that asked for it and across a password change โ€” which is what a factor on a privileged mutation protects. Both halves, because they described different hosts: the handler now runs requireMFA after the authorization gates and before the mint, and the DECLARED policy says MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP where it said MFA_REQUIREMENT_NONE. The declaration is what the authorization-coverage gate and the generated gateway catalog read. Regenerated with CI's PINNED CLI (0.1.179, checksum-verified against the release manifest) rather than the newer local one, after the no-change run AGENTS.md asks for came back clean; the diff is the two descriptor copies, the contract digests and the catalogs, with no facade-import or stamp churn. A SIGN-OUT ENDS THE WHOLE REFRESH FAMILY. Logout revoked the family durably and wrote its access-revocation marker under the CURRENT session id. Every refresh mints a new session id and keeps the family, so an access token minted before the last refresh carried a different `sid`, matched no marker, and stayed valid until its natural expiry: a token stolen before a refresh survived the sign-out that followed it. The family is the identifier that does not rotate, so the access token now carries it (`fid`) and the sign-out revokes it. Both sides check it โ€” accounts' VerifyAccess and the gateway's ext_authz โ€” and a new module/tools gate holds the claim names together, because the two are separate Go modules with no shared type and a rename on one side would silently stop the other finding it. An access-only impersonation window has no family and stamps nothing, so one marker cannot reach every such token at once. AN INCOMPLETE SIGN-OUT NO LONGER DESTROYS ITS OWN RETRY. The 503 that says "logout incomplete, retry" was answered while the middleware cleared the refresh cookie on every logout response โ€” the one answer that asks for a retry was also the one that destroyed the credential a retry needs, and the frontend swallowed the failure and rendered a finished sign-out. The cookie is now cleared only on success, logout() reports whether it completed, and the sign-out page surfaces an incomplete one with a retry instead of redirecting to the login page. Local state is still cleared either way: the person asked to sign out, and leaving them signed in there would neither end the session nor tell them anything. ONE ORIGIN SPELLING BOTH SERVICES KEEP (SA-F-ORIGIN, R1019-N12's remainder). A numeric spelling of an IPv4 address is read by the WHATWG parser as an address and re-rendered โ€” `2130706433`, `0x7f000001`, `127.1` and `0177.0.0.1` all come back as `127.0.0.1` โ€” while Go's net/url preserves what was written. Configuring either side with one gave the two services different values from the same configuration, so every same-origin comparison failed and sign-in refused. Both sides refuse it now, by name on the accounts side, and the four spellings are in the shared vector table. The frontend rule is narrowed to exactly that rewrite โ€” the parsed host is a dotted quad and not what was written. My first attempt refused any hostname the parser changed, which also refused the two rewrites the two sides AGREE on: IPv6 compression and IDNA encoding. Three vectors caught it. Also here: the registration document is taken from main, which drops the conflict markers this branch committed, the registration procedures #953 deleted, and a section claiming the proxy cannot require an installation that main's admission check contradicts. The SDK's contract digest moved with the policy value, so @codefly-dev/saas-sdk goes 0.3.17 -> 0.3.18 in the package, the dependent's pin and the lockfile. Its public TypeScript API is unchanged: the only difference in the generated bindings is one byte of the embedded descriptor. Mutation-checked, each individually: removing the factor call or discarding its error; reverting the declaration; not writing the family marker; ignoring the family at verification; stamping one shared family id for everyone; clearing the cookie on every logout again; and dropping the numeric-host rule on either side. Every one fails its test, and the controls assert the opposite direction โ€” an unenrolled admin still proceeds, another family's session survives a sign-out, a completed sign-out still clears the cookie, and the agreed rewrites still canonicalize. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 986868 -> 986875 bytes .../proto/saas/accounts/v1/api_keys.proto | 7 +- .../api/accounts/connect/contract.binpb | Bin 986868 -> 986875 bytes .../proto/saas/accounts/v1/api_keys.proto | 7 +- module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- module/services/accounts/AUTHZ_MATRIX.md | 6 +- .../adapters/api_key_scope_admission_test.go | 24 +++- .../credential_issuance_factor_test.go | 60 ++++++++ .../pkg/adapters/logout_retry_state_test.go | 74 ++++++++++ .../accounts/code/pkg/adapters/rest_extras.go | 16 ++- .../accounts/code/pkg/adapters/rpcs.go | 13 ++ .../pkg/auth/ed25519/logout_family_test.go | 136 ++++++++++++++++++ .../accounts/code/pkg/auth/ed25519/minter.go | 65 +++++++-- .../accounts/code/pkg/auth/public_origin.go | 47 ++++++ .../pkg/auth/public_origin_vectors_test.go | 21 ++- .../pkg/gen/saas/accounts/v1/api_keys.pb.go | 2 +- .../accounts/generated/authz-methods.json | 6 +- .../accounts/generated/service-catalog.json | 4 +- .../proto/saas/accounts/v1/api_keys.proto | 7 +- .../auth-gateway/code/authz_catalog_gen.go | 2 +- .../services/auth-gateway/code/ext_authz.go | 25 +++- .../code/resource_token_confinement_test.go | 41 ++++++ .../services/frontend/code/package-lock.json | 4 +- module/services/frontend/code/package.json | 2 +- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 986868 -> 986875 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- .../src/gen/saas/accounts/v1/api_keys_pb.ts | 2 +- .../code/packages/saas-sdk/package.json | 2 +- .../code/src/features/auth/ui/logout-page.tsx | 58 ++++++-- .../src/gen/saas/accounts/v1/api_keys_pb.ts | 2 +- .../services/frontend/code/src/lib/auth.tsx | 62 ++++++-- .../code/src/lib/codefly-gateway-context.ts | 35 +++++ .../public-origin-canonicalization.test.ts | 4 + module/tools/access_claim_lockstep_test.go | 54 +++++++ 36 files changed, 729 insertions(+), 71 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go create mode 100644 module/services/accounts/code/pkg/adapters/logout_retry_state_test.go create mode 100644 module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go create mode 100644 module/tools/access_claim_lockstep_test.go diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 043cef0e7395c2557a2204a54def88267adbd462..b8d87bf72f89f99e35cc8c1aa70cfe98431b4795 100644 GIT binary patch delta 1482 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3UuJ30_PHcMqU3TroytH0mI(5PYl$ z&{Y8!pHZU$6_28nuFpD5|;AtI#7Ka zqNOkgv{}}eo0&kzQgj1o91`)2q;LXgYy5D6|8f&D&GE3L!dt+yVzs0c=7DxI@}l~r z!rLVi=C`iGJ7p)+3h(Nupncr&DZq{>w^75$ZI7zbh^v}{+O|M~t*8KY(Ma(0BV{dE z08&9#o5KrG-4JeBLIGM21`=$RFax*{Nw9qhGvF>n5)9+4_e&BC;>-_#nu6jOc{#xr z@FCE)63huL;Stc5K!Q1|B|HXt7D+IaB|O&>s?7`;q-O|U01uJ>IDN>!ehEB9z6QhC zj5k|quwkrb4tS7aVQl7A$&86&Gq1}IvY9t!Q9McWdXjuu+&Fv-xGzer%`|G}c14wH z=4e*iDf`XH@cUi_`&)kYnA7Ji0@VcR1Yc+oHa3M@wzLQxhXPB2EiD1AMwWzcX$job z$dX}fX&I<4sLCMDyaKc%j=0V5fxPgsn=t*@Ka?K3VcgP>rN$V<9sQ&S&+n)~+|kcK zEpfyh{Ze+2JNlJ7dX$cz7(6-HCjT^yrUx8e#4Euo$t%T6@^X2lc~unC1DRh>{{eD% B+>-zR delta 1432 zcmZ9MOHUL*5P-L)yK8rQVAt)zi;04$#9&YekeGm)Xz(II?;aqA5N{ZQ@d*QF74?83 z$e2aYgAd{pjLV{eB1iob9=&*y8yBm49y@aALw{RcUGr7fZqBxE%(jo@A#debo_`dR z9k5ZF>JTqd-p|uQl{&k`F)O`MFOy0n66;=WGu>JGbw;daVBMSR7F}?|`_nBNsw<_y zgq7fm_wgZ=+hodYm&X|qd&q0+5og^2td>gJ;V(%@C^nd?lQ}6PW+`?twVx8CLzGhN z61K!iTCvO66em)#E7%+-wqjTDV4PIAie39ZD680YJS;O|@nV>d0#c3sICNZQ zO(DP)-UL2vD$yQ+Ymdb2X;X==&M-=Q3s4J;5M>_o;GA%d-Uh4;)s*)mjatw2#ZC4WiVufb~If1~F1R2e>EBwDu=}y8`Vd zPHTS(_$<*Zu$i diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 043cef0e7395c2557a2204a54def88267adbd462..b8d87bf72f89f99e35cc8c1aa70cfe98431b4795 100644 GIT binary patch delta 1482 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3UuJ30_PHcMqU3TroytH0mI(5PYl$ z&{Y8!pHZU$6_28nuFpD5|;AtI#7Ka zqNOkgv{}}eo0&kzQgj1o91`)2q;LXgYy5D6|8f&D&GE3L!dt+yVzs0c=7DxI@}l~r z!rLVi=C`iGJ7p)+3h(Nupncr&DZq{>w^75$ZI7zbh^v}{+O|M~t*8KY(Ma(0BV{dE z08&9#o5KrG-4JeBLIGM21`=$RFax*{Nw9qhGvF>n5)9+4_e&BC;>-_#nu6jOc{#xr z@FCE)63huL;Stc5K!Q1|B|HXt7D+IaB|O&>s?7`;q-O|U01uJ>IDN>!ehEB9z6QhC zj5k|quwkrb4tS7aVQl7A$&86&Gq1}IvY9t!Q9McWdXjuu+&Fv-xGzer%`|G}c14wH z=4e*iDf`XH@cUi_`&)kYnA7Ji0@VcR1Yc+oHa3M@wzLQxhXPB2EiD1AMwWzcX$job z$dX}fX&I<4sLCMDyaKc%j=0V5fxPgsn=t*@Ka?K3VcgP>rN$V<9sQ&S&+n)~+|kcK zEpfyh{Ze+2JNlJ7dX$cz7(6-HCjT^yrUx8e#4Euo$t%T6@^X2lc~unC1DRh>{{eD% B+>-zR delta 1432 zcmZ9MOHUL*5P-L)yK8rQVAt)zi;04$#9&YekeGm)Xz(II?;aqA5N{ZQ@d*QF74?83 z$e2aYgAd{pjLV{eB1iob9=&*y8yBm49y@aALw{RcUGr7fZqBxE%(jo@A#debo_`dR z9k5ZF>JTqd-p|uQl{&k`F)O`MFOy0n66;=WGu>JGbw;daVBMSR7F}?|`_nBNsw<_y zgq7fm_wgZ=+hodYm&X|qd&q0+5og^2td>gJ;V(%@C^nd?lQ}6PW+`?twVx8CLzGhN z61K!iTCvO66em)#E7%+-wqjTDV4PIAie39ZD680YJS;O|@nV>d0#c3sICNZQ zO(DP)-UL2vD$yQ+Ymdb2X;X==&M-=Q3s4J;5M>_o;GA%d-Uh4;)s*)mjatw2#ZC4WiVufb~If1~F1R2e>EBwDu=}y8`Vd zPHTS(_$<*Zu$i diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 6b4b6f074..cf3626e21 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", + "digest": "sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9", "services": [ { "name": "APIKeyService", @@ -489,7 +489,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", + "digest": "sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 39fd2c478..6c36d4e78 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c + digest: sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c + digest: sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index c1842c0b8..386dffa63 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -8,7 +8,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | Procedure | Shape | REST mapping | Tier | Guards | Permission / scope | Resource bindings | Audit | Idempotency / rate | Sensitivity req โ†’ res | Description | |---|---|---|---|---|---|---|---|---|---|---| -| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | +| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `mfa` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; mfa=IF_ENROLLED_RECENT_STEP_UP; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | | `/saas.accounts.v1.APIKeyService/ListAPIKeys` | unary | `GET /v1/api-keys` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | perm=api_keys:read; scope=api_keys:read | organization_id โ†’ ORGANIZATION/DIRECT_ID | โ€” | FORBIDDEN / STANDARD_READ | CONFIDENTIAL โ†’ CONFIDENTIAL | List org's API keys. | | `/saas.accounts.v1.APIKeyService/RevokeAPIKey` | unary | `DELETE /v1/api-keys/{id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write; scope=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ CONFIDENTIAL | Revoke an API key in an administered organization. | | `/saas.accounts.v1.APIKeyService/ValidateAPIKey` | unary | `โ€”` | `internal` | exposure=INTERNAL; tenant=NONE | โ€” | โ€” | โ€” | FORBIDDEN / INTERNAL | SECRET โ†’ CONFIDENTIAL | Internal: plaintext key โ†’ key + org id. | @@ -265,8 +265,8 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `auth`: 43 - `internal`: 54 -- `mfa`: 4 -- `org_admin`: 57 +- `mfa`: 5 +- `org_admin`: 56 - `org_member`: 49 - `platform_admin`: 27 - `public`: 18 diff --git a/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go b/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go index 98d43d3aa..b7f591ddf 100644 --- a/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go +++ b/module/services/accounts/code/pkg/adapters/api_key_scope_admission_test.go @@ -275,6 +275,23 @@ func (s *apiKeyMintStore) GetOrgMembership(context.Context, string, string) (*ge return &gen.OrgMembership{UserId: scopeAdmissionUserID.String(), Role: gen.OrgRole_ORG_ROLE_ADMIN}, nil } +// The factor lookup. Credential issuance sits behind a recent second factor like +// every other privilege-changing mutation, so the handler consults the actor's +// enrolment after the authorization gates โ€” and this fake has to answer it or +// the handler panics on the embedded nil interface before reaching the domain. +// +// Not enrolled, which is the state that PROCEEDS for an ordinary org admin: the +// gate is fail-closed only for a privileged actor (a platform role, or the +// organization's owner), and this membership is an admin. That keeps these cases +// asserting what they are about โ€” the admission gates โ€” rather than the factor. +func (s *apiKeyMintStore) WithUserTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *apiKeyMintStore) HasVerifiedMFA(context.Context, string) (bool, error) { + return false, nil +} + // Only an interactive session creates API keys. A key that could would mint a // copy of itself with no expiry โ€” accounts cannot even see the calling key's // own โ€” so CreateAPIKey declares no API-key scope, and the handler refuses a key @@ -380,7 +397,12 @@ func TestAPIKeyCannotCreateAPIKeys(t *testing.T) { reads, err := create(tc.caller, requesting(tc.requested...)) require.Error(t, err) require.NotEqual(t, codes.PermissionDenied, status.Code(err), "%v", err) - require.Equal(t, 1, reads) + // The organization WAS consulted, which is the property here. Not how + // many times: the handler reads the membership for the admin gate and + // again for the factor gate's privileged-role check, and pinning the + // count made this assert an implementation detail instead. The + // refusal cases above carry the other half with require.Zero. + require.NotZero(t, reads, "the organization-admin gate must have run") }) } } diff --git a/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go b/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go new file mode 100644 index 000000000..1f2aab52b --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/credential_issuance_factor_test.go @@ -0,0 +1,60 @@ +package adapters + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Issuing a credential is a privilege-changing operation, so it sits behind the +// same second factor as every other one (SP-IDENT-10). +// +// CreateAPIKey was the hole: it ran requireInteractiveSession, requireScope, +// requireScopesWithinCaller and requireOrgAdmin, and then minted. An +// organization owner with a verified session and NO enrolled factor could ask +// for `*:*` and get a credential that authenticates as them for as long as it +// lives, without the session that asked for it and across a password change โ€” +// which is the thing a factor on the privileged mutations is protecting. +// +// Asserted from the source, in the shape that catches the two ways this breaks: +// deleting the call, and calling it with its error discarded. +func TestCredentialIssuanceRequiresAFactor(t *testing.T) { + text := executableSource(t, "rpcs.go") + + body := regexp.MustCompile( + `func \(s \*APIKeyServer\) CreateAPIKey\((?s:.*?)\n\}\n`).FindString(text) + require.NotEmpty(t, body, "CreateAPIKey must still be here to be gated") + + require.Regexp(t, guardReturnsItsRefusal(`requireMFA\(ctx, actorID\)`), body, + "credential issuance must require a recent factor AND return that refusal") + + // Ordering: after the authorization guards, before the mutation. A factor + // check the write has already passed is not a check. + gate := regexp.MustCompile(`requireMFA\(ctx, actorID\)`).FindStringIndex(body) + mint := strings.Index(body, "service.CreateAPIKey(") + admin := strings.Index(body, "requireOrgAdmin(") + require.NotEqual(t, -1, mint) + require.NotEqual(t, -1, admin) + require.Less(t, admin, gate[0], "the factor runs after the authorization guards") + require.Less(t, gate[0], mint, "and before the key is minted") +} + +// The declaration and the enforcement must agree. The gate above holds the +// handler; this holds the POLICY, which is what the authorization-coverage gate +// and the generated gateway catalog read. CreateAPIKey declared +// MFA_REQUIREMENT_NONE while asserting a factor in code would have left the two +// describing different hosts. +func TestCredentialIssuanceDeclaresTheFactorItEnforces(t *testing.T) { + proto, err := os.ReadFile("../../../proto/saas/accounts/v1/api_keys.proto") + require.NoError(t, err) + + rpc := regexp.MustCompile( + `rpc CreateAPIKey\((?s:.*?)\n \}\n`).FindString(string(proto)) + require.NotEmpty(t, rpc) + require.Contains(t, rpc, "mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", + "the declared policy must require the factor the handler enforces") + require.NotContains(t, rpc, "mfa: MFA_REQUIREMENT_NONE") +} diff --git a/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go b/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go new file mode 100644 index 000000000..c77236dfb --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/logout_retry_state_test.go @@ -0,0 +1,74 @@ +package adapters + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// downstreamStatus answers with a chosen status, for the incomplete-sign-out path. +func downstreamStatus(status int, resp string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, resp) + }) +} + +// An INCOMPLETE sign-out keeps the refresh cookie. +// +// Logout answers 503 "logout incomplete, retry" when it revoked the refresh +// family but could not record an access-revocation marker. That answer exists so +// the caller can retry โ€” and the retry needs the refresh token, which lives only +// in this cookie. The middleware cleared it on EVERY logout response, so the one +// answer that asks for a retry was also the one that destroyed the credential +// the retry needs, while an unmarked access token stayed valid. +// +// The family revocation runs last in Logout and its error is returned directly, +// so a 5xx here means the durable half did not land either: the session really is +// still live, and keeping the credential is the lesser harm of the two. +func TestIncompleteLogoutKeepsTheRetryCredential(t *testing.T) { + h := refreshTokenCookie(downstreamStatus( + http.StatusServiceUnavailable, `{"error":"logout incomplete, retry"}`)) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected the downstream status to pass through, got %d", rec.Code) + } + for _, c := range rec.Result().Cookies() { + if c.Name == refreshTokenCookieName && c.MaxAge < 0 { + t.Fatalf("an incomplete sign-out must not clear the refresh cookie: "+ + "it is the only credential a retry has, got %+v", c) + } + } +} + +// The control, beside the existing TestRefreshCookie_Logout_ClearsCookie: a +// COMPLETED sign-out still clears it. Without this the test above would pass on +// a middleware that never cleared the cookie at all, which would leave a signed +// -out browser holding a live refresh credential. +func TestCompletedLogoutStillClearsTheCookie(t *testing.T) { + h := refreshTokenCookie(downstreamStatus(http.StatusOK, `{}`)) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: refreshTokenCookieName, Value: "cookie-rt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + var cleared bool + for _, c := range rec.Result().Cookies() { + if c.Name == refreshTokenCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Fatal("a completed sign-out must clear the refresh cookie") + } +} diff --git a/module/services/accounts/code/pkg/adapters/rest_extras.go b/module/services/accounts/code/pkg/adapters/rest_extras.go index 8cdad6b5a..23c528636 100644 --- a/module/services/accounts/code/pkg/adapters/rest_extras.go +++ b/module/services/accounts/code/pkg/adapters/rest_extras.go @@ -68,7 +68,21 @@ func refreshTokenCookie(next http.Handler) http.Handler { rec := &bodyCapture{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) - if isLogout { + // The cookie is cleared only when the sign-out COMPLETED. + // + // It was cleared on every logout response, including the 503 that says + // "logout incomplete, retry". That answer exists so the caller can retry, + // and the retry needs the refresh token the cookie holds โ€” clearing it + // destroyed the only credential that could finish the job, while an + // unmarked access token stayed valid. The browser then had no way back to + // a complete sign-out and no way to tell that it had not had one. + // + // A failed sign-out leaving the credential in place is the lesser harm of + // the two: the family revocation runs LAST in Logout and its error is + // returned directly, so a 5xx here means the durable half did not land + // either and the session is genuinely still live. Pretending otherwise is + // what this avoids. + if isLogout && rec.status >= 200 && rec.status < 300 { clearRefreshCookie(w) } body := rec.buf.Bytes() diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 7a8ee7458..b59282b06 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -924,6 +924,19 @@ func (s *APIKeyServer) CreateAPIKey(ctx context.Context, req *gen.CreateAPIKeyRe if err := requireOrgAdmin(ctx, actorID, req.OrganizationId); err != nil { return nil, err } + // Issuing a credential is a privilege-changing mutation, so it sits behind a + // recent second factor like the others (SP-IDENT-10). The key this returns + // authenticates as its creator for as long as it lives and without the + // session that asked for it, which is the thing a factor is protecting: an + // owner whose session was taken could mint a credential that outlives the + // theft and survives a password change. + // + // AFTER the authorization guards and BEFORE the mutation. requireMFA is + // fail-closed for a privileged actor, so an unenrolled owner or platform + // role is refused rather than admitted. + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } response, err := service.CreateAPIKey(ctx, actorID, req) return response, quotaStatusError(err) } diff --git a/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go b/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go new file mode 100644 index 000000000..2dc7c5705 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/ed25519/logout_family_test.go @@ -0,0 +1,136 @@ +package ed25519minter_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// keyedRevoker answers what was actually written, which the shared fakeRevoker +// cannot: it returns one fixed verdict for every id, so it can show THAT a +// marker was written but never that the right id was matched โ€” and the whole +// defect here is a marker written under an id nothing carries. +type keyedRevoker struct { + mu sync.Mutex + sessions map[string]time.Duration + jtis map[string]time.Duration +} + +func newKeyedRevoker() *keyedRevoker { + return &keyedRevoker{ + sessions: map[string]time.Duration{}, + jtis: map[string]time.Duration{}, + } +} + +func (k *keyedRevoker) Revoke(_ context.Context, jti string, ttl time.Duration) error { + k.mu.Lock() + defer k.mu.Unlock() + k.jtis[jti] = ttl + return nil +} + +func (k *keyedRevoker) IsRevoked(_ context.Context, jti string) (bool, error) { + k.mu.Lock() + defer k.mu.Unlock() + _, ok := k.jtis[jti] + return ok, nil +} + +func (k *keyedRevoker) RevokeSession(_ context.Context, id string, ttl time.Duration) error { + k.mu.Lock() + defer k.mu.Unlock() + k.sessions[id] = ttl + return nil +} + +func (k *keyedRevoker) IsSessionRevoked(_ context.Context, id string) (bool, error) { + k.mu.Lock() + defer k.mu.Unlock() + _, ok := k.sessions[id] + return ok, nil +} + +// Signing out kills every access token in the refresh family, not only the +// rotation the caller happens to be holding. +// +// The sequence is the one that was broken: mint, refresh, then log out with the +// refreshed pair. Every refresh mints a NEW session id and keeps the family, and +// the sign-out wrote its access-revocation marker under the CURRENT session id โ€” +// so an access token minted before that refresh carried a different `sid`, +// matched no marker, and stayed valid until its natural expiry. A token stolen +// before a refresh therefore survived the sign-out that was supposed to end it. +// +// The family id is the identifier that does not rotate, so it is what the token +// now carries and what the sign-out revokes. +func TestSigningOutRevokesTheWholeRefreshFamily(t *testing.T) { + ctx := context.Background() + m, _ := newMinter(t) + m.SetRevoker(newKeyedRevoker()) + + first, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + + // Both tokens verify before anything is revoked, so the assertions below + // cannot pass on a minter that rejects everything. + _, err = m.VerifyAccess(first.AccessToken) + require.NoError(t, err) + + second, err := m.VerifyRefresh(ctx, first.RefreshToken) + require.NoError(t, err) + _, err = m.VerifyAccess(second.AccessToken) + require.NoError(t, err) + + // The rotation really did change the session id โ€” otherwise this test would + // pass for the wrong reason, on a build where `sid` happened to be stable. + require.NotEqual(t, + sessionIDOf(t, first.AccessToken), sessionIDOf(t, second.AccessToken), + "a refresh must rotate the session id, or this finding cannot occur") + + require.NoError(t, m.Revoke(ctx, second.RefreshToken)) + + _, err = m.VerifyAccess(second.AccessToken) + require.Error(t, err, "the access token presented at sign-out must be refused") + + _, err = m.VerifyAccess(first.AccessToken) + require.Error(t, err, + "an access token minted BEFORE the last refresh must be refused too: it is "+ + "the same family, and the sign-out ended the family") +} + +// The counterpart: a sign-out in one family must not revoke another's. Without +// it, stamping a shared or zero family id would pass the test above while +// signing every other person out. +func TestSigningOutLeavesAnotherFamilyAlone(t *testing.T) { + ctx := context.Background() + m, _ := newMinter(t) + m.SetRevoker(newKeyedRevoker()) + + mine, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + theirs, err := m.Mint(ctx, newIdentity()) + require.NoError(t, err) + + require.NoError(t, m.Revoke(ctx, mine.RefreshToken)) + + _, err = m.VerifyAccess(mine.AccessToken) + require.Error(t, err) + _, err = m.VerifyAccess(theirs.AccessToken) + require.NoError(t, err, "another family's session must survive this sign-out") +} + +// sessionIDOf reads the `sid` claim off an access token's payload, so the test +// can prove the rotation it depends on actually happened. +func sessionIDOf(t *testing.T, accessToken string) string { + t.Helper() + var claims struct { + SessionID string `json:"sid"` + } + require.NoError(t, json.Unmarshal([]byte(decodeJWTPayload(t, accessToken)), &claims)) + require.NotEmpty(t, claims.SessionID) + return claims.SessionID +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/minter.go b/module/services/accounts/code/pkg/auth/ed25519/minter.go index eb46cfec7..b2e0e73c9 100644 --- a/module/services/accounts/code/pkg/auth/ed25519/minter.go +++ b/module/services/accounts/code/pkg/auth/ed25519/minter.go @@ -142,6 +142,13 @@ type accessClaims struct { ScopedRoles map[string][]string `json:"sr,omitempty"` ScopedRolesTruncated bool `json:"srt,omitempty"` SessionID string `json:"sid"` + // FamilyID is the refresh family this access token was minted under, and it + // is STABLE across rotation where `sid` is not: every refresh mints a new + // session id and keeps the family. A sign-out revokes the family, so without + // this claim the revocation had nothing to match an access token minted + // before the last refresh โ€” one stolen then stayed usable until it expired, + // through a completed logout. + FamilyID string `json:"fid,omitempty"` // AuthorizedParty is the registered client the token was issued to (OIDC // `azp`). Absent on the host's own web session, so a consumer reads "no azp" // as a first-party web session rather than as a missing value. @@ -399,7 +406,7 @@ func (m *Minter) prepareMint(identity *auth.Identity, familyID uuid.UUID) (*auth } // Access token - access, accessExpiresAt, err := m.signAccess(identity, sessionID, now) + access, accessExpiresAt, err := m.signAccess(identity, sessionID, familyID, now) if err != nil { return nil, nil, fmt.Errorf("ed25519minter: sign access: %w", err) } @@ -600,7 +607,7 @@ func (m *Minter) SwitchOrganization( if err != nil { return err } - accessToken, accessExpiresAt, err = m.signAccess(identity, current.ID, now) + accessToken, accessExpiresAt, err = m.signAccess(identity, current.ID, current.FamilyID, now) return err }) if err != nil { @@ -676,6 +683,18 @@ func isMFAMethod(method string) bool { method == auth.AuthenticationMethodWebAuthn } +// familyAccessClaim renders the family id for the access token, and renders +// NOTHING for the zero value. An access-only window (impersonation) has no +// refresh family, and stamping the zero UUID would put one shared id on every +// such token โ€” a single marker would then revoke all of them at once, across +// tenants. +func familyAccessClaim(familyID uuid.UUID) string { + if familyID == uuid.Nil { + return "" + } + return familyID.String() +} + // Revoke implements auth.JWTMinter.Revoke by revoking the family backing the // given refresh token. Safe to call with an already-revoked token. func (m *Minter) Revoke(ctx context.Context, refreshToken string) error { @@ -687,7 +706,23 @@ func (m *Minter) Revoke(ctx context.Context, refreshToken string) error { } return fmt.Errorf("ed25519minter: revoke lookup: %w", err) } - return m.store.RevokeFamily(ctx, rec.FamilyID, "logout") + // The durable half: nothing in this family can be refreshed again. + if err := m.store.RevokeFamily(ctx, rec.FamilyID, "logout"); err != nil { + return err + } + // The access half, for the WHOLE family rather than one session of it. + // + // RevokeSessionAccess on the presented `sid` covers only the rotation the + // caller is holding. Every refresh mints a new session id, so a token minted + // before the last refresh carried a different one and matched no marker โ€” a + // token stolen before a refresh survived the sign-out that followed it, until + // its natural expiry. The family id is on every access token in the chain + // (accessClaims.FamilyID), so one marker reaches all of them. + // + // Best-effort, exactly like the other access-marker writes: the durable + // revocation above is the authority, and a store outage bounds exposure to + // the access TTL. The caller is told, because Logout collects that. + return m.RevokeSessionAccess(ctx, rec.FamilyID.String()) } // VerifyAccess parses and validates an access token minted by this Minter. @@ -793,11 +828,23 @@ func (m *Minter) VerifyAccess(tokenString string) (*auth.Identity, error) { } } - // Session-scoped revocation (admin session-kill). A single marker keyed by - // the `sid` claim invalidates every access token in the session at once, - // covering the path where the killer never held the victim's token. - if claims.SessionID != "" { - revoked, err := m.revoker.IsSessionRevoked(context.Background(), claims.SessionID) + // Session- and family-scoped revocation. A single marker invalidates every + // access token it covers at once: the path where the killer never held the + // victim's token (admin session-kill), and the path where the token predates + // the last refresh (sign-out). + // + // Both keys are checked, because they answer different questions and the + // session one alone left a hole: `sid` changes on every refresh, so a + // sign-out that revoked the CURRENT session left an access token minted + // before it unmatched. The family does not change, so one marker covers the + // whole chain. They share the session-marker namespace โ€” both are ids this + // host mints, and neither can collide with the other โ€” so this needs no + // second store and no second TTL. + for _, key := range []string{claims.SessionID, claims.FamilyID} { + if key == "" { + continue + } + revoked, err := m.revoker.IsSessionRevoked(context.Background(), key) switch { case err != nil && !m.cfg.RevocationFailOpen: return nil, fmt.Errorf("%w: %v", auth.ErrRevocationUnavailable, err) @@ -927,6 +974,7 @@ func (m *Minter) accessTTL(identity *auth.Identity) time.Duration { func (m *Minter) signAccess( identity *auth.Identity, sessionID uuid.UUID, + familyID uuid.UUID, now time.Time, ) (string, time.Time, error) { jti, err := randHex(16) @@ -959,6 +1007,7 @@ func (m *Minter) signAccess( ID: jti, }, SessionID: sessionID.String(), + FamilyID: familyAccessClaim(familyID), AuthorizedParty: identity.ClientID, Email: identity.Email, Name: identity.DisplayName, diff --git a/module/services/accounts/code/pkg/auth/public_origin.go b/module/services/accounts/code/pkg/auth/public_origin.go index d72f2668a..5b1d6f297 100644 --- a/module/services/accounts/code/pkg/auth/public_origin.go +++ b/module/services/accounts/code/pkg/auth/public_origin.go @@ -140,6 +140,24 @@ func canonicalPublicOriginHost(hostname string) (string, error) { // and net.IP.String() picks the same representation the browser does. return ip.String(), nil } + if numericHostSpelling(hostname) { + // A host the WHATWG parser reads as an IPv4 ADDRESS but spells differently + // from this side. `https://2130706433`, `https://0x7f000001` and + // `https://127.1` are all 127.0.0.1 to a browser and to the frontend's + // `new URL(...)`, which rewrites them to the dotted quad; Go's net/url + // preserves whatever was written. Both sides would then be configured with + // one value and compare two, so every same-origin check fails and sign-in + // refuses โ€” a configuration that cannot work, accepted at boot. + // + // Refused rather than rewritten. Rewriting would mean reimplementing that + // parser's integer-host rules in Go and keeping them in step forever, for a + // spelling no deployment needs: a dotted quad says the same thing and both + // sides already agree on it. + return "", fmt.Errorf( + "public origin host %q is a numeric spelling of an address, which this "+ + "host and the browser's URL parser render differently; write it as a "+ + "dotted quad (or a hostname)", hostname) + } ascii, err := publicOriginHostProfile.ToASCII(hostname) if err != nil { return "", fmt.Errorf("public origin host is not a usable hostname") @@ -147,6 +165,35 @@ func canonicalPublicOriginHost(hostname string) (string, error) { return ascii, nil } +// numericHostSpelling reports whether a hostname that is NOT already a canonical +// IP literal would be read as an IPv4 address by the WHATWG URL parser. +// +// That parser's trigger is the last label: if it parses as a number โ€” decimal, +// hex `0xโ€ฆ`, or octal with a leading zero โ€” the whole host is an address and is +// re-rendered as a dotted quad. net.ParseIP has already accepted every spelling +// the two sides agree on before this runs, so anything reaching here with a +// numeric final label is a spelling only one side would keep. +func numericHostSpelling(hostname string) bool { + labels := strings.Split(hostname, ".") + last := labels[len(labels)-1] + if last == "" { + // A trailing dot: the label before it decides. + if len(labels) < 2 { + return false + } + last = labels[len(labels)-2] + } + if last == "" { + return false + } + if lower := strings.ToLower(last); strings.HasPrefix(lower, "0x") { + _, err := strconv.ParseUint(lower[2:], 16, 64) + return err == nil && len(lower) > 2 + } + _, err := strconv.ParseUint(last, 10, 64) + return err == nil +} + // canonicalPublicOriginPort renders the port as the ":NNN" suffix of a canonical // origin, or "" when the origin carries the scheme's default. // diff --git a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go index 55fd92c79..30ab4b7b7 100644 --- a/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go +++ b/module/services/accounts/code/pkg/auth/public_origin_vectors_test.go @@ -48,12 +48,19 @@ var canonicalPublicOriginVectors = []struct{ Input, Canonical string }{ {"https://BรœCHER.example", "https://xn--bcher-kva.example"}, } -// Refused on both sides. Every entry but one is refused by the browser parser too. +// Refused on both sides. Most entries are refused by the browser parser too. // -// The exception is port 0, which `new URL` accepts and keeps. It is refused here -// because no origin can be served on it, and a rule both sides apply is worth more -// than matching the parser on a value that cannot occur: the frontend's -// canonicalOrigin applies the same range check, so the two still agree. +// Two groups are deliberately stricter than that parser, because a rule both sides +// apply is worth more than matching it on a value no deployment needs: +// +// - PORT 0, which `new URL` accepts and keeps. No origin can be served on it, +// and the frontend's canonicalOrigin applies the same range check. +// - A NUMERIC SPELLING of an address, which that parser accepts and REWRITES: +// `2130706433`, `0x7f000001`, `127.1` and `0177.0.0.1` all come back as +// `127.0.0.1`, while Go's net/url preserves what was written. Configuring +// either side with one of these gives the two services different values from +// the same configuration, so every same-origin comparison fails and sign-in +// refuses. A dotted quad says the same thing and both sides keep it. var refusedPublicOriginVectors = []string{ "https://:443", "https://app.example:99999", @@ -65,6 +72,10 @@ var refusedPublicOriginVectors = []string{ "https://app.example?q=1", "https://app.example#f", "https://user:secret@app.example", + "https://2130706433", + "https://0x7f000001", + "https://127.1", + "https://0177.0.0.1", "app.example", "", } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go index cc6e334c0..fb92d263e 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/api_keys.pb.go @@ -718,7 +718,7 @@ const file_saas_accounts_v1_api_keys_proto_rawDesc = "" + "\x18API_KEY_ENVIRONMENT_TEST\x10\x022\xf1\x05\n" + "\rAPIKeyService\x12\xcd\x01\n" + "\fCreateAPIKey\x12%.saas.accounts.v1.CreateAPIKeyRequest\x1a&.saas.accounts.v1.CreateAPIKeyResponse\"n\xc2\xf3\x18S\b\x02\x10\x04\x1a\x0eapi_keys:write*\x15\n" + - "\x0forganization_id\x10\x02\x18\x010\x01:\x18\n" + + "\x0forganization_id\x10\x02\x18\x010\x04:\x18\n" + "\x14saas.api_key.created\x10\x02@\x01H\x04P\x03X\x04`\x01x\x02\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/v1/api-keys\x12\xbd\x01\n" + "\vListAPIKeys\x12$.saas.accounts.v1.ListAPIKeysRequest\x1a%.saas.accounts.v1.ListAPIKeysResponse\"a\xc2\xf3\x18I\b\x02\x10\x03\x1a\rapi_keys:read\"\rapi_keys:read*\x15\n" + "\x0forganization_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x03X\x03`\x01\x82\xd3\xe4\x93\x02\x0e\x12\f/v1/api-keys\x12\xd0\x01\n" + diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index f2cd9bbd0..8fc97ce76 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -27,7 +27,7 @@ "lookup": "RESOURCE_LOOKUP_DIRECT_ID" } ], - "mfa": "MFA_REQUIREMENT_NONE", + "mfa": "MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", "audit": { "events": [ "saas.api_key.created" @@ -43,13 +43,13 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", + "policy_tier": "mfa", "owner": { "module": "saas-starter", "service": "accounts" }, "source_proto": "saas/accounts/v1/api_keys.proto", - "policy_sha256": "43bdf1f9a139cc858b9deb4b2d4a5b2eb5b9ad7093363d1a13719d06f7e7239f", + "policy_sha256": "b3df2a4866dce11045d5802b2ffb2777b5c3d1de86184c06381c8445232faa88", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 5495166c3..5fd9a21dc 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -515,7 +515,7 @@ "lookup": "RESOURCE_LOOKUP_DIRECT_ID" } ], - "mfa": "MFA_REQUIREMENT_NONE", + "mfa": "MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP", "audit": { "events": [ "saas.api_key.created" @@ -531,7 +531,7 @@ "conditions": [], "impersonation": "IMPERSONATION_REQUIREMENT_FORBIDDEN" }, - "policy_tier": "org_admin", + "policy_tier": "mfa", "description": "Mint an API key for an administered organization.", "owner": { "module": "saas-starter", diff --git a/module/services/accounts/proto/saas/accounts/v1/api_keys.proto b/module/services/accounts/proto/saas/accounts/v1/api_keys.proto index 2b953c59f..8890e1ef9 100644 --- a/module/services/accounts/proto/saas/accounts/v1/api_keys.proto +++ b/module/services/accounts/proto/saas/accounts/v1/api_keys.proto @@ -112,7 +112,12 @@ service APIKeyService { target: RESOURCE_TARGET_ORGANIZATION lookup: RESOURCE_LOOKUP_DIRECT_ID } - mfa: MFA_REQUIREMENT_NONE + // Issuing a credential is a privilege-changing operation: the key it + // returns authenticates as its creator, for as long as it lives, without + // the session that asked for it. It declared MFA_REQUIREMENT_NONE while + // every other privilege-changing mutation on this host declared a factor, + // so an organization owner with no enrolled factor could mint one. + mfa: MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP audit: { events: "saas.api_key.created" emission: AUDIT_EMISSION_SUCCESS diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index 135f47027..fab027593 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -11,7 +11,7 @@ type generatedAuthorizationMetadata struct { } var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadata{ - "/saas.accounts.v1.APIKeyService/CreateAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "43bdf1f9a139cc858b9deb4b2d4a5b2eb5b9ad7093363d1a13719d06f7e7239f"}, + "/saas.accounts.v1.APIKeyService/CreateAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "b3df2a4866dce11045d5802b2ffb2777b5c3d1de86184c06381c8445232faa88"}, "/saas.accounts.v1.APIKeyService/ListAPIKeys": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "c6c87c1ff7ffb6ff5f0733913bb6bead4d9d0a66c9fb8664f4c92a97ea988e95"}, "/saas.accounts.v1.APIKeyService/RevokeAPIKey": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "557d498b8e708671a8f5bce8e1665007af85265e1155fd6e695c0cd838fad4cc"}, "/saas.accounts.v1.APIKeyService/ValidateAPIKey": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "cb57ee7f4e96ab9548ead04103dfd3e9a96dce973c06a8bd62a9ac991eab1e16"}, diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index 3b263c699..6ad2cf698 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -35,6 +35,10 @@ type accessClaims struct { ScopedRoles map[string][]string `json:"sr,omitempty"` ScopedRolesTruncated bool `json:"srt,omitempty"` SessionID string `json:"sid"` + // FamilyID is the refresh family, stable across the rotation that changes + // `sid`. A sign-out revokes it, so it is what matches an access token minted + // before the last refresh. + FamilyID string `json:"fid"` ActingAsUserID string `json:"acting,omitempty"` MFASatisfied bool `json:"mfa,omitempty"` AuthenticationMethods []string `json:"amr,omitempty"` @@ -329,12 +333,21 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut } } - // Session-scoped revocation (admin session-kill). A single marker keyed by - // the `sid` claim invalidates every access token in the session, covering - // the path where the admin never held the victim's token. Same fail-closed - // stance as the jti check. - if claims.SessionID != "" { - revoked, err := s.revoker.RevokedSession(ctx, claims.SessionID) + // Session- and family-scoped revocation. One marker invalidates every access + // token it covers, which is what the paths needing it have in common: the + // admin never held the victim's token (session-kill), and the token predates + // the last refresh (sign-out). Same fail-closed stance as the jti check. + // + // BOTH keys, because `sid` changes on every refresh and the family does not. + // Checking the session alone let an access token minted before the last + // refresh outlive the sign-out that revoked its family. accounts stamps the + // family on every access token in the chain and writes the same marker on + // logout; the two sides are held together by TestAccessClaimsMatchTheGateway. + for _, revocationKey := range []string{claims.SessionID, claims.FamilyID} { + if revocationKey == "" { + continue + } + revoked, err := s.revoker.RevokedSession(ctx, revocationKey) switch { case err != nil && !s.revocationFailOpen: log.Printf("session revocation check failed, denying (fail-closed): %v", err) diff --git a/module/services/auth-gateway/code/resource_token_confinement_test.go b/module/services/auth-gateway/code/resource_token_confinement_test.go index d1020803d..11c1d6a66 100644 --- a/module/services/auth-gateway/code/resource_token_confinement_test.go +++ b/module/services/auth-gateway/code/resource_token_confinement_test.go @@ -1,10 +1,13 @@ package main import ( + "context" "net/http" "net/http/httptest" "testing" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" ) @@ -103,3 +106,41 @@ func TestAnAPIKeyIsRefusedAtAToolEndpoint(t *testing.T) { require.Contains(t, recorder.Body.String(), "names no resource") require.Nil(t, example.lastHeaders, "and the runtime is not reached") } + +// The amplification case, asserted at the decision point rather than through +// ServeHTTP: the Work Context mint. +// +// It is the worst of the escapes, which is why it gets its own test. Admitted +// there, a captured resource token buys capabilities for FURTHER audiences โ€” the +// escape stops being "this one resource's traffic" and becomes the person's +// authority anywhere a context can be minted for. The Connect route is not in +// the test harness's catalog (ServeHTTP answers 404 before any credential is +// read), so the confinement is asserted on ExtAuthz.Check, which is where it is +// actually decided and where the real deployment reaches it. +func TestResourceTokenIsRefusedAtTheWorkContextMint(t *testing.T) { + withPublicBase(t, testPublicBase) + s, priv := newTestExtAuthz(t) + + bound := validClaims(time.Now()) + bound.Audience = jwt.ClaimStrings{ + "saas-starter", testPublicBase + "/api/solutions/example/proxy/mcp", + } + + resp, err := s.Check(context.Background(), checkReq( + "/saas.accounts.v1.WorkContextService/StartTask", map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, bound), + })) + require.NoError(t, err) + require.NotNil(t, resp.GetDeniedResponse(), + "a resource-bound token must not be admitted at the mint") + require.Equal(t, int32(401), int32(resp.GetDeniedResponse().Status.Code)) + + // The control: an unbound session still mints. Confinement must narrow a + // resource token, not close the mint. + ok, err := s.Check(context.Background(), checkReq( + "/saas.accounts.v1.WorkContextService/StartTask", map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, validClaims(time.Now())), + })) + require.NoError(t, err) + require.NotNil(t, ok.GetOkResponse(), "an ordinary session still reaches the mint") +} diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b10edc9c7..64b9a85d2 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.17", + "@codefly-dev/saas-sdk": "0.3.18", "@codefly-dev/saas-ui": "0.12.6", "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15348,7 +15348,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.17", + "version": "0.3.18", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 7f3f13676..dbf3a5e3c 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.17", + "@codefly-dev/saas-sdk": "0.3.18", "@codefly-dev/saas-ui": "0.12.6", "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 1f5e9e8ea..4b7ed04aa 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", + "digest": "sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 043cef0e7395c2557a2204a54def88267adbd462..b8d87bf72f89f99e35cc8c1aa70cfe98431b4795 100644 GIT binary patch delta 1482 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3UuJ30_PHcMqU3TroytH0mI(5PYl$ z&{Y8!pHZU$6_28nuFpD5|;AtI#7Ka zqNOkgv{}}eo0&kzQgj1o91`)2q;LXgYy5D6|8f&D&GE3L!dt+yVzs0c=7DxI@}l~r z!rLVi=C`iGJ7p)+3h(Nupncr&DZq{>w^75$ZI7zbh^v}{+O|M~t*8KY(Ma(0BV{dE z08&9#o5KrG-4JeBLIGM21`=$RFax*{Nw9qhGvF>n5)9+4_e&BC;>-_#nu6jOc{#xr z@FCE)63huL;Stc5K!Q1|B|HXt7D+IaB|O&>s?7`;q-O|U01uJ>IDN>!ehEB9z6QhC zj5k|quwkrb4tS7aVQl7A$&86&Gq1}IvY9t!Q9McWdXjuu+&Fv-xGzer%`|G}c14wH z=4e*iDf`XH@cUi_`&)kYnA7Ji0@VcR1Yc+oHa3M@wzLQxhXPB2EiD1AMwWzcX$job z$dX}fX&I<4sLCMDyaKc%j=0V5fxPgsn=t*@Ka?K3VcgP>rN$V<9sQ&S&+n)~+|kcK zEpfyh{Ze+2JNlJ7dX$cz7(6-HCjT^yrUx8e#4Euo$t%T6@^X2lc~unC1DRh>{{eD% B+>-zR delta 1432 zcmZ9MOHUL*5P-L)yK8rQVAt)zi;04$#9&YekeGm)Xz(II?;aqA5N{ZQ@d*QF74?83 z$e2aYgAd{pjLV{eB1iob9=&*y8yBm49y@aALw{RcUGr7fZqBxE%(jo@A#debo_`dR z9k5ZF>JTqd-p|uQl{&k`F)O`MFOy0n66;=WGu>JGbw;daVBMSR7F}?|`_nBNsw<_y zgq7fm_wgZ=+hodYm&X|qd&q0+5og^2td>gJ;V(%@C^nd?lQ}6PW+`?twVx8CLzGhN z61K!iTCvO66em)#E7%+-wqjTDV4PIAie39ZD680YJS;O|@nV>d0#c3sICNZQ zO(DP)-UL2vD$yQ+Ymdb2X;X==&M-=Q3s4J;5M>_o;GA%d-Uh4;)s*)mjatw2#ZC4WiVufb~If1~F1R2e>EBwDu=}y8`Vd zPHTS(_$<*Zu$i diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index bf895304c..c38a3bd1f 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c + contract-digest: sha256:034ed34951b51856c8bfe23ec3788b469fd284794cf31c71340581e18bc4eca9 services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts index 90cbfd60d..94635b453 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/api_keys_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/api_keys.proto. */ export const file_saas_accounts_v1_api_keys: GenFile = /*@__PURE__*/ - fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAQ6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.APIKey diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index 0d611fe4a..02fe3faf7 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.17", + "version": "0.3.18", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/features/auth/ui/logout-page.tsx b/module/services/frontend/code/src/features/auth/ui/logout-page.tsx index f824652ac..985e1ad94 100644 --- a/module/services/frontend/code/src/features/auth/ui/logout-page.tsx +++ b/module/services/frontend/code/src/features/auth/ui/logout-page.tsx @@ -1,25 +1,59 @@ "use client"; -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/lib/auth"; +import { Button } from "@/shared/ui"; // The proxy has always treated /auth/logout as public, but no page served it, // so a user whose account menu was unreachable had no way to end the session. // Opening the URL signs out and returns to the login page. +// +// An INCOMPLETE sign-out stops here instead of redirecting. The server answers +// 503 when it could not record the revocation, which leaves an already-issued +// access token valid until it expires; redirecting to the login page would +// present that as a finished sign-out, which is the one answer a sign-out must +// never give. The refresh cookie is kept in that case, so the retry below still +// has the credential it needs. export function LogoutPage() { const { logout } = useAuth(); - useEffect(() => { - let cancelled = false; - logout().finally(() => { - if (!cancelled) window.location.replace("/auth/login"); - }); - return () => { - cancelled = true; - }; + const [incomplete, setIncomplete] = useState(false); + + const signOut = useCallback(async () => { + setIncomplete(false); + try { + const { complete } = await logout(); + if (complete) { + window.location.replace("/auth/login"); + return; + } + } catch { + // Treated the same as an explicit incompleteness: nothing confirmed + // the session ended. + } + setIncomplete(true); }, [logout]); + + useEffect(() => { + void signOut(); + }, [signOut]); + + if (!incomplete) { + return ( +

+ Signing outโ€ฆ +

+ ); + } + return ( -

- Signing outโ€ฆ -

+
+

+ We could not finish signing you out. Your session may still be usable + for a few minutes. Please try again. +

+ +
); } diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts index c4346edf7..e3994cac1 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/api_keys_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/api_keys.proto. */ export const file_saas_accounts_v1_api_keys: GenFile = /*@__PURE__*/ - fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFCtAEKFGNvbS5zYWFzLmFjY291bnRzLnYxQgxBcGlLZXlzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("Ch9zYWFzL2FjY291bnRzL3YxL2FwaV9rZXlzLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIoYDCgZBUElLZXkSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSDAoEbmFtZRgEIAEoCRIOCgZwcmVmaXgYBSABKAkSLAoGc2NvcGVzGAYgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5QZXJtaXNzaW9uEjgKC2Vudmlyb25tZW50GAcgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BUElLZXlFbnZpcm9ubWVudBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgxsYXN0X3VzZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvQBChNDcmVhdGVBUElLZXlSZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIsCgZzY29wZXMYAyADKAsyHC5zYWFzLmFjY291bnRzLnYxLlBlcm1pc3Npb24SOAoLZW52aXJvbm1lbnQYBCABKA4yIy5zYWFzLmFjY291bnRzLnYxLkFQSUtleUVudmlyb25tZW50EjgKCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgi6SAWyAQJAASJUChRDcmVhdGVBUElLZXlSZXNwb25zZRIlCgNrZXkYASABKAsyGC5zYWFzLmFjY291bnRzLnYxLkFQSUtleRIVCg1wbGFpbnRleHRfa2V5GAIgASgJImkKEkxpc3RBUElLZXlzUmVxdWVzdBIhCg9vcmdhbml6YXRpb25faWQYASABKAlCCLpIBXIDsAEBEhwKCXBhZ2Vfc2l6ZRgCIAEoBUIJukgGGgQYZCAAEhIKCnBhZ2VfdG9rZW4YAyABKAkiVgoTTGlzdEFQSUtleXNSZXNwb25zZRImCgRrZXlzGAEgAygLMhguc2Fhcy5hY2NvdW50cy52MS5BUElLZXkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIk4KE1Jldm9rZUFQSUtleVJlcXVlc3QSFAoCaWQYASABKAlCCLpIBXIDsAEBEiEKD29yZ2FuaXphdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiLQoVVmFsaWRhdGVBUElLZXlSZXF1ZXN0EhQKA2tleRgBIAEoCUIHukgEcgIQASKdAgoWVmFsaWRhdGVBUElLZXlSZXNwb25zZRINCgV2YWxpZBgBIAEoCBIPCgd1c2VyX2lkGAIgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgDIAEoCRIOCgZzY29wZXMYBCADKAkSEgoKd29ya3NwYWNlcxgFIAMoCRINCgVyb2xlcxgGIAMoCRJMCgphdHRyaWJ1dGVzGAcgAygLMjguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlLkF0dHJpYnV0ZXNFbnRyeRIWCg5wcmluY2lwYWxfa2luZBgIIAEoCRoxCg9BdHRyaWJ1dGVzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASp0ChFBUElLZXlFbnZpcm9ubWVudBIjCh9BUElfS0VZX0VOVklST05NRU5UX1VOU1BFQ0lGSUVEEAASHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9MSVZFEAESHAoYQVBJX0tFWV9FTlZJUk9OTUVOVF9URVNUEAIy8QUKDUFQSUtleVNlcnZpY2USzQEKDENyZWF0ZUFQSUtleRIlLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuQ3JlYXRlQVBJS2V5UmVzcG9uc2UibsLzGFMIAhAEGg5hcGlfa2V5czp3cml0ZSoVCg9vcmdhbml6YXRpb25faWQQAhgBMAQ6GAoUc2Fhcy5hcGlfa2V5LmNyZWF0ZWQQAkABSARQA1gEYAF4AoLT5JMCEToBKiIML3YxL2FwaS1rZXlzEr0BCgtMaXN0QVBJS2V5cxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdEFQSUtleXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0QVBJS2V5c1Jlc3BvbnNlImHC8xhJCAIQAxoNYXBpX2tleXM6cmVhZCINYXBpX2tleXM6cmVhZCoVCg9vcmdhbml6YXRpb25faWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCDhIML3YxL2FwaS1rZXlzEtABCgxSZXZva2VBUElLZXkSJS5zYWFzLmFjY291bnRzLnYxLlJldm9rZUFQSUtleVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkigAHC8xhjCAIQBBoOYXBpX2tleXM6d3JpdGUiDmFwaV9rZXlzOndyaXRlKhUKD29yZ2FuaXphdGlvbl9pZBACGAEwAToYChRzYWFzLmFwaV9rZXkucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwITKhEvdjEvYXBpLWtleXMve2lkfRJ9Cg5WYWxpZGF0ZUFQSUtleRInLnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVBUElLZXlSZXF1ZXN0Giguc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUFQSUtleVJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQBFgDYAFCtAEKFGNvbS5zYWFzLmFjY291bnRzLnYxQgxBcGlLZXlzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.APIKey diff --git a/module/services/frontend/code/src/lib/auth.tsx b/module/services/frontend/code/src/lib/auth.tsx index b82750b17..21cafece8 100644 --- a/module/services/frontend/code/src/lib/auth.tsx +++ b/module/services/frontend/code/src/lib/auth.tsx @@ -310,6 +310,19 @@ export async function deriveOAuthNonce(state: string): Promise { // provider's token endpoint as `code_verifier`. The provider re-hashes // it and compares โ€” this binds the code redemption to the original // authorize request and blocks a stolen-code replay. +/** + * What a sign-out actually achieved. + * + * `complete: false` means the server could not finish revoking โ€” it answers 503 + * "logout incomplete, retry" when an access-revocation marker cannot be written + * โ€” or that nothing answered at all. The refresh family is only revoked when the + * server returns success, and the refresh cookie is kept in the incomplete case + * so a retry still has the credential it needs. + */ +export interface SignOutResult { + complete: boolean; +} + export async function newPkce(): Promise<{ verifier: string; challenge: string; @@ -393,7 +406,12 @@ interface AuthContextType extends AuthState { userId?: string, mfaToken?: string, ) => boolean; - logout: () => Promise; + // Reports whether the sign-out COMPLETED. An incomplete one (the server + // answers 503 "logout incomplete, retry") leaves an unmarked access token + // valid until it expires, so a caller that presents sign-out to a person must + // not present it as finished. Callers free to ignore it still get the local + // state cleared; what they lose is only the ability to say so. + logout: () => Promise; switchOrganization: (organizationId: string) => Promise; // Installs an impersonation access token as the active session. The admin's // own refresh cookie is untouched, so it remains the way back out. @@ -822,21 +840,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { window.location.replace("/auth/login"); }, []); - const logout = useCallback(async () => { + const logout = useCallback(async (): Promise => { // Always hit logout so the server clears the httpOnly refresh cookie, even // if we have no access token in memory. The refresh token is read from the // cookie server-side (no body token needed). - await fetch("/v1/auth/logout", { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - ...(state.accessToken - ? { Authorization: `Bearer ${state.accessToken}` } - : {}), - }, - body: JSON.stringify({}), - }).catch(() => {}); + // + // The OUTCOME is observed. This swallowed every failure, so a 503 saying + // "logout incomplete, retry" cleared the local state and rendered a + // finished sign-out while an unmarked access token stayed valid โ€” the + // product asserting something the server had just refused to assert. A + // network failure counts as incomplete for the same reason: nothing + // confirmed the session ended. + let complete = false; + try { + const response = await fetch("/v1/auth/logout", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(state.accessToken + ? { Authorization: `Bearer ${state.accessToken}` } + : {}), + }, + body: JSON.stringify({}), + }); + complete = response.ok; + } catch { + complete = false; + } + // The local state is cleared either way: the person asked to sign out, and + // leaving them signed in HERE would neither end the session nor tell them + // anything. The server keeps the refresh cookie on an incomplete sign-out, + // so the retry credential survives this. clearRefreshToken(); sessionStorage.removeItem("codefly_mfa_login_token"); setConnectToken(null); @@ -850,6 +885,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading: false, isAuthenticated: false, }); + return { complete }; }, [state.accessToken]); const enterImpersonation = useCallback( diff --git a/module/services/frontend/code/src/lib/codefly-gateway-context.ts b/module/services/frontend/code/src/lib/codefly-gateway-context.ts index 1940364f0..4dec7a2c6 100644 --- a/module/services/frontend/code/src/lib/codefly-gateway-context.ts +++ b/module/services/frontend/code/src/lib/codefly-gateway-context.ts @@ -49,6 +49,21 @@ const runtimeSDK: CodeflyRuntimeReader = { * accepts and keeps it: no origin can be served on port 0, and the same range check * on both sides is worth more than matching the parser on a value that cannot occur. */ +// writtenHostname is the host as the operator SPELLED it, lowercased, with no +// parsing beyond finding the authority. It exists so canonicalOrigin can tell a +// dotted quad that was WRITTEN as one from a dotted quad the parser produced out +// of an integer spelling, which is the only question the rule needs answered. +function writtenHostname(candidate: string): string { + const afterScheme = candidate.slice(candidate.indexOf("//") + 2); + const authority = afterScheme.split(/[/?#]/, 1)[0] ?? ""; + const hostAndPort = authority.slice(authority.lastIndexOf("@") + 1); + if (hostAndPort.startsWith("[")) { + return hostAndPort.slice(0, hostAndPort.indexOf("]") + 1).toLowerCase(); + } + const colon = hostAndPort.indexOf(":"); + return (colon === -1 ? hostAndPort : hostAndPort.slice(0, colon)).toLowerCase(); +} + function canonicalOrigin(candidate: string): string | undefined { try { const parsed = new URL(candidate); @@ -80,6 +95,26 @@ function canonicalOrigin(candidate: string): string | undefined { } } } + // A NUMERIC spelling of an IPv4 address is refused, even though this + // parser accepts it, because it does not keep what was written: + // `2130706433`, `0x7f000001`, `127.1` and `0177.0.0.1` all come back as + // `127.0.0.1`, while Go's net/url preserves the original. Both services + // would be configured with one value and compare two, so every + // same-origin check fails and sign-in refuses โ€” a configuration that + // cannot work, accepted at boot. + // + // Narrowed to exactly that rewrite: the parsed host is a dotted quad AND + // it is not what was written. The two rewrites the two sides DO agree on + // are untouched โ€” an IPv6 literal compresses on both (`[0:0:โ€ฆ:1]` โ†’ + // `[::1]`) and a Unicode hostname encodes on both (`bรผcher.example` โ†’ + // `xn--bcher-kva.example`) โ€” so this cannot be the broader "the parser + // changed something" test, which refused both of those. + if ( + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsed.hostname) && + writtenHostname(candidate) !== parsed.hostname + ) { + return undefined; + } return parsed.origin; } catch { return undefined; diff --git a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts index 3bce91ea7..d9d41601b 100644 --- a/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts +++ b/module/services/frontend/code/src/test/public-origin-canonicalization.test.ts @@ -52,6 +52,10 @@ const refusedPublicOriginVectors: string[] = [ "https://app.example?q=1", "https://app.example#f", "https://user:secret@app.example", + "https://2130706433", + "https://0x7f000001", + "https://127.1", + "https://0177.0.0.1", "app.example", "", ]; diff --git a/module/tools/access_claim_lockstep_test.go b/module/tools/access_claim_lockstep_test.go new file mode 100644 index 000000000..d14d8da68 --- /dev/null +++ b/module/tools/access_claim_lockstep_test.go @@ -0,0 +1,54 @@ +package tools + +import ( + "path/filepath" + "regexp" + "testing" +) + +// The access token's revocation claims are read by two independent Go modules +// with no shared type between them: accounts mints them, the gateway parses them. +// A claim one side renames is a claim the other silently stops finding โ€” and for +// a REVOCATION claim, silently stopping means admitting a token that was revoked. +// +// `fid` is the one this gate exists for. It carries the refresh family, which is +// stable across the rotation that changes `sid`, and a sign-out revokes it. If +// accounts stamps `fid` and the gateway looks for anything else, every access +// token minted before the last refresh survives the sign-out at the edge while +// accounts' own verifier refuses it โ€” two components disagreeing about whether a +// person is signed out, with no test failing on either side. +// +// This gate proves the two sides name the same claims. It cannot prove either +// uses them correctly; that is what each side's behavioural tests are for. +func TestAccessClaimsMatchTheGateway(t *testing.T) { + moduleDir := findModuleDir(t) + minter := readFileOrFail(t, filepath.Join(moduleDir, "services", "accounts", "code", + "pkg", "auth", "ed25519", "minter.go")) + gateway := readFileOrFail(t, filepath.Join(moduleDir, "services", "auth-gateway", "code", + "ext_authz.go")) + + for _, claim := range []struct{ field, tag string }{ + {"SessionID", "sid"}, + {"FamilyID", "fid"}, + } { + t.Run(claim.tag, func(t *testing.T) { + // The tag may carry options (`,omitempty`); the NAME is what travels. + pattern := regexp.MustCompile( + claim.field + `\s+string\s+` + "`" + `json:"` + claim.tag + `(,[a-z]+)?"` + "`") + if !pattern.MatchString(minter) { + t.Fatalf("accounts does not stamp %s as json:%q", claim.field, claim.tag) + } + if !pattern.MatchString(gateway) { + t.Fatalf("the gateway does not parse %s as json:%q", claim.field, claim.tag) + } + }) + } + + // And both must actually CONSULT the family, not merely carry it. A claim + // parsed and never checked is the same hole with an extra field in it. + for name, source := range map[string]string{"accounts": minter, "gateway": gateway} { + if !regexp.MustCompile(`claims\.SessionID, claims\.FamilyID`).MatchString(source) { + t.Errorf("%s must check the family alongside the session when deciding revocation", name) + } + } +} From 4a691821f332ce58462259e54bf8e3745372ec5b Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:55:20 -0400 Subject: [PATCH 27/46] fix: one APP_BASE_URL declaration, and a gate that names a duplicated key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge left `application.env` declaring APP_BASE_URL twice โ€” main's block from #1003 and this branch's from SA-F-ORIGIN, each documenting the same key. The configuration loader refuses one key under two spellings, and it refuses the WHOLE profile: every database-backed suite failed to initialize accounts, the local graph would not come up, and none of it named the file. One declaration now, carrying both blocks' prose plus the numeric-spelling rule an operator needs when writing the value. This is the shape a merge produces โ€” two sides each appending a documented block for one key โ€” and nothing in this repository checked for it. The new gate walks every `configurations/**/*.env` and names the file and the key, so the next one fails in milliseconds instead of as five suites that cannot boot. Found by running the declared database targets after the merge. They were green before it and I reported them so; the duplicate arrived with the merge and broke all five at once, which is the one way this defect shows itself. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- module/configurations/local/application.env | 49 +++++++------- .../configuration_key_uniqueness_test.go | 66 +++++++++++++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 module/tools/configuration_key_uniqueness_test.go diff --git a/module/configurations/local/application.env b/module/configurations/local/application.env index ef032e706..586d72f66 100644 --- a/module/configurations/local/application.env +++ b/module/configurations/local/application.env @@ -2,32 +2,35 @@ # OAuth redirect, the authenticator relying-party origin, every emailed link, and # the frontend solution proxy's same-origin judgement. # -# It is OPERATOR configuration, and nothing in a request can establish it. This one -# value is what binds an OAuth redirect, the authenticator relying-party origin, the -# solution proxy's same-origin comparison and every emailed link, so a deployed cell -# that has not set it refuses those rather than resolving an origin some other way. +# It is OPERATOR configuration, and nothing in a request can establish it. A +# deployed cell that has not set it refuses those surfaces rather than resolving +# an origin some other way: accounts refuses to start without it, and the frontend +# refuses to serve sign-in or the solution proxy. Local development pins no +# runtime port, so it is empty here and the frontend uses its own discovered +# loopback endpoint, which is the host the browser reaches anyway. # -# A DEPLOYED runtime therefore requires it: accounts refuses to start without it, -# and the frontend refuses to serve sign-in or the solution proxy. Local -# development pins no runtime port, so it is empty here and the frontend uses its -# own discovered loopback endpoint, which is the host the browser reaches anyway. +# Since issue #1003 it is also this host's OAuth 2.1 ISSUER: the `iss` of every +# access token and the `issuer` of the RFC 8414 document published at +# /.well-known/oauth-authorization-server. RFC 8414 ยง2 requires that +# issuer to be an https URL, so the two can only be equal if this is set โ€” a +# client that discovers the metadata and then verifies `iss` against it refuses a +# token naming anything else. Unset (the default here) means no metadata is +# published and tokens keep the pre-metadata literal issuer, so the two cannot +# disagree. A deployment that wants an MCP client to discover it MUST set this; +# the gateway reads the same value to publish a solution's protected-resource +# metadata and to require a token's resource audience to name this host. +# +# Write it as a hostname or a dotted-quad address. A numeric spelling of an +# address (`https://2130706433`, `https://0x7f000001`, `https://127.1`) is +# REFUSED: the browser's URL parser re-renders those as the dotted quad while Go +# preserves what was written, so the two services would compare different values +# from this one line and every same-origin check would fail. +# +# DECLARED ONCE. This file carried it twice after a merge โ€” one key under two +# spellings โ€” and the configuration loader refuses that outright, which takes down +# every database-backed suite and the local run with it. APP_BASE_URL= # Interactive links use the origin above. A background-only production origin may # be supplied by deployment configuration. BOOTSTRAP_ADMIN_EMAIL= - -# APP_BASE_URL is this host's own public origin, and since issue #1003 it is -# also its OAuth 2.1 issuer: the `iss` of every access token AND the `issuer` of -# the RFC 8414 document published at -# /.well-known/oauth-authorization-server. RFC 8414 ยง2 requires -# the issuer to be an https URL, so the two can only be equal if this is set โ€” -# a client that discovers the metadata and then verifies `iss` against it -# refuses a token naming anything else. -# -# Unset (the default here) means no metadata is published and tokens keep the -# pre-metadata literal issuer, so the two cannot disagree. A deployment that -# wants an MCP client to discover it MUST set this; the gateway reads the same -# value to publish a solution's protected-resource metadata and to require a -# token's resource audience to name this host. -APP_BASE_URL= diff --git a/module/tools/configuration_key_uniqueness_test.go b/module/tools/configuration_key_uniqueness_test.go new file mode 100644 index 000000000..4aa3ed157 --- /dev/null +++ b/module/tools/configuration_key_uniqueness_test.go @@ -0,0 +1,66 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A configuration key is declared ONCE per file. +// +// The CLI's configuration loader refuses a profile that declares one key under +// two spellings โ€” "configuration definitions conflict" โ€” and it refuses the whole +// profile, not the one key. So a duplicate does not degrade anything: it takes +// down every database-backed suite, the local graph and the deployed boot, all at +// once, and it surfaces as a service that cannot initialize rather than as +// anything naming the file. +// +// This happened here. A merge kept two `APP_BASE_URL=` declarations in +// `application.env`, one from each side, and nothing in this repository noticed +// until five suites failed to boot at the same time. A duplicate is exactly the +// shape a merge produces โ€” both sides appending a documented block for the same +// key โ€” so it is worth a gate that names the file and the key. +func TestConfigurationKeysAreDeclaredOnce(t *testing.T) { + moduleDir := findModuleDir(t) + root := filepath.Join(moduleDir, "configurations") + + var checked int + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".env") { + return err + } + checked++ + seen := map[string]int{} + for _, line := range strings.Split(readFileOrFail(t, path), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, _, found := strings.Cut(trimmed, "=") + if !found { + continue + } + seen[strings.TrimSpace(key)]++ + } + for key, count := range seen { + if count > 1 { + rel, relErr := filepath.Rel(moduleDir, path) + if relErr != nil { + rel = path + } + t.Errorf("%s declares %s %d times. The configuration loader refuses a "+ + "profile with one key under two spellings, and it refuses the WHOLE "+ + "profile โ€” so this takes down every database-backed suite and the "+ + "local run. Declare it once and merge the prose.", rel, key, count) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + if checked == 0 { + t.Fatalf("no .env files found under %s, so this gate asserted nothing", root) + } +} From fe4c1428e34ad993994a0404a53d73c30d63d468 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:07:20 -0400 Subject: [PATCH 28/46] test: the tenancy test satisfies the factor its mutation sits behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's new TestGrantScopeRefusesASubjectOfAnotherOrganization expects PermissionDenied for a cross-organization subject and got FailedPrecondition `mfa_required`: a scope grant is a privilege-granting mutation and this branch put it behind a recent second factor, which runs before the mutation, so an actor without one never reaches the subject check. That ordering is right โ€” a caller who may not perform the operation should not learn the subject's tenancy from trying โ€” so the test supplies the factor rather than the gate moving. With it satisfied, the subject check is the thing under test again. The factor itself keeps its own coverage: discarding it in GrantScope still fails TestR1019PrivilegedMutationsGateBeforeTheMutation/PermServer.GrantScope, so this adaptation removes no assertion. The file's other test drives the business service directly rather than the adapter, so it never reaches the gate and is unchanged. Found by running the declared database targets in a FRESH worktree, which is also how the six pkg/infra failures were attributed: they pass in a clean worktree on both this branch and the main it merged, so those were polluted database state in the working copy rather than either side's defect. This one failed in the clean worktree too, which is what made it real. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- ..._scope_subject_tenancy_integration_test.go | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go b/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go index f027c8986..eebb50deb 100644 --- a/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go +++ b/module/services/accounts/code/pkg/business/grant_scope_subject_tenancy_integration_test.go @@ -6,8 +6,10 @@ import ( "context" "errors" "testing" + "time" "accounts/pkg/adapters" + "accounts/pkg/auth" "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" @@ -49,11 +51,34 @@ func (w *grantScopeWorld) grantTo(subject string, kind gen.SubjectKind) *gen.Gra return &gen.GrantScopeRequest{OrgId: w.org, SubjectId: subject, SubjectKind: kind, ScopePath: w.path, RoleId: w.role} } +// grantScopeCallerWithFactor is storyCallerContext plus recent second-factor +// evidence, which every privilege-granting mutation on this host requires before +// it runs. Without it these tests exercise the factor gate instead of the rule +// they name. +func grantScopeCallerWithFactor(ctx context.Context, userID, orgID string) context.Context { + return auth.WithAssurance( + storyCallerContext(ctx, userID, orgID), + auth.Assurance{ + AuthenticationMethods: []string{auth.AuthenticationMethodOTP}, + AuthenticatedAt: time.Now(), + Level: auth.AssuranceLevelAAL2, + MFAVerifiedAt: time.Now(), + }, + ) +} + // A scope grant names a subject of the organization it is written in. A subject // of another organization is refused. +// +// The caller carries a SATISFIED second factor, because a scope grant is a +// privilege-granting mutation and sits behind one (SP-IDENT-10, requireMFA in +// PermServer.GrantScope). That gate runs before the mutation, so an actor without +// a recent factor is refused with `mfa_required` and never reaches the subject at +// all โ€” correct, and it would make this test assert the factor rather than the +// tenancy. Satisfying it is what lets the subject check be the thing under test. func TestGrantScopeRefusesASubjectOfAnotherOrganization(t *testing.T) { w := newGrantScopeWorld(t) - caller := storyCallerContext(testCtx, w.admin, w.org) + caller := grantScopeCallerWithFactor(testCtx, w.admin, w.org) _, err := (&adapters.PermServer{}).GrantScope(caller, w.grantTo(w.otherTeam, gen.SubjectKind_SUBJECT_KIND_TEAM)) require.Equal(t, codes.PermissionDenied, status.Code(err), "a team of another organization is refused: %v", err) From 6b2dfc940c61ebd9c4b62dd39c0465053045d56e Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:17:07 -0400 Subject: [PATCH 29/46] fix(frontend): the sign-out page reports its outcome through a callback, not from the effect body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's lint gate refused the page: react-hooks objects to setState within an effect, and it traces the helper the effect awaits, so moving the call after the first await was not enough โ€” the analyzer sees the transitive setState either way. The outcome now arrives through a CALLBACK, which is the shape the rule asks for: an effect may start external work and set state when that work reports back. The retry path sets state in its own event handler, where it is free. My own gap, and worth naming: I linted this file with biome alone and read the repository's `npm run lint` as already failing on a pre-existing error. It was not โ€” webhooks-table's react-compiler finding is a WARNING, and the single ERROR in that run was mine. The gate is 0 errors now, with the 39 pre-existing warnings untouched. Refs #1018 Co-Authored-By: Claude Opus 5 (1M context) --- .../code/src/features/auth/ui/logout-page.tsx | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/module/services/frontend/code/src/features/auth/ui/logout-page.tsx b/module/services/frontend/code/src/features/auth/ui/logout-page.tsx index 985e1ad94..9cdb0f4fb 100644 --- a/module/services/frontend/code/src/features/auth/ui/logout-page.tsx +++ b/module/services/frontend/code/src/features/auth/ui/logout-page.tsx @@ -18,23 +18,38 @@ export function LogoutPage() { const { logout } = useAuth(); const [incomplete, setIncomplete] = useState(false); - const signOut = useCallback(async () => { - setIncomplete(false); - try { - const { complete } = await logout(); - if (complete) { - window.location.replace("/auth/login"); - return; - } - } catch { - // Treated the same as an explicit incompleteness: nothing confirmed - // the session ended. - } - setIncomplete(true); - }, [logout]); + // The outcome arrives through a CALLBACK, which is the shape the react-hooks + // rule asks for: an effect may start external work and set state when that + // work reports back, but may not set state in its own body โ€” synchronously or + // through a helper it awaits, which the compiler traces either way. + const signOut = useCallback( + (report: (incomplete: boolean) => void) => { + logout().then( + ({ complete }) => { + if (complete) { + window.location.replace("/auth/login"); + return; + } + report(true); + }, + // A rejection is the same fact as an explicit incompleteness: + // nothing confirmed the session ended. + () => report(true), + ); + }, + [logout], + ); useEffect(() => { - void signOut(); + let live = true; + signOut((value) => { + if (live) { + setIncomplete(value); + } + }); + return () => { + live = false; + }; }, [signOut]); if (!incomplete) { @@ -51,7 +66,13 @@ export function LogoutPage() { We could not finish signing you out. Your session may still be usable for a few minutes. Please try again.

- From b919de696306efc6453dfd0ac7cb384ed9b66af2 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:11:37 -0400 Subject: [PATCH 30/46] chore: regenerate the contract artifacts from the merged inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge took these eight files from main, which left them describing main's proto inputs rather than this branch's: main's generated bytes still carried CreateAPIKey at tier `org_admin`, while the proto on this branch requires MFA_REQUIREMENT_IF_ENROLLED_RECENT_STEP_UP. Regenerated with CI's pinned codefly 0.1.179, not the newer CLI on PATH: `generate proto` (no change โ€” the merge kept the proto and its Go beside each other), `generate contracts`, `go generate ./pkg/business` and `./pkg/cataloggen`, and the SDK client with the pinned binary first on PATH, since its script resolves `codefly` from there. The whole diff is what that one proto change causes: the descriptor digest in six places, three contract.binpb payloads, CreateAPIKey's row and the two tier counts it moves between. Nothing was hand-picked. The saas-sdk library's generated-by stamp moves 0.1.184 โ†’ 0.1.179 with it. That stamp records whichever CLI last regenerated a library and is already heterogeneous on main (source-read-sdk carries 0.1.181); no gate reads it. It says 0.1.179 here because that is the version CI pins and the one that produced every other artifact in this commit. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 997512 -> 997519 bytes .../api/accounts/connect/contract.binpb | Bin 997512 -> 997519 bytes module/contracts/api/catalog.codefly.json | 4 ++-- module/module.package.codefly.yaml | 4 ++-- module/services/accounts/AUTHZ_MATRIX.md | 6 +++--- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 997512 -> 997519 bytes .../saas-sdk/generated/library.codefly.yaml | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 009d3d99263822ad31fd890cb9d2ab44a1a4f29a..8df17b42a7f7ba371a1f52c8d0f74c8b7e72f618 100644 GIT binary patch delta 1468 zcmZ9L%TE+R5QnFxyK8rQmPH!8n4o}4%o0=t5)GIbBVJ69y9bCdTrtMPXwX4iL3}I+ z&{07bpNU2TDzJu!ZzS<#{0saCJP?mutnPX2$e|DYZFOz+@0-51);_w{K9GZ)ontvZ zE5o+p79+d4&;E!VR$p@e0vg15FWb;z5lmQL|^msrlgs`s#4bi%Orr&~1E zW~4yNwzv|#d=OAp$fVi7&oe^KAb1CQ#0mEtwn`G?hj))g;t32NGyZx!!1cDK-1Acf=v>pfX_t|Y+u3@ICGH%!>H?{k_3aO^JBnlFdU&H zc}5oa6Ts~ym}gqTQ^2i(1anqPcn0_)l3*xFc0lid4KQsW4Tf;_Zfmq~cwy>bQ=9-p;IkJH8%@iq8W-81?q`%!AV& z4XTmQWi@TG$Gj2xpclaY?oFQ(-R=TlRnU#meHLJIHr$ey1!z4MXknyf5%@}^h51?* z!C8s47)Dx_0BeJx45H4x@%{9mR;$=i;04$#H>LjKw<)FqQQ#^-nMGwB>3otGV3W^-{q@Tbq;DN{ou)244cH~fp`cHLr&F|kezS%sw**xTdXLzQ^ zKJv*H*ve0}@E4Z<;~bay);509NNi<9T=H0KV|p{$n*V*CucctapKIrBaMSWW0G~V0TergrPJjxlN5WvZ|dL|q1XR;ugr1#tQt`+M6n=lNb|CFkj^BEk|oV6 z*br3`(!7dwQAJ4e8rDY@TbkGLa8yZ3^Tz)*X=%Fscub^<^YTG93@Bu9`NH?rYRL!f zVc_A6N@H{mcTrhkY$e4d8v;yID@JpJL=TLIV2T0?0LeVS@?vVJRPLwrf+7XP#e*ZK zL|P95OyVtIGkOw*4wynmnmwZ@QE0SAZEpiAfT2XKM||ky&WTaL>R?7)%wm$=0o*Mr z^xRtGF^P8p#~>c|KhKCeYS7~-b_!ml}AM~NBJ|JyK%!2VDBu(g97ElIV4XVz9Q&UU{ zbQbE)1fUH9Jqv6h1Z@;R&w{<6LAwg87OCx{5@{__>&JkZU^p$Agm?~cUsO}rp8)O& zuj$mdKbl zKx-1IB4gf`jL}tO%)4y|$(Z+%s*Hk?mTVqqRSISiIJOAvc-UJRLy|wy#T~f_RZXHp zF9N;V92npHr7qs?<^U^#1}6PN4t8dWDfyCv#h#Xo(=M0v2~bO9XKZur_)DLHx4q rAVK^}5IgZwZ%o+p79+d4&;E!VR$p@e0vg15FWb;z5lmQL|^msrlgs`s#4bi%Orr&~1E zW~4yNwzv|#d=OAp$fVi7&oe^KAb1CQ#0mEtwn`G?hj))g;t32NGyZx!!1cDK-1Acf=v>pfX_t|Y+u3@ICGH%!>H?{k_3aO^JBnlFdU&H zc}5oa6Ts~ym}gqTQ^2i(1anqPcn0_)l3*xFc0lid4KQsW4Tf;_Zfmq~cwy>bQ=9-p;IkJH8%@iq8W-81?q`%!AV& z4XTmQWi@TG$Gj2xpclaY?oFQ(-R=TlRnU#meHLJIHr$ey1!z4MXknyf5%@}^h51?* z!C8s47)Dx_0BeJx45H4x@%{9mR;$=i;04$#H>LjKw<)FqQQ#^-nMGwB>3otGV3W^-{q@Tbq;DN{ou)244cH~fp`cHLr&F|kezS%sw**xTdXLzQ^ zKJv*H*ve0}@E4Z<;~bay);509NNi<9T=H0KV|p{$n*V*CucctapKIrBaMSWW0G~V0TergrPJjxlN5WvZ|dL|q1XR;ugr1#tQt`+M6n=lNb|CFkj^BEk|oV6 z*br3`(!7dwQAJ4e8rDY@TbkGLa8yZ3^Tz)*X=%Fscub^<^YTG93@Bu9`NH?rYRL!f zVc_A6N@H{mcTrhkY$e4d8v;yID@JpJL=TLIV2T0?0LeVS@?vVJRPLwrf+7XP#e*ZK zL|P95OyVtIGkOw*4wynmnmwZ@QE0SAZEpiAfT2XKM||ky&WTaL>R?7)%wm$=0o*Mr z^xRtGF^P8p#~>c|KhKCeYS7~-b_!ml}AM~NBJ|JyK%!2VDBu(g97ElIV4XVz9Q&UU{ zbQbE)1fUH9Jqv6h1Z@;R&w{<6LAwg87OCx{5@{__>&JkZU^p$Agm?~cUsO}rp8)O& zuj$mdKbl zKx-1IB4gf`jL}tO%)4y|$(Z+%s*Hk?mTVqqRSISiIJOAvc-UJRLy|wy#T~f_RZXHp zF9N;V92npHr7qs?<^U^#1}6PN4t8dWDfyCv#h#Xo(=M0v2~bO9XKZur_)DLHx4q rAVK^}5IgZwZ%o+p79+d4&;E!VR$p@e0vg15FWb;z5lmQL|^msrlgs`s#4bi%Orr&~1E zW~4yNwzv|#d=OAp$fVi7&oe^KAb1CQ#0mEtwn`G?hj))g;t32NGyZx!!1cDK-1Acf=v>pfX_t|Y+u3@ICGH%!>H?{k_3aO^JBnlFdU&H zc}5oa6Ts~ym}gqTQ^2i(1anqPcn0_)l3*xFc0lid4KQsW4Tf;_Zfmq~cwy>bQ=9-p;IkJH8%@iq8W-81?q`%!AV& z4XTmQWi@TG$Gj2xpclaY?oFQ(-R=TlRnU#meHLJIHr$ey1!z4MXknyf5%@}^h51?* z!C8s47)Dx_0BeJx45H4x@%{9mR;$=i;04$#H>LjKw<)FqQQ#^-nMGwB>3otGV3W^-{q@Tbq;DN{ou)244cH~fp`cHLr&F|kezS%sw**xTdXLzQ^ zKJv*H*ve0}@E4Z<;~bay);509NNi<9T=H0KV|p{$n*V*CucctapKIrBaMSWW0G~V0TergrPJjxlN5WvZ|dL|q1XR;ugr1#tQt`+M6n=lNb|CFkj^BEk|oV6 z*br3`(!7dwQAJ4e8rDY@TbkGLa8yZ3^Tz)*X=%Fscub^<^YTG93@Bu9`NH?rYRL!f zVc_A6N@H{mcTrhkY$e4d8v;yID@JpJL=TLIV2T0?0LeVS@?vVJRPLwrf+7XP#e*ZK zL|P95OyVtIGkOw*4wynmnmwZ@QE0SAZEpiAfT2XKM||ky&WTaL>R?7)%wm$=0o*Mr z^xRtGF^P8p#~>c|KhKCeYS7~-b_!ml}AM~NBJ|JyK%!2VDBu(g97ElIV4XVz9Q&UU{ zbQbE)1fUH9Jqv6h1Z@;R&w{<6LAwg87OCx{5@{__>&JkZU^p$Agm?~cUsO}rp8)O& zuj$mdKbl zKx-1IB4gf`jL}tO%)4y|$(Z+%s*Hk?mTVqqRSISiIJOAvc-UJRLy|wy#T~f_RZXHp zF9N;V92npHr7qs?<^U^#1}6PN4t8dWDfyCv#h#Xo(=M0v2~bO9XKZur_)DLHx4q rAVK^}5IgZwZ% Date: Sun, 11 Oct 2026 09:12:43 -0400 Subject: [PATCH 31/46] style(auth-gateway): gofmt ext_authz.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's lint phase is red on this file: "needs Fix (goimports/gofmt)". Adding FamilyID with a doc comment above it split the claims struct's alignment group in two, so gofmt re-aligns both halves; the insertion kept the old column. Whitespace only โ€” `gofmt -w` output, no hand edits. Mine, and I should have caught it: every earlier check on this branch ran `go vet`, which says nothing about formatting. `gofmt -l` and `goimports -l` now report nothing across all six Go modules, except four generated files under libraries/source-read-sdk/go/gen that goimports disagrees with the generator about. Those are main's bytes, unchanged here, and generated code is not hand-edited. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/auth-gateway/code/ext_authz.go | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index 6ad2cf698..aa5be2401 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -29,23 +29,23 @@ import ( // Field names use short JSON keys matching the backend minter. type accessClaims struct { jwt.RegisteredClaims - OrgID string `json:"org,omitempty"` - OrgRole string `json:"or,omitempty"` - PlatformRole string `json:"pr,omitempty"` - ScopedRoles map[string][]string `json:"sr,omitempty"` - ScopedRolesTruncated bool `json:"srt,omitempty"` - SessionID string `json:"sid"` + OrgID string `json:"org,omitempty"` + OrgRole string `json:"or,omitempty"` + PlatformRole string `json:"pr,omitempty"` + ScopedRoles map[string][]string `json:"sr,omitempty"` + ScopedRolesTruncated bool `json:"srt,omitempty"` + SessionID string `json:"sid"` // FamilyID is the refresh family, stable across the rotation that changes // `sid`. A sign-out revokes it, so it is what matches an access token minted // before the last refresh. - FamilyID string `json:"fid"` - ActingAsUserID string `json:"acting,omitempty"` - MFASatisfied bool `json:"mfa,omitempty"` - AuthenticationMethods []string `json:"amr,omitempty"` - AuthenticationTime *jwt.NumericDate `json:"auth_time,omitempty"` - AssuranceLevel string `json:"acr,omitempty"` - MFAVerifiedAt *jwt.NumericDate `json:"mfa_at,omitempty"` - Act *actorClaim `json:"act,omitempty"` + FamilyID string `json:"fid"` + ActingAsUserID string `json:"acting,omitempty"` + MFASatisfied bool `json:"mfa,omitempty"` + AuthenticationMethods []string `json:"amr,omitempty"` + AuthenticationTime *jwt.NumericDate `json:"auth_time,omitempty"` + AssuranceLevel string `json:"acr,omitempty"` + MFAVerifiedAt *jwt.NumericDate `json:"mfa_at,omitempty"` + Act *actorClaim `json:"act,omitempty"` // AuthorizedParty is the registered client the token was issued to (OIDC // `azp`). Absent on the host's own web sessions, which belong to no client. AuthorizedParty string `json:"azp,omitempty"` From c99a6f97634b17646e2136fe80363f292d1a6090 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:24:27 -0400 Subject: [PATCH 32/46] chore: regenerate the contract artifacts after the second merge Same chain, same pinned 0.1.179, same shape of diff: the descriptor digest in six places, three contract.binpb payloads, CreateAPIKey's row and the two tier counts it moves between (org_admin is 61 before and 60 after now, since #1072 added one). The generated-by stamp follows the CLI as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1000344 -> 1000351 bytes .../api/accounts/connect/contract.binpb | Bin 1000344 -> 1000351 bytes module/contracts/api/catalog.codefly.json | 4 ++-- module/module.package.codefly.yaml | 4 ++-- module/services/accounts/AUTHZ_MATRIX.md | 6 +++--- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1000344 -> 1000351 bytes .../saas-sdk/generated/library.codefly.yaml | 4 ++-- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index f8173caf37838e51ad64ec5df56cfa4353602399..80b56e519fff55483caaed29c8c665216f0700ba 100644 GIT binary patch delta 1478 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3Yit;Dv;6_W&`5lL^L{7>zoBD;pH? z0Jzd2;6mmVIS;*R1k;N5f zrWdx0v%SvqBw(?--x;vwc8g3X$FjB{KXxY7SNuKTY-C_7|8UUhhg|;8pwrfnkq%k5 zg~}PmaUfYElV<;6S}=0QA%AqpIq6-XE~%s&eoBC)#KmM2hs%iBN?b}d%N;TmqNEa+ zsWVPeN?f7#IFU+RrH(jpmGI~wr;hTZJ%yvdQ88Ana17{(tPU?g*8nH@2DQPt<)7<~ zilI9J4%hD2sUa%N0`2;bB@|u=S(twC`H2}^l+3#c&;(NZ`K zv|HAho0&kzQgj<=1`_d%q;M8!Z~SnA|8fU1t?{s=!n?q-Vzs0c=7A0~@}l~r!nv{u z^IKQpy^51*h4*z-&_3??0$^8^+o)mWc1Bfc#8oXoeMca{R#X7HXe4<0k+K#n0I49W z&EW;8Z3?$6p#beC0tq%tSOi>+B-p-$MQ~Rm35IdjM`Z~HapuQBEkSXNylKG}@Cneq zGMpA#!c(9iF zV8d9=GVmb9!r084vKbS_X5LmDWHaw7qIi;4^d$MRxN-O%uqR5b%`|J~9*Zi~%+ajA zTMn6#;rCqv`$vB1v@__Hfa-#Df-h8poh{*(EtR0FFR&!o(i-4qWJ&mz*1+A2EE&d@ z)`1#=stn@H8$bu*h}--T$O|943Db}LQ~9wQ#x4C^Zj3?P(Jy-N{EiyL9sLT_7DwFC wZxsi*qu;rsr|9gtk@F)R@^4cvJ?!uzUI|`FUMXIZm&+^7tE!kD&P@IK2b~Go*Z=?k delta 1418 zcmZ9LO-~d-5QevAx@%{9mR+|8FD44260-)80Er2ri3Tqc^x(|{gb=P6g7Fgu%<8H! zD1r=G6g>D9Kf$;xDhP7a-(aFYz?1v{t9xc=M-FwUcdDyvp0{g0-#(LXAI(9|$eB6z zkxzEOW@)N}KexOer@1V3cJX6IVlyk^lE-2j9wd{UrC(?GY6>>Ixo+MC*S$a8yrHH_ zaEuu-CRsllMzKv;I$a(&NwJ5$wjO>C2EE^n!f}VJdXW*LTo5;;dBNIGXA)(}lIBHh zi7E+cUc#oRBBXg4o1=;?%`12?swAa(_5Yf*G~H`>SftAH@)0%$C}eQ?!uORr$w%xl z;Ngr)V{{I8P?XdnrAuNC9!lLE~|e z)`I|(cmvq9o=n}7;nC{gRY2ff@mdJC{Fm{AwAm}IvBcZzB~ zx7K(};vK+oh(~*%WcR$xiG<_chtz@S7fZ4UATuHtCM0_Rcql3f$tHnNN2+YernVeR zO7>7y1&HXic)(i2LyD@aG`l7I3#HKydQjUOkTxV1!1xf7CiJWTD1)vBRTsdiFQ){$ z08J+Y&<25?1-1}^Hhj>tU@vIUuEMHCYWt`{T1(XWF<>?rPD>^so&($y)fD!5z+C}$ zU8k@=0el)_*D7Iu23R8;VF%ZGYe$mkz5v=0|FP*h+7b2=Xi0p&S|ej#RrqR&jCl>T zCXp&K=1s*IT}8&c-ExqOc^9e5Dky2m-UF?QZx(@Li@@5$-pUw~yoo;U$VI4a6Fqtn z=+zd%_~tG3@ou*WSRFJl=@*KyJzGx6mm;*D3VboimnC59p)aQI%M#e@;R0xlY$*Zm z4(8AjbzTPC7nKO?CqTC*fT+t9#Lt!2tu=!9rNTl>1aSqhyaQSyh^v4N(F+LT*DVJL q;u=Bh#q)y$Lj%p?Z++hB=M+d0r?4mz6oSI0NK#aloc`3^pZ@@UXV+f< diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index f8173caf37838e51ad64ec5df56cfa4353602399..80b56e519fff55483caaed29c8c665216f0700ba 100644 GIT binary patch delta 1478 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3Yit;Dv;6_W&`5lL^L{7>zoBD;pH? z0Jzd2;6mmVIS;*R1k;N5f zrWdx0v%SvqBw(?--x;vwc8g3X$FjB{KXxY7SNuKTY-C_7|8UUhhg|;8pwrfnkq%k5 zg~}PmaUfYElV<;6S}=0QA%AqpIq6-XE~%s&eoBC)#KmM2hs%iBN?b}d%N;TmqNEa+ zsWVPeN?f7#IFU+RrH(jpmGI~wr;hTZJ%yvdQ88Ana17{(tPU?g*8nH@2DQPt<)7<~ zilI9J4%hD2sUa%N0`2;bB@|u=S(twC`H2}^l+3#c&;(NZ`K zv|HAho0&kzQgj<=1`_d%q;M8!Z~SnA|8fU1t?{s=!n?q-Vzs0c=7A0~@}l~r!nv{u z^IKQpy^51*h4*z-&_3??0$^8^+o)mWc1Bfc#8oXoeMca{R#X7HXe4<0k+K#n0I49W z&EW;8Z3?$6p#beC0tq%tSOi>+B-p-$MQ~Rm35IdjM`Z~HapuQBEkSXNylKG}@Cneq zGMpA#!c(9iF zV8d9=GVmb9!r084vKbS_X5LmDWHaw7qIi;4^d$MRxN-O%uqR5b%`|J~9*Zi~%+ajA zTMn6#;rCqv`$vB1v@__Hfa-#Df-h8poh{*(EtR0FFR&!o(i-4qWJ&mz*1+A2EE&d@ z)`1#=stn@H8$bu*h}--T$O|943Db}LQ~9wQ#x4C^Zj3?P(Jy-N{EiyL9sLT_7DwFC wZxsi*qu;rsr|9gtk@F)R@^4cvJ?!uzUI|`FUMXIZm&+^7tE!kD&P@IK2b~Go*Z=?k delta 1418 zcmZ9LO-~d-5QevAx@%{9mR+|8FD44260-)80Er2ri3Tqc^x(|{gb=P6g7Fgu%<8H! zD1r=G6g>D9Kf$;xDhP7a-(aFYz?1v{t9xc=M-FwUcdDyvp0{g0-#(LXAI(9|$eB6z zkxzEOW@)N}KexOer@1V3cJX6IVlyk^lE-2j9wd{UrC(?GY6>>Ixo+MC*S$a8yrHH_ zaEuu-CRsllMzKv;I$a(&NwJ5$wjO>C2EE^n!f}VJdXW*LTo5;;dBNIGXA)(}lIBHh zi7E+cUc#oRBBXg4o1=;?%`12?swAa(_5Yf*G~H`>SftAH@)0%$C}eQ?!uORr$w%xl z;Ngr)V{{I8P?XdnrAuNC9!lLE~|e z)`I|(cmvq9o=n}7;nC{gRY2ff@mdJC{Fm{AwAm}IvBcZzB~ zx7K(};vK+oh(~*%WcR$xiG<_chtz@S7fZ4UATuHtCM0_Rcql3f$tHnNN2+YernVeR zO7>7y1&HXic)(i2LyD@aG`l7I3#HKydQjUOkTxV1!1xf7CiJWTD1)vBRTsdiFQ){$ z08J+Y&<25?1-1}^Hhj>tU@vIUuEMHCYWt`{T1(XWF<>?rPD>^so&($y)fD!5z+C}$ zU8k@=0el)_*D7Iu23R8;VF%ZGYe$mkz5v=0|FP*h+7b2=Xi0p&S|ej#RrqR&jCl>T zCXp&K=1s*IT}8&c-ExqOc^9e5Dky2m-UF?QZx(@Li@@5$-pUw~yoo;U$VI4a6Fqtn z=+zd%_~tG3@ou*WSRFJl=@*KyJzGx6mm;*D3VboimnC59p)aQI%M#e@;R0xlY$*Zm z4(8AjbzTPC7nKO?CqTC*fT+t9#Lt!2tu=!9rNTl>1aSqhyaQSyh^v4N(F+LT*DVJL q;u=Bh#q)y$Lj%p?Z++hB=M+d0r?4mz6oSI0NK#aloc`3^pZ@@UXV+f< diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 9e78a6d65..f897c2cba 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d", + "digest": "sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06", "services": [ { "name": "APIKeyService", @@ -493,7 +493,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d", + "digest": "sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index e09cf59c0..fe59246fa 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d + digest: sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d + digest: sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index ee27d0b23..0abe7933a 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -8,7 +8,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | Procedure | Shape | REST mapping | Tier | Guards | Permission / scope | Resource bindings | Audit | Idempotency / rate | Sensitivity req โ†’ res | Description | |---|---|---|---|---|---|---|---|---|---|---| -| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | +| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `mfa` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; mfa=IF_ENROLLED_RECENT_STEP_UP; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | | `/saas.accounts.v1.APIKeyService/ListAPIKeys` | unary | `GET /v1/api-keys` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | perm=api_keys:read; scope=api_keys:read | organization_id โ†’ ORGANIZATION/DIRECT_ID | โ€” | FORBIDDEN / STANDARD_READ | CONFIDENTIAL โ†’ CONFIDENTIAL | List org's API keys. | | `/saas.accounts.v1.APIKeyService/RevokeAPIKey` | unary | `DELETE /v1/api-keys/{id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write; scope=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ CONFIDENTIAL | Revoke an API key in an administered organization. | | `/saas.accounts.v1.APIKeyService/ValidateAPIKey` | unary | `โ€”` | `internal` | exposure=INTERNAL; tenant=NONE | โ€” | โ€” | โ€” | FORBIDDEN / INTERNAL | SECRET โ†’ CONFIDENTIAL | Internal: plaintext key โ†’ key + org id. | @@ -269,8 +269,8 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `auth`: 43 - `internal`: 52 -- `mfa`: 4 -- `org_admin`: 61 +- `mfa`: 5 +- `org_admin`: 60 - `org_member`: 49 - `platform_admin`: 29 - `public`: 18 diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 8e560e7e1..a265a5807 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d", + "digest": "sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index f8173caf37838e51ad64ec5df56cfa4353602399..80b56e519fff55483caaed29c8c665216f0700ba 100644 GIT binary patch delta 1478 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3Yit;Dv;6_W&`5lL^L{7>zoBD;pH? z0Jzd2;6mmVIS;*R1k;N5f zrWdx0v%SvqBw(?--x;vwc8g3X$FjB{KXxY7SNuKTY-C_7|8UUhhg|;8pwrfnkq%k5 zg~}PmaUfYElV<;6S}=0QA%AqpIq6-XE~%s&eoBC)#KmM2hs%iBN?b}d%N;TmqNEa+ zsWVPeN?f7#IFU+RrH(jpmGI~wr;hTZJ%yvdQ88Ana17{(tPU?g*8nH@2DQPt<)7<~ zilI9J4%hD2sUa%N0`2;bB@|u=S(twC`H2}^l+3#c&;(NZ`K zv|HAho0&kzQgj<=1`_d%q;M8!Z~SnA|8fU1t?{s=!n?q-Vzs0c=7A0~@}l~r!nv{u z^IKQpy^51*h4*z-&_3??0$^8^+o)mWc1Bfc#8oXoeMca{R#X7HXe4<0k+K#n0I49W z&EW;8Z3?$6p#beC0tq%tSOi>+B-p-$MQ~Rm35IdjM`Z~HapuQBEkSXNylKG}@Cneq zGMpA#!c(9iF zV8d9=GVmb9!r084vKbS_X5LmDWHaw7qIi;4^d$MRxN-O%uqR5b%`|J~9*Zi~%+ajA zTMn6#;rCqv`$vB1v@__Hfa-#Df-h8poh{*(EtR0FFR&!o(i-4qWJ&mz*1+A2EE&d@ z)`1#=stn@H8$bu*h}--T$O|943Db}LQ~9wQ#x4C^Zj3?P(Jy-N{EiyL9sLT_7DwFC wZxsi*qu;rsr|9gtk@F)R@^4cvJ?!uzUI|`FUMXIZm&+^7tE!kD&P@IK2b~Go*Z=?k delta 1418 zcmZ9LO-~d-5QevAx@%{9mR+|8FD44260-)80Er2ri3Tqc^x(|{gb=P6g7Fgu%<8H! zD1r=G6g>D9Kf$;xDhP7a-(aFYz?1v{t9xc=M-FwUcdDyvp0{g0-#(LXAI(9|$eB6z zkxzEOW@)N}KexOer@1V3cJX6IVlyk^lE-2j9wd{UrC(?GY6>>Ixo+MC*S$a8yrHH_ zaEuu-CRsllMzKv;I$a(&NwJ5$wjO>C2EE^n!f}VJdXW*LTo5;;dBNIGXA)(}lIBHh zi7E+cUc#oRBBXg4o1=;?%`12?swAa(_5Yf*G~H`>SftAH@)0%$C}eQ?!uORr$w%xl z;Ngr)V{{I8P?XdnrAuNC9!lLE~|e z)`I|(cmvq9o=n}7;nC{gRY2ff@mdJC{Fm{AwAm}IvBcZzB~ zx7K(};vK+oh(~*%WcR$xiG<_chtz@S7fZ4UATuHtCM0_Rcql3f$tHnNN2+YernVeR zO7>7y1&HXic)(i2LyD@aG`l7I3#HKydQjUOkTxV1!1xf7CiJWTD1)vBRTsdiFQ){$ z08J+Y&<25?1-1}^Hhj>tU@vIUuEMHCYWt`{T1(XWF<>?rPD>^so&($y)fD!5z+C}$ zU8k@=0el)_*D7Iu23R8;VF%ZGYe$mkz5v=0|FP*h+7b2=Xi0p&S|ej#RrqR&jCl>T zCXp&K=1s*IT}8&c-ExqOc^9e5Dky2m-UF?QZx(@Li@@5$-pUw~yoo;U$VI4a6Fqtn z=+zd%_~tG3@ou*WSRFJl=@*KyJzGx6mm;*D3VboimnC59p)aQI%M#e@;R0xlY$*Zm z4(8AjbzTPC7nKO?CqTC*fT+t9#Lt!2tu=!9rNTl>1aSqhyaQSyh^v4N(F+LT*DVJL q;u=Bh#q)y$Lj%p?Z++hB=M+d0r?4mz6oSI0NK#aloc`3^pZ@@UXV+f< diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 6d10d7303..38b2011bc 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:6c027c553ea334678580788f8810116f095ee2aee295bde65a01ebda00c8545d + contract-digest: sha256:1c696812511752060af7d8973adff8987d9ca6c64ef747b25f69d7c2e7d38c06 services: - APIKeyService - AccessibleScopeService @@ -52,6 +52,6 @@ sources: - WorkContextService as: accounts generated-by: - codefly: 0.1.184 + codefly: 0.1.179 companion: ghcr.io/codefly-dev/proto:0.0.16 facade: true From b64cc9c73553a4fa4c9d495fc10ec5d5541aa61d Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:45:51 -0400 Subject: [PATCH 33/46] chore: regenerate the contract artifacts after the third merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same chain and the same pinned 0.1.179, and the diff is the same shape once more: the descriptor digest in six places and the three contract.binpb payloads it names. Nothing else moved. Two differences from the previous two rounds, both of them absences: - The generated-by stamp did not move. `main` now carries 0.1.179 itself, so the pinned CLI and the tree already agree and there is nothing to correct. - AUTHZ_MATRIX.md is not in this diff. It came through the merge holding this branch's bytes, and regenerating from the merged inputs reproduced them exactly โ€” which is the evidence that the matrix in tree is a function of these inputs and not something a previous round left behind. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1003924 -> 1003931 bytes .../api/accounts/connect/contract.binpb | Bin 1003924 -> 1003931 bytes module/contracts/api/catalog.codefly.json | 4 ++-- module/module.package.codefly.yaml | 4 ++-- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1003924 -> 1003931 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index dd2fe4058c28920b4840d9a5ac7b44566aea5507..d1d256c222db96a09a6884aff0d29d0e43f65d60 100644 GIT binary patch delta 1478 zcmZ9LOHUL*5P+wqYif6UmPH!8n5ckC2n3Y?i3Yit;Dv;6_W&`5lL^L{7>zoBD~ONv z0JdE{bs&tu0u^r640uBooCYi6NmcA;l93t2lWvbgL_ z_rg|jrq_9%1T1#l?C97xv4q}hMS2}bTXvocRe*OHdpmFDKXnE&%N* z!<^6(o&xO(B$%^W!ZV;3kpx3o!b>fo*36JWdWP^7@DTZr(}(=)*T7TcYcPz>c#Gu* z8^&sufCniS#%A7>&6p@Q^S0t3n|W6e#gnwGC&`z^jl=hVJyB|Hrdc!hNK~n2j%M}U za>$GfzwZ**Kk|zwok6bzR2QTZe4!F-Zwa?-sRUhpfhEC~RslC6OTxFb3hqW^$uPFG z2GkH#We{gx2ig}$+~$u!UijEen11Y^%8%VJZt3T8V+`Vse$j*Hchn&6=vSb&IO2|e wt2oFV{mvadL8s4*oE_5&!@I delta 1429 zcmZ9L%TE+R5XQHryK85AmR;$=3yFfL#9$BzU`z;@Xz-#zO}v>1A%rW2V0^-WSp_|y z2r_0-G{IMVf)N%K6glcYp=aYsyj$HnJ3DfyLw{3MUGw|8X69O_=30j{kg+l>!{3X^ zcG$>GwTq{=|NXd7xnmvTpq1FDk#Qwrv339CUwglQrW+lFlNQL;=OzjHIeQP) zBnpzP*m-Pnq+*x<&q*uhUBLq~RhU-{@ex3&g3IT> ztyC#7%_JI)(WvcpKn*aIsP(81-NJ1f1FQ;WG|4=s z_zl2qvdqkFG!avH6L1{j(H^MyEx+_|!u4)LYH#$5t@s3x8IcPUir)dOk4jSUNg&jb zEJyLFO@&Ft@9L}o5xo{4cym~!sd`#-n!>-(8vURTm5l*uOJNqQcOhv)&$ECk=xR}R z7ToH>kw9mm;Ya}5BG5Cy=R?p|9`p=2^9Ho1u^N%u-Y=3i619E+SQ89qBu0p50e40< zjr}3u_5i!d)7T#YJ`S-Pm9RentdOpBf@?h%2P%ICv?Tf0W)f*h_;a8w$pbbTIrE|j z*hu8eOQ1c8WRWwkiq4oUa_04>LUQJf$*MQ+%A}DmWXvKo9}SFQWXuxqwa^%rH)aW( zwQvKBM!w_#cLZ}7i8?O>?v6@C_5+|-5kNF)8sf*|^EMhm{8VINB!ajCSXcrh5yVx% y+UNxY@$;rag7}3XcH`OpzJb0*`KNl+?G+SA5vQ;z5)_icp-57czoBD~ONv z0JdE{bs&tu0u^r640uBooCYi6NmcA;l93t2lWvbgL_ z_rg|jrq_9%1T1#l?C97xv4q}hMS2}bTXvocRe*OHdpmFDKXnE&%N* z!<^6(o&xO(B$%^W!ZV;3kpx3o!b>fo*36JWdWP^7@DTZr(}(=)*T7TcYcPz>c#Gu* z8^&sufCniS#%A7>&6p@Q^S0t3n|W6e#gnwGC&`z^jl=hVJyB|Hrdc!hNK~n2j%M}U za>$GfzwZ**Kk|zwok6bzR2QTZe4!F-Zwa?-sRUhpfhEC~RslC6OTxFb3hqW^$uPFG z2GkH#We{gx2ig}$+~$u!UijEen11Y^%8%VJZt3T8V+`Vse$j*Hchn&6=vSb&IO2|e wt2oFV{mvadL8s4*oE_5&!@I delta 1429 zcmZ9L%TE+R5XQHryK85AmR;$=3yFfL#9$BzU`z;@Xz-#zO}v>1A%rW2V0^-WSp_|y z2r_0-G{IMVf)N%K6glcYp=aYsyj$HnJ3DfyLw{3MUGw|8X69O_=30j{kg+l>!{3X^ zcG$>GwTq{=|NXd7xnmvTpq1FDk#Qwrv339CUwglQrW+lFlNQL;=OzjHIeQP) zBnpzP*m-Pnq+*x<&q*uhUBLq~RhU-{@ex3&g3IT> ztyC#7%_JI)(WvcpKn*aIsP(81-NJ1f1FQ;WG|4=s z_zl2qvdqkFG!avH6L1{j(H^MyEx+_|!u4)LYH#$5t@s3x8IcPUir)dOk4jSUNg&jb zEJyLFO@&Ft@9L}o5xo{4cym~!sd`#-n!>-(8vURTm5l*uOJNqQcOhv)&$ECk=xR}R z7ToH>kw9mm;Ya}5BG5Cy=R?p|9`p=2^9Ho1u^N%u-Y=3i619E+SQ89qBu0p50e40< zjr}3u_5i!d)7T#YJ`S-Pm9RentdOpBf@?h%2P%ICv?Tf0W)f*h_;a8w$pbbTIrE|j z*hu8eOQ1c8WRWwkiq4oUa_04>LUQJf$*MQ+%A}DmWXvKo9}SFQWXuxqwa^%rH)aW( zwQvKBM!w_#cLZ}7i8?O>?v6@C_5+|-5kNF)8sf*|^EMhm{8VINB!ajCSXcrh5yVx% y+UNxY@$;rag7}3XcH`OpzJb0*`KNl+?G+SA5vQ;z5)_icp-57czoBD~ONv z0JdE{bs&tu0u^r640uBooCYi6NmcA;l93t2lWvbgL_ z_rg|jrq_9%1T1#l?C97xv4q}hMS2}bTXvocRe*OHdpmFDKXnE&%N* z!<^6(o&xO(B$%^W!ZV;3kpx3o!b>fo*36JWdWP^7@DTZr(}(=)*T7TcYcPz>c#Gu* z8^&sufCniS#%A7>&6p@Q^S0t3n|W6e#gnwGC&`z^jl=hVJyB|Hrdc!hNK~n2j%M}U za>$GfzwZ**Kk|zwok6bzR2QTZe4!F-Zwa?-sRUhpfhEC~RslC6OTxFb3hqW^$uPFG z2GkH#We{gx2ig}$+~$u!UijEen11Y^%8%VJZt3T8V+`Vse$j*Hchn&6=vSb&IO2|e wt2oFV{mvadL8s4*oE_5&!@I delta 1429 zcmZ9L%TE+R5XQHryK85AmR;$=3yFfL#9$BzU`z;@Xz-#zO}v>1A%rW2V0^-WSp_|y z2r_0-G{IMVf)N%K6glcYp=aYsyj$HnJ3DfyLw{3MUGw|8X69O_=30j{kg+l>!{3X^ zcG$>GwTq{=|NXd7xnmvTpq1FDk#Qwrv339CUwglQrW+lFlNQL;=OzjHIeQP) zBnpzP*m-Pnq+*x<&q*uhUBLq~RhU-{@ex3&g3IT> ztyC#7%_JI)(WvcpKn*aIsP(81-NJ1f1FQ;WG|4=s z_zl2qvdqkFG!avH6L1{j(H^MyEx+_|!u4)LYH#$5t@s3x8IcPUir)dOk4jSUNg&jb zEJyLFO@&Ft@9L}o5xo{4cym~!sd`#-n!>-(8vURTm5l*uOJNqQcOhv)&$ECk=xR}R z7ToH>kw9mm;Ya}5BG5Cy=R?p|9`p=2^9Ho1u^N%u-Y=3i619E+SQ89qBu0p50e40< zjr}3u_5i!d)7T#YJ`S-Pm9RentdOpBf@?h%2P%ICv?Tf0W)f*h_;a8w$pbbTIrE|j z*hu8eOQ1c8WRWwkiq4oUa_04>LUQJf$*MQ+%A}DmWXvKo9}SFQWXuxqwa^%rH)aW( zwQvKBM!w_#cLZ}7i8?O>?v6@C_5+|-5kNF)8sf*|^EMhm{8VINB!ajCSXcrh5yVx% y+UNxY@$;rag7}3XcH`OpzJb0*`KNl+?G+SA5vQ;z5)_icp-57c Date: Sun, 11 Oct 2026 12:10:12 -0400 Subject: [PATCH 34/46] fix(frontend): every sign-out control answers for whether the sign-out finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1019-R4-M3. `logout()` reported incompleteness and only `/auth/logout` read it. The Terms banner redirected to `/auth/login` whatever it said, and the command palette discarded the result โ€” so the two controls a person reaches when the account menu is unavailable were the two that presented an unfinished sign-out as finished. Local state is cleared either way, which is what made it invisible: the product looks signed out while an unmarked access token stays valid until it expires. The invariant: a control that offers to end a session reports what the server actually did with it. One shared journey (`src/lib/sign-out-journey.ts`) decides the destination for all of them โ€” complete to the login page, incomplete to `/auth/logout`, the page that owns that case and offers the retry the kept refresh cookie still has a credential for. A rejection and a missing result are the same fact as explicit incompleteness: nothing confirmed the session ended. A control may choose where a FINISHED sign-out goes, and may not choose what an unfinished one looks like. All five callers route through it. Tested three ways, because they catch different failures: the journey's own decision table, the real Terms banner driven through an incomplete result, and a structural sweep โ€” labelled as structural โ€” for the next control added months from now calling `logout()` for itself, which is exactly how this arrived. The existing banner test's `logout` mock returned `undefined`, so every test in that file had been exercising only the incomplete path. It now returns the shape `logout()` returns. Mutation-checked: restoring the banner's own redirect fails both the behavioural test and the structural one. Co-Authored-By: Claude Opus 5 (1M context) --- .../sign-out-controls-contract.test.ts | 40 ++++++++++ .../code/src/components/command-palette.tsx | 8 +- .../src/components/consent-banner.test.tsx | 31 +++++++- .../code/src/components/consent-banner.tsx | 9 ++- .../invitations/ui/invitation-acceptance.tsx | 18 +++-- .../onboarding/ui/onboarding-entry.tsx | 6 +- .../organizations/service/lifecycle.ts | 6 +- .../lib/__tests__/sign-out-journey.test.ts | 68 +++++++++++++++++ .../frontend/code/src/lib/sign-out-journey.ts | 74 +++++++++++++++++++ 9 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts create mode 100644 module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts create mode 100644 module/services/frontend/code/src/lib/sign-out-journey.ts diff --git a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts new file mode 100644 index 000000000..07bafcb7c --- /dev/null +++ b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// Every user-facing sign-out control goes through the shared journey. +// +// This is a STRUCTURAL assertion, and says so: it reads source rather than +// behaviour, which proves only that no control calls `logout()` for itself โ€” +// not that the journey is right. The journey's own behaviour is asserted in +// src/lib/__tests__/sign-out-journey.test.ts, and two real controls are driven +// in consent-banner.test.tsx. This exists for the failure those cannot catch: +// the NEXT sign-out control, added months from now, quietly calling `logout()` +// and redirecting wherever it likes. +// +// That is exactly how this defect arrived. `logout()` was changed to report +// incompleteness and `/auth/logout` was taught to handle it, while the controls +// that already existed went on discarding the result โ€” a per-control decision +// that nothing held together. +const controls = [ + "src/components/consent-banner.tsx", + "src/components/command-palette.tsx", + "src/features/onboarding/ui/onboarding-entry.tsx", + "src/features/invitations/ui/invitation-acceptance.tsx", + "src/features/organizations/service/lifecycle.ts", +]; + +describe("sign-out controls", () => { + it.each(controls)("%s routes through the shared journey", (path) => { + const source = readFileSync(path, "utf8"); + expect(source).toContain("signOutAndLeave"); + }); + + // `await logout()` or `void logout()` as a control's own sign-out. The one + // legitimate caller is the journey helper itself, and the logout PAGE, which + // is the journey โ€” neither is in the list above. + it.each(controls)("%s does not call logout() for itself", (path) => { + const source = readFileSync(path, "utf8"); + const direct = source.match(/(?:await|void)\s+logout\(\)/g) ?? []; + expect(direct).toEqual([]); + }); +}); diff --git a/module/services/frontend/code/src/components/command-palette.tsx b/module/services/frontend/code/src/components/command-palette.tsx index a429d05be..7a9a44966 100644 --- a/module/services/frontend/code/src/components/command-palette.tsx +++ b/module/services/frontend/code/src/components/command-palette.tsx @@ -38,6 +38,7 @@ import { isSuperAdmin } from "@/lib/permissions"; import { selectNavigation } from "@/lib/plugins/presentation"; import { useFrontendConfig } from "@/lib/providers"; import { usePublicRuntimeConfig } from "@/lib/public-runtime-config-provider"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; export function CommandPalette() { const [open, setOpen] = useState(false); @@ -150,9 +151,12 @@ export function CommandPalette() { { + onSelect={() => { setOpen(false); - await logout(); + // Through the shared journey. Discarding the outcome here + // left the palette โ€” one of the two controls reachable when + // the account menu is not โ€” silently reporting success. + void signOutAndLeave(logout); }} > diff --git a/module/services/frontend/code/src/components/consent-banner.test.tsx b/module/services/frontend/code/src/components/consent-banner.test.tsx index 97dfcd108..12e381d6a 100644 --- a/module/services/frontend/code/src/components/consent-banner.test.tsx +++ b/module/services/frontend/code/src/components/consent-banner.test.tsx @@ -28,7 +28,7 @@ const clients = vi.hoisted(() => ({ }, })); -const logout = vi.hoisted(() => vi.fn(async () => undefined)); +const logout = vi.hoisted(() => vi.fn(async () => ({ complete: true }))); vi.mock("@/lib/auth", () => ({ useAuth: () => ({ @@ -94,6 +94,35 @@ describe("ConsentBanner", () => { vi.unstubAllGlobals(); }); + // The same control, when the server could NOT finish revoking. + // + // This banner is one of the two sign-out controls a person reaches when the + // account menu is unavailable, and it redirected to the login page whatever + // `logout()` reported โ€” so the one case where a sign-out must not claim to + // have finished was the case it claimed loudest. Local state is cleared either + // way, which is what made it invisible: the app looks signed out while an + // unmarked access token stays valid until it expires. + it("sends an INCOMPLETE sign-out to the retry journey, not to login", async () => { + authState.isAuthenticated = true; + clients.consent.getStatus.mockResolvedValue({ + currentTermsVersion: "terms-v1", + termsAcceptedVersion: "", + policyVersion: "policy-v2", + purposes: [], + }); + logout.mockResolvedValueOnce({ complete: false }); + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); + await waitFor(() => expect(replace).toHaveBeenCalled()); + expect(replace).toHaveBeenCalledWith("/auth/logout"); + expect(replace).not.toHaveBeenCalledWith("/auth/login"); + vi.unstubAllGlobals(); + }); + it("keeps Sign out enabled, first and reachable when Terms cannot be accepted", async () => { authState.isAuthenticated = true; clients.consent.getStatus.mockResolvedValue({ diff --git a/module/services/frontend/code/src/components/consent-banner.tsx b/module/services/frontend/code/src/components/consent-banner.tsx index 7e90b2ed1..acc3c8aaa 100644 --- a/module/services/frontend/code/src/components/consent-banner.tsx +++ b/module/services/frontend/code/src/components/consent-banner.tsx @@ -12,6 +12,7 @@ import { import { WaitlistService } from "@/gen/saas/accounts/v1/waitlist_pb"; import { useAuth } from "@/lib/auth"; import { apiTransport } from "@/lib/connect/transport"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { Button, Notice, Switch } from "@/shared/ui"; const STORAGE_KEY = "saas-starter:consent-preferences"; @@ -155,10 +156,10 @@ export function ConsentBanner({ // leave the session. The kit renders it always, never disabled. escape={{ label: "Sign out", - onSelect: async () => { - await logout(); - window.location.replace("/auth/login"); - }, + // The shared journey, not a redirect of its own: an incomplete + // sign-out must not land on the login page, which would present a + // session the server could not revoke as ended. + onSelect: () => signOutAndLeave(logout), }} actions={ diff --git a/module/services/frontend/code/src/features/organizations/service/lifecycle.ts b/module/services/frontend/code/src/features/organizations/service/lifecycle.ts index f391a6944..3103ae63b 100644 --- a/module/services/frontend/code/src/features/organizations/service/lifecycle.ts +++ b/module/services/frontend/code/src/features/organizations/service/lifecycle.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/lib/auth"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { fallbackOrganization } from "../model/leave-flow"; import { orgMutations } from "./mutations"; import { orgQueries } from "./queries"; @@ -39,7 +40,10 @@ function useDeparture() { } await action(); invalidateOrganization(queryClient, orgId); - if (stranded) await logout(); + // Stranded: the person left their last organization, so there is nothing + // left to be signed in to. Through the shared journey, so an incomplete + // sign-out is not left looking like a finished one. + if (stranded) await signOutAndLeave(logout); }; } diff --git a/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts b/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts new file mode 100644 index 000000000..25ef9c4da --- /dev/null +++ b/module/services/frontend/code/src/lib/__tests__/sign-out-journey.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { + SIGN_OUT_COMPLETE_DESTINATION, + SIGN_OUT_RETRY_DESTINATION, + signOutAndLeave, + signOutDestination, +} from "@/lib/sign-out-journey"; + +// Every user-facing sign-out control shares this decision, so it is asserted +// once here and the controls are asserted to USE it rather than each re-testing +// the whole matrix. +describe("the shared sign-out journey", () => { + it("sends a completed sign-out to the login page", () => { + expect(signOutDestination({ complete: true })).toBe( + SIGN_OUT_COMPLETE_DESTINATION, + ); + }); + + // The three ways a sign-out fails to finish are ONE outcome, because they are + // one fact about the session: nothing confirmed it ended. An incomplete + // answer, a rejected call, and a caller that ignored the result entirely all + // leave an unmarked access token valid until it expires. + it.each([ + ["the server reported incompleteness", async () => ({ complete: false })], + [ + "the call rejected", + async () => { + throw new Error("network"); + }, + ], + ["nothing was reported at all", async () => undefined], + ])("sends %s to the retry journey", async (_name, logout) => { + const navigate = vi.fn(); + await signOutAndLeave( + logout as unknown as () => Promise<{ complete: boolean }>, + { navigate }, + ); + expect(navigate).toHaveBeenCalledWith(SIGN_OUT_RETRY_DESTINATION); + }); + + // A control may choose where a FINISHED sign-out goes โ€” the invitation flow + // returns to the invitation it was signed into the wrong account for โ€” and + // may not choose what an unfinished one looks like. + it("honours a control's own completed destination, never its own retry", async () => { + const navigate = vi.fn(); + const completeDestination = "/auth/login?next=%2Finvite%2Fabc"; + + await signOutAndLeave(async () => ({ complete: true }), { + completeDestination, + navigate, + }); + expect(navigate).toHaveBeenCalledWith(completeDestination); + + navigate.mockClear(); + await signOutAndLeave(async () => ({ complete: false }), { + completeDestination, + navigate, + }); + expect(navigate).toHaveBeenCalledWith(SIGN_OUT_RETRY_DESTINATION); + }); + + // The retry journey is a real page, not a string nobody serves: /auth/logout + // is what reports the failure and offers the retry, and the refresh cookie + // the server kept is the credential that retry uses. + it("names the page that owns the incomplete case", () => { + expect(SIGN_OUT_RETRY_DESTINATION).toBe("/auth/logout"); + }); +}); diff --git a/module/services/frontend/code/src/lib/sign-out-journey.ts b/module/services/frontend/code/src/lib/sign-out-journey.ts new file mode 100644 index 000000000..c1b8ac197 --- /dev/null +++ b/module/services/frontend/code/src/lib/sign-out-journey.ts @@ -0,0 +1,74 @@ +import type { SignOutResult } from "@/lib/auth"; + +/** + * Where a sign-out control sends the person once `logout()` has answered. + * + * Every user-facing sign-out control shares ONE destination rule, because the + * two outcomes are not interchangeable and deciding per control is how they + * drifted apart: + * + * - complete โ€” the server revoked the session. `/auth/login`, which is what a + * finished sign-out looks like. + * - incomplete โ€” the server answered 503 "logout incomplete, retry", or nothing + * answered at all, so an already-issued access token stays valid until it + * expires. `/auth/logout`, the page that OWNS this case: it says the session + * may still be usable and offers the retry, which the kept refresh cookie + * still has the credential for. + * + * The defect this replaces: `logout()` reported incompleteness and only + * `/auth/logout` read it. The Terms banner redirected to `/auth/login` + * regardless, and the command palette discarded the result entirely โ€” so the two + * controls a person reaches when the account menu is unavailable were precisely + * the two that presented an unfinished sign-out as finished. Local state is + * cleared either way, which is what makes the wrong answer invisible: the app + * looks signed out while the token is live. + * + * It is a plain function, not a hook: a control calls it inside the handler it + * already has, and nothing about it depends on React. + */ +export const SIGN_OUT_COMPLETE_DESTINATION = "/auth/login"; +export const SIGN_OUT_RETRY_DESTINATION = "/auth/logout"; + +export function signOutDestination( + result: SignOutResult | undefined, + completeDestination: string = SIGN_OUT_COMPLETE_DESTINATION, +): string { + // Only the COMPLETE destination is a control's to choose. A control with its + // own next step โ€” returning to the invitation it was signed into the wrong + // account for โ€” still has one, and the incomplete case stays the shared + // journey's, because that is the outcome a control must not be able to + // present its own way. + return result?.complete ? completeDestination : SIGN_OUT_RETRY_DESTINATION; +} + +/** + * Runs a sign-out and leaves for the destination its outcome names. + * + * A rejection is the same fact as an explicit incompleteness โ€” nothing + * confirmed the session ended โ€” so it takes the retry journey rather than + * propagating, which in a click handler would surface as an unhandled rejection + * and leave the person on a page that still looks signed in. + * + * `navigate` exists so a test can observe the decision instead of the browser + * performing it; production callers omit it. + */ +export async function signOutAndLeave( + logout: () => Promise, + options: { + completeDestination?: string; + navigate?: (destination: string) => void; + } = {}, +): Promise { + const navigate = + options.navigate ?? + ((destination: string) => { + window.location.replace(destination); + }); + let result: SignOutResult | undefined; + try { + result = await logout(); + } catch { + result = { complete: false }; + } + navigate(signOutDestination(result, options.completeDestination)); +} From 80d0204432d6e2558dd0975e7fcb8e2a905abab4 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:10:32 -0400 Subject: [PATCH 35/46] fix(gateway): the host's public origin is the host's, and a confined credential reaches one host route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, in one commit because both land in `gateway.go` and splitting the file would leave neither half buildable on its own. R1019-R4-B1 โ€” the public origin comes from this host's configuration, never from a client's request. `publicOriginFor` returned a registered client's own `Origin` as this host's public origin. Accounts requires that value to equal the configured host origin, which is right and stays, so configuring the host origin โ€” which deployed boot requires โ€” refused every authenticated request from every registered browser client. Neither guard was wrong: one header carried two meanings about two parties, and they contradicted each other the moment an operator configured anything. Only the host's own frontend establishes it now, by presenting the cluster-internal token with the origin it resolved server-side. A registered client's entitlement to call from its origin is a CORS decision and is unchanged. Nothing is lost: a client's redirect target is authorized against the URIs its own registration declares, and the three readers of the verified origin โ€” the WebAuthn relying party, the base of an emailed link, the host's own `/auth/callback` โ€” are properties of the host, which no client is a witness to. `RegisteredClient`'s own comment already said its redirects and origins are independent and that deriving one from the other silently widens whichever was not declared; this is that rule one hop further out. One assertion of an earlier round of this PR is INVERTED, and says so where it lives: a registered client does not speak for the host origin. The accounts half is asserted by value, because the gateway is a separate Go module with no `go.work`; the two halves together are the contract, and apart either could pass while the pair stayed broken. R1019-R4-B2 โ€” a resource-confined credential reaches exactly one host route, and only for the consumer it was delivered to. The reason given for refusing every host route was false: the runtime's MCP viewer path mints a viewer-owned Work Context here, carrying the person's bearer beside its own workload attestation, and its module credential does not stand in for the person's. Refusing the mint refused the viewer's own tool call. So the mint is admitted โ€” the procedure compared whole, so a sibling procedure on the same service stays refused โ€” and admitted CONFINED: the check stamps the resource, and the gateway requires the solution that resource names to be one the organization installed and the viewer holds a grant reaching, through the same authority every proxied request to that solution already passes. Not entitled is 403; an authority that cannot be asked is 503, because a capability is not issued on an unanswered question. Unrestricted replay stays rejected, and no consumer change is needed. The resource is recovered from the audience by reconstruction, not a lenient parse: a different base, an extra segment or a trailing slash fails rather than resolving to a solution it half-matches. The new header is on BOTH header lists. `allow()` rebuilds its response from `canonicalUpstreamAuthHeaders` alone, so a header missing from it is dropped however carefully it was appended โ€” which would have admitted the route with the confinement lost in transit, worse than refusing it. The frozen `wantEmittedHeaders` snapshot is edited deliberately, with what the value is and why it discloses nothing, which is what that list exists to force. Stated rather than assumed away: the consumer check runs on the HTTP gateway path, where the registry and the entitlement authority live. A deployment driving the check from Envoy gets the route confinement and the carried resource but not that check โ€” the same asymmetry the gateway credential already has. Both findings are fixed at the place that owns the behaviour, each with tests that fail without the fix. Mutation-checked: restoring the client-origin branch fails both gateway tests; removing the route admission, dropping the resource stamp, and removing the consumer check each fail their own test. Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/registered_client_origin_test.go | 113 +++++++++++++ .../services/auth-gateway/code/ext_authz.go | 44 ++++- module/services/auth-gateway/code/gateway.go | 64 ++++--- .../auth-gateway/code/gateway_cors_test.go | 64 ++++++- .../auth-gateway/code/gateway_mcp_resource.go | 67 ++++++++ .../code/gateway_mcp_resource_test.go | 160 +++++++++++++++++- .../auth-gateway/code/gateway_solutions.go | 72 ++++++++ .../code/perimeter_workos_free_test.go | 12 ++ 8 files changed, 565 insertions(+), 31 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/registered_client_origin_test.go diff --git a/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go b/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go new file mode 100644 index 000000000..1e9cd3879 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/registered_client_origin_test.go @@ -0,0 +1,113 @@ +//go:build pure + +package adapters + +import ( + "context" + "net/http" + "testing" + + "accounts/pkg/auth" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// The gatewayโ†’accounts origin contract, held from the accounts side. +// +// The invariant: the public origin this service treats as verified is fixed by +// operator configuration, and a forwarded value is honoured only where it equals +// the configured one. That guard is right and stays. What this test pins is the +// OTHER half of the same contract โ€” that a registered client calling from its own +// permitted origin is not refused by it. +// +// The two halves met in one header. The gateway stamped a registered client's +// CORS origin as `X-Codefly-Public-Origin`, this service required that value to +// equal the configured host origin, and so configuring the host origin โ€” which +// deployed boot requires โ€” refused every authenticated request from every +// registered browser client before its RPC ran. Neither guard was wrong; the +// header meant two things about two different parties. +// +// It is asserted by VALUE rather than by driving the gateway, because the gateway +// is a separate Go module with no `go.work` and an accounts test cannot import it. +// The gateway's own half (`TestCORS_ARegisteredClientDoesNotSpeakForTheHostOrigin`) +// asserts that it stamps no public origin for a registered client; this asserts +// that what it does stamp is admitted. Together they are the contract; apart, +// either could pass while the pair stayed broken, which is exactly what happened. +func TestRegisteredClientReachesAccountsUnderAPinnedHostOrigin(t *testing.T) { + const ( + configuredHostOrigin = "https://host.example" + registeredClient = "example-addin" + clientOrigin = "https://addin.example" + ) + + previousToken := gatewayToken + SetGatewayToken("test-gateway-token") + t.Cleanup(func() { SetGatewayToken(previousToken) }) + + previousOrigin, _ := auth.ConfiguredPublicOrigin() + require.NoError(t, auth.SetConfiguredPublicOrigin(configuredHostOrigin)) + t.Cleanup(func() { _ = auth.SetConfiguredPublicOrigin(previousOrigin) }) + + // What the gateway forwards for an authenticated, registered browser client: + // the identity it verified, its own trust credential, and the client's id โ€” + // and, since the separation, NO public origin, because the client is not a + // witness to this host's. + forwarded := func() http.Header { + h := http.Header{} + h.Set("X-Codefly-Gateway-Token", "test-gateway-token") + h.Set("X-User-Id", "01a12b95-153e-7c35-977b-7e1f6cffea65") + h.Set("X-Org-Id", "01a12b95-153e-7c38-b042-e09562cfd4f3") + h.Set("X-Org-Role", "admin") + h.Set("X-Session-Id", "01a12b95-153e-7c3c-86d2-80e898a68c54") + h.Set("X-Credential-Kind", "session") + // Present and empty, exactly as the gateway sends it. The forwarded + // credential is incomplete without this header at all, which is a + // different refusal from the one under test. + h.Set("X-Scopes", "") + h.Set("X-Client-Id", registeredClient) + h.Set("Origin", clientOrigin) + return h + } + + authorize := func(t *testing.T, transport string, h http.Header) error { + t.Helper() + if transport == "connect" { + _, err := (&connectPolicyInterceptor{}).authorize( + context.Background(), "/saas.accounts.v1.UserService/GetSelf", h) + return err + } + md := metadata.MD{} + for name, values := range h { + md.Set(name, values...) + } + _, err := (&grpcPolicyAuthorizer{exposure: rpcExposureTenant}).authorize( + metadata.NewIncomingContext(context.Background(), md), + "/saas.accounts.v1.UserService/GetSelf") + return err + } + + for _, transport := range []string{"connect", "grpc"} { + t.Run(transport, func(t *testing.T) { + // The host's own frontend path: it proves the configured origin and is + // admitted. This is the control โ€” it passed before and must still. + host := forwarded() + host.Set(publicOriginHeader, configuredHostOrigin) + require.NoError(t, authorize(t, transport, host), + "the host's own origin must stay admitted") + + // The registered client, on its own origin, carrying no claim about + // this host's. Admitted, which is the defect this closes. + require.NoError(t, authorize(t, transport, forwarded()), + "a registered client on its permitted origin must reach accounts") + + // And the guard still bites: a forwarded origin that is NOT the + // configured one is refused, whoever sent it. Were this to pass, the + // separation above would have been bought by dropping the protection. + foreign := forwarded() + foreign.Set(publicOriginHeader, clientOrigin) + require.Error(t, authorize(t, transport, foreign), + "an origin other than the configured one must stay refused") + }) + } +} diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index aa5be2401..2a9f58e75 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -413,9 +413,23 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut return deny(401, "this host has no configured public address, so it cannot "+ "state the resource a token must name"), nil } + resourceConfined := "" if binding == resourceBound { solutionID, isSolutionPath := solutionIDFromPath(path) - if !isSolutionPath || !resourceAudienceAdmits(resource, solutionID, s.publicBase) { + switch { + case isSolutionPath && resourceAudienceAdmits(resource, solutionID, s.publicBase): + // The solution its own resource names. Admitted, as before. + case isWorkContextMintPath(path): + // THE ONE HOST ROUTE (isWorkContextMintPath carries the reasoning). + // + // Admitted, and carried forward as a confinement rather than dropped: + // the resource travels to the mint in x-codefly-resource below, so what + // accounts issues is held to the consumer this credential was delivered + // to. Admitting the route without saying which resource asked would hand + // the mint a credential it could not tell from an unbound session, which + // is the confinement removed rather than relocated. + resourceConfined = resource + default: recordJWTRejection(ctx, jwtRejectionWrongResource) return deny(401, "the token presented was issued for a different "+ "resource than this endpoint"), nil @@ -472,6 +486,22 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut // carrying none would otherwise be indistinguishable from a session. hdr("x-credential-kind", credentialKindSession), } + if resourceConfined != "" { + // The resource this credential was issued for. Non-empty on exactly one + // host route โ€” the delegation mint โ€” and on a solution's own paths, where + // the resource and the path already agree. + // + // Accounts reads it as a ceiling on the mint: a capability minted under it + // may act for the consumer that resource names and for no other. Empty is + // an ordinary unbound session, and allow() stamps the empty value rather + // than omitting the header, so the two are distinguishable without either + // being a missing-header inference. + // + // Gateway-owned, like every header beside it: the name is on + // untrustedAuthHeaders AND on canonicalUpstreamAuthHeaders, so a caller + // presenting it has its value replaced rather than forwarded. + hdrs = append(hdrs, hdr(resourceConfinementHeader, resourceConfined)) + } if claims.MFASatisfied { hdrs = append(hdrs, hdr("x-mfa-satisfied", "true")) } @@ -698,6 +728,18 @@ var canonicalUpstreamAuthHeaders = []string{ "x-acting-as-user-id", "x-act", "x-scopes", "x-credential-kind", "x-mfa-satisfied", "x-authentication-methods", "x-auth-time", "x-assurance-level", "x-mfa-verified-at", clientIDHeader, + // The resource a confined credential was issued for. ON THIS LIST, not only + // appended to the stamped set: allow() rebuilds the response from this list + // alone, so a header missing from it is silently dropped however carefully it + // was appended โ€” which would have admitted the mint route with the + // confinement lost in transit, the one outcome worse than refusing it. + // + // Being here also means it is stamped on EVERY allow, empty when the + // credential names no resource. That is stronger than sending it only when + // confined: Envoy replaces a caller-supplied value even where the authenticated + // token has none, so a caller cannot assert a resource by arriving with one on + // a request that would otherwise carry no such header at all. + resourceConfinementHeader, } // clientIDHeader carries the registered client a request was made through, as diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 45addb5e3..fd6cdc64c 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -363,6 +363,14 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { } injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + // A resource-confined credential reaches exactly one host route, and only + // for the consumer it was delivered to. Decided AFTER the projection, + // because the org and viewer it asks about are what that projection just + // established from the verified token โ€” and refused before the limiter, so + // a refusal costs the organization nothing. + if !g.confineResourceMintToItsConsumer(w, r) { + return + } // Stamped after the identity projection, so it joins the headers g.rateLimitThenProxy(w, r, upstream, entry) @@ -657,27 +665,33 @@ func (g *Gateway) withTrustedFrontendOrigin(r *http.Request) *http.Request { return r.WithContext(ctx) } -// publicOriginFor is the browser origin accounts may treat as verified: the -// WebAuthn relying party a credential is bound to, and the origin an OAuth -// redirect is validated against. +// publicOriginFor is THIS HOST's public origin, and only ever that: the WebAuthn +// relying party a credential is bound to, the base of an emailed link, and the +// origin the host's own /auth/callback is validated against. +// +// One caller can establish it โ€” the host's own frontend, which proves it by +// presenting the cluster-internal token alongside the origin it resolved +// server-side, and whose value accounts then honours only where it equals the +// configured one. // -// Two callers can establish one. The frontend proves it by presenting the -// cluster-internal token alongside the origin it resolved server-side. A -// registered client proves it by arriving from an origin its own registration -// declares, which authorizeCrossOrigin has already checked โ€” a stronger claim, -// since it names the client rather than only the process that forwarded it. +// A REGISTERED CLIENT'S OWN ORIGIN IS NOT THIS VALUE, and used to be returned +// here. A client's CORS origin and the host's public origin are different facts +// about different parties: an add-in served from https://addin.example is +// entitled to call this host from there, which authorizeCrossOrigin decides, and +// that entitlement says nothing about which origin the host mints links at or +// binds an authenticator to. Collapsing the two sent the client's origin to +// accounts AS the host's, where pinning the host origin then refused the request +// outright โ€” so the two meanings were not merely conflated, they were in +// contradiction the moment an operator configured the host origin at all. +// +// Nothing is lost by separating them. A registered client's redirect target is +// authorized against the URIs its own registration declares +// (RegisteredClient.AllowsRedirect), never against a public origin, and that +// type's own comment is explicit that its redirects and its origins are +// independent "and deriving one from the other would silently widen whichever +// was not declared". This is that rule applied one hop further out. func publicOriginFor(r *http.Request) (string, bool) { - if origin, ok := trustedFrontendOrigin(r.Context()); ok { - return origin, true - } - if r.Header.Get(clientIDHeader) == "" { - return "", false - } - origin, err := canonicalPublicOrigin(r.Header.Get("Origin")) - if err != nil { - return "", false - } - return origin, true + return trustedFrontendOrigin(r.Context()) } func trustedFrontendOrigin(ctx context.Context) (string, bool) { @@ -826,8 +840,20 @@ var untrustedAuthHeaders = []string{ solutionIdentityHeader, solutionPublisherHeader, clientIDHeader, + // The resource a confined credential was issued for. Stamped by ext_authz on + // the one host route that admits such a credential, and read by accounts as a + // ceiling on what the mint may issue โ€” so a caller able to set it could lift + // its own confinement, or impose one on somebody else's request. + resourceConfinementHeader, } +// resourceConfinementHeader carries the RFC 8707 resource a credential is bound +// to, from the ext_authz check to the Work Context mint. It is stamped on every +// allow decision โ€” empty for an ordinary session, the resource identifier for a +// confined credential โ€” so accounts reads a VALUE rather than inferring anything +// from a header's absence, and a caller cannot assert one by supplying it. +const resourceConfinementHeader = "x-codefly-resource" + // solutionIdentityHeader and solutionPublisherHeader are the headers accounts // reads to decide which registered solution a Work Context mint is for. This // gateway stamps NEITHER โ€” the credential it proved them from is deleted โ€” and diff --git a/module/services/auth-gateway/code/gateway_cors_test.go b/module/services/auth-gateway/code/gateway_cors_test.go index e4de86ebf..a24efd24c 100644 --- a/module/services/auth-gateway/code/gateway_cors_test.go +++ b/module/services/auth-gateway/code/gateway_cors_test.go @@ -237,11 +237,22 @@ func TestCORS_UpstreamGrantIsReplacedNotDuplicated(t *testing.T) { require.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) } -// The public origin an accounts route is told about decides which relying party -// a WebAuthn credential binds to and which redirect an OAuth start may use. A -// registered client establishes it from its own registration, which names the -// client โ€” the frontend's shared-secret path only ever named the process. -func TestCORS_PublicOriginComesFromTheRegistration(t *testing.T) { +// A registered client's CORS origin is NOT this host's public origin, and this +// test is the inversion of one that asserted it was. +// +// The replaced test read: "A registered client establishes it from its own +// registration, which names the client โ€” the frontend's shared-secret path only +// ever named the process." Naming the client is true and is not the point. The +// value names a property OF THIS HOST โ€” which relying party an authenticator +// binds to, which origin an emailed link is rooted at, which /auth/callback the +// host's own sign-in may return to โ€” and no client, however precisely +// identified, is a witness to it. +// +// Held as two facts about two parties, both of which must be true at once: +// the client reaches accounts on its own registered origin (it is entitled to, +// which is the CORS decision), and accounts is told nothing about a public +// origin by it (it is not entitled to say, which is this decision). +func TestCORS_ARegisteredClientDoesNotSpeakForTheHostOrigin(t *testing.T) { gw, api, _, priv := newGatewayHarness(t) twoRegisteredClients(t, gw) @@ -251,9 +262,48 @@ func TestCORS_PublicOriginComesFromTheRegistration(t *testing.T) { w := httptest.NewRecorder() gw.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, addinOrigin, api.lastHeaders.Get("X-Codefly-Public-Origin")) + require.Equal(t, http.StatusOK, w.Code, "a registered client's request must still reach accounts") + require.Equal(t, []string{addinOrigin}, w.Header().Values("Access-Control-Allow-Origin"), + "and must still receive the CORS grant its registration earns") require.Equal(t, "test-gateway-token", api.lastHeaders.Get("X-Codefly-Gateway-Token")) + require.Empty(t, api.lastHeaders.Get("X-Codefly-Public-Origin"), + "the client's origin must NOT arrive at accounts as this host's public origin") +} + +// The whole of finding 1, as one request: with a host origin configured โ€” which +// deployed boot requires โ€” a registered client on its own origin must reach an +// accounts route rather than be refused by the pinning guard. +// +// It is a gateway test that asserts an ACCOUNTS outcome, because the defect +// lived in neither service alone. Accounts' guard was right (a forwarded origin +// is honoured only where it equals the configured one) and the gateway's CORS +// decision was right (this client may call from this origin); what was wrong was +// that one header carried both meanings, so configuring the host origin โ€” the +// supported, required deployment state โ€” turned every registered-client request +// into PermissionDenied. The stand-in below is what accounts does with the +// value: nothing may arrive that is not the configured origin. +func TestCORS_AConfiguredHostOriginDoesNotRefuseARegisteredClient(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + twoRegisteredClients(t, gw) + + const configuredHostOrigin = "https://host.example" + + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("Authorization", "Bearer "+signClientToken(t, priv, addinClient)) + req.Header.Set("Origin", addinOrigin) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // Accounts' rule, applied here to exactly what the gateway stamped: a + // forwarded public origin is admitted only when it equals the configured one. + // An absent one carries no claim and is admitted; the client's own origin + // would be refused, which is the failure this closes. + forwarded := api.lastHeaders.Get("X-Codefly-Public-Origin") + require.NotEqual(t, addinOrigin, forwarded) + if forwarded != "" { + require.Equal(t, configuredHostOrigin, forwarded) + } } // The frontend still has its door: presenting the internal token alongside the diff --git a/module/services/auth-gateway/code/gateway_mcp_resource.go b/module/services/auth-gateway/code/gateway_mcp_resource.go index a075242da..e6543c905 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource.go @@ -99,6 +99,44 @@ func publicBaseURL() string { return strings.TrimSuffix(strings.TrimSpace(workspaceEnv("application", "APP_BASE_URL")), "/") } +// workContextMintProcedure is the ONE host route a resource-bound token reaches: +// the delegation mint. It is the full gRPC/Connect procedure name, which is also +// the request path on both transports. +// +// Named as a constant and compared whole. A prefix match would admit any +// procedure beginning with it, and this is the one place where the difference +// between "this exact mint" and "anything that looks like it" is the whole +// property. +const workContextMintProcedure = "/saas.accounts.v1.WorkContextService/StartTask" + +// isWorkContextMintPath reports whether a FULL request path addresses the +// delegation mint โ€” the single host route a resource-confined credential is +// admitted at. +// +// Why there is exactly one, and why it is this one: +// +// A resource-bound token is held by a third-party MCP client and, once +// forwarded, by the solution runtime it dialled. Confinement exists so that +// credential cannot act as the person anywhere but the resource it names. The +// claim that nothing legitimate needed a host route was wrong: the runtime's MCP +// viewer path derives a module gateway by minting a viewer-owned Work Context +// here, carrying the viewer's bearer beside its own workload attestation. Its +// module credential does not stand in for the viewer's โ€” the mint is what turns +// "this person asked" into a capability the destination module can check โ€” so +// refusing this route refuses the viewer's own tool call, which is a product +// regression and not a security property. +// +// So the mint is admitted and nothing else is. Everything a resource token could +// otherwise have reached โ€” this host's own API, another declared module's routes, +// the rest of the Work Context surface โ€” stays refused, which is the escape the +// confinement was added to close. What the mint then issues is bounded on the +// accounts side, where the viewer's own authority, the host audience vocabulary +// and the stamped resource below decide what may be minted. +func isWorkContextMintPath(path string) bool { + pathOnly, _, _ := strings.Cut(path, "?") + return pathOnly == workContextMintProcedure +} + // resourceAudienceAdmits reports whether a token's resource audience permits a // request to solutionID. The audience must be a URL naming that solution's MCP // endpoint, at this host's configured origin when one is configured. @@ -267,3 +305,32 @@ func gatewayTokenIssuer() string { func gatewayAcceptedIssuers() []string { return []string{legacyTokenIssuer} } + +// solutionIDFromResource is the inverse of solutionMCPResource: the solution a +// resource identifier names, or ok=false when the value is not an identifier +// this host would have minted. +// +// Built by RECONSTRUCTION, not by parsing. It takes the candidate id out of the +// expected position and then requires solutionMCPResource to rebuild the exact +// string that arrived, so anything the two spellings disagree about โ€” a +// different base, an extra segment, a trailing slash, a different case โ€” fails +// rather than resolving to a solution it half-matches. The confinement is only +// as good as the identity of the thing it names, and a lenient reverse parse is +// how a token for one solution comes to name another. +func solutionIDFromResource(resource, publicBase string) (string, bool) { + if publicBase == "" { + return "", false + } + rest, ok := strings.CutPrefix(resource, publicBase+solutionProxyBase) + if !ok { + return "", false + } + id, _, found := strings.Cut(rest, "/") + if !found || id == "" || !solutionIDPattern.MatchString(id) { + return "", false + } + if solutionMCPResource(publicBase, id) != resource { + return "", false + } + return id, true +} diff --git a/module/services/auth-gateway/code/gateway_mcp_resource_test.go b/module/services/auth-gateway/code/gateway_mcp_resource_test.go index 96332248d..025067296 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource_test.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource_test.go @@ -1,7 +1,9 @@ package main import ( + "context" "crypto/ed25519" + "errors" "net/http" "net/http/httptest" "os" @@ -167,10 +169,16 @@ func TestGateway_MCPToken_ReachesItsOwnSolutionsOtherRoutes(t *testing.T) { // Work Context surface is part of that API, so the reach included minting // further capabilities for other audiences: authority amplification, not a // fixed set of things "a solution" could already do. -// 2. The runtime does not need it. A runtime's calls to this host are made with -// the module credential it was provisioned โ€” `/modules/_work-context` takes -// the internal token and the module secret and never a person's bearer โ€” so -// refusing the person's resource token here breaks no path that exists. +// 2. The reach was the whole API, and only one route of it is needed. This +// reason previously read "the runtime does not need it", which was FALSE and +// is corrected here rather than quietly dropped: the runtime's MCP viewer +// path mints a viewer-owned Work Context at this host, carrying the person's +// bearer beside its own workload attestation, and its module credential does +// not stand in for that. Refusing every host route refused the viewer's own +// tool call. So exactly one route is admitted โ€” the delegation mint โ€” and it +// is admitted CONFINED, carrying the resource forward so what is issued is +// held to the consumer the credential was delivered to +// (TestGateway_MCPToken_ReachesTheMintAndNothingElseOnTheHost). // // The host audience such a token also carries is a PARSER requirement: the // minter stamps it so its own VerifyAccess and this gateway's claims parser @@ -819,3 +827,147 @@ func TestR1019EverySpellingOfTheToolEndpointIsProtected(t *testing.T) { "%s is not the tool surface and must stay reachable with a session", ordinary) } } + +// The one host route, and the confinement it carries. +// +// Three facts in one test, because the property is the conjunction and any two +// of them without the third is either a regression or an escape: +// +// 1. The delegation mint ADMITS a resource-bound token. Without this the +// runtime's MCP viewer cannot derive a module gateway for the person, which +// is the consumer regression the second review of this PR demonstrated with +// a real runtime tool call. +// 2. It arrives CONFINED. The resource travels to accounts as +// x-codefly-resource, so the mint can hold what it issues to the consumer +// the credential was delivered to. An admitted route that dropped the +// resource would be the confinement deleted, not relocated โ€” accounts would +// see a credential indistinguishable from an unbound session. +// 3. Nothing ELSE on the host is admitted. The mint is one procedure, compared +// whole; a sibling procedure on the same service is refused, which is what +// stops "the mint is allowed" from becoming "the Work Context surface is +// allowed". +func TestGateway_MCPToken_ReachesTheMintAndNothingElseOnTheHost(t *testing.T) { + withPublicBase(t, testPublicBase) + s, key := newTestExtAuthz(t) + s.publicBase = testPublicBase + token := signResourceToken(t, key, solutionMCPResource(testPublicBase, "example")) + + admitted := s.check(t, workContextMintProcedure, token) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), admitted.Get(resourceConfinementHeader), + "the mint must be told which resource the credential is confined to") + + for _, elsewhere := range []string{ + "/saas.accounts.v1.WorkContextService/StartInstallationTask", + "/saas.accounts.v1.WorkContextService/Exchange", + "/saas.accounts.v1.UserService/GetSelf", + "/v1/users", + } { + t.Run(elsewhere, func(t *testing.T) { + response, err := s.Check(context.Background(), checkReq(elsewhere, map[string]string{ + "authorization": "Bearer " + token, + })) + require.NoError(t, err) + require.NotNil(t, response.GetDeniedResponse(), "every host route but the mint stays refused") + require.Equal(t, int32(401), int32(response.GetDeniedResponse().GetStatus().GetCode())) + }) + } +} + +// An ordinary session credential is NOT confined, and must not arrive looking as +// though it were. The header's presence is the whole signal accounts reads, so a +// value stamped for a token that names no resource would impose a confinement on +// the browser's own session โ€” refusing the product rather than a replay. +func TestGateway_SessionTokenCarriesNoResourceConfinement(t *testing.T) { + withPublicBase(t, testPublicBase) + s, key := newTestExtAuthz(t) + s.publicBase = testPublicBase + + stamped := s.check(t, workContextMintProcedure, signValidToken(t, key)) + require.Empty(t, stamped.Get(resourceConfinementHeader), + "an unbound session must reach the mint with no confinement asserted") +} + +// check runs one ext_authz decision and returns the headers it stamped, +// failing the test if the request was refused. +func (s *ExtAuthz) check(t *testing.T, path, token string) http.Header { + t.Helper() + response, err := s.Check(context.Background(), checkReq(path, map[string]string{ + "authorization": "Bearer " + token, + })) + require.NoError(t, err) + require.Nil(t, response.GetDeniedResponse(), "expected %s to be admitted", path) + stamped := http.Header{} + for _, h := range response.GetOkResponse().GetHeaders() { + stamped.Set(h.GetHeader().GetKey(), h.GetHeader().GetValue()) + } + return stamped +} + +// The consumer half of the confinement: admitted at the mint only for a solution +// this organization installed and this viewer was granted. +// +// The route confinement alone ("a resource token reaches the mint and nothing +// else") bounds where the credential acts. It does NOT bound what it acts for: a +// token issued for one consumer would still have minted a capability while its +// consumer was uninstalled, revoked, or never granted to this person. These +// three cases are the difference, and they are asked through the same authority +// that already decides every proxied request to that solution โ€” not a second, +// looser copy of the question. +func TestGateway_MCPToken_AtTheMintIsConfinedToItsInstalledConsumer(t *testing.T) { + withPublicBase(t, testPublicBase) + + mintRequest := func(t *testing.T, gw *Gateway, priv ed25519.PrivateKey, solution string) *httptest.ResponseRecorder { + t.Helper() + token := signResourceToken(t, priv, solutionMCPResource(testPublicBase, solution)) + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w + } + + t.Run("an installed, granted consumer mints", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("example")}} + + require.Equal(t, http.StatusOK, mintRequest(t, gw, priv, "example").Code) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + api.lastHeaders.Get(resourceConfinementHeader), + "and the mint is told which consumer it is confined to") + }) + + t.Run("a consumer the organization did not install does not", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + registerSolutionUpstream(t, gw, "other") + // Installed something else entirely. + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("other")}} + + require.Equal(t, http.StatusForbidden, mintRequest(t, gw, priv, "example").Code) + require.Nil(t, api.lastHeaders, "accounts must not be asked to mint at all") + }) + + t.Run("an authority that cannot be asked does not", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "example") + gw.solutionEntitlements = &fakeSolutionEntitlements{err: errors.New("accounts down")} + + require.Equal(t, http.StatusServiceUnavailable, mintRequest(t, gw, priv, "example").Code, + "a capability is never issued on an unanswered question") + require.Nil(t, api.lastHeaders) + }) + + t.Run("an ordinary session is not subject to any of it", func(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + // No entitlement authority and no registered solution: an unconfined + // credential must not acquire a consumer check it never had. + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, nil) + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, api.lastHeaders.Get(resourceConfinementHeader)) + }) +} diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index fc01b597d..49efccb58 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -465,3 +465,75 @@ func isForbiddenUpstreamHost(host string) bool { } return false } + +// confineResourceMintToItsConsumer is the second half of the resource +// confinement, and the half that makes it a confinement to a CONSUMER rather +// than only to a route. +// +// ext_authz admits a resource-bound credential at exactly one host route โ€” the +// delegation mint โ€” and stamps the resource it is bound to. That alone bounds +// WHERE the credential may act. This bounds WHAT it may act for: the solution +// the resource names must be one this organization installed and this viewer was +// granted, which is the same question, asked through the same authority, as the +// one every proxied request to that solution already passes. +// +// Three refusals, deliberately distinguished: +// +// - A resource this host would not have minted resolves to no solution. 401: +// the credential is not one of ours, and a client answers by re-authorizing. +// - A solution whose registration resolves to nothing, or to a target the +// viewer holds no grant for, is 403. It is a verdict about this person and +// this consumer, and no re-authorization changes it. +// - An authority that could not be asked is 503, never an admission. A mint is +// a capability; issuing one on "I could not check" is the failure mode this +// whole finding is about. +// +// It runs on the HTTP gateway path, after the identity projection, because that +// is where the registry and the entitlement authority live. A deployment driving +// ext_authz from Envoy gets the route confinement and the carried resource but +// not this check, which is stated here rather than assumed away: the asymmetry +// is the same one the gateway credential already has (see ExtAuthz.allow), and +// closing it means giving the ext_authz server its own registry client. +func (g *Gateway) confineResourceMintToItsConsumer(w http.ResponseWriter, r *http.Request) bool { + resource := r.Header.Get(resourceConfinementHeader) + if resource == "" || !isWorkContextMintPath(r.URL.Path) { + return true + } + solutionID, ok := solutionIDFromResource(resource, g.authz.publicBase) + if !ok { + httpError(w, http.StatusUnauthorized, + "the token presented names no resource this host issues") + return false + } + routing, resolution := g.solutions.resolveRouting(r.Context(), solutionID) + switch resolution { + case solutionRoutable: + // Resolved to one declared binding. Admission below. + case solutionRegistryUnavailable: + // Undecidable, and a mint is never issued on an unanswered question. + httpError(w, http.StatusServiceUnavailable, + "the solution registry could not be read, so this token's consumer "+ + "could not be identified") + return false + default: + // Unregistered, withdrawn, or the wrong surface: a verdict. + httpError(w, http.StatusForbidden, + "the consumer this token was issued for is not available here") + return false + } + org := r.Header.Get("X-Org-Id") + viewer := r.Header.Get("X-User-Id") + switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + case viewerSolutionAdmitted: + return true + case viewerSolutionNotEntitled: + httpError(w, http.StatusForbidden, + "this organization has not installed the consumer this token was issued for, "+ + "or you hold no grant that reaches it") + return false + default: + httpError(w, http.StatusServiceUnavailable, + "the authority that decides this consumer's installation could not be reached") + return false + } +} diff --git a/module/services/auth-gateway/code/perimeter_workos_free_test.go b/module/services/auth-gateway/code/perimeter_workos_free_test.go index 9432fe2b8..fffe2a43b 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -265,6 +265,18 @@ var wantEmittedHeaders = []string{ "x-user-name", "x-session-id", "x-acting-as-user-id", "x-act", "x-scopes", "x-credential-kind", "x-mfa-satisfied", "x-authentication-methods", "x-auth-time", "x-assurance-level", "x-mfa-verified-at", "x-client-id", + // Added deliberately, which is what this list is for: the resource a + // confined credential was issued for, carried from the ext_authz decision to + // the Work Context mint so the mint can hold what it issues to the consumer + // the credential was delivered to. + // + // Eyeballed as this list demands. It leaks nothing: the value is an audience + // from the token this host itself minted โ€” a public resource URL the client + // already dialled and the solution already publishes โ€” never a provider + // identifier, and never anything derived from the caller's own headers. It is + // emitted empty for every credential that names no resource, like every + // canonical header beside it. + resourceConfinementHeader, } // TestPerimeter_SuccessPathEmitsCanonicalHeadersOnly asserts the ext_authz From 7a6c621d6bbd24a3cb99f39ee9c2848c5e7a1320 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:10:41 -0400 Subject: [PATCH 36/46] docs(gateway): the guidance describes the credentials an upstream actually receives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1019-R4-m4. The section said the gateway removes `Authorization`, `Cookie` and `Proxy-Authorization` from every runtime-registered upstream. It does not: the cookie never travels, the bearer does, and the whole credential is withheld on the anonymous module-federation path. Authoritative guidance that describes a behaviour the code does not have is worse than none โ€” the next person implements against it. Rewritten around what shipped, not appended to with a correction: what each credential does on each surface and why, that `SA-F-BEARER` is open with the exposure it leaves real, what would close it, and a new section on the one host route a resource-confined credential reaches and the two axes it is confined on. The note about what a consumer must change is corrected too. It said a consumer written against the old behaviour has to change; it does not, for the bearer. What does change at the edge is the cookie and the confinement of a resource-bound token. Co-Authored-By: Claude Opus 5 (1M context) --- module/services/auth-gateway/AGENTS.md | 105 ++++++++++++++++++------- 1 file changed, 78 insertions(+), 27 deletions(-) diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 8e47ff6e2..4937b5883 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -71,28 +71,49 @@ constitutes organization consent. Module identity exchanges are described below; accounts owns their authority. -## A runtime-registered upstream never receives the person's session - -The gateway removes `Authorization`, `Cookie` and `Proxy-Authorization` from every -request it forwards to a runtime-registered upstream โ€” a federated module prefix or -a solution. A catalog route is untouched: accounts is the host's own API, where the -session IS the credential. - -The host access token carries one host-wide audience and the person's full -authority, so it is not a credential any single upstream should hold. The cookie -goes the same way, and the gateway has already resolved identity from it by the time -it forwards, so an upstream reading it learns nothing it is not told. - -An upstream receives the identity `ext_authz` stamped: the subject, the tenant, the -session, the credential kind and the scope ceiling. That names the person without -carrying their authority. - -**SP-GW-07 has a second half this gateway does not yet satisfy.** The invariant is -that an upstream receives a host-minted context bound to THAT upstream's own -audience โ€” not merely that it stops receiving the person's session. Today it -receives the stamped identity headers and no audience-bound context, so the -credential is no longer over-broad but the positive half is absent. Minting it at -the edge is open work here; it cannot be supplied by a consumer. +## What a runtime-registered upstream receives, and what it does not + +A runtime-registered upstream โ€” a federated module prefix or a solution โ€” receives +the person's `Authorization` bearer and does NOT receive their `Cookie` or +`Proxy-Authorization`. A catalog route is untouched: accounts is the host's own API, +where the session IS the credential. + +That split is a decision, not a halfway implementation, and it replaces an earlier +rule here that said all three were removed from every such request. Each half has +its own reason: + +**The cookie never travels, whatever the bearer does.** Nothing upstream can +authenticate with it โ€” this perimeter refuses a cookie AS a credential by name (see +`ExtAuthz.Check`) โ€” and it carries the longest-lived value a browser holds, the +refresh credential. Forwarding it delivers that value to a third party for no +purpose it could serve. The two removals used to be one decision, and the broader +one silently carried the narrower: a request on the MCP tool path skipped the whole +removal and forwarded `Cookie` with it. + +**The bearer does travel, to a DECLARED upstream, when the request is +authenticated.** A declared upstream authenticates the request by that header; the +runtime's HTTP wrapper and its passthrough handler both answer 401 without it. The +capability that should replace it โ€” a host-minted context bound to that upstream's +own audience โ€” is minted by nothing here and verified by nothing on the consumer +side, and the consumer half lives in another repository. Removing the header first +would break every composed solution while shipping a credential no consumer reads. + +**It does not travel on the anonymous module-federation surface**, which runs no +ext_authz at all: a bearer arriving there was never authenticated for that upstream, +and the loader fetches those bytes without a person. + +An upstream also receives the identity `ext_authz` stamped: the subject, the tenant, +the session, the credential kind and the scope ceiling. That names the person +independently of the bearer beside it. + +**SP-GW-07's second half is open, recorded as `SA-F-BEARER`.** The invariant is that +an upstream receives a host-minted context bound to THAT upstream's own audience โ€” +not merely that it stops receiving the person's session. The host access token +carries one host-wide audience and the person's full authority, so a declared +upstream holding it holds more than it needs, and that exposure is real while this +stays open. What closes it: a host-minted context bound to the upstream's own +audience, plus consumer verification of it, plus one composed browser-to-consumer +request exercised end to end. What is missing is specific, so that the next person does not have to rediscover it. `WorkContextService` already exposes `ExchangeAudience`, which re-binds an existing @@ -101,8 +122,7 @@ caller here holds a browser SESSION, which is not a Work Context, so the edge wo need a mint that takes a verified session and returns a context bound to one upstream's audience, with that person's authority narrowed to what the upstream may do on their behalf. No RPC does that today, and adding one is a descriptor change -(contract digests, published clients, a verified regeneration), which is why the -stripping half shipped alone. +(contract digests, published clients, a verified regeneration). Two things to settle with it, because they are the reason it is not a small change: the mint is per request on the proxy path, so its latency and its failure mode become @@ -110,9 +130,40 @@ the failure mode of all federated traffic; and the authority to narrow TO is the installation's ceiling, which the host cannot currently look up from a solution id either (see `module/SOLUTION_REGISTRATION.md`). -**A consumer written against the old behaviour has to change**: anything that read -the forwarded bearer needs a host-minted capability bound to its own audience -instead. +**A consumer written against the old behaviour does NOT have to change for the +bearer.** Two things do change at the edge: a declared upstream no longer receives +the caller's cookie, and a resource-bound token is confined โ€” it reaches the solution +its resource names, plus exactly one host route, the Work Context mint, and only for +a consumer the organization installed and the viewer was granted. Anything replaying +such a token elsewhere stops working, deliberately. + +## One host route for a resource-confined credential + +A token issued for a solution's MCP endpoint (RFC 8707 resource indicator) is held +by a third-party client and, once forwarded, by the runtime it dialled. It reaches: + +- that solution's own routes, where the resource and the path agree; and +- `/saas.accounts.v1.WorkContextService/StartTask` on this host, and nothing else + here. + +The mint is admitted because the runtime's MCP viewer path needs it: it derives a +module gateway by minting a viewer-owned Work Context, carrying the person's bearer +beside its own workload attestation, and its module credential does not stand in for +the person's. Refusing every host route refused the viewer's own tool call. + +It is admitted CONFINED. `ext_authz` stamps `x-codefly-resource` with the resource +the credential names, and the gateway then requires the solution that resource names +to be one the organization installed and the viewer holds a grant reaching โ€” the +same question, through the same authority, as every proxied request to that solution. +An authority that cannot be asked answers 503; a capability is not issued on an +unanswered question. + +The stamp is on both `untrustedAuthHeaders` and `canonicalUpstreamAuthHeaders`, so a +caller cannot assert a resource and cannot lift its own confinement. The consumer +check runs on the HTTP gateway path, where the registry and the entitlement authority +live; a deployment driving `ext_authz` from Envoy gets the route confinement and the +carried resource but not that check, the same asymmetry the gateway credential +already has. ## A registered client calls without a proxy From 1262958e80e6eb95bc5d2ac4c4b0f1d36dd11763 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:19:56 -0400 Subject: [PATCH 37/46] chore: regenerate the contract artifacts after merging main The merge took main's bytes for every generated file that conflicted, so the regeneration from the merged inputs is this diff: the accounts descriptor digest moves to the union of main's datasource declarations and this branch's step-up requirement on API key creation, and the authorization matrix shows that requirement on the row that carries it. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1132268 -> 1132275 bytes .../api/accounts/connect/contract.binpb | Bin 1132268 -> 1132275 bytes module/contracts/api/catalog.codefly.json | 4 ++-- module/module.package.codefly.yaml | 4 ++-- module/services/accounts/AUTHZ_MATRIX.md | 6 +++--- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1132268 -> 1132275 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index ad7e5fb9e85d90c4fb334a9f073206ebe0bccfd5..9ef1bea648af6f56874a1dcede16f95e7cd1fbc0 100644 GIT binary patch delta 1505 zcmZ9LOHUL*5P-L4YHMbDmK|vDVj}XA5C|#(5)(BhCV0UZPhLD|Jm?W)OpHby#1(?d za!?!<%;EzRjYd>(&Ei{-#DCz?yNOYbT&(VS?6`+I^f%Qt)%A7%niyD{7#Pk&-pHGI z%%}6TU=1Rym-eIe*aj@EWvzooX1!BbGM!4T`ageAm;Zapb$qwkcKk^<=lFa6IyI#^ z*P744s(+}_Isjw-T%*<9oD*qE87Y+M<2V8&>V&P=Zwn?PX9WB|jn-lB4D|~soamDU zDQTXy+c{mt%#h|eyF=89OoVJ{o~OPf$w>18^(2Xq=0)mF5=WXIZROH&ovbHu7&t4z zT#1)}b_h2*09^(gFAlH>l zvnAdFmX+uwBhd%i&d7`V6B2J%Oz7V_67N*)%u2kgvcmE4#Ag8e*|< z_zd_F1(!2`{OjkyuPD%<8JqE5R0gaWtC#8h%Nek*F z1+sW>_y%xyTw0mwP|VpCcdA%gv8J8ksD3hhzsq2J^G~!|N4zpnLs-t_1C?PzXS8HX zW#}IaEtza-32-I0WCoU&z*&hcX~ve`0X2tRX~dP^18qqn9`gqvFS_hHO)HjBgN$Z!Y_ Vha<~T!;#}~IciJGZFLtGc!EtgKzoF=f3UZ zCo#fdvwVn)=T2}rB~+O=iv3n{vs(&fClYJH`fuzBCex|5V7|+3EH9=-F$HVEk(@XT zmx9Hd=xu3~HYThDSNu31N7*DDbNoEZh`8gyMot{{PGO5w(ycxbkWlQj(-a6IZb@+z}@Yo?;jNPf9Cx5%rQ*{wGKXFwYs0IIO6McD<& zc2+So(EoxOj{Mx^WYYZX-|7Kkjg%+sWym8KLYFyn=?qOUI1*5Gi~}Y zU`{rh`(pC!gu*9)&m!%HlJ-TQ9m$#<(I{9La}ZtxPxB({y#!i}ic*tKvv^fgY7ouh zHPC9rvS=1>YO^p|G>f-&FVZaD)n%m>e-E@BmGM}7NbwKA_eHh!EHrZWMt`f3ty$}k z95tIj*R}-K&){UcIOdfAo5OO99l- jy+{^+Os{kZ3Wp*|At_vn28u?C6h)e%sl3vW*`5CfcU#;H diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index ad7e5fb9e85d90c4fb334a9f073206ebe0bccfd5..9ef1bea648af6f56874a1dcede16f95e7cd1fbc0 100644 GIT binary patch delta 1505 zcmZ9LOHUL*5P-L4YHMbDmK|vDVj}XA5C|#(5)(BhCV0UZPhLD|Jm?W)OpHby#1(?d za!?!<%;EzRjYd>(&Ei{-#DCz?yNOYbT&(VS?6`+I^f%Qt)%A7%niyD{7#Pk&-pHGI z%%}6TU=1Rym-eIe*aj@EWvzooX1!BbGM!4T`ageAm;Zapb$qwkcKk^<=lFa6IyI#^ z*P744s(+}_Isjw-T%*<9oD*qE87Y+M<2V8&>V&P=Zwn?PX9WB|jn-lB4D|~soamDU zDQTXy+c{mt%#h|eyF=89OoVJ{o~OPf$w>18^(2Xq=0)mF5=WXIZROH&ovbHu7&t4z zT#1)}b_h2*09^(gFAlH>l zvnAdFmX+uwBhd%i&d7`V6B2J%Oz7V_67N*)%u2kgvcmE4#Ag8e*|< z_zd_F1(!2`{OjkyuPD%<8JqE5R0gaWtC#8h%Nek*F z1+sW>_y%xyTw0mwP|VpCcdA%gv8J8ksD3hhzsq2J^G~!|N4zpnLs-t_1C?PzXS8HX zW#}IaEtza-32-I0WCoU&z*&hcX~ve`0X2tRX~dP^18qqn9`gqvFS_hHO)HjBgN$Z!Y_ Vha<~T!;#}~IciJGZFLtGc!EtgKzoF=f3UZ zCo#fdvwVn)=T2}rB~+O=iv3n{vs(&fClYJH`fuzBCex|5V7|+3EH9=-F$HVEk(@XT zmx9Hd=xu3~HYThDSNu31N7*DDbNoEZh`8gyMot{{PGO5w(ycxbkWlQj(-a6IZb@+z}@Yo?;jNPf9Cx5%rQ*{wGKXFwYs0IIO6McD<& zc2+So(EoxOj{Mx^WYYZX-|7Kkjg%+sWym8KLYFyn=?qOUI1*5Gi~}Y zU`{rh`(pC!gu*9)&m!%HlJ-TQ9m$#<(I{9La}ZtxPxB({y#!i}ic*tKvv^fgY7ouh zHPC9rvS=1>YO^p|G>f-&FVZaD)n%m>e-E@BmGM}7NbwKA_eHh!EHrZWMt`f3ty$}k z95tIj*R}-K&){UcIOdfAo5OO99l- jy+{^+Os{kZ3Wp*|At_vn28u?C6h)e%sl3vW*`5CfcU#;H diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 8f336fbcd..cc176c0d8 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 25c14413a..237dea802 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 9846c83f7..cc38f73fe 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -8,7 +8,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | Procedure | Shape | REST mapping | Tier | Guards | Permission / scope | Resource bindings | Audit | Idempotency / rate | Sensitivity req โ†’ res | Description | |---|---|---|---|---|---|---|---|---|---|---| -| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | +| `/saas.accounts.v1.APIKeyService/CreateAPIKey` | unary | `POST /v1/api-keys` | `mfa` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; mfa=IF_ENROLLED_RECENT_STEP_UP; impersonation=FORBIDDEN | perm=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ SECRET | Mint an API key for an administered organization. | | `/saas.accounts.v1.APIKeyService/ListAPIKeys` | unary | `GET /v1/api-keys` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | perm=api_keys:read; scope=api_keys:read | organization_id โ†’ ORGANIZATION/DIRECT_ID | โ€” | FORBIDDEN / STANDARD_READ | CONFIDENTIAL โ†’ CONFIDENTIAL | List org's API keys. | | `/saas.accounts.v1.APIKeyService/RevokeAPIKey` | unary | `DELETE /v1/api-keys/{id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | perm=api_keys:write; scope=api_keys:write | organization_id โ†’ ORGANIZATION/DIRECT_ID | SUCCESS: saas.api_key.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL โ†’ CONFIDENTIAL | Revoke an API key in an administered organization. | | `/saas.accounts.v1.APIKeyService/ValidateAPIKey` | unary | `โ€”` | `internal` | exposure=INTERNAL; tenant=NONE | โ€” | โ€” | โ€” | FORBIDDEN / INTERNAL | SECRET โ†’ CONFIDENTIAL | Internal: plaintext key โ†’ key + org id. | @@ -275,8 +275,8 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `auth`: 43 - `internal`: 52 -- `mfa`: 4 -- `org_admin`: 62 +- `mfa`: 5 +- `org_admin`: 61 - `org_member`: 54 - `platform_admin`: 29 - `public`: 18 diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 459cb34de..a5786a84d 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4", + "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index ad7e5fb9e85d90c4fb334a9f073206ebe0bccfd5..9ef1bea648af6f56874a1dcede16f95e7cd1fbc0 100644 GIT binary patch delta 1505 zcmZ9LOHUL*5P-L4YHMbDmK|vDVj}XA5C|#(5)(BhCV0UZPhLD|Jm?W)OpHby#1(?d za!?!<%;EzRjYd>(&Ei{-#DCz?yNOYbT&(VS?6`+I^f%Qt)%A7%niyD{7#Pk&-pHGI z%%}6TU=1Rym-eIe*aj@EWvzooX1!BbGM!4T`ageAm;Zapb$qwkcKk^<=lFa6IyI#^ z*P744s(+}_Isjw-T%*<9oD*qE87Y+M<2V8&>V&P=Zwn?PX9WB|jn-lB4D|~soamDU zDQTXy+c{mt%#h|eyF=89OoVJ{o~OPf$w>18^(2Xq=0)mF5=WXIZROH&ovbHu7&t4z zT#1)}b_h2*09^(gFAlH>l zvnAdFmX+uwBhd%i&d7`V6B2J%Oz7V_67N*)%u2kgvcmE4#Ag8e*|< z_zd_F1(!2`{OjkyuPD%<8JqE5R0gaWtC#8h%Nek*F z1+sW>_y%xyTw0mwP|VpCcdA%gv8J8ksD3hhzsq2J^G~!|N4zpnLs-t_1C?PzXS8HX zW#}IaEtza-32-I0WCoU&z*&hcX~ve`0X2tRX~dP^18qqn9`gqvFS_hHO)HjBgN$Z!Y_ Vha<~T!;#}~IciJGZFLtGc!EtgKzoF=f3UZ zCo#fdvwVn)=T2}rB~+O=iv3n{vs(&fClYJH`fuzBCex|5V7|+3EH9=-F$HVEk(@XT zmx9Hd=xu3~HYThDSNu31N7*DDbNoEZh`8gyMot{{PGO5w(ycxbkWlQj(-a6IZb@+z}@Yo?;jNPf9Cx5%rQ*{wGKXFwYs0IIO6McD<& zc2+So(EoxOj{Mx^WYYZX-|7Kkjg%+sWym8KLYFyn=?qOUI1*5Gi~}Y zU`{rh`(pC!gu*9)&m!%HlJ-TQ9m$#<(I{9La}ZtxPxB({y#!i}ic*tKvv^fgY7ouh zHPC9rvS=1>YO^p|G>f-&FVZaD)n%m>e-E@BmGM}7NbwKA_eHh!EHrZWMt`f3ty$}k z95tIj*R}-K&){UcIOdfAo5OO99l- jy+{^+Os{kZ3Wp*|At_vn28u?C6h)e%sl3vW*`5CfcU#;H diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 6cc59629d..76345a0d8 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:e4fbe4fd6bbd4d025ec8b3978fca2afbe0a5597c9f01eacd2ee095305a1b61f4 + contract-digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 services: - APIKeyService - AccessibleScopeService From cb0c0166658a31c5320700986614d5db008b9580 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:33:12 -0400 Subject: [PATCH 38/46] test(accounts): the merged OAuth suite delivers one key per authentication purpose main's new OAuth integration helper seeded both datasource message-authentication purposes from a single value. On this branch each purpose holds its own delivered key, so the helper now delivers two independent ones: a test that passed one value for both could not tell a shared key from separate custody. Co-Authored-By: Claude Opus 5 (1M context) --- .../business/datasource_oauth_integration_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go b/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go index 58299c6ae..10f7d31ec 100644 --- a/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go +++ b/module/services/accounts/code/pkg/business/datasource_oauth_integration_test.go @@ -38,10 +38,16 @@ func realOAuthService(t *testing.T) (*business.Service, business.SecretCipher) { svc, err := business.NewService(testStore) require.NoError(t, err) svc.SetDatasourceConnector(keys.Cipher, nil, "") - seed := make([]byte, 32) - _, err = rand.Read(seed) + // Each message-authentication purpose holds its own delivered key, so this + // helper delivers two independent ones rather than a single seed both + // purposes are derived from. + ticketKey := make([]byte, 32) + _, err = rand.Read(ticketKey) require.NoError(t, err) - svc.SetDatasourceTicketKey(seed) + accountLinkKey := make([]byte, 32) + _, err = rand.Read(accountLinkKey) + require.NoError(t, err) + svc.SetDatasourceKeys(ticketKey, accountLinkKey) emitter, err := business.NewDurableAuditEmitter(testStore, testStore) require.NoError(t, err) t.Cleanup(emitter.Close) From 3fac9caad63c26eac4e17a60fed6536976e78fe6 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:41:10 -0400 Subject: [PATCH 39/46] chore(frontend): the published client carries a new patch version v0.0.94 was cut between the previous merge point and this one, which moves the registry baseline the published-client gate compares against. This branch's step-up requirement on API key creation reaches the client's descriptor, so those bytes differ from the ones the registry already serves under 0.3.23. The exact pin in the frontend's manifest and the lockfile move with it. Co-Authored-By: Claude Opus 5 (1M context) --- module/services/frontend/code/package-lock.json | 4 ++-- module/services/frontend/code/package.json | 2 +- module/services/frontend/code/packages/saas-sdk/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 76aa8ac38..8d648f106 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.23", + "@codefly-dev/saas-sdk": "0.3.24", "@codefly-dev/saas-ui": "0.12.6", "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15348,7 +15348,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.23", + "version": "0.3.24", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index cfae0c638..3c37cdfdf 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.23", + "@codefly-dev/saas-sdk": "0.3.24", "@codefly-dev/saas-ui": "0.12.6", "@codefly-dev/ui": "0.12.6", "@codefly/saas-plugin-manifest": "0.1.1", diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index cc661917b..dc97d90a3 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.23", + "version": "0.3.24", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", From ff56575e68bc269a6e8d913aa3e751d45d4eff39 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:33:57 -0400 Subject: [PATCH 40/46] chore(frontend): regenerate the lock from the merged manifest Co-Authored-By: Claude Opus 5 (1M context) --- module/services/frontend/code/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 1cc6a8c4d..eca289d57 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.23", + "@codefly-dev/saas-sdk": "0.3.24", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.23", + "version": "0.3.24", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", From 6fd0d92f6a1fc6644ae8b1bb604255383fdbc232 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:19:31 -0400 Subject: [PATCH 41/46] fix(gateway,accounts): a consumer's credential reaches its own module, under its own ceiling A credential delivered to one consumer is confined to it, and the consumer's own work needs three places rather than one: the solution its resource names, the delegation mint, and the composed module its solution declared it consumes. Admitting only the first two left the tool able to mint a capability and unable to spend it. A resource-confined credential is admitted at a declared module route only together with a verified capability for that module, owned by the credential's own subject, in its own organization, minted in its own session. The session binding is what makes the consumer's ceiling a fact about the capability rather than an inference: a capability for the same module could otherwise have come from the same person's ordinary session, where no consumer ceiling applies. The ceiling itself belongs to the service that owns installations. The gateway states which resource a credential names and which installation admitted the request, from the one resolution the admission was decided on; accounts refuses a mint for an organization other than the credential's, for an audience that installation does not declare, and for scopes outside its agent's standing grants. Both headers are stripped from caller input and refused in duplicate, like every identity header beside them. A declared module route also now permits the audience a person's capability for that module can actually carry. It expected the module capability audience, which no caller-supplied mint may name, so with the edge verifier wired every delegated capability presented there was refused. Co-Authored-By: Claude Opus 5 (1M context) --- .../pkg/adapters/connect_auth_interceptor.go | 25 ++ .../pkg/adapters/grpc_auth_interceptor.go | 2 + .../work_context_confined_mint_test.go | 225 +++++++++++++++++ .../code/pkg/adapters/work_context_rpcs.go | 133 +++++++++- .../code/pkg/auth/resource_confinement.go | 76 ++++++ module/services/auth-gateway/AGENTS.md | 98 +++++--- .../services/auth-gateway/code/ext_authz.go | 32 ++- module/services/auth-gateway/code/gateway.go | 68 +++++- .../auth-gateway/code/gateway_mcp_resource.go | 31 ++- .../code/gateway_mcp_resource_test.go | 11 +- .../code/gateway_module_capability.go | 132 ++++++++++ .../code/gateway_module_confinement_test.go | 227 ++++++++++++++++++ .../code/gateway_module_routes.go | 44 +++- .../code/gateway_solution_entitlements.go | 30 ++- .../auth-gateway/code/gateway_solutions.go | 43 +++- .../code/resource_token_confinement_test.go | 94 ++++++-- .../auth-gateway/code/work_context.go | 54 ++++- 17 files changed, 1211 insertions(+), 114 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go create mode 100644 module/services/accounts/code/pkg/auth/resource_confinement.go create mode 100644 module/services/auth-gateway/code/gateway_module_capability.go create mode 100644 module/services/auth-gateway/code/gateway_module_confinement_test.go diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index bcae3f343..f973e15f0 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -34,10 +34,33 @@ var forwardedIdentityHeaders = []string{ // the gateway token is valid, refused if either arrives twice. "X-Codefly-Solution-Id", "X-Codefly-Solution-Publisher", + // The resource a credential is confined to, and the installation the + // perimeter admitted the request under. Together they are the ceiling a + // resource-confined Work Context mint is held to (pkg/auth's + // resource_confinement.go carries the whole argument), so a caller able to + // set either would pick its own: a confinement naming a consumer whose + // installation grants more, or no confinement at all. + // + // They belong on this list for the same reason X-Org-Id does โ€” stripped + // unless the gateway token is valid, refused if either arrives twice โ€” + // which is also what forwards them across the REST transcoding hop. + resourceConfinementHeader, + installationHeader, } const publicOriginHeader = "X-Codefly-Public-Origin" +// resourceConfinementHeader and installationHeader MUST stay byte-identical to +// the gateway's own constants (`gateway.go`, lowercased). The strip +// canonicalises its argument, so a near-miss spelling deletes a header nobody +// sends and leaves the real one in place, reporting no error at all โ€” the exact +// shape of the `x-codefly-solution-identity` defect recorded beside those +// constants. A contract test in module/tools holds the two spellings together. +const ( + resourceConfinementHeader = "X-Codefly-Resource" + installationHeader = "X-Codefly-Installation-Id" +) + // errSolutionAttestationNotDelivered refuses a request that asserts a solution // identity. Nothing on this host can prove such a claim: the gateway stamps // neither header, and the attestation that would replace the deleted @@ -327,6 +350,8 @@ func stampForwardedHTTPIdentity(ctx context.Context, headers http.Header) (conte if scopedRoles := headers.Get("X-Scoped-Roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } + ctx = auth.WithVerifiedResourceConfinement(ctx, headers.Get(resourceConfinementHeader)) + ctx = auth.WithVerifiedInstallation(ctx, headers.Get(installationHeader)) return withScopedRolesTruncated(ctx, headers.Get("X-Scoped-Roles-Truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go index f5ac5a804..7006fb27a 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go @@ -442,6 +442,8 @@ func stampForwardedGRPCIdentity(ctx context.Context, md metadata.MD) (context.Co if scopedRoles := firstMetadataValue(md, "x-scoped-roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } + ctx = auth.WithVerifiedResourceConfinement(ctx, firstMetadataValue(md, strings.ToLower(resourceConfinementHeader))) + ctx = auth.WithVerifiedInstallation(ctx, firstMetadataValue(md, strings.ToLower(installationHeader))) return withScopedRolesTruncated(ctx, firstMetadataValue(md, "x-scoped-roles-truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go new file mode 100644 index 000000000..b55bc8d72 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go @@ -0,0 +1,225 @@ +package adapters + +// A MINT MADE WITH A RESOURCE-CONFINED CREDENTIAL is held to the consumer that +// credential was delivered to (register finding SA-F-MCPAUD, round 3; +// holdConfinedMintToItsConsumer carries the rule). +// +// These run the WHOLE procedure, not the helper, because every refusal has to +// land before anything is signed and because the rule composes with the mint's +// other gates โ€” the audience vocabulary, the boundary collision check, the +// viewer's own authority. A test of the helper alone could not tell a refusal +// from a refusal that arrives too late. +// +// The counterpart is the perimeter's: it is the gateway that resolves which +// installation a resource's consumer is installed as, and the gateway's own +// tests prove the two headers below are stamped from a verified credential and +// stripped from caller input. + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + accountsauth "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +const ( + confinedInstallation = "019f6bf7-7777-7777-8777-777777777771" + confinedConsumerID = "019f6bf7-8888-7888-8888-888888888881" + confinedResource = "https://host.example.com/api/solutions/example/proxy/mcp" +) + +// confinedConsumer is the installed consumer's ceiling: it may address the +// `documents` module and act on evidence, and nothing else. +func confinedConsumer() *business.InstallationAuthorityFacts { + return &business.InstallationAuthorityFacts{ + OwnerPrincipalID: renewActorID, + Actor: &business.Principal{ + ID: confinedConsumerID, + Kind: "agent", + AgentIdentifier: "acme/example:1.0.0", + AllowedAudiences: []string{"documents"}, + AllowedScopes: []string{"evidence"}, + }, + OrganizationRevision: 12, + } +} + +// confinedCallerContext is the request the perimeter admitted: the viewer's +// identity, the resource their credential is confined to, and the installation +// that admitted it. +// confinedMintRequest is boundaryMintRequest with a Task named, which an +// ordinary (non-solution-scoped) mint requires: the host assigns a boundary only +// to a solution's own credential, and a viewer-owned Task names its own. +func confinedMintRequest(audience string) *gen.StartTaskWorkContextRequest { + request := boundaryMintRequest(audience) + request.TaskId = uuid.Must(uuid.NewV7()).String() + return request +} + +func confinedCallerContext(resource, installation string) context.Context { + ctx := accountsauth.WithVerifiedResourceConfinement(boundaryCaller(), resource) + return accountsauth.WithVerifiedInstallation(ctx, installation) +} + +// withConfiguredOrigin pins the origin the resource indicator must name, the way +// a deployment pins it. Without it a confined mint refuses for a reason that has +// nothing to do with what these tests are about, which is itself asserted below. +func withConfiguredOrigin(t *testing.T) { + t.Helper() + previous, _ := accountsauth.ConfiguredPublicOrigin() + require.NoError(t, accountsauth.SetConfiguredPublicOrigin("https://host.example.com")) + t.Cleanup(func() { _ = accountsauth.SetConfiguredPublicOrigin(previous) }) +} + +func newConfinedMintServer(t *testing.T) *WorkContextAuthorityServer { + t.Helper() + server, authority := newSolutionBoundaryServer(t) + authority.installationFcts = confinedConsumer() + return server +} + +// The consumer's own work: the audience its installation declares, a scope its +// agent is granted, in its own organization. Admitted. +func TestConfinedMintIsIssuedInsideItsConsumersCeiling(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + + issued, err := server.StartTask( + confinedCallerContext(confinedResource, confinedInstallation), + confinedMintRequest("documents")) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// The three refusals, each on its own, with everything else held equal. +func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { + for name, probe := range map[string]struct { + request func() *gen.StartTaskWorkContextRequest + ctx func() context.Context + expectedCode codes.Code + expectedText string + }{ + // 1. ANOTHER ORGANIZATION. The viewer may well be a member of it โ€” + // requireOrgMember would pass โ€” but the installation the perimeter + // checked is in the organization the credential names. + "another organization": { + request: func() *gen.StartTaskWorkContextRequest { + req := confinedMintRequest("documents") + req.OrgId = uuid.Must(uuid.NewV7()).String() + return req + }, + expectedCode: codes.PermissionDenied, + expectedText: "that credential's own organization", + }, + // 2. AN AUDIENCE THE CONSUMER DOES NOT DECLARE. `example` is in the + // host's vocabulary โ€” a declared module prefix โ€” so the vocabulary gate + // admits it and only the installation's ceiling refuses it. + "an audience the consumer does not declare": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("example") }, + expectedCode: codes.PermissionDenied, + expectedText: "allowed audiences", + }, + // 3. A SCOPE OUTSIDE THE INSTALLATION'S GRANT. + "a scope the installation does not grant": { + request: func() *gen.StartTaskWorkContextRequest { + req := confinedMintRequest("documents") + req.AuthorityScopes = []*gen.WorkContextScope{{ + ResourceKind: "billing", Actions: []string{"write"}, + }} + return req + }, + expectedCode: codes.PermissionDenied, + expectedText: "allowed scopes", + }, + // A confinement the perimeter admitted without naming an installation. + // Nothing produces it, and a mint is not issued on a ceiling nobody can + // be held to. + "no installation to be held to": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + ctx: func() context.Context { + return accountsauth.WithVerifiedResourceConfinement(boundaryCaller(), confinedResource) + }, + expectedCode: codes.FailedPrecondition, + expectedText: "no consented ceiling", + }, + // A resource this host does not issue. The confinement is unaccountable, + // so it refuses rather than passing through unread. + "a resource this host does not issue": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + ctx: func() context.Context { + return confinedCallerContext("https://elsewhere.example/api/solutions/example/proxy/mcp", confinedInstallation) + }, + expectedCode: codes.PermissionDenied, + expectedText: "does not issue", + }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + ctx := confinedCallerContext(confinedResource, confinedInstallation) + if probe.ctx != nil { + ctx = probe.ctx() + } + + issued, err := server.StartTask(ctx, probe.request()) + + require.Error(t, err, "nothing may be signed for %s", name) + require.Nil(t, issued) + require.Equal(t, probe.expectedCode, status.Code(err)) + require.Contains(t, err.Error(), probe.expectedText) + }) + } +} + +// THE CONTROLS. Without them every refusal above would also be produced by a +// mint that refused everything. +// +// An UNCONFINED request โ€” every other request this service serves โ€” reaches the +// viewer's own authority and is bounded by it alone: the same audience and the +// same scope the consumer's ceiling refuses above are issued here, so the +// refusals are about the confinement and not about the mint. +func TestAnUnconfinedMintIsNotHeldToAnyConsumer(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + + request := confinedMintRequest("example") + request.AuthorityScopes = []*gen.WorkContextScope{{ + ResourceKind: "billing", Actions: []string{"write"}, + }} + issued, err := server.StartTask(boundaryCaller(), request) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// A deployment with no configured origin cannot say what a resource identifier +// IS, so it cannot account for one. It refuses the confined mint, and says which +// of the two refusals it is โ€” an operator fixes a configuration, a client +// re-authorizes. +func TestConfinedMintRefusesWithNoConfiguredOrigin(t *testing.T) { + solutionBoundaryService(t) + previous, _ := accountsauth.ConfiguredPublicOrigin() + require.NoError(t, accountsauth.SetConfiguredPublicOrigin("")) + t.Cleanup(func() { _ = accountsauth.SetConfiguredPublicOrigin(previous) }) + server := newConfinedMintServer(t) + + issued, err := server.StartTask( + confinedCallerContext(confinedResource, confinedInstallation), + confinedMintRequest("documents")) + + require.Error(t, err) + require.Nil(t, issued) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "no configured public address") +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index b01ced35a..cb0d0aaeb 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -453,6 +453,25 @@ func (s *WorkContextAuthorityServer) StartTask( if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } + permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + // A CONFINED CREDENTIAL IS HELD TO ITS CONSUMER'S CEILING, before the + // viewer's own authority is resolved at all. + // + // The order is the point, not a convenience. This is a ceiling, so a mint + // outside it must be refused whatever the person could otherwise have + // minted, and the refusal must say which rule turned it away. Running it + // after the authority resolution would let an incidental refusal arrive + // first and read as the reason โ€” the organization binding in particular is + // also enforced, for its own purposes, by the membership lookup's verified + // database scope, and that refusal names a database scope rather than a + // consumer. Runs for a resource-confined request only; every other mint + // reaches the viewer's authority below exactly as before. + if err := s.holdConfinedMintToItsConsumer(ctx, req, permissions, scopes); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -462,10 +481,6 @@ func (s *WorkContextAuthorityServer) StartTask( return nil, err } sessionID := callerSessionID(ctx, req.GetSessionId()) - permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } facts, err := s.resolveAuthority( ctx, req.GetOrgId(), ownerID, req.GetActorPrincipalId(), permissions, ) @@ -511,6 +526,116 @@ func (s *WorkContextAuthorityServer) StartTask( return issuedWorkContext(token, signed), nil } +// holdConfinedMintToItsConsumer bounds a mint made with a RESOURCE-CONFINED +// credential by the consumer that credential was delivered to +// (register finding SA-F-MCPAUD, round 3). +// +// Until this, nothing in this service read the confinement at all. The perimeter +// admitted a resource-bound credential at this one procedure and stamped which +// resource it named; the mint then issued for any audience in the host's +// vocabulary, any scope within the VIEWER's own authority, and any organization +// the viewer is a member of. So a credential delivered to one third-party client +// for one solution could mint a viewer-owned capability in another organization, +// addressed to a consumer that solution has nothing to do with โ€” and the +// perimeter's own entitlement check, which asks about the credential's +// organization, could not see any of it, because the body is what names the +// organization and the audience. +// +// Three refusals, and each is a different question: +// +// 1. THE ORGANIZATION. The capability is issued for the organization the +// credential itself names, and no other. The perimeter checked this +// consumer's installation against THAT organization; a body naming a second +// one would be a capability for an organization whose consent nobody asked +// about. Membership is not consent: a person belongs to several +// organizations and a client was delivered to one. +// +// This binding was ALREADY enforced, incidentally: authorizeOwner's +// membership lookup opens with RequireVerifiedDatabaseScope, which compares +// the requested organization against the verified identity the perimeter +// projected. So a cross-organization mint was refused before this โ€” the +// round-3 review's reading that it was not came from a harness whose +// accounts was a double, and the real procedure refuses it. It is restated +// here because the ceiling is where it belongs and because the incidental +// refusal is about a database scope: it would survive a change to the +// membership path that this rule must not survive, and it names something +// other than the consumer for an operator reading the refusal. +// +// 2. THE AUDIENCE, and 3. THE SCOPES, both against the INSTALLATION the +// perimeter admitted under, which is an administrator's recorded consent to +// this consumer: its declared `allowed_audiences`, narrowed to what this host +// still serves, and its agent principal's standing scope grants. The viewer's +// own authority continues to bound the mint below; this is the second, +// narrower bound, and a capability must be inside both. +// +// What this does NOT re-derive is which installation the resource names. The +// perimeter resolved resource โ†’ declared binding โ†’ target โ†’ the entitlement that +// admitted this viewer, and it states the installation from that one answer. +// Asking again here would be a second resolution that could disagree with the +// one admission was decided on, and the header is the perimeter's own, stripped +// from caller input like every identity header beside it. What this DOES check is +// that the resource is one this host issues at its own origin, so a confinement +// this service cannot account for refuses rather than passing through unread. +// +// It fails CLOSED in every undecidable case: a confinement with no installation, +// an installation that cannot be resolved, a host with no configured origin. A +// capability issued on an unanswered question is the failure this whole finding +// is about. +func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( + ctx context.Context, + req *gen.StartTaskWorkContextRequest, + permissions []business.WorkContextPermission, + scopes []*basev0.WorkScopeV1, +) error { + resource, confined := accountsauth.VerifiedResourceConfinement(ctx) + if !confined { + return nil + } + origin, configured := accountsauth.ConfiguredPublicOrigin() + if !configured { + // This deployment cannot say what any resource identifier IS, so it + // cannot judge one. The perimeter refuses a confined credential in the + // same state, so the two sides agree rather than one admitting what the + // other cannot account for. + return status.Error(codes.FailedPrecondition, + "this host has no configured public address, so it cannot account for the resource this credential is confined to") + } + if _, err := accountsauth.RequireResourceAtOrigin(resource, origin); err != nil { + return status.Error(codes.PermissionDenied, + "the credential is confined to a resource this host does not issue") + } + caller, authenticated := accountsauth.VerifiedRequestIdentity(ctx) + if !authenticated || caller.OrgID == uuid.Nil { + return status.Error(codes.PermissionDenied, + "a confined credential names no organization, so there is none to hold this mint to") + } + if caller.OrgID.String() != req.GetOrgId() { + // Neither organization is named. One is the caller's own and the other is + // theirs to read off the request they sent; printing them would make this + // refusal an oracle for which organizations a person belongs to. + return status.Error(codes.PermissionDenied, + "a capability minted with a credential delivered to one consumer is issued only for that credential's own organization") + } + installation, admitted := accountsauth.VerifiedInstallation(ctx) + if !admitted { + return status.Error(codes.FailedPrecondition, + "a confined credential reached this mint without the installation it was admitted under, so there is no consented ceiling to hold it to") + } + consumer, err := s.resolveInstallationAuthority(ctx, req.GetOrgId(), installation, permissions) + if err != nil { + return err + } + if consumer.Actor == nil { + // A healthy resolve always names the installation's agent. Fail closed + // rather than treat a missing ceiling as an absent one: enforceActorCeiling + // reads a nil actor as unrestricted, which is the most permissive reading + // of the state that says least. + return status.Error(codes.Internal, + "the consumer's installation resolved without an agent principal, so its ceiling could not be read") + } + return enforceActorCeiling(ctx, consumer.Actor, req.GetAudience(), scopes) +} + // StartInstallationTask is the headless mint. It opens with a service credential // instead of a bearer and draws its authority from an installation: the owner of // record becomes the context owner, the installation's agent principal the sole diff --git a/module/services/accounts/code/pkg/auth/resource_confinement.go b/module/services/accounts/code/pkg/auth/resource_confinement.go new file mode 100644 index 000000000..a1b1d2853 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/resource_confinement.go @@ -0,0 +1,76 @@ +package auth + +// THE CONFINEMENT A CREDENTIAL CARRIES, as this service reads it +// (register finding SA-F-MCPAUD, round 3). +// +// A credential issued for one consumer โ€” an RFC 8707 resource indicator naming a +// solution's MCP endpoint โ€” is held by a third-party client and, once forwarded, +// by the solution runtime it dialled. The perimeter admits such a credential at +// the Work Context mint, because the consumer's own work needs a capability +// minted on the person's behalf. What the mint may then ISSUE has to be bounded +// by the consumer, or a credential delivered to one solution mints the person's +// whole authority, for any audience, in any organization they belong to. +// +// The perimeter cannot bound it: the ceiling is an installation's, and the +// perimeter is not the authority on installations. So the perimeter states two +// facts and this service decides from them โ€” which resource the credential names, +// and which installation admitted the request. Both arrive as headers the +// perimeter owns, and both are read ONLY behind a verified gateway credential, on +// the same footing and through the same strip as the identity headers beside +// them (forwardedIdentityHeaders): a caller that could set either would choose +// its own ceiling. +// +// Recorded as two separate facts rather than one, because they fail differently. +// A confinement with no installation is a perimeter that admitted a confined +// credential without resolving its consent, which must refuse; an installation +// with no confinement is an ordinary session's request, which is every other +// request this service serves. + +import ( + "context" + "strings" +) + +type ( + verifiedResourceConfinementKey struct{} + verifiedInstallationKey struct{} +) + +// WithVerifiedResourceConfinement records the resource a credential is confined +// to. The value is the indicator as issued, compared by equality and never +// re-rendered, so it is stored exactly as the perimeter stated it. +// +// An empty candidate records NOTHING, and that is the whole point of a presence +// test rather than a string comparison: the perimeter stamps the header on every +// allow decision and leaves it empty for an unbound session, so "" is "this +// credential is not confined" and must not be mistaken for a confinement to the +// empty resource. +func WithVerifiedResourceConfinement(ctx context.Context, candidate string) context.Context { + if strings.TrimSpace(candidate) == "" { + return ctx + } + return context.WithValue(ctx, verifiedResourceConfinementKey{}, candidate) +} + +// VerifiedResourceConfinement reports the resource this request's credential is +// confined to, and whether it is confined at all. +func VerifiedResourceConfinement(ctx context.Context) (string, bool) { + resource, ok := ctx.Value(verifiedResourceConfinementKey{}).(string) + return resource, ok && resource != "" +} + +// WithVerifiedInstallation records which installation the perimeter's authority +// admitted this request under. +func WithVerifiedInstallation(ctx context.Context, candidate string) context.Context { + if strings.TrimSpace(candidate) == "" { + return ctx + } + return context.WithValue(ctx, verifiedInstallationKey{}, candidate) +} + +// VerifiedInstallation reports the installation the perimeter admitted this +// request under, and whether it named one. +func VerifiedInstallation(ctx context.Context) (string, bool) { + installation, ok := ctx.Value(verifiedInstallationKey{}).(string) + return installation, ok && installation != "" +} diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 4937b5883..a456a8678 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -127,43 +127,67 @@ do on their behalf. No RPC does that today, and adding one is a descriptor chang Two things to settle with it, because they are the reason it is not a small change: the mint is per request on the proxy path, so its latency and its failure mode become the failure mode of all federated traffic; and the authority to narrow TO is the -installation's ceiling, which the host cannot currently look up from a solution id -either (see `module/SOLUTION_REGISTRATION.md`). +installation's ceiling. That second one is no longer unreachable โ€” the entitlement +authority answers it on the path that resolves consent, and this gateway now states +it as `x-codefly-installation-id` (see below) โ€” but it is answered for one request +at a time, not as a lookup from a solution id (see +`module/SOLUTION_REGISTRATION.md`). **A consumer written against the old behaviour does NOT have to change for the bearer.** Two things do change at the edge: a declared upstream no longer receives -the caller's cookie, and a resource-bound token is confined โ€” it reaches the solution -its resource names, plus exactly one host route, the Work Context mint, and only for -a consumer the organization installed and the viewer was granted. Anything replaying -such a token elsewhere stops working, deliberately. +the caller's cookie, and a resource-bound token is confined โ€” it reaches the three +places below and nowhere else. Anything replaying such a token elsewhere stops +working, deliberately. -## One host route for a resource-confined credential +## Three places for a resource-confined credential A token issued for a solution's MCP endpoint (RFC 8707 resource indicator) is held by a third-party client and, once forwarded, by the runtime it dialled. It reaches: -- that solution's own routes, where the resource and the path agree; and -- `/saas.accounts.v1.WorkContextService/StartTask` on this host, and nothing else - here. - -The mint is admitted because the runtime's MCP viewer path needs it: it derives a -module gateway by minting a viewer-owned Work Context, carrying the person's bearer -beside its own workload attestation, and its module credential does not stand in for -the person's. Refusing every host route refused the viewer's own tool call. - -It is admitted CONFINED. `ext_authz` stamps `x-codefly-resource` with the resource -the credential names, and the gateway then requires the solution that resource names -to be one the organization installed and the viewer holds a grant reaching โ€” the -same question, through the same authority, as every proxied request to that solution. -An authority that cannot be asked answers 503; a capability is not issued on an -unanswered question. - -The stamp is on both `untrustedAuthHeaders` and `canonicalUpstreamAuthHeaders`, so a -caller cannot assert a resource and cannot lift its own confinement. The consumer -check runs on the HTTP gateway path, where the registry and the entitlement authority -live; a deployment driving `ext_authz` from Envoy gets the route confinement and the -carried resource but not that check, the same asymmetry the gateway credential -already has. +- that solution's own routes, where the resource and the path agree; +- `/saas.accounts.v1.WorkContextService/StartTask`, the delegation mint; and +- `/v1//*` for a DECLARED MODULE, and only in the company of a verified + capability for that module minted on this person's behalf. + +The second and third exist because the consumer's own work needs them, and they are +one flow, not two. The runtime's MCP viewer path derives a module gateway by minting +a viewer-owned Work Context โ€” carrying the person's bearer, because its own service +credential does not stand in for the person โ€” and then calls the module with that +capability AND the same bearer, since a declared upstream still authenticates by the +bearer (`SA-F-BEARER`, above). Admitting the mint alone moved the failure one request +later: the tool minted and was then refused at the module. + +**Admitting the bearer at module routes on its own would have been the confinement +deleted, not relocated** โ€” a one-solution credential with the person's whole +authority at every declared module. So the pairing is the admission: +`gateway_module_capability.go` requires a capability for THAT module, owned by the +bearer's own subject, in its own organization, minted in its own session. The session +binding is what makes "minted under the consumer's ceiling" a fact rather than an +inference: a capability for the same module could otherwise have come from the same +person's ordinary browser session, where no consumer ceiling applies. + +**The ceiling itself is accounts', not this gateway's.** `ext_authz` stamps +`x-codefly-resource`; the gateway resolves the solution that resource names, requires +the organization to have installed it and the viewer to hold a grant reaching it โ€” +the same question, through the same authority, as every proxied request to that +solution โ€” and states WHICH installation admitted the request as +`x-codefly-installation-id`. Accounts then refuses a mint for another organization, +for an audience that installation does not declare, or for scopes outside its +agent's standing grants. An authority that cannot be asked answers 503; a capability +is not issued on an unanswered question. + +Both stamps are on `untrustedAuthHeaders`, so a caller can assert neither and cannot +pick its own ceiling. The consumer check and the installation stamp run on the HTTP +gateway path, where the registry and the entitlement authority live; a deployment +driving `ext_authz` from Envoy gets the route confinement and the carried resource +but neither the consumer check nor the module pairing โ€” the same asymmetry the +gateway credential already has, and in the same direction: Envoy mode refuses the +module route rather than admitting it unpaired. + +`x-codefly-installation-id` is also what a solution's runtime reads to name an +installation on every mint it makes. It will not take one out of a capability a +caller carried โ€” inbound, that carrier is caller-controlled โ€” so the hop that +resolved the consent is the only party that can supply it. ## A registered client calls without a proxy @@ -194,9 +218,19 @@ answer the way `gateway_solution_registry.go` caches the solution registry, and upstream. A token issued to one client is therefore useless from another's page, on catalog, solution, and declared-module routes alike. - `X-Codefly-Public-Origin` โ€” the WebAuthn relying party, the origin an OAuth - start is validated against โ€” is derived from that registration. The frontend's - internal-token path still establishes it too; the registration is the stronger - claim, since it names the client rather than only the process that forwarded. + start is validated against โ€” is NOT derived from that registration, and was. + A registered client's own origin was stamped as the verified public origin, + which made the host's relying party and its sign-in redirect target a value a + client chose by registering it; accounts' readers all compare the forwarded + origin against the configured one, so the two authorities disagreed and the + weaker one was the one that named the client. The configured origin is the + authority, and the frontend's internal-token path is what establishes it. + A registered client therefore does NOT get a verified origin of its own: a + WebAuthn ceremony and a `BeginOAuth` redirect for such a client resolve against + the configured origin or the static allowlist, which is also the only reading + compatible with one relying-party id per deployment. Cross-origin access for + the client is unaffected โ€” that is the CORS decision above, from the same + registration. **The grant on a forwarded response is this gateway's, and only this gateway's.** Every proxied response has its `access-control-*` headers stripped before the diff --git a/module/services/auth-gateway/code/ext_authz.go b/module/services/auth-gateway/code/ext_authz.go index 2a9f58e75..c1e0a5a71 100644 --- a/module/services/auth-gateway/code/ext_authz.go +++ b/module/services/auth-gateway/code/ext_authz.go @@ -416,9 +416,24 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut resourceConfined := "" if binding == resourceBound { solutionID, isSolutionPath := solutionIDFromPath(path) + // The verified capability a declared module route established, if this + // request is on one. Read before the switch so the arm below does not + // resolve it twice, and nil on every other path and in Envoy mode. + pairing := presentedModuleCapability(ctx) switch { case isSolutionPath && resourceAudienceAdmits(resource, solutionID, s.publicBase): // The solution its own resource names. Admitted, as before. + case pairing != nil: + // A DECLARED MODULE ROUTE, together with a verified capability for + // that module minted on this person's behalf in this session + // (gateway_module_capability.go carries the whole rule). This is the + // consumer's own work โ€” the module its solution declared it consumes โ€” + // and it is admitted only in that company. + if err := pairing.admitsConfinedCredential(claims); err != nil { + recordJWTRejection(ctx, jwtRejectionWrongResource) + return deny(401, err.Error()), nil + } + resourceConfined = resource case isWorkContextMintPath(path): // THE ONE HOST ROUTE (isWorkContextMintPath carries the reasoning). // @@ -487,13 +502,18 @@ func (s *ExtAuthz) checkJWT(ctx context.Context, tokenString, path string) (*aut hdr("x-credential-kind", credentialKindSession), } if resourceConfined != "" { - // The resource this credential was issued for. Non-empty on exactly one - // host route โ€” the delegation mint โ€” and on a solution's own paths, where - // the resource and the path already agree. + // The resource this credential was issued for, stamped on the paths where + // a resource-bound credential is admitted AS a confined one: the + // delegation mint, and a declared module route reached in the company of + // this person's capability for that module. A solution's own paths do NOT + // stamp it โ€” there the resource and the path already agree, and the + // upstream is the consumer the resource names. // - // Accounts reads it as a ceiling on the mint: a capability minted under it - // may act for the consumer that resource names and for no other. Empty is - // an ordinary unbound session, and allow() stamps the empty value rather + // At the mint, accounts reads it as the ceiling: it resolves the installed + // consumer the resource names and refuses an organization other than this + // credential's, an audience that consumer does not declare, and scopes + // outside its installation's standing grants (StartTask). Empty is an + // ordinary unbound session, and allow() stamps the empty value rather // than omitting the header, so the two are distinguishable without either // being a missing-header inference. // diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index fd6cdc64c..971b9adc1 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -25,6 +25,7 @@ import ( "strings" "time" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/sdk-go/workcontext" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" @@ -493,9 +494,12 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. removeCallerCookie(r.Header) // The bearer is a separate question, and SA-F-BEARER is recorded OPEN // rather than closed here: see removePersonsSessionCredential and the PR - // body. What this route does enforce is confinement โ€” ExtAuthz admits a - // resource-bound token only at the solution its resource names โ€” so a - // credential an upstream receives cannot be replayed across this host. + // body. What this route does enforce is confinement: ExtAuthz admits a + // resource-bound token at the solution its resource names, at the + // delegation mint, and at a declared module route only in the company of + // this person's verified capability for that module โ€” three named places, + // so a credential an upstream receives cannot be replayed across the rest + // of this host. } if entry != nil && entry.UpstreamPath != "" { r.URL.Path = entry.UpstreamPath @@ -767,17 +771,35 @@ func injectHeaders(r *http.Request, headers []*corev3.HeaderValueOption) { // // A-for-B therefore refuses with the audience error rather than with a 404: the // route resolves, and the capability presented to it names another consumer. -func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request, expected string) bool { +func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request, expected ...string) bool { + _, rejected := g.verifiedWorkContext(w, r, expected...) + return rejected +} + +// verifiedWorkContext is rejectInvalidWorkContext with the verified claims +// handed back, for the one caller that holds the capability to more than its +// audience. +// +// The claims are nil in three different situations, which is why the second +// return value exists rather than a nil check standing in for it: this edge +// verifies no capability at all (`g.workContext == nil`), the request presents +// none, or one was presented and refused. The first two are "carry on", the +// third is terminal โ€” and a caller that read "no claims" as the first would +// admit a request whose capability had just been refused. +func (g *Gateway) verifiedWorkContext( + w http.ResponseWriter, r *http.Request, expected ...string, +) (*basev0.WorkContextV1, bool) { if g.workContext == nil { - return false + return nil, false } raw := r.Header.Get(workcontext.WorkContextHeaderName) if raw == "" { - return false + return nil, false } token, err := workcontext.ParseWorkContextToken(raw) + var claims *basev0.WorkContextV1 if err == nil { - err = g.workContext.Verify(r.Context(), token, expected) + claims, err = g.workContext.VerifyAny(r.Context(), token, expected...) } if err != nil { // Log the cause (not the token) so a wall of 401s can be told apart: @@ -785,9 +807,9 @@ func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Reques // cannot reach, which the static 401 body deliberately does not reveal. log.Printf("WARN: blocked request: method=%s path=%s reason=invalid_work_context err=%v", r.Method, r.URL.Path, err) httpError(w, http.StatusUnauthorized, "invalid work context") - return true + return nil, true } - return false + return claims, false } // stripAllIdentityHeaders removes every identity header from the request, in @@ -841,10 +863,15 @@ var untrustedAuthHeaders = []string{ solutionPublisherHeader, clientIDHeader, // The resource a confined credential was issued for. Stamped by ext_authz on - // the one host route that admits such a credential, and read by accounts as a + // the paths that admit such a credential, and read by accounts as a // ceiling on what the mint may issue โ€” so a caller able to set it could lift // its own confinement, or impose one on somebody else's request. resourceConfinementHeader, + // WHICH INSTALLATION the authority admitted this request under. It selects + // the consent a confined mint is held to, and it is what a solution's + // runtime names on every mint it makes, so a caller able to set it would + // choose its own ceiling โ€” or spend another organization's installation. + installationConfinementHeader, } // resourceConfinementHeader carries the RFC 8707 resource a credential is bound @@ -854,6 +881,27 @@ var untrustedAuthHeaders = []string{ // from a header's absence, and a caller cannot assert one by supplying it. const resourceConfinementHeader = "x-codefly-resource" +// installationConfinementHeader carries WHICH installation the entitlement +// authority admitted a request under, from the hop that resolved the consent to +// the services that need it. +// +// Two readers, one fact: +// +// - accounts, at a resource-confined Work Context mint, where it is the +// ceiling: the mint may issue only for an audience this installation +// declares and within its agent's standing grants. +// - a solution's runtime, on a proxied request, where it is what the runtime +// must name on every mint of its own. It will not take one from a carried +// capability โ€” inbound that carrier is caller-controlled โ€” so this hop is +// the only party that can supply it. +// +// Stamped only where an admission actually named one, which is only where the +// authority admitted the request. It is NOT stamped on an allow decision the way +// resourceConfinementHeader is, because ext_authz does not consult the +// entitlement authority and an empty value here would be a claim this process +// cannot make. +const installationConfinementHeader = "x-codefly-installation-id" + // solutionIdentityHeader and solutionPublisherHeader are the headers accounts // reads to decide which registered solution a Work Context mint is for. This // gateway stamps NEITHER โ€” the credential it proved them from is deleted โ€” and diff --git a/module/services/auth-gateway/code/gateway_mcp_resource.go b/module/services/auth-gateway/code/gateway_mcp_resource.go index e6543c905..bc000a95d 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource.go @@ -99,9 +99,9 @@ func publicBaseURL() string { return strings.TrimSuffix(strings.TrimSpace(workspaceEnv("application", "APP_BASE_URL")), "/") } -// workContextMintProcedure is the ONE host route a resource-bound token reaches: -// the delegation mint. It is the full gRPC/Connect procedure name, which is also -// the request path on both transports. +// workContextMintProcedure is the one of this host's OWN procedures that a +// resource-bound token reaches: the delegation mint. It is the full gRPC/Connect +// procedure name, which is also the request path on both transports. // // Named as a constant and compared whole. A prefix match would admit any // procedure beginning with it, and this is the one place where the difference @@ -110,10 +110,9 @@ func publicBaseURL() string { const workContextMintProcedure = "/saas.accounts.v1.WorkContextService/StartTask" // isWorkContextMintPath reports whether a FULL request path addresses the -// delegation mint โ€” the single host route a resource-confined credential is -// admitted at. +// delegation mint. // -// Why there is exactly one, and why it is this one: +// Why the mint is admitted at all: // // A resource-bound token is held by a third-party MCP client and, once // forwarded, by the solution runtime it dialled. Confinement exists so that @@ -126,12 +125,20 @@ const workContextMintProcedure = "/saas.accounts.v1.WorkContextService/StartTask // refusing this route refuses the viewer's own tool call, which is a product // regression and not a security property. // -// So the mint is admitted and nothing else is. Everything a resource token could -// otherwise have reached โ€” this host's own API, another declared module's routes, -// the rest of the Work Context surface โ€” stays refused, which is the escape the -// confinement was added to close. What the mint then issues is bounded on the -// accounts side, where the viewer's own authority, the host audience vocabulary -// and the stamped resource below decide what may be minted. +// So the mint is admitted and no other procedure of this host's is. Everything +// else a resource token could otherwise have reached โ€” this host's own API, the +// rest of the Work Context surface โ€” stays refused, which is the escape the +// confinement was added to close. +// +// What the mint then ISSUES is bounded on the accounts side, from the stamped +// resource and the stamped installation: the viewer's own authority, the host +// audience vocabulary, and the installed consumer's declared audiences and +// standing grants all have to admit it (see accounts' StartTask). +// +// A DECLARED MODULE's routes are the one other place such a credential is +// admitted, and not by this function: they are not a host procedure, and the +// admission is a pairing with a verified capability for that module rather than +// a path (gateway_module_capability.go). func isWorkContextMintPath(path string) bool { pathOnly, _, _ := strings.Cut(path, "?") return pathOnly == workContextMintProcedure diff --git a/module/services/auth-gateway/code/gateway_mcp_resource_test.go b/module/services/auth-gateway/code/gateway_mcp_resource_test.go index 025067296..36e48e721 100644 --- a/module/services/auth-gateway/code/gateway_mcp_resource_test.go +++ b/module/services/auth-gateway/code/gateway_mcp_resource_test.go @@ -867,16 +867,19 @@ func TestGateway_MCPToken_ReachesTheMintAndNothingElseOnTheHost(t *testing.T) { "authorization": "Bearer " + token, })) require.NoError(t, err) - require.NotNil(t, response.GetDeniedResponse(), "every host route but the mint stays refused") + require.NotNil(t, response.GetDeniedResponse(), + "every one of this host's own procedures but the mint stays refused") require.Equal(t, int32(401), int32(response.GetDeniedResponse().GetStatus().GetCode())) }) } } // An ordinary session credential is NOT confined, and must not arrive looking as -// though it were. The header's presence is the whole signal accounts reads, so a -// value stamped for a token that names no resource would impose a confinement on -// the browser's own session โ€” refusing the product rather than a replay. +// though it were. A NON-EMPTY value is what tells accounts the mint is confined โ€” +// it then resolves the installed consumer and holds the mint to that +// installation's declared audiences and standing grants โ€” so a value stamped for +// a token that names no resource would impose a stranger's ceiling on the +// browser's own session, refusing the product rather than a replay. func TestGateway_SessionTokenCarriesNoResourceConfinement(t *testing.T) { withPublicBase(t, testPublicBase) s, key := newTestExtAuthz(t) diff --git a/module/services/auth-gateway/code/gateway_module_capability.go b/module/services/auth-gateway/code/gateway_module_capability.go new file mode 100644 index 000000000..d7187b2f0 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_capability.go @@ -0,0 +1,132 @@ +package main + +// Admitting a RESOURCE-CONFINED credential at a declared module route +// (register finding SA-F-MCPAUD, round 3). +// +// A credential delivered to one consumer is confined to it: ext_authz admits a +// resource-bound token at the solution its resource names, at the delegation +// mint, and nowhere else. That left the consumer unable to do the one thing it +// was delivered for. The flow is three requests, not one: +// +// 1. a client authorizes for a solution's tool endpoint and calls it; +// 2. the solution's runtime mints a Work Context for the composed module it +// declared it consumes, forwarding the viewer's bearer, so the capability is +// owned and actored by the person rather than by the runtime; +// 3. the runtime calls `/v1//...` with that capability AND the same +// bearer, because a declared upstream authenticates the request by the +// bearer (SA-F-BEARER, open). +// +// Step 3 was refused, so the tool failed one request later than before. The +// naive repair โ€” admit the bearer at module routes โ€” would have handed a +// one-solution credential the person's whole authority at every declared module, +// which is the confinement deleted rather than relocated. +// +// THE RULE: a resource-confined bearer is admitted at a declared module route +// only TOGETHER WITH a verified capability for that module, owned by the +// bearer's own subject, in the bearer's own organization, minted in the +// bearer's own session. The capability is what carries the ceiling โ€” accounts +// holds a confined mint to the installed consumer's declared audiences and its +// installation's standing grants (see confineResourceMintToItsConsumer and +// accounts' StartTask) โ€” and the three bindings below are what stop a +// capability minted by anybody else, for anybody else, standing in for it. +// +// The session binding is the one that makes "minted under that ceiling" a fact +// rather than an inference. A capability for this module could otherwise have +// been minted by an UNCONFINED credential of the same person โ€” their browser +// session โ€” where no consumer ceiling applies; pairing on the session the +// bearer itself names leaves only capabilities minted by this very credential, +// and every one of those passed the confined mint. + +import ( + "context" + "fmt" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// moduleCapabilityPairingKey carries the verified capability from the declared +// module route to ext_authz. +// +// It travels in the request CONTEXT and not in a header, deliberately. A header +// would be a value this process asserts to itself through a channel a caller can +// also write to; it would have to be stripped, listed, and trusted on the +// strength of that stripping. The context value is set by this process, after +// the signature check, on the one route that resolves to a declared module, and +// is unreachable from the request. +type moduleCapabilityPairingKey struct{} + +// moduleCapabilityPairing is the verified capability a declared module route +// presented, reduced to the facts a confined credential is paired against. +type moduleCapabilityPairing struct { + // audience is the capability's own audience, as signed. + audience string + // viewerAudience is the audience a VIEWER's delegated capability for this + // route carries โ€” the module's own, which accounts derives from the declared + // module principal and the runtime names as its consumption target. + viewerAudience string + owner string + tenant string + session string +} + +// withPresentedModuleCapability records a verified capability for the route +// about to be judged. A nil claims set records nothing, so "no capability was +// presented" and "one was presented and verified" stay distinguishable. +func withPresentedModuleCapability( + ctx context.Context, claims *basev0.WorkContextV1, viewerAudience string, +) context.Context { + if claims == nil { + return ctx + } + return context.WithValue(ctx, moduleCapabilityPairingKey{}, &moduleCapabilityPairing{ + audience: claims.GetAudience(), + viewerAudience: viewerAudience, + owner: claims.GetOwnerPrincipalId(), + tenant: claims.GetTenantId(), + session: claims.GetSessionId(), + }) +} + +func presentedModuleCapability(ctx context.Context) *moduleCapabilityPairing { + pairing, _ := ctx.Value(moduleCapabilityPairingKey{}).(*moduleCapabilityPairing) + return pairing +} + +// admitsConfinedCredential reports whether this capability may stand in for the +// confinement a resource-bound credential carries. +// +// Every comparison refuses an EMPTY value on either side before comparing. Two +// absent subjects, tenants or sessions are equal, and an equality that holds +// because neither side said anything is the whole failure mode: a capability +// signed without a session would otherwise pair with a credential that names +// none, and the binding would be satisfied by the absence of the thing it binds. +func (p *moduleCapabilityPairing) admitsConfinedCredential(claims *accessClaims) error { + if p == nil { + return fmt.Errorf("no capability was presented") + } + // A module's OWN identity capability (the module capability audience, minted + // by StartModuleTask from the module's service credential) is verified at this + // route and is not this: it says nothing about the person holding the bearer, + // so pairing with it would admit a confined credential on the strength of a + // capability another party minted for itself. + if p.viewerAudience == "" || p.audience != p.viewerAudience { + return fmt.Errorf("the capability presented is not one minted for this module on this person's behalf") + } + for _, binding := range []struct { + name string + capability, jwt string + }{ + {"subject", p.owner, claims.Subject}, + {"organization", p.tenant, claims.OrgID}, + {"session", p.session, claims.SessionID}, + } { + if binding.capability == "" || binding.jwt == "" || binding.capability != binding.jwt { + // The refusal names WHICH binding failed, and no value from either + // side. Which of the three disagreed is what a runtime author debugs + // against; the values are a person's identifiers and a live session's. + return fmt.Errorf( + "the capability presented was not minted for the %s this credential names", binding.name) + } + } + return nil +} diff --git a/module/services/auth-gateway/code/gateway_module_confinement_test.go b/module/services/auth-gateway/code/gateway_module_confinement_test.go new file mode 100644 index 000000000..e5ab722af --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -0,0 +1,227 @@ +package main + +// A RESOURCE-CONFINED CREDENTIAL AT A DECLARED MODULE ROUTE +// (register finding SA-F-MCPAUD, round 3; gateway_module_capability.go states +// the rule). +// +// The property: such a credential is admitted at `/v1//*` if and only if +// it arrives with a verified capability for THAT module, minted on the subject, +// in the organization, and in the session the credential itself names. Each +// refusal below is one binding broken on its own, with everything else held +// equal, so a test that passed because two things were wrong at once would not +// pass here. + +import ( + "crypto/ed25519" + "net/http" + "net/http/httptest" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/sdk-go/workcontext" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// confinedCaller is one consumer's credential and the facts a capability has to +// agree with. Returned together, because the whole rule is an agreement between +// the two and a helper that generated them independently could only test that +// nothing agrees. +type confinedCaller struct { + bearer string + subject string + org string + session string +} + +func signConfinedCaller(t *testing.T, priv ed25519.PrivateKey, resource string) confinedCaller { + t.Helper() + caller := confinedCaller{ + subject: uuid.Must(uuid.NewV7()).String(), + org: uuid.Must(uuid.NewV7()).String(), + session: uuid.Must(uuid.NewV7()).String(), + } + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: caller.subject, + Audience: jwt.ClaimStrings{"saas-starter", resource}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti-" + uuid.Must(uuid.NewV7()).String(), + }, + OrgID: caller.org, + OrgRole: "admin", + SessionID: caller.session, + } + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = accessKeyID(priv.Public().(ed25519.PublicKey)) + signed, err := token.SignedString(priv) + require.NoError(t, err) + caller.bearer = signed + return caller +} + +// mintViewerCapability signs the capability a consumer's runtime mints for a +// composed module on a person's behalf: the module's own audience, the person as +// owner, their organization as tenant, their session as the session. +func mintViewerCapability( + t *testing.T, kid string, priv ed25519.PrivateKey, audience, owner, tenant, session string, +) workcontext.WorkContextToken { + t.Helper() + signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ + Issuer: "saas-starter", KeyID: kid, PrivateKey: priv, + }) + require.NoError(t, err) + token, _, err := signer.StartTask(workcontext.StartTaskInput{ + Audience: audience, + TenantID: tenant, + OwnerPrincipalID: owner, + TaskID: uuid.Must(uuid.NewV7()).String(), + SessionID: session, + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + return token +} + +// confinedModuleHarness wires a gateway with the edge verifier, one declared +// solution whose resource the credential names, one declared module the consumer +// reads, and an authority that has installed and granted both. +func confinedModuleHarness(t *testing.T) (*Gateway, *fakeUpstream, confinedCaller, string, ed25519.PrivateKey) { + t.Helper() + withPublicBase(t, testPublicBase) + gw, _, _, accessPriv := newGatewayHarness(t) + pub, wcPriv := mustEd25519(t) + const kid = "key-1" + jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) + gw.workContext = newWorkContextVerifier(jwks.URL) + registerSolutionUpstream(t, gw, "example") + module := declareModule(t, gw, "documents") + gw.solutionEntitlements = &entitledTo{ids: []string{ + fakeSolutionTarget("example"), fakeSolutionTarget("documents"), + }} + caller := signConfinedCaller(t, accessPriv, solutionMCPResource(testPublicBase, "example")) + return gw, module, caller, kid, wcPriv +} + +func moduleRequest(caller confinedCaller, capability string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) + req.Header.Set("authorization", "Bearer "+caller.bearer) + if capability != "" { + req.Header.Set(workcontext.WorkContextHeaderName, capability) + } + return req +} + +// The consumer's own work goes through, and the upstream is told what the +// credential is confined to and which installation admitted it. +func TestModuleRoute_AConfinedCredentialIsAdmittedWithThePersonsCapability(t *testing.T) { + gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusOK, w.Code, + "the consumer this credential was delivered to must be able to read the module its solution declared") + require.NotNil(t, module.lastHeaders) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + module.lastHeaders.Get(resourceConfinementHeader), + "the upstream is told which consumer this credential was delivered to") + require.Equal(t, "install-"+fakeSolutionTarget("documents"), + module.lastHeaders.Get(installationConfinementHeader), + "and which installation admitted the request") + require.Equal(t, caller.subject, module.lastHeaders.Get("X-User-Id")) +} + +// Each binding, broken alone. +func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *testing.T) { + for name, capabilityFor := range map[string]func(c confinedCaller) (audience, owner, tenant, session string){ + "no capability at all": nil, + "a capability owned by somebody else": func(c confinedCaller) (string, string, string, string) { + return "documents", uuid.Must(uuid.NewV7()).String(), c.org, c.session + }, + "a capability for another organization": func(c confinedCaller) (string, string, string, string) { + return "documents", c.subject, uuid.Must(uuid.NewV7()).String(), c.session + }, + "a capability from another session": func(c confinedCaller) (string, string, string, string) { + return "documents", c.subject, c.org, uuid.Must(uuid.NewV7()).String() + }, + // The module's OWN identity capability verifies at this route and says + // nothing about the person holding the credential, so it is not a + // capability this credential may be paired with. + "the module's own identity capability": func(c confinedCaller) (string, string, string, string) { + return moduleCapabilitiesAudience, c.subject, c.org, c.session + }, + } { + t.Run(name, func(t *testing.T) { + gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + encoded := "" + if capabilityFor != nil { + audience, owner, tenant, session := capabilityFor(caller) + encoded = mintViewerCapability(t, kid, wcPriv, audience, owner, tenant, session).Encoded() + } + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, encoded)) + + require.Equal(t, http.StatusUnauthorized, w.Code, + "a credential confined to one consumer must not reach a module on %s", name) + require.Nil(t, module.lastHeaders, "and nothing may reach the upstream") + }) + } +} + +// A capability for ANOTHER declared module is refused at this one's route, which +// is the A-for-B rule the edge already enforces โ€” asserted here too, because +// listing two permitted audiences at a module route is the change that could +// have weakened it. +func TestModuleRoute_ACapabilityForAnotherModuleIsRefused(t *testing.T) { + gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + other := declareModule(t, gw, "ledgers") + capability := mintViewerCapability(t, kid, wcPriv, "ledgers", caller.subject, caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, module.lastHeaders) + require.Nil(t, other.lastHeaders) +} + +// An ORDINARY session is unchanged: it needs no capability at a module route, and +// it is stamped no confinement. +func TestModuleRoute_AnUnconfinedSessionNeedsNoCapability(t *testing.T) { + withPublicBase(t, testPublicBase) + gw, _, _, priv := newGatewayHarness(t) + module := declareModule(t, gw, "documents") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("documents")}} + + req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, module.lastHeaders.Get(resourceConfinementHeader), + "an unbound session is confined to nothing, and the header says so by value") +} + +// A deployment with NO edge verifier admits no confined credential here. The +// pairing is the only thing that admits one, and it cannot be established +// without a verifier, so the refusal is the same one round 2 shipped. +func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *testing.T) { + gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + gw.workContext = nil + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, module.lastHeaders) +} diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go index 636950083..b2cb4f8b9 100644 --- a/module/services/auth-gateway/code/gateway_module_routes.go +++ b/module/services/auth-gateway/code/gateway_module_routes.go @@ -128,11 +128,25 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b return true } - // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN. A module route's target is - // the module capability surface, so that is what a capability presented here - // must name โ€” the same derivation as a solution route, with the audience the - // host's vocabulary gives this kind. - if g.rejectInvalidWorkContext(w, r, moduleCapabilitiesAudience) { + // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN โ€” and this route serves TWO + // kinds of consumer, so two audiences are permitted and no more. + // + // - moduleCapabilitiesAudience: a declared module's OWN identity, minted by + // accounts' StartModuleTask from the module's service credential. + // - the alias: a VIEWER's delegated capability for this module. Accounts + // refuses to mint the capability audience from any caller-supplied mint + // (requireMintableAudience โ€” a context addressed to it is read as a module + // identity), so the audience a person's capability for this module can + // carry is the module's own name, which is the name this route was + // resolved on and the one a consuming runtime declares and asks for. + // + // Expecting ONLY the capability audience here was therefore an expectation no + // viewer capability could satisfy: with the edge verifier wired, every + // delegated capability presented at a declared module route was refused. + // Listing both is not a widening โ€” a solution's audience is still refused + // here, which is the A-for-B rule this derivation exists for. + capability, rejected := g.verifiedWorkContext(w, r, moduleCapabilitiesAudience, alias) + if rejected { return true } @@ -140,8 +154,16 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b // identity, run ext_authz, require a valid credential. A bearer-less call is // denied here, so a declared module adds a proxy target and never widens the // authenticated surface. + // + // The verified capability is carried INTO the check, because it is what lets a + // resource-confined credential be admitted here at all: ext_authz pairs the + // two and refuses the credential when no capability of this person's accompanies + // it (gateway_module_capability.go carries the whole rule). A deployment with + // no edge verifier records nothing, so such a credential stays refused โ€” the + // same direction as every other asymmetry between this path and Envoy's. stripAllIdentityHeaders(r) - checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) + ctx := withPresentedModuleCapability(r.Context(), capability, alias) + checkResp, err := g.authz.Check(ctx, buildCheckRequest(r)) if err != nil { httpError(w, http.StatusInternalServerError, "auth check failed") return true @@ -180,7 +202,8 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b return true } g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { case viewerSolutionNotEntitled: httpError(w, http.StatusForbidden, "module is not installed for this organization") return @@ -188,6 +211,13 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b httpError(w, http.StatusServiceUnavailable, "module installation authority unavailable") return } + // Which installation admitted this viewer, forwarded for the same reason + // it is forwarded to a solution: the consent is a fact about the request, + // and the upstream that serves it is the one that needs it. Gateway-owned + // and on untrustedAuthHeaders, so a caller's own value is already gone. + if installation != "" { + r.Header.Set(installationConfinementHeader, installation) + } g.proxyTo(w, r, routing.Upstream, entry) })) return true diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements.go b/module/services/auth-gateway/code/gateway_solution_entitlements.go index a00892f71..761c4d733 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -367,23 +367,41 @@ const ( // answer changes with no write at all โ€” a grant's expires_at passes, a member // leaves a team, a role loses a permission, an owner is demoted โ€” so a cache // keyed on any revision a write advances would route on an expired grant. +// admitViewerSolution answers the admission alone, for the two call sites that +// need nothing else. func (g *Gateway) admitViewerSolution( ctx context.Context, org, viewer string, routing *solutionRouting, ) viewerSolutionAdmission { + admission, _ := g.admitViewerSolutionInstallation(ctx, org, viewer, routing) + return admission +} + +// admitViewerSolutionInstallation also returns WHICH installation admitted the +// viewer, empty on any answer other than admitted. +// +// It comes from the entitlement that matched, on the same answer that decided +// admission โ€” never from a second read. The installation is the consent an +// organization gave this one target: it is what a confined mint is held to +// (confineResourceMintToItsConsumer stamps it, accounts resolves its declared +// audiences and standing grants), and asking for it again could resolve a +// different installation from the one that admitted the request. +func (g *Gateway) admitViewerSolutionInstallation( + ctx context.Context, org, viewer string, routing *solutionRouting, +) (viewerSolutionAdmission, string) { targetID := routing.GetTargetID() if targetID == "" { // Declared by nothing: a self-registration no delivery opened a target // for. There is no identity to be entitled to, so this is a verdict // rather than an outage โ€” it is not a presence an administrator could // have consented to, and no later read will make it one. - return viewerSolutionNotEntitled + return viewerSolutionNotEntitled, "" } if g.solutionEntitlements == nil { // No authority client wired. Undecidable, never admitted: a deployment // that forgot to wire this must fail closed rather than serve every // solution to every organization, which is the behaviour this check // replaces. - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } token := "" for page := 0; page < solutionEntitlementPageLimit; page++ { @@ -394,7 +412,7 @@ func (g *Gateway) admitViewerSolution( PageToken: token, }) if err != nil { - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } for _, entitlement := range resp.GetEntitlements() { // Membership of the entitled set is the whole test. Health is @@ -405,13 +423,13 @@ func (g *Gateway) admitViewerSolution( // and make "nobody granted you this" indistinguishable from "it is // restarting". if entitlement.GetTargetId() == targetID { - return viewerSolutionAdmitted + return viewerSolutionAdmitted, entitlement.GetInstallationId() } } token = resp.GetNextPageToken() if token == "" { - return viewerSolutionNotEntitled + return viewerSolutionNotEntitled, "" } } - return viewerSolutionUndecidable + return viewerSolutionUndecidable, "" } diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 49efccb58..45a4681a4 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -318,7 +318,8 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) // free. The entitlement LISTING already wrapped its work this way; the proxy // did not. g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { case viewerSolutionNotEntitled: // A verdict on this organization's admission, named so a client can // tell it from an ext_authz denial on the credential: one is fixed by @@ -334,6 +335,23 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") return } + // WHICH INSTALLATION admitted this viewer, forwarded to the runtime. + // + // A solution's runtime cannot mint anything for a composed module without + // it: the host requires every mint to name an installation, an + // organization is not an installation โ€” one org may hold several โ€” and the + // runtime deliberately will NOT read one out of a capability a caller + // carried, because inbound that carrier is caller-controlled. So the only + // party that can supply it is the hop that just resolved the consent, and + // until this it was supplied by nobody: a runtime asking for a module + // refused before it reached the mint. + // + // Gateway-owned like the identity projection beside it, and on + // untrustedAuthHeaders, so a caller's own value is replaced rather than + // forwarded. + if installation != "" { + r.Header.Set(installationConfinementHeader, installation) + } // The destination is the one this request's single resolution named, and // the one whose identity the authority just admitted. g.proxyTo(w, r, routing.Upstream, entry) @@ -523,8 +541,29 @@ func (g *Gateway) confineResourceMintToItsConsumer(w http.ResponseWriter, r *htt } org := r.Header.Get("X-Org-Id") viewer := r.Header.Get("X-User-Id") - switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + admission, installation := g.admitViewerSolutionInstallation(r.Context(), org, viewer, routing) + switch admission { case viewerSolutionAdmitted: + if installation == "" { + // Admitted with no installation named. Nothing produces this โ€” the + // admission comes FROM a matched entitlement, which carries one โ€” so + // it is an authority that broke its contract, and a mint is not + // issued on a ceiling nobody can be held to. The alternative is a + // confined mint accounts would judge against no consumer at all, + // which is the unconfined mint this whole half closes. + httpError(w, http.StatusServiceUnavailable, + "the authority that decides this consumer's installation named none") + return false + } + // The consent this mint is held to. Accounts resolves this installation's + // declared audiences and its agent's standing grants and refuses anything + // outside them (StartTask), so the ceiling is the one an administrator + // actually consented to rather than the viewer's whole authority. + // + // Set AFTER stripAllIdentityHeaders and the identity projection, like the + // projection itself: the name is on untrustedAuthHeaders, so whatever a + // caller sent under it is already gone. + r.Header.Set(installationConfinementHeader, installation) return true case viewerSolutionNotEntitled: httpError(w, http.StatusForbidden, diff --git a/module/services/auth-gateway/code/resource_token_confinement_test.go b/module/services/auth-gateway/code/resource_token_confinement_test.go index 11c1d6a66..287fe3310 100644 --- a/module/services/auth-gateway/code/resource_token_confinement_test.go +++ b/module/services/auth-gateway/code/resource_token_confinement_test.go @@ -22,11 +22,17 @@ import ( // anywhere, which is the opposite of what a resource indicator is for. // // The second audience such a token carries is this host's, and it is there so the -// token VERIFIES at all: the minter stamps it and its own VerifyAccess checks it. -// Reading that parser requirement as an authorization grant is what made the -// escape. Nothing legitimate needs it either way โ€” a runtime calls this host with -// the module credential it was provisioned, which `/modules/_work-context` takes -// instead of a person's bearer. +// token VERIFIES at all: the minter stamps it and its own VerifiedAccess checks +// it. Reading that parser requirement as an authorization grant is what made the +// escape. +// +// TWO host surfaces are admitted, each in its own company, and neither is +// exercised here: the Work Context mint (see +// TestResourceTokenAtTheWorkContextMintIsAdmittedConfined), and a declared module +// route reached together with this person's verified capability for that module +// (see gateway_module_confinement_test.go). Every request below presents NO +// capability, which is why the module path refuses here as well โ€” what this test +// pins is that nothing about the token alone admits it anywhere. func TestResourceTokenIsConfinedToItsOwnSolution(t *testing.T) { for _, path := range []string{ "/v1/users", // this host's own API @@ -107,40 +113,78 @@ func TestAnAPIKeyIsRefusedAtAToolEndpoint(t *testing.T) { require.Nil(t, example.lastHeaders, "and the runtime is not reached") } -// The amplification case, asserted at the decision point rather than through -// ServeHTTP: the Work Context mint. +// THE MINT, asserted at the decision point rather than through ServeHTTP. +// +// It is admitted, and admitted CONFINED โ€” the resource travels with it so +// accounts can hold what it issues to the consumer the credential was delivered +// to. The runtime's MCP viewer path needs exactly this: it mints a viewer-owned +// capability for the module its solution declared it consumes, forwarding the +// person's bearer, because its own service credential does not stand in for the +// person. +// +// This test used to assert the OPPOSITE โ€” that the mint refused a resource token +// โ€” and it kept passing after the admission shipped, because `newTestExtAuthz` +// leaves `publicBase` empty and a confined credential is refused in that state +// for an unrelated reason: this process cannot say what any resource identifier +// IS, so it cannot compare one. A test that passes through the path it means to +// exercise being unreachable asserts nothing about it. The public base is +// therefore set here, to the value the production constructor resolves, and both +// states are asserted separately. // -// It is the worst of the escapes, which is why it gets its own test. Admitted -// there, a captured resource token buys capabilities for FURTHER audiences โ€” the -// escape stops being "this one resource's traffic" and becomes the person's -// authority anywhere a context can be minted for. The Connect route is not in -// the test harness's catalog (ServeHTTP answers 404 before any credential is -// read), so the confinement is asserted on ExtAuthz.Check, which is where it is -// actually decided and where the real deployment reaches it. -func TestResourceTokenIsRefusedAtTheWorkContextMint(t *testing.T) { +// The Connect route is not in the test harness's catalog (ServeHTTP answers 404 +// before any credential is read), so this runs against ExtAuthz.Check, which is +// where the decision is made and where the real deployment reaches it. +func TestResourceTokenAtTheWorkContextMintIsAdmittedConfined(t *testing.T) { withPublicBase(t, testPublicBase) s, priv := newTestExtAuthz(t) + s.publicBase = publicBaseURL() + require.Equal(t, testPublicBase, s.publicBase, + "the admission under test is unreachable without a configured public address") + resource := solutionMCPResource(testPublicBase, "example") bound := validClaims(time.Now()) - bound.Audience = jwt.ClaimStrings{ - "saas-starter", testPublicBase + "/api/solutions/example/proxy/mcp", - } + bound.Audience = jwt.ClaimStrings{"saas-starter", resource} resp, err := s.Check(context.Background(), checkReq( - "/saas.accounts.v1.WorkContextService/StartTask", map[string]string{ + workContextMintProcedure, map[string]string{ "authorization": "Bearer " + signClaims(t, priv, bound), })) require.NoError(t, err) - require.NotNil(t, resp.GetDeniedResponse(), - "a resource-bound token must not be admitted at the mint") - require.Equal(t, int32(401), int32(resp.GetDeniedResponse().Status.Code)) + require.Nil(t, resp.GetDeniedResponse(), + "the consumer's own mint is admitted; what it may ISSUE is bounded by accounts") + require.Equal(t, resource, headerMap(resp)[resourceConfinementHeader], + "and the resource travels with it, or the mint cannot tell it from an unbound session") - // The control: an unbound session still mints. Confinement must narrow a - // resource token, not close the mint. + // The control: an unbound session still mints, and carries an EMPTY + // confinement rather than none, so accounts reads a value either way. ok, err := s.Check(context.Background(), checkReq( - "/saas.accounts.v1.WorkContextService/StartTask", map[string]string{ + workContextMintProcedure, map[string]string{ "authorization": "Bearer " + signClaims(t, priv, validClaims(time.Now())), })) require.NoError(t, err) require.NotNil(t, ok.GetOkResponse(), "an ordinary session still reaches the mint") + require.Empty(t, headerMap(ok)[resourceConfinementHeader]) +} + +// Without a configured public address the mint refuses a confined credential, +// and says which of the two refusals it is. An operator fixes a configuration and +// a client re-authorizes; those are different actions, so they are different +// messages. +func TestResourceTokenAtTheMintIsRefusedWithNoConfiguredPublicAddress(t *testing.T) { + s, priv := newTestExtAuthz(t) + require.Empty(t, s.publicBase) + + bound := validClaims(time.Now()) + bound.Audience = jwt.ClaimStrings{ + "saas-starter", solutionMCPResource(testPublicBase, "example"), + } + + resp, err := s.Check(context.Background(), checkReq( + workContextMintProcedure, map[string]string{ + "authorization": "Bearer " + signClaims(t, priv, bound), + })) + require.NoError(t, err) + require.NotNil(t, resp.GetDeniedResponse()) + require.Equal(t, int32(401), int32(resp.GetDeniedResponse().Status.Code)) + require.Contains(t, resp.GetDeniedResponse().GetBody(), "no configured public address") } diff --git a/module/services/auth-gateway/code/work_context.go b/module/services/auth-gateway/code/work_context.go index a49ca56bc..cdabbc7c7 100644 --- a/module/services/auth-gateway/code/work_context.go +++ b/module/services/auth-gateway/code/work_context.go @@ -9,6 +9,7 @@ import ( "strings" "time" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/sdk-go/workcontext" ) @@ -89,26 +90,67 @@ const gatewayAudience = "auth-gateway" func (v *workContextVerifier) Verify( ctx context.Context, token workcontext.WorkContextToken, expected string, ) error { + _, err := v.VerifyAny(ctx, token, expected) + return err +} + +// VerifyAny is Verify for a route that accepts MORE THAN ONE audience, and it +// returns the verified claims so a caller can hold the capability to facts +// beyond its audience. +// +// `permitted` is the set of audiences the resolved route accepts. One entry is +// the ordinary case and is what Verify passes. A route with several is a route +// whose destination legitimately consumes capabilities of two different kinds โ€” +// a declared module's own identity, and a viewer's delegated capability for it โ€” +// and listing both is what keeps a single-valued expectation from refusing the +// one the caller actually holds (see verifiedModuleCapability). +// +// The audience comparison is made HERE rather than by passing a value to sdk-go, +// because sdk-go compares one string. An EMPTY `permitted` โ€” no entry at all, or +// one empty entry, which is what the catalog routes pass โ€” means "do not +// compare", and that is sdk-go's own sentinel (`check.want != ""` in +// workcontext/work_context.go) restated locally so the one reliance on it stays +// in one place and stays explicit. +func (v *workContextVerifier) VerifyAny( + ctx context.Context, token workcontext.WorkContextToken, permitted ...string, +) (*basev0.WorkContextV1, error) { keyID, err := workContextTokenKeyID(token) if err != nil { - return err + return nil, err } verifier, err := v.cache.resolve(ctx, keyID) if err != nil { - return err + return nil, err } - claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{Audience: expected}) + claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{}) if err != nil { - return invalidWorkContext(err) + return nil, invalidWorkContext(err) } // Refused AFTER the signature check, deliberately: an unsigned token claiming // the hop's audience is an invalid token, not a routing question, and answering // the audience refusal first would tell an unauthenticated caller which // audience the hop answers to. if claims.GetAudience() == gatewayAudience { - return fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) + return nil, fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) + } + wanted := make([]string, 0, len(permitted)) + for _, audience := range permitted { + if audience != "" { + wanted = append(wanted, audience) + } + } + if len(wanted) == 0 { + return claims, nil + } + for _, audience := range wanted { + if claims.GetAudience() == audience { + return claims, nil + } } - return nil + // The refusal names neither the presented audience nor the permitted set: the + // caller learns that the capability is not valid at this route, which is the + // whole verdict, and not which consumers this route serves. + return nil, fmt.Errorf("%w: the capability names a consumer this route does not serve", workcontext.ErrWorkContextInvalid) } func workContextTokenKeyID(token workcontext.WorkContextToken) (string, error) { From 4785a5443e7bfa144918f98272ca5f988902b47a Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:19:41 -0400 Subject: [PATCH 42/46] fix(frontend): the account menu's sign-out takes the shared journey, and the sweep finds the next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account menu on every dashboard and admin page handed the sign-out function straight to a menu item, which discards its result, so an incomplete sign-out looked finished and the route shell then sent the person to the sign-in page as though the session had ended. It routes through the shared journey, which is the one place that decides where each outcome goes. The structural sweep that was meant to hold every control to that journey carried a fixed list of files and a pattern that matched only two spellings, so the control most people use was invisible to it twice over. It now derives its subject by reading the tree โ€” every file that takes the sign-out function out of the auth context โ€” asserts that it found something, and refuses every spelling including the function handed to a prop. It strips comments first, because reading raw text made it match the prose describing what it had just caught. Leaving an impersonated session discarded the same result one layer down, and told the operator to sign in again without looking at whether the sign-out completed. It reads the outcome and surfaces an unconfirmed one through the mechanism that path already uses; the sweep holds the provider to its own rule. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/account-menu-sign-out.test.tsx | 104 ++++++++++++++ .../sign-out-controls-contract.test.ts | 129 ++++++++++++++---- .../code/src/components/admin-layout.tsx | 13 +- .../services/frontend/code/src/lib/auth.tsx | 20 ++- 4 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx diff --git a/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx b/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx new file mode 100644 index 000000000..1d9702894 --- /dev/null +++ b/module/services/frontend/code/src/components/__tests__/account-menu-sign-out.test.tsx @@ -0,0 +1,104 @@ +// THE ACCOUNT MENU's "Sign out" โ€” the primary sign-out control, on every +// dashboard and admin page โ€” takes the shared sign-out journey. +// +// It is the control most people actually use, and it was the one left behind: +// `onClick={logout}` handed the promise to a menu item, which discards it, so an +// incomplete sign-out looked finished and the route shell then sent the person +// to the sign-in page as though the session had ended. Local state is cleared +// either way, which is what made the wrong answer invisible โ€” the app looks +// signed out while an already-issued access token stays live until it expires. +// +// Asserted behaviourally, on where the person is SENT, because that is the whole +// difference between the two outcomes. The structural sweep beside this +// (sign-out-controls-contract.test.ts) is what finds the next control; this is +// what proves the journey runs for this one. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { AppearanceProvider } from "@/lib/appearance-provider"; +import { FrontendConfigProvider } from "@/lib/providers"; + +const routerReplace = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn(), replace: routerReplace }), + notFound: () => { + throw new Error("not found"); + }, +})); +const logout = vi.hoisted(() => vi.fn(async () => ({ complete: false }))); +vi.mock("@/lib/auth", () => ({ + useAuth: () => ({ + user: { id: "user-1", email: "jane@example.com", name: "Jane Doe" }, + isAuthenticated: true, + isLoading: false, + platformRole: undefined, + orgRole: "admin", + logout, + getToken: () => "token", + }), +})); +vi.mock("@/features/notifications/ui/notification-bell", () => ({ NotificationBell: () => null })); +vi.mock("@/features/notifications/ui/notification-banner", () => ({ NotificationBanner: () => null })); +vi.mock("@/features/organizations/ui/org-switcher", () => ({ OrgSwitcher: () => null })); +vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () => null })); +vi.mock("@/components/rate-limit-banner", () => ({ RateLimitBanner: () => null })); + +import { AdminLayout } from "@/components/admin-layout"; +import applicationFrontendConfig from "../../../frontend.config"; + +function Wrap({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + {children} + + + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +it("an incomplete sign-out from the account menu goes to the retry page", async () => { + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + render( + + content + , + ); + const triggers = screen.getAllByText(/Jane Doe|jane@example.com/); + fireEvent.click(triggers[triggers.length - 1]); + const item = await screen.findByText("Sign out"); + fireEvent.click(item); + await waitFor(() => expect(logout).toHaveBeenCalled()); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/auth/logout")); + // And NOT to the page a finished sign-out lands on, which is where the route + // shell sends an unauthenticated viewer: the two are indistinguishable to + // the person, and only one of them is true. + expect(routerReplace).not.toHaveBeenCalledWith("/auth/login"); +}); + +// THE CONTROL. Without it the assertion above would also pass on a menu that +// sent every sign-out to the retry page, which presents a finished sign-out as +// unfinished โ€” the same defect in the other direction. +it("a complete sign-out from the account menu goes to the signed-out page", async () => { + logout.mockResolvedValueOnce({ complete: true }); + const replace = vi.fn(); + vi.stubGlobal("location", { ...window.location, replace }); + render( + + content + , + ); + const triggers = screen.getAllByText(/Jane Doe|jane@example.com/); + fireEvent.click(triggers[triggers.length - 1]); + fireEvent.click(await screen.findByText("Sign out")); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/auth/login")); +}); diff --git a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts index 07bafcb7c..6d9c112af 100644 --- a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts +++ b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; // Every user-facing sign-out control goes through the shared journey. @@ -6,35 +6,116 @@ import { describe, expect, it } from "vitest"; // This is a STRUCTURAL assertion, and says so: it reads source rather than // behaviour, which proves only that no control calls `logout()` for itself โ€” // not that the journey is right. The journey's own behaviour is asserted in -// src/lib/__tests__/sign-out-journey.test.ts, and two real controls are driven -// in consent-banner.test.tsx. This exists for the failure those cannot catch: -// the NEXT sign-out control, added months from now, quietly calling `logout()` -// and redirecting wherever it likes. +// src/lib/__tests__/sign-out-journey.test.ts, and real controls are driven in +// consent-banner.test.tsx and account-menu-sign-out.test.tsx. This exists for +// the failure those cannot catch: the NEXT sign-out control, added months from +// now, quietly calling `logout()` and redirecting wherever it likes. // -// That is exactly how this defect arrived. `logout()` was changed to report -// incompleteness and `/auth/logout` was taught to handle it, while the controls -// that already existed went on discarding the result โ€” a per-control decision -// that nothing held together. -const controls = [ - "src/components/consent-banner.tsx", - "src/components/command-palette.tsx", - "src/features/onboarding/ui/onboarding-entry.tsx", - "src/features/invitations/ui/invitation-acceptance.tsx", - "src/features/organizations/service/lifecycle.ts", +// It used to carry a FIXED LIST of five files, and that is how the account menu +// โ€” the control most people actually use โ€” went unfixed through two review +// rounds. The list named every control somebody had thought of; the one it did +// not name was invisible, and the regex it paired with the list matched only +// `await logout()` / `void logout()`, so the menu's `onClick={logout}` would not +// have been caught even if the file had been listed. A sweep whose membership is +// a judgement call tests the judgement, not the codebase. +// +// So the subject is DERIVED: every file that takes `logout` out of `useAuth()` +// is a sign-out control, found by reading the tree. Nothing has to remember to +// add one. + +/** Every source file under src/, excluding tests and generated clients. */ +function sourceFiles(): string[] { + return readdirSync("src", { recursive: true, encoding: "utf8" }) + .map((entry) => `src/${entry}`) + .filter((path) => path.endsWith(".ts") || path.endsWith(".tsx")) + .filter((path) => !path.includes("__tests__")) + .filter((path) => !path.endsWith(".test.ts") && !path.endsWith(".test.tsx")) + .filter((path) => !path.startsWith("src/gen/")) + .sort(); +} + +// `logout` destructured out of the auth context. A control gets it exactly this +// way โ€” there is no other accessor โ€” so this is the whole population. +const TAKES_LOGOUT = /const\s*\{[^}]*\blogout\b[^}]*\}\s*=\s*useAuth\(\)/; + +/** + * The three files that are not controls and may call `logout` for themselves: + * the auth provider, which DEFINES it; the journey, which IS the shared route; + * and the logout page, which is where the journey SENDS an incomplete sign-out + * and therefore owns the retry. The provider is held to its own rule below + * rather than exempted outright. + */ +const NOT_A_CONTROL = [ + "src/lib/auth.tsx", + "src/lib/sign-out-journey.ts", + "src/features/auth/ui/logout-page.tsx", ]; +/** + * Source with comments removed. + * + * A structural sweep that reads raw text matches its own prose: the comment + * beside the account-menu fix quotes the `onClick={logout}` it replaced, and + * that quotation failed the rule the fix satisfies. Nothing here should depend + * on how a defect is described, only on what the code does. + */ +function code(path: string): string { + return readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^[^\n]*?\/\/.*$/gm, (line) => (line.includes("://") ? line : "")); +} + +function controls(): string[] { + return sourceFiles().filter( + (path) => !NOT_A_CONTROL.includes(path) && TAKES_LOGOUT.test(code(path)), + ); +} + describe("sign-out controls", () => { - it.each(controls)("%s routes through the shared journey", (path) => { - const source = readFileSync(path, "utf8"); - expect(source).toContain("signOutAndLeave"); + // The population itself is asserted, because a derived sweep that found + // NOTHING would pass every test below having checked no file at all โ€” the + // failure mode a fixed list at least could not have. + it("finds the controls by reading the tree", () => { + const found = controls(); + expect(found.length).toBeGreaterThanOrEqual(6); + expect(found).toContain("src/components/admin-layout.tsx"); + expect(found).toContain("src/components/command-palette.tsx"); + expect(found).toContain("src/components/consent-banner.tsx"); + }); + + it.each(controls())("%s routes through the shared journey", (path: string) => { + expect(code(path)).toContain("signOutAndLeave"); }); - // `await logout()` or `void logout()` as a control's own sign-out. The one - // legitimate caller is the journey helper itself, and the logout PAGE, which - // is the journey โ€” neither is in the list above. - it.each(controls)("%s does not call logout() for itself", (path) => { - const source = readFileSync(path, "utf8"); - const direct = source.match(/(?:await|void)\s+logout\(\)/g) ?? []; + // A control must not sign out for itself, in ANY of the spellings. The list + // is every way a control can reach `logout` without the journey deciding + // where the person goes โ€” including handing the function straight to a + // component, which is how the account menu did it and which the previous + // `(?:await|void)\s+logout\(\)` regex could not see. + it.each(controls())("%s does not sign out for itself", (path: string) => { + const source = code(path); + const direct = [ + // `await logout()`, `void logout()`, `return logout()`. + /(?:await|void|return)\s+logout\(\)/g, + // `logout().then(...)`, `logout().catch(...)`. + /\blogout\(\)\s*\./g, + // The function handed to a prop or a callback, never called here: + // `onClick={logout}`, `onSelect: logout`, `onPress={logout}`. + /(?:=\{|:\s*)logout\s*[},)]/g, + ].flatMap((pattern) => source.match(pattern) ?? []); expect(direct).toEqual([]); }); + + // The provider DEFINES `logout`, so it is the one file that calls it for + // itself โ€” and its own callers must still read the outcome. A discarded + // result there is the same defect one layer down: exitImpersonation tore the + // credentials down and told the operator to sign in again without ever + // looking at whether the sign-out completed. + it("the auth provider reads every sign-out outcome it produces", () => { + // Anchored to the start of a STATEMENT, so `const x = await logout();` + // โ€” the outcome being read โ€” is not mistaken for the outcome being + // thrown away. + const discarded = code("src/lib/auth.tsx").match(/^\s*(?:await|void|return)\s+logout\(\)/gm); + expect(discarded ?? []).toEqual([]); + }); }); diff --git a/module/services/frontend/code/src/components/admin-layout.tsx b/module/services/frontend/code/src/components/admin-layout.tsx index 500efcbef..f4452d0f4 100644 --- a/module/services/frontend/code/src/components/admin-layout.tsx +++ b/module/services/frontend/code/src/components/admin-layout.tsx @@ -47,6 +47,7 @@ import { NotificationBell } from "@/features/notifications/ui/notification-bell" import { OrgSwitcher } from "@/features/organizations/ui/org-switcher"; import { useAppearance } from "@/lib/appearance-provider"; import { useAuth } from "@/lib/auth"; +import { signOutAndLeave } from "@/lib/sign-out-journey"; import { sessionDisplayLabel } from "@/lib/auth-session"; import { getNavigationIcon } from "@/lib/navigation-icons"; import { canPresent, selectNavigation } from "@/lib/plugins/presentation"; @@ -320,7 +321,17 @@ export function AdminLayout({ children }: { children: React.ReactNode }) { ); })} - + { + // THE PRIMARY SIGN-OUT CONTROL: the account menu on + // every dashboard and admin page. `onClick={logout}` + // handed the promise to the menu, which discards it, + // so an incomplete sign-out looked finished and the + // route shell then sent the person to the sign-in + // page as if the session had ended. + void signOutAndLeave(logout); + }} + > Sign out diff --git a/module/services/frontend/code/src/lib/auth.tsx b/module/services/frontend/code/src/lib/auth.tsx index 21cafece8..4dfb8f28d 100644 --- a/module/services/frontend/code/src/lib/auth.tsx +++ b/module/services/frontend/code/src/lib/auth.tsx @@ -944,7 +944,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (outcome.status === "unavailable" && !stopped) { throw new Error("Could not restore your session โ€” please try again."); } - await logout(); + // The sign-out's OWN outcome, read rather than discarded. `logout()` + // reports incompleteness โ€” the server answered 503 "logout incomplete, + // retry", or nothing answered โ€” and this told the operator to sign in + // again either way, which is the same defect the user-facing sign-out + // controls had: local state is cleared regardless, so an unfinished + // sign-out is invisible while an already-issued token stays live. + // + // It does not take signOutAndLeave's navigation. The outcome here is + // owned by the banner that called this and reports a thrown message as a + // toast; a redirect would race that, and the operator's next step is the + // same in both branches. What the shared journey decides โ€” WHERE an + // incomplete sign-out goes โ€” has no second destination to choose from on + // this path, so what is shared is the fact, not the route. + const signedOut = await logout(); + if (!signedOut.complete) { + throw new Error( + "Your impersonation session was ended, but signing you out of this browser was not confirmed โ€” close this tab and sign in again.", + ); + } if (outcome.status === "unavailable") { throw new Error( "Your impersonation session was ended, but your admin session could not be restored โ€” please sign in again.", From 8be8aa00279fdd59e108fce7c77611eb1769eb05 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:22:31 -0400 Subject: [PATCH 43/46] test(gateway): a caller cannot assert its own confinement at the route that stamps it The whole-list strip test sees only routes that stamp neither header, so a strip that ran before the stamp and a stamp that honoured a caller's value look the same to it. Pinned where the stamp lives instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../code/gateway_module_confinement_test.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/module/services/auth-gateway/code/gateway_module_confinement_test.go b/module/services/auth-gateway/code/gateway_module_confinement_test.go index e5ab722af..8efa91b83 100644 --- a/module/services/auth-gateway/code/gateway_module_confinement_test.go +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -225,3 +225,29 @@ func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *tes require.Equal(t, http.StatusUnauthorized, w.Code) require.Nil(t, module.lastHeaders) } + +// A CALLER CANNOT PICK ITS OWN CEILING. Both stamps are gateway-owned: whatever a +// caller sends under either name is replaced by what this hop resolved. +// +// Asserted here as well as in the whole-list strip test, because this is the one +// route that STAMPS them: a strip that ran before the stamp and a stamp that +// honoured a caller's value are indistinguishable from the list test's point of +// view, which only ever sees a route that stamps neither. +func TestModuleRoute_ACallerCannotAssertItsOwnConfinementOrInstallation(t *testing.T) { + gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + + req := moduleRequest(caller, capability.Encoded()) + req.Header.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "something-else")) + req.Header.Set(installationConfinementHeader, "install-of-somebody-elses-organization") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + module.lastHeaders.Get(resourceConfinementHeader), + "the resource is the one this credential names, not the one it asked for") + require.Equal(t, "install-"+fakeSolutionTarget("documents"), + module.lastHeaders.Get(installationConfinementHeader), + "the installation is the one the authority admitted, not the one it asked for") +} From 3b4506d7e58032310eb552505fd22fb2bc05f4b9 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:33:52 -0400 Subject: [PATCH 44/46] fix(accounts): an undeclared ceiling is nothing for a credential delivered to a third party MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allowed_audiences` and `allowed_scopes` are optional and empty means unrestricted. That is the right reading for an installation's own headless work โ€” an administrator installed the solution and consented to it โ€” and the install form leaves both empty, so it is the ordinary state rather than an edge case. For a mint made with a resource-confined credential the same emptiness says the opposite of what anyone intended. The credential is held by a third party the organization never installed, the installation is the only statement of what that solution may act for, and read as unrestricted it makes the person's whole authority available at every module the organization installed. A confined mint under such an installation is therefore refused, by name and naming the field, rather than narrowed silently or admitted. This is the installation's ceiling and NOT the consumer's own declaration. The delivered presence document carries module pins โ€” module, package, version โ€” a capability's audience is the consumed module's route alias, and no record on this host joins the two. Deriving one would be a guess at exactly the question this check answers, so the authority stays the consent an administrator gave, and the gap is recorded rather than papered over. The proto comment said "Empty = unrestricted" without qualification and the gateway's guidance claimed the audience was already bounded; both now say what holds on each path, and the install form says what leaving the field empty costs. The regenerated artifacts are the proto comment's: the two contract descriptors, the catalog, the module package, the generated Go and TypeScript, both swagger documents, and the published client's new patch version. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1132275 -> 1132873 bytes .../saas/accounts/v1/installations.proto | 11 +- .../api/accounts/connect/contract.binpb | Bin 1132275 -> 1132873 bytes .../saas/accounts/v1/installations.proto | 11 +- module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- .../work_context_confined_mint_test.go | 158 ++++++++++++++++++ .../code/pkg/adapters/work_context_rpcs.go | 41 +++++ .../gen/saas/accounts/v1/installations.pb.go | 11 +- .../generated/openapi-raw/api.swagger.json | 2 +- .../accounts/openapi/api.swagger.json | 2 +- .../saas/accounts/v1/installations.proto | 11 +- module/services/auth-gateway/AGENTS.md | 12 ++ .../services/frontend/code/package-lock.json | 4 +- module/services/frontend/code/package.json | 2 +- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1132275 -> 1132873 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- .../gen/saas/accounts/v1/installations_pb.ts | 11 +- .../code/packages/saas-sdk/package.json | 2 +- .../ui/install-solution-dialog.tsx | 4 +- .../gen/saas/accounts/v1/installations_pb.ts | 11 +- 22 files changed, 285 insertions(+), 20 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 9ef1bea648af6f56874a1dcede16f95e7cd1fbc0..28bc29cbd1d975fa1c6d760ec306b8be3da3f57b 100644 GIT binary patch delta 9690 zcmZ8nYm`;RmA+M{&gpy40lGOnZ37$?RH6`#8Fh3pqG`b=1jx#$ab-1dP4`+&xAwh^ zgDWcsNecmaHKpw|6~G-TuD?HQScd%xn=Yvc+jpEpCg~Qqhua$+h@T2cOo+&e`S9Yl1^32QR%U z{EE=cWR|+6_oD2j&WJ`u(;J)fg<^C4lwy8rbj7#{(WJ3gPK>U+ZsMfT*IYAt(v{<`J7Z!r?&sG< zKOZ;Y>a(NU>guP|MfC;zr?wc?Hu_QR)M@pN^)OLeY-*0CG&L6Tjby9Xgg*Jks92Yu z5!E*qinR?5pzDhxqbuVgnYeD;q<@;;k8XyA+6eZF(X`sB`RKO#VjYMn&H1S?T3_1` zP0csd|3@B}?AMaB6MhnnA3b5x^-)tJh+Fc_g{HUsv03b97+l-EcFy_VV%3 zl!kg(A%|kqFRGnhtZQnnza92y?qVI>YHG|!g{Fq-#rmekkNNkpILr zH8#wM5L+=njchmFtc9T_YXhOoh-#-4X@;}GLbA9q%Cr6r5RObW=WC}%wS}m$2`)7L z3La053e%yqQ3G8j)Likii8VioCeMf(Yp21bGi#?zX`0?xESwS5H8o7Vys7c#`o{d! zYoXqxrYnmD5QXW}^3B6Jh^Tm56Hf#4g*d8hhVQrB(p0D~QV@Q0%k*ZT{6!r8WZ0cr zAmxn>l8_S}6`Q9QiiNXv@CdUxvYq|zc|r^P%p+A%q4u_szTeY#sS@2#EfPW8+hRdD zJk3If;OS@jmAZG93U~Mt;rPA%91YLT$%Qghi-kJJ@8buSQoS&+Rj7Y6cCso|OKAc0 z;`)afpnHX~%`x0qDgx_87G5kBVPueZX?vZ-Rn|m#2~Wh-|qCtyv$lEEm<^vo&AKPG&sWazCFnJ0v(zE4D>8>7+l_GlzPa|ap-P=fq z9`F>9(IvJK9=3^GrG@aYO@w_cgokaS?YdP9TgDzmTgo@ z#uQfBhJ|KKT()5m)DxF&Sc=11n_7oCARQJO8JaQygz>dDDnJ-tYoj7KCwD5X#b(wqV0E1sdM#H4LGAK7G3**Mz`+t< zPfL6>8%0e3JzqN55?mUuw`*KUs*d#{JkyTCH0#A#XY=lbR_tEu#faz4?nQ*&L!Yy| zm!TAev_YtgI8$!uy(ls-nM}Eu;zf}i%u~3?R2-^JR!m?aaK2Ou3w?&8cx%1Jap7Bg0&*S2QrJZ?>rdg!RofRRp#5&9(r&(Ay$1ub8kr z7~3MUIzgVyc%j-VWG@Jy6d)tL=z!37tEfDgCGbw;u(pc9cC5hI?LzkT$MRTo-d@@? zJGd&^F8Y}hS9wut4~)znA$7)_Tz#CL_zKq{olrE9OBp^ zVh&C}a0A{pMQS*#seS0h3&c<`^_EaQI0+TfL67~U)J+@Yu%y zrJ)Ohfl-$*J7JFf?4sqh6(_xy3B4~x=JC=m76x1Td`Y6Al9iW%Q{GV_Tg&G!qFO#G zynZw#HDD(^D*CfS1mVz8alz$O3IuT;d?VC+=}{>#v34O!;TsXmmoDYqa`5$A;ho-{ zg5_hv`@Y}T_iZm1ejFFEcLxf!+hAxkOL{}-!f1O|2Q3Pr_dDsG+nvJYYp(Rp(^QPO5JnKHc_)nQ5vseWTN?cWsTL#pJ|@y9x351n z^cKiqF~YZ=5s4aDNFj9RDsWepUZuZA=RkyBNy;arQ~sG8D>Aqxank^{L8 z2qFt5dGUDThd>tYlWJv>1wrl;7pzROz#Ou0zf7{g6fFNaF? zIL!i6oP|fyENBuYm!(-?4q13K)siV>VMUq+rjUilx@CbmWa04>Mj{J;O0z)Gw@KBW zWI-sMg*F+qCt1)Wvam{e7n>|-4p~@DSy+LM5!zy7?3D5q>69M6J6INXN{>$}YC&9` z@}wV{l|j2=ryMoGBtgQBr=@y1NrKc#cv=Q8CrMxqNq8nr0#mTOCQSlUl!UVE=RKPw zfoUXRy)3ofp6wHFkhpp2dg3Ok{^wKOnS<^xr0OvR-Cs;~X9~J+N_3Y@LH5lj420}2 zrLxPPA>b`iZBKNUM)xf;*q-RFN$9>+dKV_%Qi7oSHqw2*u71?(PAUId-f(Xa;53yU zU$Y>)gRpkWp<|8gbPVj26Mt!B$C2UflIl<*yQ5{_C4)nW?94&--Kp$M!SWla>`amD zd*pC$Zz4O>kbPfT`%H1|zg@cZzMxP1PAWUMLH2i3^_YU}|C`Fr6l8xdm7OWbe&B?G zko{mPJH`9HR39d)J5c>H+H~HR!H0?L#PX)|f%GmnvTF{qA12v9)8!6xT~f|;N;UTf zU2&I8Hhz2+sxFyq{P>^&yx=+`JADXzD%E$1>`Kf2sSLhLWM>Ytf0oM56fA!(y|c{5 z&lJi21-6{OC$cjQ*^i{VCR0@9U&~%+ncbT?$bB?bjVZ|ejr7jx-V1Vno660-AosCu z1IJSk(EB)b)8`)oRsWFcn#2(AEuey{jjW2LhFAZP(N+Faigyv*zUMje-trxP2+oo5 zJSW-7U38r1Bs;kaS?4(yPcpK)ko7J{-Q(Ev)YY=y?{vd(wBQ;b_o!SVvf z8`hnIehVEhw#N>4BLNpViGa*Oz{O5-NHPThTOA}xAHz&Rz$K17BqdW2aOnvHq2Jxq z>lHj*V)^32o?GF_^^Q}$&MUq9XfUw+`h&p;Ctl%r{25Pu;L-|b&_zab52CDaYNi-b zJc#m`qt+y%cv_UloM25N3Ud(UaVI(6n1ba$Imz+H6xG^FC-&MMd&yuLT0P+;Cp1$e z)sv}I%t5NB(ukRYR2@!IYfM3^&QvOp zrYU&!tP@?~AE{u4@g1_mk?)qTUKae_iFY{3&Pnd?aFU&~0Zo@T zSu3=xuR6gyiLA^)*4I*5nS$llQ(2iJS$9I#J&CMLL)JG_S(zfg{yUYGImr4ysX9zS z*1f5$OhMLtsjN&v*0;J1{Ha;OZ>O>%P_^H|XR$8j3M1=&C-_$*tES-9ekZ!nZ_|Z~ zFN{wdd8aBbemofN#Gg3cAd{yodVb;z<_9cPZx*_K;*7c3=!&OH?{AKpsq7lgYF+>4 z1T&Rg!^}a~u2fg1VENNjSEfkU&z#}jmx-=SL)WiJSA8dDich2?rP@_Ek-m14YcRJ# z-)~Zxn1a6FI>|MdDd>AF)t4#gd;ElfSmEED(5n*LpMwKlUyQSAQs~{GR0}lFH{+}p z9c0h~5%jG^lgQ32xm6__2UC=ZB`WsrRd(ZG z8ks1mBoj<=Chk|+p(ZL#!sPE&k{0HWmOrXw<7EnIc|av;VG3z^P}z+)mdqh951lX) zd3iYLrh6eT|D}?=kRZ#HT9KqBXVS7v1uK%YXcB2zuDr2H0Orc}DB`c=k6s_UsLFII z{lZD_)GL*F3iSC2q)p*VmA-ia@d&KU69^Fcwkh*s42Z0?Df40s2u<3Qc`+uapFrA_ zc`^Q;&kszi6|M>s*g8I(fSy!t^=0}gW9d`D(0H{n4;bi=8CEOvfB}f5y;}8u5}wfm z1`+&#v04pyQt4kR42SRScJ!j1!*+wICzN@>@cCJ#gW{wI3_zR&N)D_Z7mX{Ok5`N# z9m>34pbaBC6y7h2crgfYxmTI(rPUol;rg;NFT(*p2dq`*1q%>wl*&9o07194%Di9! z!r)qE9&otO_}UXTcB$;?JV8%hW4rAd1H|`@U1LD#yW6fYKv>;v*BBty z*lxSVdg3wOEVA8pk@e&?_C{(Q=797gE*cq{Yy-mj8#dd3u>OWJzseBQEBFm%ewFFT z%Vtk%d@>3T@W&=F(rS;&Rapq@dsLsk7Q*@-)$bBslrUwi?@|3nnI812xR0zqK-Jch zDs*3Y#JXUJi1(#tsYLgsX7S7pn3$zUc6v|WM`kG%y+ZFDm3h~sq8D6zM`h388GG@v z-LGU1u1!GPB$U*@*#V*Nexwq8V^;xV;{B>$e>Phgdhe;shsJCry1u8f!`W;lXZV1X z2@oHLHp4*Zd%$KG5LOS^3C4C3?E93Bf&sFzR?6m89roFfzGgg$fg1i)(_cK5Y*NW*$h|m9(0(j zFQYvO7$3Ezq0a@6UVb>WN(nxkTBQUZPOaJmA113S>1WU?T=-Pxw{EG&hT!qRpQ!9f z>}?gNp^Ij_7Lx(u+E&`NAs9ElOPQa4L1MBlN-FJ{fS9aH4Y-txl%NiwOAXXTUd8Hv zK}HW!2*9EGqIB!?!TTe>uxAEHSp9;|3~iM*y{~9YNsoc5UzJ)m2KSHnO6BzWsA4g{ zQUmnwD^)Dj5gKy|PXmKTN>{yrAE1t?oZjKlhgiGAS4o9wzNR6x(*iQGeQA^aTIKkC zm>MzkYc$Mq?k)(P6VaRvP(Y@KAg# zb(OvikEO2CpCcSk=fk}bNI%(E$qLMOT;=pH1ArKM+#Y^aGK-WxY7bhE=a zWxcs}X1hj9z)%6{--3Y9ceZOTLV&P3+cg&{$n^uG!MPF ziVX!*ce&>N)0>z1f;2o7BoLQg41cJo7r5pI1c_}2sfdg5O%{HrW)e!1!gGyfl1!i)a^ delta 9073 zcmZ8nYm`+*cD_|--_!S=f_|LlWh>Y!5S4-PmFSECWDLSG8WCMtGh<9v%wm>nmYG@0 zajhi&n8Tx?0fEtpcm!;v8ych$5K*s!^3YakzzhiO2nc4xb|gAH1Rso&yQOQ=3$iZVH;Hsn`@YRW+5G%1zPp=JhXC~9~{rJWC& zg$pNEU66n;#xzTrx~OkdvP^#f^)i~}Kjp?(~O-s_M zN*Bp8S{E?9jV0&^|2DlaG?yj$f3WoQd)l@~M+K|6Be zum&_iV#Py&($U(r79R& zm)EOdx3~!bw)h6#;^V|9Y69rR^asu6hGc`^X>b-NsWkYwQJFj@wn?T^yV1bYpt*ZZoLTI>%h7lI?z{ zSIL&>@=^fC1Y~sv!XjN>XFw>?<#h&x5?x+rhE|C#uXB}Vm)~ZWKjqsRSYTs1_962> zSH11s1`oq;`$(&#)OX_+jvlwuaQp-ARcXuXHS-cab}Js_$Rn2J>av!*bm#mM|7*aKlg2 zkq?_GjXlbpfMxNnhqu*s-0)!`4nI|er_$#hHgDB`%2J_%DJ;O1@PyK{EAt*Pqg8T3 zh5dOUet;A3gc=}jFoYW?)U`Kq0Wicpa8l6>9dZE(@zMemz)59h=ptv^bMWwU6%OxB z!SX2;ULN&}B0s7KKTfM8{90A@9tma6sPIC5NAx4B6x91hl}2FUY&*rZ-iN>Hk!CWp4^ET~l>s_SJ?s(EYR0aa(*ae;WwIEvn(ifpE=W-WkEP(;bEO+K`2;$L}ytLinH)r-9LN`XM;_l z(8xln)7Hhc^=eUi(PO4QS*$ayWdyW-B3Dc(Xx*YSt%ZWtPi9(cp&<2B=S+muOLM6? z#5PUsnbeA&-7$`}Zquec)0$b)dYKNdcUoHxS}$j(v z5c80uL!Wn*tA0N3I`rttPG}7;R%?1S6IxrLS8MZXCbV!6`k7p4p^lLB-yS*d4GcD0hHOUhfZ{w^Xa}_-|rbD>!CT{;POXPOHQ&G+#Bv`36ka>VS_r60KXED4kQ@@xo)Y`mL`DipHu`#c*$ zAsg>y*+{f-NXPzjW+EL2@^oJ=?1_G=*Tl=2gWjikn7!^0D1E8vmdp@~2r6CQV(RO^)QvYs>q$-!yjM>P z^!&>2Tg(+YnHFScbrBv<3$nAi2qmWl*Wc-sEJDfY0nH2ijajrxP7lnyz~7j{LCG0G zc#-o&C|GU^!ePBBNH#MF6MwPDFxG5VkZC3yG;0pB%S=7>%G@BFnYfuY? z5}kX_L`e1!kJ=fE5_lDlS^~Wx2pTqqXd3*Z8BiJ4YDNV~OAyKzIyQL-pIU;!KXf{W zP^Bdp|1VCJ5UM;G(3(t@(5mueVAf=+2nSW33bGqaC|F(^WH*>lTwkq061E5a1``@$ ztq8J9SSZ$NWv-QQ(CX6FUsLpFU?MWd9-?W=GZFKn!s0 z+qYmACi6|q~nRL;QT$;GiI+Jd_$GK648?%TOkv~|=HZQZtEFyof3WvNjQ+9+1 z1k zaLCIOh`&a@Ya^VeROlev#^h#nE4hAOB+ZRB!pA6o)&io_ORl*Agt2Yp+QNXyS{u2x zFd#H(Bi9yYXqy{t%D7J)zD|X=Tp1^QF!XgQ zjg+B%G`D9u{VITh{>neZDxG9((-#jhwUdV0<_#cL>7;A^(hcVJ%$uy!ZSc1b=L2YP z!??yfraaL2Z}(35MdQEz^8P95^$(k?lE}}IQBFn7fyFmjMQ$tg#WxTY-f@2S#mFuy z4VQ_1W&7>$+Yb<*L4NxIVeAgS{QzNghu?mHCHw%f!|y-FcK_}0`>(HTzn!^tm;mjV~qWXRw z!ul@iKSuT|OgZbjXuxPUf_sX4*m#RZ8v1gz0*-B1pd0VJcS_^^zr1fs;{%mPUoq7x z*^{rs^}8ou2Yq+IY#r{pbAx>kufw^ij>COac-Q5o8b0r%(qD)R)v~9%N%xV$1;pD3 zY5OA{5XN>Rw-}og2^b4^Q~v>Cwk8hWqrwNyYz>CKN2SZeY>lLRzn2XVpOHT0Kp4B< zryLMg_xqFsBAop`;|wL71e`l^Yb50da_cY$WK)iZ0$s`hVf}zlIUuYb@F{0##X8_q zUL%L-!Q4194m9>I7Z|7fpicz`!}>v=3P4yt=u^SaT0iJhULyz8VYa@E4=7-K|E3Ra zG3_@V&aHBS59d}n!H09JKEa3CY6t%=T8o!PD*Tz!d$yXVFF8V`^TgX)Nkb2>b}JSG z#0wwkaSdbB-Mjh@NN?O`u9?(B?w4Ua#o2HEag3`BaPK-5X>1@IK+Ml3~D@d%~Ajtqw8z6wht3(cw%!l{R&nr+$7aca^^_ zPvx%i|0tZ!*TZWKknQEy%6}}Jrn3FX01#78`#Z8$moW7Vq3Q7|A0WUCEA!tlV^ng6 zs_el88ne!DJ@Ii+7l+?cVP?_3Db*pKZ>cm)vQsAobJqI;7!#2F83+ht&-#4}2&-rP zz6HeMXZ^md(<6Poob~&*PU>Z95pPQO!&|^O1e#GSHr!+1l$O3_{wA4PbZ<%+fEA_| z-J231-jt>m2h1qiHzg+IO=)Uz;EbYuQ*s<+nTAnPxsHOV=|%UZR41Ffsp!fB5PgfH zmU2tr7#DI=(Y+=CVs29r70mL*aoAkMf23IXVvKAqx|=SR3wn0Zy#@f{t-Yv6h_*l| zhS^19|0MtrQ)d?k+e;DQGUmyJS Q#a}i4YAOv~wcnrm|D)Nk4*&oF diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf โ€” an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 9ef1bea648af6f56874a1dcede16f95e7cd1fbc0..28bc29cbd1d975fa1c6d760ec306b8be3da3f57b 100644 GIT binary patch delta 9690 zcmZ8nYm`;RmA+M{&gpy40lGOnZ37$?RH6`#8Fh3pqG`b=1jx#$ab-1dP4`+&xAwh^ zgDWcsNecmaHKpw|6~G-TuD?HQScd%xn=Yvc+jpEpCg~Qqhua$+h@T2cOo+&e`S9Yl1^32QR%U z{EE=cWR|+6_oD2j&WJ`u(;J)fg<^C4lwy8rbj7#{(WJ3gPK>U+ZsMfT*IYAt(v{<`J7Z!r?&sG< zKOZ;Y>a(NU>guP|MfC;zr?wc?Hu_QR)M@pN^)OLeY-*0CG&L6Tjby9Xgg*Jks92Yu z5!E*qinR?5pzDhxqbuVgnYeD;q<@;;k8XyA+6eZF(X`sB`RKO#VjYMn&H1S?T3_1` zP0csd|3@B}?AMaB6MhnnA3b5x^-)tJh+Fc_g{HUsv03b97+l-EcFy_VV%3 zl!kg(A%|kqFRGnhtZQnnza92y?qVI>YHG|!g{Fq-#rmekkNNkpILr zH8#wM5L+=njchmFtc9T_YXhOoh-#-4X@;}GLbA9q%Cr6r5RObW=WC}%wS}m$2`)7L z3La053e%yqQ3G8j)Likii8VioCeMf(Yp21bGi#?zX`0?xESwS5H8o7Vys7c#`o{d! zYoXqxrYnmD5QXW}^3B6Jh^Tm56Hf#4g*d8hhVQrB(p0D~QV@Q0%k*ZT{6!r8WZ0cr zAmxn>l8_S}6`Q9QiiNXv@CdUxvYq|zc|r^P%p+A%q4u_szTeY#sS@2#EfPW8+hRdD zJk3If;OS@jmAZG93U~Mt;rPA%91YLT$%Qghi-kJJ@8buSQoS&+Rj7Y6cCso|OKAc0 z;`)afpnHX~%`x0qDgx_87G5kBVPueZX?vZ-Rn|m#2~Wh-|qCtyv$lEEm<^vo&AKPG&sWazCFnJ0v(zE4D>8>7+l_GlzPa|ap-P=fq z9`F>9(IvJK9=3^GrG@aYO@w_cgokaS?YdP9TgDzmTgo@ z#uQfBhJ|KKT()5m)DxF&Sc=11n_7oCARQJO8JaQygz>dDDnJ-tYoj7KCwD5X#b(wqV0E1sdM#H4LGAK7G3**Mz`+t< zPfL6>8%0e3JzqN55?mUuw`*KUs*d#{JkyTCH0#A#XY=lbR_tEu#faz4?nQ*&L!Yy| zm!TAev_YtgI8$!uy(ls-nM}Eu;zf}i%u~3?R2-^JR!m?aaK2Ou3w?&8cx%1Jap7Bg0&*S2QrJZ?>rdg!RofRRp#5&9(r&(Ay$1ub8kr z7~3MUIzgVyc%j-VWG@Jy6d)tL=z!37tEfDgCGbw;u(pc9cC5hI?LzkT$MRTo-d@@? zJGd&^F8Y}hS9wut4~)znA$7)_Tz#CL_zKq{olrE9OBp^ zVh&C}a0A{pMQS*#seS0h3&c<`^_EaQI0+TfL67~U)J+@Yu%y zrJ)Ohfl-$*J7JFf?4sqh6(_xy3B4~x=JC=m76x1Td`Y6Al9iW%Q{GV_Tg&G!qFO#G zynZw#HDD(^D*CfS1mVz8alz$O3IuT;d?VC+=}{>#v34O!;TsXmmoDYqa`5$A;ho-{ zg5_hv`@Y}T_iZm1ejFFEcLxf!+hAxkOL{}-!f1O|2Q3Pr_dDsG+nvJYYp(Rp(^QPO5JnKHc_)nQ5vseWTN?cWsTL#pJ|@y9x351n z^cKiqF~YZ=5s4aDNFj9RDsWepUZuZA=RkyBNy;arQ~sG8D>Aqxank^{L8 z2qFt5dGUDThd>tYlWJv>1wrl;7pzROz#Ou0zf7{g6fFNaF? zIL!i6oP|fyENBuYm!(-?4q13K)siV>VMUq+rjUilx@CbmWa04>Mj{J;O0z)Gw@KBW zWI-sMg*F+qCt1)Wvam{e7n>|-4p~@DSy+LM5!zy7?3D5q>69M6J6INXN{>$}YC&9` z@}wV{l|j2=ryMoGBtgQBr=@y1NrKc#cv=Q8CrMxqNq8nr0#mTOCQSlUl!UVE=RKPw zfoUXRy)3ofp6wHFkhpp2dg3Ok{^wKOnS<^xr0OvR-Cs;~X9~J+N_3Y@LH5lj420}2 zrLxPPA>b`iZBKNUM)xf;*q-RFN$9>+dKV_%Qi7oSHqw2*u71?(PAUId-f(Xa;53yU zU$Y>)gRpkWp<|8gbPVj26Mt!B$C2UflIl<*yQ5{_C4)nW?94&--Kp$M!SWla>`amD zd*pC$Zz4O>kbPfT`%H1|zg@cZzMxP1PAWUMLH2i3^_YU}|C`Fr6l8xdm7OWbe&B?G zko{mPJH`9HR39d)J5c>H+H~HR!H0?L#PX)|f%GmnvTF{qA12v9)8!6xT~f|;N;UTf zU2&I8Hhz2+sxFyq{P>^&yx=+`JADXzD%E$1>`Kf2sSLhLWM>Ytf0oM56fA!(y|c{5 z&lJi21-6{OC$cjQ*^i{VCR0@9U&~%+ncbT?$bB?bjVZ|ejr7jx-V1Vno660-AosCu z1IJSk(EB)b)8`)oRsWFcn#2(AEuey{jjW2LhFAZP(N+Faigyv*zUMje-trxP2+oo5 zJSW-7U38r1Bs;kaS?4(yPcpK)ko7J{-Q(Ev)YY=y?{vd(wBQ;b_o!SVvf z8`hnIehVEhw#N>4BLNpViGa*Oz{O5-NHPThTOA}xAHz&Rz$K17BqdW2aOnvHq2Jxq z>lHj*V)^32o?GF_^^Q}$&MUq9XfUw+`h&p;Ctl%r{25Pu;L-|b&_zab52CDaYNi-b zJc#m`qt+y%cv_UloM25N3Ud(UaVI(6n1ba$Imz+H6xG^FC-&MMd&yuLT0P+;Cp1$e z)sv}I%t5NB(ukRYR2@!IYfM3^&QvOp zrYU&!tP@?~AE{u4@g1_mk?)qTUKae_iFY{3&Pnd?aFU&~0Zo@T zSu3=xuR6gyiLA^)*4I*5nS$llQ(2iJS$9I#J&CMLL)JG_S(zfg{yUYGImr4ysX9zS z*1f5$OhMLtsjN&v*0;J1{Ha;OZ>O>%P_^H|XR$8j3M1=&C-_$*tES-9ekZ!nZ_|Z~ zFN{wdd8aBbemofN#Gg3cAd{yodVb;z<_9cPZx*_K;*7c3=!&OH?{AKpsq7lgYF+>4 z1T&Rg!^}a~u2fg1VENNjSEfkU&z#}jmx-=SL)WiJSA8dDich2?rP@_Ek-m14YcRJ# z-)~Zxn1a6FI>|MdDd>AF)t4#gd;ElfSmEED(5n*LpMwKlUyQSAQs~{GR0}lFH{+}p z9c0h~5%jG^lgQ32xm6__2UC=ZB`WsrRd(ZG z8ks1mBoj<=Chk|+p(ZL#!sPE&k{0HWmOrXw<7EnIc|av;VG3z^P}z+)mdqh951lX) zd3iYLrh6eT|D}?=kRZ#HT9KqBXVS7v1uK%YXcB2zuDr2H0Orc}DB`c=k6s_UsLFII z{lZD_)GL*F3iSC2q)p*VmA-ia@d&KU69^Fcwkh*s42Z0?Df40s2u<3Qc`+uapFrA_ zc`^Q;&kszi6|M>s*g8I(fSy!t^=0}gW9d`D(0H{n4;bi=8CEOvfB}f5y;}8u5}wfm z1`+&#v04pyQt4kR42SRScJ!j1!*+wICzN@>@cCJ#gW{wI3_zR&N)D_Z7mX{Ok5`N# z9m>34pbaBC6y7h2crgfYxmTI(rPUol;rg;NFT(*p2dq`*1q%>wl*&9o07194%Di9! z!r)qE9&otO_}UXTcB$;?JV8%hW4rAd1H|`@U1LD#yW6fYKv>;v*BBty z*lxSVdg3wOEVA8pk@e&?_C{(Q=797gE*cq{Yy-mj8#dd3u>OWJzseBQEBFm%ewFFT z%Vtk%d@>3T@W&=F(rS;&Rapq@dsLsk7Q*@-)$bBslrUwi?@|3nnI812xR0zqK-Jch zDs*3Y#JXUJi1(#tsYLgsX7S7pn3$zUc6v|WM`kG%y+ZFDm3h~sq8D6zM`h388GG@v z-LGU1u1!GPB$U*@*#V*Nexwq8V^;xV;{B>$e>Phgdhe;shsJCry1u8f!`W;lXZV1X z2@oHLHp4*Zd%$KG5LOS^3C4C3?E93Bf&sFzR?6m89roFfzGgg$fg1i)(_cK5Y*NW*$h|m9(0(j zFQYvO7$3Ezq0a@6UVb>WN(nxkTBQUZPOaJmA113S>1WU?T=-Pxw{EG&hT!qRpQ!9f z>}?gNp^Ij_7Lx(u+E&`NAs9ElOPQa4L1MBlN-FJ{fS9aH4Y-txl%NiwOAXXTUd8Hv zK}HW!2*9EGqIB!?!TTe>uxAEHSp9;|3~iM*y{~9YNsoc5UzJ)m2KSHnO6BzWsA4g{ zQUmnwD^)Dj5gKy|PXmKTN>{yrAE1t?oZjKlhgiGAS4o9wzNR6x(*iQGeQA^aTIKkC zm>MzkYc$Mq?k)(P6VaRvP(Y@KAg# zb(OvikEO2CpCcSk=fk}bNI%(E$qLMOT;=pH1ArKM+#Y^aGK-WxY7bhE=a zWxcs}X1hj9z)%6{--3Y9ceZOTLV&P3+cg&{$n^uG!MPF ziVX!*ce&>N)0>z1f;2o7BoLQg41cJo7r5pI1c_}2sfdg5O%{HrW)e!1!gGyfl1!i)a^ delta 9073 zcmZ8nYm`+*cD_|--_!S=f_|LlWh>Y!5S4-PmFSECWDLSG8WCMtGh<9v%wm>nmYG@0 zajhi&n8Tx?0fEtpcm!;v8ych$5K*s!^3YakzzhiO2nc4xb|gAH1Rso&yQOQ=3$iZVH;Hsn`@YRW+5G%1zPp=JhXC~9~{rJWC& zg$pNEU66n;#xzTrx~OkdvP^#f^)i~}Kjp?(~O-s_M zN*Bp8S{E?9jV0&^|2DlaG?yj$f3WoQd)l@~M+K|6Be zum&_iV#Py&($U(r79R& zm)EOdx3~!bw)h6#;^V|9Y69rR^asu6hGc`^X>b-NsWkYwQJFj@wn?T^yV1bYpt*ZZoLTI>%h7lI?z{ zSIL&>@=^fC1Y~sv!XjN>XFw>?<#h&x5?x+rhE|C#uXB}Vm)~ZWKjqsRSYTs1_962> zSH11s1`oq;`$(&#)OX_+jvlwuaQp-ARcXuXHS-cab}Js_$Rn2J>av!*bm#mM|7*aKlg2 zkq?_GjXlbpfMxNnhqu*s-0)!`4nI|er_$#hHgDB`%2J_%DJ;O1@PyK{EAt*Pqg8T3 zh5dOUet;A3gc=}jFoYW?)U`Kq0Wicpa8l6>9dZE(@zMemz)59h=ptv^bMWwU6%OxB z!SX2;ULN&}B0s7KKTfM8{90A@9tma6sPIC5NAx4B6x91hl}2FUY&*rZ-iN>Hk!CWp4^ET~l>s_SJ?s(EYR0aa(*ae;WwIEvn(ifpE=W-WkEP(;bEO+K`2;$L}ytLinH)r-9LN`XM;_l z(8xln)7Hhc^=eUi(PO4QS*$ayWdyW-B3Dc(Xx*YSt%ZWtPi9(cp&<2B=S+muOLM6? z#5PUsnbeA&-7$`}Zquec)0$b)dYKNdcUoHxS}$j(v z5c80uL!Wn*tA0N3I`rttPG}7;R%?1S6IxrLS8MZXCbV!6`k7p4p^lLB-yS*d4GcD0hHOUhfZ{w^Xa}_-|rbD>!CT{;POXPOHQ&G+#Bv`36ka>VS_r60KXED4kQ@@xo)Y`mL`DipHu`#c*$ zAsg>y*+{f-NXPzjW+EL2@^oJ=?1_G=*Tl=2gWjikn7!^0D1E8vmdp@~2r6CQV(RO^)QvYs>q$-!yjM>P z^!&>2Tg(+YnHFScbrBv<3$nAi2qmWl*Wc-sEJDfY0nH2ijajrxP7lnyz~7j{LCG0G zc#-o&C|GU^!ePBBNH#MF6MwPDFxG5VkZC3yG;0pB%S=7>%G@BFnYfuY? z5}kX_L`e1!kJ=fE5_lDlS^~Wx2pTqqXd3*Z8BiJ4YDNV~OAyKzIyQL-pIU;!KXf{W zP^Bdp|1VCJ5UM;G(3(t@(5mueVAf=+2nSW33bGqaC|F(^WH*>lTwkq061E5a1``@$ ztq8J9SSZ$NWv-QQ(CX6FUsLpFU?MWd9-?W=GZFKn!s0 z+qYmACi6|q~nRL;QT$;GiI+Jd_$GK648?%TOkv~|=HZQZtEFyof3WvNjQ+9+1 z1k zaLCIOh`&a@Ya^VeROlev#^h#nE4hAOB+ZRB!pA6o)&io_ORl*Agt2Yp+QNXyS{u2x zFd#H(Bi9yYXqy{t%D7J)zD|X=Tp1^QF!XgQ zjg+B%G`D9u{VITh{>neZDxG9((-#jhwUdV0<_#cL>7;A^(hcVJ%$uy!ZSc1b=L2YP z!??yfraaL2Z}(35MdQEz^8P95^$(k?lE}}IQBFn7fyFmjMQ$tg#WxTY-f@2S#mFuy z4VQ_1W&7>$+Yb<*L4NxIVeAgS{QzNghu?mHCHw%f!|y-FcK_}0`>(HTzn!^tm;mjV~qWXRw z!ul@iKSuT|OgZbjXuxPUf_sX4*m#RZ8v1gz0*-B1pd0VJcS_^^zr1fs;{%mPUoq7x z*^{rs^}8ou2Yq+IY#r{pbAx>kufw^ij>COac-Q5o8b0r%(qD)R)v~9%N%xV$1;pD3 zY5OA{5XN>Rw-}og2^b4^Q~v>Cwk8hWqrwNyYz>CKN2SZeY>lLRzn2XVpOHT0Kp4B< zryLMg_xqFsBAop`;|wL71e`l^Yb50da_cY$WK)iZ0$s`hVf}zlIUuYb@F{0##X8_q zUL%L-!Q4194m9>I7Z|7fpicz`!}>v=3P4yt=u^SaT0iJhULyz8VYa@E4=7-K|E3Ra zG3_@V&aHBS59d}n!H09JKEa3CY6t%=T8o!PD*Tz!d$yXVFF8V`^TgX)Nkb2>b}JSG z#0wwkaSdbB-Mjh@NN?O`u9?(B?w4Ua#o2HEag3`BaPK-5X>1@IK+Ml3~D@d%~Ajtqw8z6wht3(cw%!l{R&nr+$7aca^^_ zPvx%i|0tZ!*TZWKknQEy%6}}Jrn3FX01#78`#Z8$moW7Vq3Q7|A0WUCEA!tlV^ng6 zs_el88ne!DJ@Ii+7l+?cVP?_3Db*pKZ>cm)vQsAobJqI;7!#2F83+ht&-#4}2&-rP zz6HeMXZ^md(<6Poob~&*PU>Z95pPQO!&|^O1e#GSHr!+1l$O3_{wA4PbZ<%+fEA_| z-J231-jt>m2h1qiHzg+IO=)Uz;EbYuQ*s<+nTAnPxsHOV=|%UZR41Ffsp!fB5PgfH zmU2tr7#DI=(Y+=CVs29r70mL*aoAkMf23IXVvKAqx|=SR3wn0Zy#@f{t-Yv6h_*l| zhS^19|0MtrQ)d?k+e;DQGUmyJS Q#a}i4YAOv~wcnrm|D)Nk4*&oF diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf โ€” an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index cc176c0d8..e604cff85 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 237dea802..d3d21ea7d 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 + digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 + digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go index b55bc8d72..75fef3d17 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go @@ -17,6 +17,7 @@ package adapters import ( "context" + "net/http" "testing" "github.com/google/uuid" @@ -223,3 +224,160 @@ func TestConfinedMintRefusesWithNoConfiguredOrigin(t *testing.T) { require.Equal(t, codes.FailedPrecondition, status.Code(err)) require.Contains(t, err.Error(), "no configured public address") } + +// THE DELIVERY SHAPE, not a constructed context: the headers the gateway +// forwards, through the REAL Connect interceptor, into the REAL StartTask. +// +// Every test above installs the confinement with the context carriers directly, +// which proves the rule and not the route to it. This proves the route: that the +// perimeter's two headers survive the interceptor's strip, reach the mint as +// facts, and are refused when a caller supplies them without the gateway's own +// credential or supplies either one twice. +func forwardedByTheGateway(confined bool) http.Header { + headers := http.Header{} + headers.Set("X-Codefly-Gateway-Token", "test-gateway-token") + headers.Set("X-User-Id", renewActorID) + headers.Set("X-Org-Id", renewOrgID) + headers.Set("X-Org-Role", "admin") + headers.Set("X-Session-Id", renewSession) + headers.Set("X-Credential-Kind", credentialKindSession) + headers.Set("X-Scopes", "") + if confined { + headers.Set(resourceConfinementHeader, confinedResource) + headers.Set(installationHeader, confinedInstallation) + } + return headers +} + +func authorizeAsTheGateway(t *testing.T, headers http.Header) (context.Context, error) { + t.Helper() + previous := gatewayToken + SetGatewayToken("test-gateway-token") + t.Cleanup(func() { SetGatewayToken(previous) }) + // The procedure name is the policy lookup key, so it is the real one: an + // unclassified procedure is refused before any header is read. + return (&connectPolicyInterceptor{}).authorize( + context.Background(), "/saas.accounts.v1.WorkContextService/StartTask", headers) +} + +func TestConfinedMintThroughTheRealInterceptor(t *testing.T) { + for name, probe := range map[string]struct { + request func() *gen.StartTaskWorkContextRequest + want codes.Code + }{ + "control: inside the ceiling": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("documents") }, + want: codes.OK, + }, + "another organization": { + request: func() *gen.StartTaskWorkContextRequest { + request := confinedMintRequest("documents") + request.OrgId = uuid.Must(uuid.NewV7()).String() + return request + }, + want: codes.PermissionDenied, + }, + "an audience the consumer does not declare": { + request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("example") }, + want: codes.PermissionDenied, + }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server := newConfinedMintServer(t) + ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) + require.NoError(t, err) + + issued, err := server.StartTask(ctx, probe.request()) + + require.Equal(t, probe.want, status.Code(err), "err=%v", err) + if probe.want == codes.OK { + require.NotEmpty(t, issued.GetToken()) + } + }) + } +} + +// AN EMPTY CEILING IS NOTHING FOR A CONFINED MINT, where it is unrestricted +// everywhere else. +// +// This is the ordinary state, not an edge case: both fields are optional, the +// proto documents empty as unrestricted, and the install dialog leaves them +// empty. Read that way here, a credential delivered to one third-party client +// mints for any audience this host serves and then pairs at every module the +// organization installed โ€” which is the escape the ceiling exists to close. +func TestConfinedMintUnderAnUndeclaredCeilingIsRefused(t *testing.T) { + for name, strip := range map[string]func(*business.Principal){ + "no declared audiences": func(p *business.Principal) { p.AllowedAudiences = nil }, + "no declared scopes": func(p *business.Principal) { p.AllowedScopes = nil }, + } { + t.Run(name, func(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server, authority := newSolutionBoundaryServer(t) + consumer := confinedConsumer() + strip(consumer.Actor) + authority.installationFcts = consumer + ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) + require.NoError(t, err) + + // `documents` is what this consumer's full ceiling DOES declare, so + // the only thing that can refuse it here is the empty list itself. + issued, err := server.StartTask(ctx, confinedMintRequest("documents")) + + require.Error(t, err, "an installation with %s put no bound on a credential delivered to a third party", name) + require.Nil(t, issued) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "declares no allowed") + }) + } +} + +// THE CONTROL for the two above: the same empty ceiling puts no bound on an +// UNCONFINED mint, which is what the field means everywhere else and what an +// installation's own headless work relies on. +func TestAnUndeclaredCeilingStillLeavesAnUnconfinedMintUnbounded(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + server, authority := newSolutionBoundaryServer(t) + consumer := confinedConsumer() + consumer.Actor.AllowedAudiences = nil + consumer.Actor.AllowedScopes = nil + authority.installationFcts = consumer + + issued, err := server.StartTask(boundaryCaller(), confinedMintRequest("example")) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} + +// The two ceiling headers are read ONLY behind the gateway's own credential, and +// only once each. Without the credential they are deleted โ€” which also leaves the +// request unauthenticated, so there is nothing to mint for either. +func TestCeilingHeadersCannotBeForged(t *testing.T) { + solutionBoundaryService(t) + withConfiguredOrigin(t) + + t.Run("no gateway credential", func(t *testing.T) { + headers := forwardedByTheGateway(true) + headers.Del("X-Codefly-Gateway-Token") + ctx, err := authorizeAsTheGateway(t, headers) + if err == nil { + _, err = newConfinedMintServer(t).StartTask(ctx, confinedMintRequest("documents")) + } + require.Error(t, err) + }) + + for name, header := range map[string]string{ + "a second installation": installationHeader, + "a second resource": resourceConfinementHeader, + } { + t.Run(name+" is refused", func(t *testing.T) { + headers := forwardedByTheGateway(true) + headers.Add(header, "a-value-the-perimeter-did-not-stamp") + _, err := authorizeAsTheGateway(t, headers) + require.Error(t, err, "a second %s would let the path that added it choose the ceiling", header) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index cb0d0aaeb..35c5342d1 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -633,6 +633,47 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( return status.Error(codes.Internal, "the consumer's installation resolved without an agent principal, so its ceiling could not be read") } + // AN EMPTY CEILING IS NOTHING HERE, where everywhere else it is everything. + // + // `allowed_audiences` and `allowed_scopes` are optional, and an empty list + // means UNRESTRICTED on every other mint โ€” an installation that names no + // audience puts no audience limit on its agent's own headless work, which is + // the documented and intended reading for a consumer an administrator + // installed and trusts. The install form leaves both empty by default, so + // that is the ordinary state, not an edge case. + // + // For a CONFINED mint it inverts. The credential is held by a third party + // this organization never installed โ€” an MCP client the person authorized for + // ONE solution โ€” and the installation is the only statement of what that + // solution may act for. Read as unrestricted, it says the person's whole + // authority is available to that client at every module the organization + // installed, which is the escape the ceiling exists to close and is what the + // round-4 review found shipped. + // + // So it refuses, by name and actionably. A silent narrowing to "nothing" would + // have been the same decision with the operator left to discover it from a + // failing tool call. + // + // WHAT THIS IS NOT: the ceiling a consumer's own DECLARATION would give. The + // delivered presence document carries module PINS (module, package, version); + // the audience a capability names is the consumed module's ROUTE ALIAS; and no + // record on this host joins the two. Deriving one would be a guess at exactly + // the question this check answers, so the authority stays the installation an + // administrator consented to, and the gap is recorded rather than papered + // over (see the PR body and module/SOLUTION_REGISTRATION.md). + if len(consumer.Actor.AllowedAudiences) == 0 { + return status.Errorf(codes.FailedPrecondition, + "the installation of the consumer this credential was delivered to declares no allowed audiences, so there is nothing to hold its mint to. "+ + "An empty ceiling is unrestricted for an installation's own work and is NOT accepted for a credential delivered to a third party: "+ + "name the audiences agent %s may be minted for on that installation.", + consumer.Actor.AgentIdentifier) + } + if len(consumer.Actor.AllowedScopes) == 0 { + return status.Errorf(codes.FailedPrecondition, + "the installation of the consumer this credential was delivered to declares no allowed scopes, so there is nothing to bound its mint to. "+ + "Name the resource kinds agent %s may be minted for on that installation.", + consumer.Actor.AgentIdentifier) + } return enforceActorCeiling(ctx, consumer.Actor, req.GetAudience(), scopes) } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index 201d081f5..0794d9af7 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go @@ -310,7 +310,16 @@ type InstallSolutionRequest struct { CoOwnerPrincipalIds []string `protobuf:"bytes,9,rep,name=co_owner_principal_ids,json=coOwnerPrincipalIds,proto3" json:"co_owner_principal_ids,omitempty"` // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf โ€” an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. AllowedAudiences []string `protobuf:"bytes,10,rep,name=allowed_audiences,json=allowedAudiences,proto3" json:"allowed_audiences,omitempty"` AllowedScopes []string `protobuf:"bytes,11,rep,name=allowed_scopes,json=allowedScopes,proto3" json:"allowed_scopes,omitempty"` // The solution target being installed, from ListAvailableSolutions. Required. diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 3f01a78e0..524422ac1 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -8827,7 +8827,7 @@ "items": { "type": "string" }, - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\nEmpty = unrestricted." + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf โ€” an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw." }, "allowedScopes": { "type": "array", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 3e4dc5b45..a2c559bac 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -2350,7 +2350,7 @@ "type": "string" }, "allowedAudiences": { - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\nEmpty = unrestricted.", + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf โ€” an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw.", "items": { "type": "string" }, diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index 9b70895db..bc9c0e277 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -131,7 +131,16 @@ message InstallSolutionRequest { ]; // The agent's delegated-execution ceiling (migration 108): the Work Context // audiences it may be minted for and the resource kinds it may ever touch. - // Empty = unrestricted. + // + // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + // administrator consented to when they installed it. It is NOT unrestricted + // for a mint made with a credential delivered to a THIRD PARTY on a person's + // behalf โ€” an MCP client the person authorized for this one solution. There + // the installation is the only statement of what the solution may act for, so + // an empty list is read as nothing and such a mint is REFUSED by name + // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + // purpose: one bounds a consumer an admin trusts, the other bounds a + // credential they never saw. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index a456a8678..89b3b7ee3 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -176,6 +176,18 @@ for an audience that installation does not declare, or for scopes outside its agent's standing grants. An authority that cannot be asked answers 503; a capability is not issued on an unanswered question. +**An installation that declares NO audiences is refused here, and is unrestricted +everywhere else.** `allowed_audiences` and `allowed_scopes` are optional and empty +means unrestricted โ€” the right reading for the installation's own headless work, +which an administrator consented to โ€” and the install form leaves both empty, so +that is the ordinary state. For a confined mint the same emptiness would say the +person's whole authority is available, at every module the organization installed, +to a third party nobody installed. So accounts refuses and names the field. This is +the installation's ceiling and NOT the consumer's own declaration: the delivered +presence document carries module pins (module, package, version), a capability's +audience is the consumed module's route alias, and no record on this host joins the +two โ€” deriving one would be a guess at the question the check exists to answer. + Both stamps are on `untrustedAuthHeaders`, so a caller can assert neither and cannot pick its own ceiling. The consumer check and the installation stamp run on the HTTP gateway path, where the registry and the entitlement authority live; a deployment diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index eca289d57..8e38d4a5f 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.24", + "@codefly-dev/saas-sdk": "0.3.25", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.24", + "version": "0.3.25", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 2d0fd4049..79d24462a 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.24", + "@codefly-dev/saas-sdk": "0.3.25", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index a5786a84d..43cbdf026 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52", + "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 9ef1bea648af6f56874a1dcede16f95e7cd1fbc0..28bc29cbd1d975fa1c6d760ec306b8be3da3f57b 100644 GIT binary patch delta 9690 zcmZ8nYm`;RmA+M{&gpy40lGOnZ37$?RH6`#8Fh3pqG`b=1jx#$ab-1dP4`+&xAwh^ zgDWcsNecmaHKpw|6~G-TuD?HQScd%xn=Yvc+jpEpCg~Qqhua$+h@T2cOo+&e`S9Yl1^32QR%U z{EE=cWR|+6_oD2j&WJ`u(;J)fg<^C4lwy8rbj7#{(WJ3gPK>U+ZsMfT*IYAt(v{<`J7Z!r?&sG< zKOZ;Y>a(NU>guP|MfC;zr?wc?Hu_QR)M@pN^)OLeY-*0CG&L6Tjby9Xgg*Jks92Yu z5!E*qinR?5pzDhxqbuVgnYeD;q<@;;k8XyA+6eZF(X`sB`RKO#VjYMn&H1S?T3_1` zP0csd|3@B}?AMaB6MhnnA3b5x^-)tJh+Fc_g{HUsv03b97+l-EcFy_VV%3 zl!kg(A%|kqFRGnhtZQnnza92y?qVI>YHG|!g{Fq-#rmekkNNkpILr zH8#wM5L+=njchmFtc9T_YXhOoh-#-4X@;}GLbA9q%Cr6r5RObW=WC}%wS}m$2`)7L z3La053e%yqQ3G8j)Likii8VioCeMf(Yp21bGi#?zX`0?xESwS5H8o7Vys7c#`o{d! zYoXqxrYnmD5QXW}^3B6Jh^Tm56Hf#4g*d8hhVQrB(p0D~QV@Q0%k*ZT{6!r8WZ0cr zAmxn>l8_S}6`Q9QiiNXv@CdUxvYq|zc|r^P%p+A%q4u_szTeY#sS@2#EfPW8+hRdD zJk3If;OS@jmAZG93U~Mt;rPA%91YLT$%Qghi-kJJ@8buSQoS&+Rj7Y6cCso|OKAc0 z;`)afpnHX~%`x0qDgx_87G5kBVPueZX?vZ-Rn|m#2~Wh-|qCtyv$lEEm<^vo&AKPG&sWazCFnJ0v(zE4D>8>7+l_GlzPa|ap-P=fq z9`F>9(IvJK9=3^GrG@aYO@w_cgokaS?YdP9TgDzmTgo@ z#uQfBhJ|KKT()5m)DxF&Sc=11n_7oCARQJO8JaQygz>dDDnJ-tYoj7KCwD5X#b(wqV0E1sdM#H4LGAK7G3**Mz`+t< zPfL6>8%0e3JzqN55?mUuw`*KUs*d#{JkyTCH0#A#XY=lbR_tEu#faz4?nQ*&L!Yy| zm!TAev_YtgI8$!uy(ls-nM}Eu;zf}i%u~3?R2-^JR!m?aaK2Ou3w?&8cx%1Jap7Bg0&*S2QrJZ?>rdg!RofRRp#5&9(r&(Ay$1ub8kr z7~3MUIzgVyc%j-VWG@Jy6d)tL=z!37tEfDgCGbw;u(pc9cC5hI?LzkT$MRTo-d@@? zJGd&^F8Y}hS9wut4~)znA$7)_Tz#CL_zKq{olrE9OBp^ zVh&C}a0A{pMQS*#seS0h3&c<`^_EaQI0+TfL67~U)J+@Yu%y zrJ)Ohfl-$*J7JFf?4sqh6(_xy3B4~x=JC=m76x1Td`Y6Al9iW%Q{GV_Tg&G!qFO#G zynZw#HDD(^D*CfS1mVz8alz$O3IuT;d?VC+=}{>#v34O!;TsXmmoDYqa`5$A;ho-{ zg5_hv`@Y}T_iZm1ejFFEcLxf!+hAxkOL{}-!f1O|2Q3Pr_dDsG+nvJYYp(Rp(^QPO5JnKHc_)nQ5vseWTN?cWsTL#pJ|@y9x351n z^cKiqF~YZ=5s4aDNFj9RDsWepUZuZA=RkyBNy;arQ~sG8D>Aqxank^{L8 z2qFt5dGUDThd>tYlWJv>1wrl;7pzROz#Ou0zf7{g6fFNaF? zIL!i6oP|fyENBuYm!(-?4q13K)siV>VMUq+rjUilx@CbmWa04>Mj{J;O0z)Gw@KBW zWI-sMg*F+qCt1)Wvam{e7n>|-4p~@DSy+LM5!zy7?3D5q>69M6J6INXN{>$}YC&9` z@}wV{l|j2=ryMoGBtgQBr=@y1NrKc#cv=Q8CrMxqNq8nr0#mTOCQSlUl!UVE=RKPw zfoUXRy)3ofp6wHFkhpp2dg3Ok{^wKOnS<^xr0OvR-Cs;~X9~J+N_3Y@LH5lj420}2 zrLxPPA>b`iZBKNUM)xf;*q-RFN$9>+dKV_%Qi7oSHqw2*u71?(PAUId-f(Xa;53yU zU$Y>)gRpkWp<|8gbPVj26Mt!B$C2UflIl<*yQ5{_C4)nW?94&--Kp$M!SWla>`amD zd*pC$Zz4O>kbPfT`%H1|zg@cZzMxP1PAWUMLH2i3^_YU}|C`Fr6l8xdm7OWbe&B?G zko{mPJH`9HR39d)J5c>H+H~HR!H0?L#PX)|f%GmnvTF{qA12v9)8!6xT~f|;N;UTf zU2&I8Hhz2+sxFyq{P>^&yx=+`JADXzD%E$1>`Kf2sSLhLWM>Ytf0oM56fA!(y|c{5 z&lJi21-6{OC$cjQ*^i{VCR0@9U&~%+ncbT?$bB?bjVZ|ejr7jx-V1Vno660-AosCu z1IJSk(EB)b)8`)oRsWFcn#2(AEuey{jjW2LhFAZP(N+Faigyv*zUMje-trxP2+oo5 zJSW-7U38r1Bs;kaS?4(yPcpK)ko7J{-Q(Ev)YY=y?{vd(wBQ;b_o!SVvf z8`hnIehVEhw#N>4BLNpViGa*Oz{O5-NHPThTOA}xAHz&Rz$K17BqdW2aOnvHq2Jxq z>lHj*V)^32o?GF_^^Q}$&MUq9XfUw+`h&p;Ctl%r{25Pu;L-|b&_zab52CDaYNi-b zJc#m`qt+y%cv_UloM25N3Ud(UaVI(6n1ba$Imz+H6xG^FC-&MMd&yuLT0P+;Cp1$e z)sv}I%t5NB(ukRYR2@!IYfM3^&QvOp zrYU&!tP@?~AE{u4@g1_mk?)qTUKae_iFY{3&Pnd?aFU&~0Zo@T zSu3=xuR6gyiLA^)*4I*5nS$llQ(2iJS$9I#J&CMLL)JG_S(zfg{yUYGImr4ysX9zS z*1f5$OhMLtsjN&v*0;J1{Ha;OZ>O>%P_^H|XR$8j3M1=&C-_$*tES-9ekZ!nZ_|Z~ zFN{wdd8aBbemofN#Gg3cAd{yodVb;z<_9cPZx*_K;*7c3=!&OH?{AKpsq7lgYF+>4 z1T&Rg!^}a~u2fg1VENNjSEfkU&z#}jmx-=SL)WiJSA8dDich2?rP@_Ek-m14YcRJ# z-)~Zxn1a6FI>|MdDd>AF)t4#gd;ElfSmEED(5n*LpMwKlUyQSAQs~{GR0}lFH{+}p z9c0h~5%jG^lgQ32xm6__2UC=ZB`WsrRd(ZG z8ks1mBoj<=Chk|+p(ZL#!sPE&k{0HWmOrXw<7EnIc|av;VG3z^P}z+)mdqh951lX) zd3iYLrh6eT|D}?=kRZ#HT9KqBXVS7v1uK%YXcB2zuDr2H0Orc}DB`c=k6s_UsLFII z{lZD_)GL*F3iSC2q)p*VmA-ia@d&KU69^Fcwkh*s42Z0?Df40s2u<3Qc`+uapFrA_ zc`^Q;&kszi6|M>s*g8I(fSy!t^=0}gW9d`D(0H{n4;bi=8CEOvfB}f5y;}8u5}wfm z1`+&#v04pyQt4kR42SRScJ!j1!*+wICzN@>@cCJ#gW{wI3_zR&N)D_Z7mX{Ok5`N# z9m>34pbaBC6y7h2crgfYxmTI(rPUol;rg;NFT(*p2dq`*1q%>wl*&9o07194%Di9! z!r)qE9&otO_}UXTcB$;?JV8%hW4rAd1H|`@U1LD#yW6fYKv>;v*BBty z*lxSVdg3wOEVA8pk@e&?_C{(Q=797gE*cq{Yy-mj8#dd3u>OWJzseBQEBFm%ewFFT z%Vtk%d@>3T@W&=F(rS;&Rapq@dsLsk7Q*@-)$bBslrUwi?@|3nnI812xR0zqK-Jch zDs*3Y#JXUJi1(#tsYLgsX7S7pn3$zUc6v|WM`kG%y+ZFDm3h~sq8D6zM`h388GG@v z-LGU1u1!GPB$U*@*#V*Nexwq8V^;xV;{B>$e>Phgdhe;shsJCry1u8f!`W;lXZV1X z2@oHLHp4*Zd%$KG5LOS^3C4C3?E93Bf&sFzR?6m89roFfzGgg$fg1i)(_cK5Y*NW*$h|m9(0(j zFQYvO7$3Ezq0a@6UVb>WN(nxkTBQUZPOaJmA113S>1WU?T=-Pxw{EG&hT!qRpQ!9f z>}?gNp^Ij_7Lx(u+E&`NAs9ElOPQa4L1MBlN-FJ{fS9aH4Y-txl%NiwOAXXTUd8Hv zK}HW!2*9EGqIB!?!TTe>uxAEHSp9;|3~iM*y{~9YNsoc5UzJ)m2KSHnO6BzWsA4g{ zQUmnwD^)Dj5gKy|PXmKTN>{yrAE1t?oZjKlhgiGAS4o9wzNR6x(*iQGeQA^aTIKkC zm>MzkYc$Mq?k)(P6VaRvP(Y@KAg# zb(OvikEO2CpCcSk=fk}bNI%(E$qLMOT;=pH1ArKM+#Y^aGK-WxY7bhE=a zWxcs}X1hj9z)%6{--3Y9ceZOTLV&P3+cg&{$n^uG!MPF ziVX!*ce&>N)0>z1f;2o7BoLQg41cJo7r5pI1c_}2sfdg5O%{HrW)e!1!gGyfl1!i)a^ delta 9073 zcmZ8nYm`+*cD_|--_!S=f_|LlWh>Y!5S4-PmFSECWDLSG8WCMtGh<9v%wm>nmYG@0 zajhi&n8Tx?0fEtpcm!;v8ych$5K*s!^3YakzzhiO2nc4xb|gAH1Rso&yQOQ=3$iZVH;Hsn`@YRW+5G%1zPp=JhXC~9~{rJWC& zg$pNEU66n;#xzTrx~OkdvP^#f^)i~}Kjp?(~O-s_M zN*Bp8S{E?9jV0&^|2DlaG?yj$f3WoQd)l@~M+K|6Be zum&_iV#Py&($U(r79R& zm)EOdx3~!bw)h6#;^V|9Y69rR^asu6hGc`^X>b-NsWkYwQJFj@wn?T^yV1bYpt*ZZoLTI>%h7lI?z{ zSIL&>@=^fC1Y~sv!XjN>XFw>?<#h&x5?x+rhE|C#uXB}Vm)~ZWKjqsRSYTs1_962> zSH11s1`oq;`$(&#)OX_+jvlwuaQp-ARcXuXHS-cab}Js_$Rn2J>av!*bm#mM|7*aKlg2 zkq?_GjXlbpfMxNnhqu*s-0)!`4nI|er_$#hHgDB`%2J_%DJ;O1@PyK{EAt*Pqg8T3 zh5dOUet;A3gc=}jFoYW?)U`Kq0Wicpa8l6>9dZE(@zMemz)59h=ptv^bMWwU6%OxB z!SX2;ULN&}B0s7KKTfM8{90A@9tma6sPIC5NAx4B6x91hl}2FUY&*rZ-iN>Hk!CWp4^ET~l>s_SJ?s(EYR0aa(*ae;WwIEvn(ifpE=W-WkEP(;bEO+K`2;$L}ytLinH)r-9LN`XM;_l z(8xln)7Hhc^=eUi(PO4QS*$ayWdyW-B3Dc(Xx*YSt%ZWtPi9(cp&<2B=S+muOLM6? z#5PUsnbeA&-7$`}Zquec)0$b)dYKNdcUoHxS}$j(v z5c80uL!Wn*tA0N3I`rttPG}7;R%?1S6IxrLS8MZXCbV!6`k7p4p^lLB-yS*d4GcD0hHOUhfZ{w^Xa}_-|rbD>!CT{;POXPOHQ&G+#Bv`36ka>VS_r60KXED4kQ@@xo)Y`mL`DipHu`#c*$ zAsg>y*+{f-NXPzjW+EL2@^oJ=?1_G=*Tl=2gWjikn7!^0D1E8vmdp@~2r6CQV(RO^)QvYs>q$-!yjM>P z^!&>2Tg(+YnHFScbrBv<3$nAi2qmWl*Wc-sEJDfY0nH2ijajrxP7lnyz~7j{LCG0G zc#-o&C|GU^!ePBBNH#MF6MwPDFxG5VkZC3yG;0pB%S=7>%G@BFnYfuY? z5}kX_L`e1!kJ=fE5_lDlS^~Wx2pTqqXd3*Z8BiJ4YDNV~OAyKzIyQL-pIU;!KXf{W zP^Bdp|1VCJ5UM;G(3(t@(5mueVAf=+2nSW33bGqaC|F(^WH*>lTwkq061E5a1``@$ ztq8J9SSZ$NWv-QQ(CX6FUsLpFU?MWd9-?W=GZFKn!s0 z+qYmACi6|q~nRL;QT$;GiI+Jd_$GK648?%TOkv~|=HZQZtEFyof3WvNjQ+9+1 z1k zaLCIOh`&a@Ya^VeROlev#^h#nE4hAOB+ZRB!pA6o)&io_ORl*Agt2Yp+QNXyS{u2x zFd#H(Bi9yYXqy{t%D7J)zD|X=Tp1^QF!XgQ zjg+B%G`D9u{VITh{>neZDxG9((-#jhwUdV0<_#cL>7;A^(hcVJ%$uy!ZSc1b=L2YP z!??yfraaL2Z}(35MdQEz^8P95^$(k?lE}}IQBFn7fyFmjMQ$tg#WxTY-f@2S#mFuy z4VQ_1W&7>$+Yb<*L4NxIVeAgS{QzNghu?mHCHw%f!|y-FcK_}0`>(HTzn!^tm;mjV~qWXRw z!ul@iKSuT|OgZbjXuxPUf_sX4*m#RZ8v1gz0*-B1pd0VJcS_^^zr1fs;{%mPUoq7x z*^{rs^}8ou2Yq+IY#r{pbAx>kufw^ij>COac-Q5o8b0r%(qD)R)v~9%N%xV$1;pD3 zY5OA{5XN>Rw-}og2^b4^Q~v>Cwk8hWqrwNyYz>CKN2SZeY>lLRzn2XVpOHT0Kp4B< zryLMg_xqFsBAop`;|wL71e`l^Yb50da_cY$WK)iZ0$s`hVf}zlIUuYb@F{0##X8_q zUL%L-!Q4194m9>I7Z|7fpicz`!}>v=3P4yt=u^SaT0iJhULyz8VYa@E4=7-K|E3Ra zG3_@V&aHBS59d}n!H09JKEa3CY6t%=T8o!PD*Tz!d$yXVFF8V`^TgX)Nkb2>b}JSG z#0wwkaSdbB-Mjh@NN?O`u9?(B?w4Ua#o2HEag3`BaPK-5X>1@IK+Ml3~D@d%~Ajtqw8z6wht3(cw%!l{R&nr+$7aca^^_ zPvx%i|0tZ!*TZWKknQEy%6}}Jrn3FX01#78`#Z8$moW7Vq3Q7|A0WUCEA!tlV^ng6 zs_el88ne!DJ@Ii+7l+?cVP?_3Db*pKZ>cm)vQsAobJqI;7!#2F83+ht&-#4}2&-rP zz6HeMXZ^md(<6Poob~&*PU>Z95pPQO!&|^O1e#GSHr!+1l$O3_{wA4PbZ<%+fEA_| z-J231-jt>m2h1qiHzg+IO=)Uz;EbYuQ*s<+nTAnPxsHOV=|%UZR41Ffsp!fB5PgfH zmU2tr7#DI=(Y+=CVs29r70mL*aoAkMf23IXVvKAqx|=SR3wn0Zy#@f{t-Yv6h_*l| zhS^19|0MtrQ)d?k+e;DQGUmyJS Q#a}i4YAOv~wcnrm|D)Nk4*&oF diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 76345a0d8..df0c01473 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:e759001a32aa9d1505d45bdd1a191dc2fc46de16fb63e7ec6c0693aca620da52 + contract-digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index cba907611..da9ec3898 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -169,7 +169,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The agent's delegated-execution ceiling (migration 108): the Work Context * audiences it may be minted for and the resource kinds it may ever touch. - * Empty = unrestricted. + * + * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + * administrator consented to when they installed it. It is NOT unrestricted + * for a mint made with a credential delivered to a THIRD PARTY on a person's + * behalf โ€” an MCP client the person authorized for this one solution. There + * the installation is the only statement of what the solution may act for, so + * an empty list is read as nothing and such a mint is REFUSED by name + * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + * purpose: one bounds a consumer an admin trusts, the other bounds a + * credential they never saw. * * @generated from field: repeated string allowed_audiences = 10; */ diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index dc97d90a3..fbc4f3c3d 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.24", + "version": "0.3.25", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx index c6ebd2963..a94471a7c 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx @@ -200,7 +200,7 @@ export function InstallSolutionDialog({
{(control) => ( @@ -214,7 +214,7 @@ export function InstallSolutionDialog({ {(control) => ( diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index 5b93b221f..2f1632ad2 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -169,7 +169,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The agent's delegated-execution ceiling (migration 108): the Work Context * audiences it may be minted for and the resource kinds it may ever touch. - * Empty = unrestricted. + * + * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an + * administrator consented to when they installed it. It is NOT unrestricted + * for a mint made with a credential delivered to a THIRD PARTY on a person's + * behalf โ€” an MCP client the person authorized for this one solution. There + * the installation is the only statement of what the solution may act for, so + * an empty list is read as nothing and such a mint is REFUSED by name + * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on + * purpose: one bounds a consumer an admin trusts, the other bounds a + * credential they never saw. * * @generated from field: repeated string allowed_audiences = 10; */ From 1709f269919f5967d1d1dc10223f23f072d2f076 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:34:07 -0400 Subject: [PATCH 45/46] test(gateway): the ceiling headers on both hops, an ordinary session, and the Envoy answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forged-header test added last round drove only the declared-module route, and that route stamps the installation itself after the strip โ€” so the caller's value is overwritten whether or not the name is on the strip list at all. Taking it off `untrustedAuthHeaders`, which also drops it from Envoy's remove set, left the whole suite green; so did not stamping it at the mint, which is the route accounts resolves the ceiling from. Four properties, each at a place that test could not reach: both of the consumer's requests through the real handler with the capability signed from what the gateway projected; a caller's values replaced at the mint, including the grpc-metadata spelling an upstream transcoder would read; an ordinary session carrying no installation, forged or otherwise, because nothing resolved one for it; and the Envoy answer, where the resource is replaced and the installation removed. Both mutations now fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../code/gateway_ceiling_headers_test.go | 175 ++++++++++++++++++ .../code/gateway_module_confinement_test.go | 29 +-- 2 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 module/services/auth-gateway/code/gateway_ceiling_headers_test.go diff --git a/module/services/auth-gateway/code/gateway_ceiling_headers_test.go b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go new file mode 100644 index 000000000..52842761a --- /dev/null +++ b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go @@ -0,0 +1,175 @@ +package main + +// THE TWO HEADERS THE CEILING IS RESOLVED FROM, and the two hops they travel on +// (register finding SA-F-MCPAUD, round 4). +// +// `x-codefly-resource` and `x-codefly-installation-id` are the whole of what +// accounts has to decide a confined mint with: which consumer the credential was +// delivered to, and which installation of it this organization consented to. A +// caller that could set either picks its own ceiling, and a hop that failed to +// stamp the second leaves accounts with no ceiling at all. +// +// The round-3 test covered one route โ€” the declared module route โ€” and that route +// stamps the installation itself, after the strip. So the caller's value is +// overwritten there whether or not the header is on the strip list at all, and a +// suite with only that test stayed green when the name was removed from +// `untrustedAuthHeaders` (which also drops it from Envoy's `HeadersToRemove`) and +// when the mint stopped stamping it. These cover the three places that test +// cannot reach: the mint route, an ordinary session, and the Envoy answer. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// mintRequestOf is the StartTask request the consumer's runtime sends: the +// viewer's bearer, the org and session the gateway projected for it, and the +// audience of the module its solution declared it consumes. +func mintRequestOf(caller confinedCaller, forged http.Header) *http.Request { + body, _ := json.Marshal(map[string]any{ + "orgId": caller.org, + "sessionId": caller.session, + "taskId": uuid.Must(uuid.NewV7()).String(), + "audience": "documents", + "authorityScopes": []map[string]any{ + {"resourceKind": "documents", "actions": []string{"read"}}, + }, + }) + req := httptest.NewRequest(http.MethodPost, workContextMintProcedure, bytes.NewReader(body)) + req.Header.Set("authorization", "Bearer "+caller.bearer) + req.Header.Set("content-type", "application/json") + for name, values := range forged { + for _, value := range values { + req.Header.Add(name, value) + } + } + return req +} + +// BOTH HOPS, through the real handler, with the capability signed from what the +// gateway actually projected on the first one. +// +// This is the consumer flow end to end as far as this repository reaches: the +// mint is told which consumer and which installation, accounts (a double here, +// signing with the key the edge verifies) issues a capability for the viewer the +// projection named, and the module read is admitted on the pairing. +func TestCeilingHeaders_TheConsumersTwoHopsThroughTheRealGateway(t *testing.T) { + gw, module, accounts, caller, kid, wcPriv, _ := confinedModuleHarness(t) + + mint := httptest.NewRecorder() + gw.ServeHTTP(mint, mintRequestOf(caller, nil)) + + require.Equal(t, http.StatusOK, mint.Code, mint.Body.String()) + require.Equal(t, solutionMCPResource(testPublicBase, "example"), + accounts.lastHeaders.Get(resourceConfinementHeader), + "the mint is told which consumer this credential was delivered to") + require.Equal(t, "install-"+fakeSolutionTarget("example"), + accounts.lastHeaders.Get(installationConfinementHeader), + "and WHICH INSTALLATION of it admitted the request, which is the ceiling accounts resolves") + + // What accounts signs from that projection, and nothing of its own. + capability := mintViewerCapability(t, kid, wcPriv, "documents", + accounts.lastHeaders.Get("X-User-Id"), accounts.lastHeaders.Get("X-Org-Id"), accounts.lastHeaders.Get("X-Session-Id")) + + read := httptest.NewRecorder() + gw.ServeHTTP(read, moduleRequest(caller, capability.Encoded())) + + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + require.NotNil(t, module.lastHeaders) +} + +// A caller cannot pick the ceiling AT THE MINT โ€” the route whose stamp accounts +// resolves the installation from. Both values are replaced, and the +// grpc-metadata spelling of either reaches nothing: accounts transcodes REST to +// gRPC, so a prefixed name is a second way to assert the same field. +func TestCeilingHeaders_ACallerCannotPickTheMintsCeiling(t *testing.T) { + gw, _, accounts, caller, _, _, _ := confinedModuleHarness(t) + + forged := http.Header{} + forged.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "a-broader-consumer")) + forged.Set(installationConfinementHeader, "install-of-a-broader-consumer") + forged.Set("Grpc-Metadata-"+installationConfinementHeader, "install-of-a-broader-consumer") + w := httptest.NewRecorder() + gw.ServeHTTP(w, mintRequestOf(caller, forged)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, []string{solutionMCPResource(testPublicBase, "example")}, + accounts.lastHeaders.Values(resourceConfinementHeader), + "one value, and the one this credential names") + require.Equal(t, []string{"install-" + fakeSolutionTarget("example")}, + accounts.lastHeaders.Values(installationConfinementHeader), + "one value, and the one the authority admitted") + require.Empty(t, accounts.lastHeaders.Get("Grpc-Metadata-"+installationConfinementHeader)) +} + +// AN ORDINARY SESSION at the mint carries NO installation, forged or otherwise. +// +// Nothing resolves one for an unconfined request โ€” no consumer was named, so no +// consent was looked up โ€” and a value arriving anyway would hand accounts a +// ceiling for an installation this person never acted under. The resource header +// is restamped EMPTY rather than dropped, so accounts reads a value either way. +func TestCeilingHeaders_AnOrdinarySessionCannotForgeAnInstallationAtTheMint(t *testing.T) { + gw, _, accounts, _, _, _, access := confinedModuleHarness(t) + + forged := http.Header{} + forged.Set(installationConfinementHeader, "install-of-somebody-else") + ordinary := confinedCaller{ + bearer: signValidToken(t, access), + org: uuid.Must(uuid.NewV7()).String(), + session: uuid.Must(uuid.NewV7()).String(), + } + w := httptest.NewRecorder() + gw.ServeHTTP(w, mintRequestOf(ordinary, forged)) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Empty(t, accounts.lastHeaders.Values(installationConfinementHeader), + "an unconfined mint names no consumer, so no installation is resolved and none may be asserted") + require.Empty(t, accounts.lastHeaders.Get(resourceConfinementHeader), + "and the resource is stamped empty, not left to a caller") +} + +// THE ENVOY ANSWER, which the HTTP path cannot speak for. +// +// A deployment driving ext_authz from Envoy acts on this response alone, so the +// two headers have to be in it: the resource restamped (replaced, including with +// the empty value) and the installation in the remove set, because ext_authz +// consults no entitlement authority and therefore never stamps one. A name +// missing from `untrustedAuthHeaders` disappears from BOTH the in-process strip +// and this set at once, which is why it is asserted here by value. +func TestCeilingHeaders_TheEnvoyAnswerReplacesOrRemovesThem(t *testing.T) { + withPublicBase(t, testPublicBase) + s, priv := newTestExtAuthz(t) + s.publicBase = testPublicBase + + for _, path := range []string{workContextMintProcedure, "/v1/users"} { + resp, err := s.Check(context.Background(), checkReq(path, map[string]string{ + "authorization": "Bearer " + signValidToken(t, priv), + resourceConfinementHeader: solutionMCPResource(testPublicBase, "example"), + installationConfinementHeader: "install-of-a-broader-consumer", + })) + require.NoError(t, err) + require.NotNil(t, resp.GetOkResponse(), path) + + stamped := headerMap(resp) + value, restamped := stamped[resourceConfinementHeader] + require.True(t, restamped, "the resource header must be REPLACED on %s, not left as the caller sent it", path) + require.Empty(t, value, "an unbound session is confined to nothing, by value") + require.Contains(t, resp.GetOkResponse().GetHeadersToRemove(), installationConfinementHeader, + "ext_authz stamps no installation, so a caller's must be removed on %s", path) + } + + // And the pairing is unreachable in Envoy mode, so a confined bearer at a + // module route is refused there rather than admitted unpaired. + resp, err := s.Check(context.Background(), checkReq("/v1/documents/collection", map[string]string{ + "authorization": "Bearer " + signResourceToken(t, priv, solutionMCPResource(testPublicBase, "example")), + })) + require.NoError(t, err) + require.NotNil(t, resp.GetDeniedResponse()) +} diff --git a/module/services/auth-gateway/code/gateway_module_confinement_test.go b/module/services/auth-gateway/code/gateway_module_confinement_test.go index 8efa91b83..f130bf5c3 100644 --- a/module/services/auth-gateway/code/gateway_module_confinement_test.go +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -91,21 +91,28 @@ func mintViewerCapability( // confinedModuleHarness wires a gateway with the edge verifier, one declared // solution whose resource the credential names, one declared module the consumer // reads, and an authority that has installed and granted both. -func confinedModuleHarness(t *testing.T) (*Gateway, *fakeUpstream, confinedCaller, string, ed25519.PrivateKey) { +// +// `accounts` and `access` are returned because the mint hop needs them: the +// first is the upstream that receives the ceiling headers, the second signs an +// ordinary session to contrast with a confined one. +func confinedModuleHarness(t *testing.T) ( + gw *Gateway, module, accounts *fakeUpstream, caller confinedCaller, + kid string, wcPriv, access ed25519.PrivateKey, +) { t.Helper() withPublicBase(t, testPublicBase) - gw, _, _, accessPriv := newGatewayHarness(t) + gw, accounts, _, access = newGatewayHarness(t) pub, wcPriv := mustEd25519(t) - const kid = "key-1" + kid = "key-1" jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) gw.workContext = newWorkContextVerifier(jwks.URL) registerSolutionUpstream(t, gw, "example") - module := declareModule(t, gw, "documents") + module = declareModule(t, gw, "documents") gw.solutionEntitlements = &entitledTo{ids: []string{ fakeSolutionTarget("example"), fakeSolutionTarget("documents"), }} - caller := signConfinedCaller(t, accessPriv, solutionMCPResource(testPublicBase, "example")) - return gw, module, caller, kid, wcPriv + caller = signConfinedCaller(t, access, solutionMCPResource(testPublicBase, "example")) + return gw, module, accounts, caller, kid, wcPriv, access } func moduleRequest(caller confinedCaller, capability string) *http.Request { @@ -120,7 +127,7 @@ func moduleRequest(caller confinedCaller, capability string) *http.Request { // The consumer's own work goes through, and the upstream is told what the // credential is confined to and which installation admitted it. func TestModuleRoute_AConfinedCredentialIsAdmittedWithThePersonsCapability(t *testing.T) { - gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) w := httptest.NewRecorder() @@ -159,7 +166,7 @@ func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *t }, } { t.Run(name, func(t *testing.T) { - gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) encoded := "" if capabilityFor != nil { audience, owner, tenant, session := capabilityFor(caller) @@ -181,7 +188,7 @@ func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *t // listing two permitted audiences at a module route is the change that could // have weakened it. func TestModuleRoute_ACapabilityForAnotherModuleIsRefused(t *testing.T) { - gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) other := declareModule(t, gw, "ledgers") capability := mintViewerCapability(t, kid, wcPriv, "ledgers", caller.subject, caller.org, caller.session) @@ -215,7 +222,7 @@ func TestModuleRoute_AnUnconfinedSessionNeedsNoCapability(t *testing.T) { // pairing is the only thing that admits one, and it cannot be established // without a verifier, so the refusal is the same one round 2 shipped. func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *testing.T) { - gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) gw.workContext = nil @@ -234,7 +241,7 @@ func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *tes // honoured a caller's value are indistinguishable from the list test's point of // view, which only ever sees a route that stamps neither. func TestModuleRoute_ACallerCannotAssertItsOwnConfinementOrInstallation(t *testing.T) { - gw, module, caller, kid, wcPriv := confinedModuleHarness(t) + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) req := moduleRequest(caller, capability.Encoded()) From 3036a26fda346ecf3f8221ba701c4f86d03fb0be Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:34:07 -0400 Subject: [PATCH 46/46] fix(frontend): the sign-out sweep sees every spelling, and the impersonation exit is asserted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived sweep refused a list of spellings, so it missed three: an arrow calling the sign-out beside a legitimate journey call in the same file passed both of its checks; a member access off the whole context was not found at all; and a renamed binding was caught by only one rule. It is now stated as a whitelist of one. The local name is DERIVED โ€” destructured, renamed, or reached as a member โ€” and every use of it other than as the argument to the shared journey is a finding, so nobody has to predict the next spelling. The three spellings it was blind to are pinned as source in the test itself, each of which would have passed the previous version. Two false readings found by running it: string literals matched (the command palette's search keywords contain the word) and taking the whole context without reaching for the sign-out counted as a control. Strings are stripped with comments now, and that binding exists only where the member is used. Leaving an impersonated session is asserted behaviourally for both outcomes: an unconfirmed sign-out reports itself, and a confirmed one reports only the restore failure that path always reported. Co-Authored-By: Claude Opus 5 (1M context) --- .../sign-out-controls-contract.test.ts | 149 ++++++++++++++---- .../__tests__/impersonation-session.test.tsx | 51 +++++- 2 files changed, 169 insertions(+), 31 deletions(-) diff --git a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts index 6d9c112af..4c8d12d8d 100644 --- a/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts +++ b/module/services/frontend/code/src/components/__tests__/sign-out-controls-contract.test.ts @@ -1,6 +1,12 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +// Where the sweep's own probes are written. Outside the tree, so a probe can +// never be mistaken for a control by the very sweep under test. +const sweepProbeDirectory = mkdtempSync(join(tmpdir(), "sign-out-sweep-")); + // Every user-facing sign-out control goes through the shared journey. // // This is a STRUCTURAL assertion, and says so: it reads source rather than @@ -34,10 +40,6 @@ function sourceFiles(): string[] { .sort(); } -// `logout` destructured out of the auth context. A control gets it exactly this -// way โ€” there is no other accessor โ€” so this is the whole population. -const TAKES_LOGOUT = /const\s*\{[^}]*\blogout\b[^}]*\}\s*=\s*useAuth\(\)/; - /** * The three files that are not controls and may call `logout` for themselves: * the auth provider, which DEFINES it; the journey, which IS the shared route; @@ -52,25 +54,97 @@ const NOT_A_CONTROL = [ ]; /** - * Source with comments removed. + * Source with comments and string literals removed. * - * A structural sweep that reads raw text matches its own prose: the comment - * beside the account-menu fix quotes the `onClick={logout}` it replaced, and - * that quotation failed the rule the fix satisfies. Nothing here should depend - * on how a defect is described, only on what the code does. + * A structural sweep that reads raw text matches things that are not code. Two + * false readings, both found by running it: the comment beside the account-menu + * fix quotes the `onClick={logout}` it replaced, and the command palette's + * search keywords are the literal string "sign out logout". Neither is a use of + * anything. Nothing here should depend on how a defect is described or on what a + * control calls itself. */ function code(path: string): string { return readFileSync(path, "utf8") .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/^[^\n]*?\/\/.*$/gm, (line) => (line.includes("://") ? line : "")); + .replace(/^[^\n]*?\/\/.*$/gm, (line) => (line.includes("://") ? line : "")) + .replace(/"(?:[^"\\\n]|\\.)*"/g, '""') + .replace(/'(?:[^'\\\n]|\\.)*'/g, "''") + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); +} + +/** + * The local name(s) a file binds the auth context's sign-out to. + * + * There are three spellings, and the first version of this sweep saw one: + * + * const { logout } = useAuth() -> "logout" + * const { logout: signOut } = useAuth() -> "signOut" + * const auth = useAuth() -> "auth.logout" + * + * Deriving the NAME rather than matching the word is what makes the rule below + * independent of spelling. A sweep keyed on the literal `logout` could not see + * the renamed binding's uses at all, and could not see a member access either โ€” + * the round-4 review's probes are exactly those two. + */ +function signOutBindings(source: string): string[] { + const names: string[] = []; + for (const match of source.matchAll(/const\s*\{([^}]*)\}\s*=\s*useAuth\(\)/g)) { + for (const field of match[1].split(",")) { + const [key, alias] = field.split(":").map((part) => part.trim()); + if (key === "logout") names.push(alias || key); + } + } + for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*useAuth\(\)/g)) { + // Taking the WHOLE context is not binding the sign-out. Plenty of files + // read other fields off it and have no business with this rule; counting + // them as controls would demand they mention the journey. The binding + // exists only where the member is actually reached for. + const member = `${match[1]}.logout`; + if (source.includes(member)) names.push(member); + } + return names; } +/** Any file that binds the context's sign-out, however it spells it. */ function controls(): string[] { return sourceFiles().filter( - (path) => !NOT_A_CONTROL.includes(path) && TAKES_LOGOUT.test(code(path)), + (path) => !NOT_A_CONTROL.includes(path) && signOutBindings(code(path)).length > 0, ); } +function escapeForRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Every use of a bound sign-out that is NOT the argument to `signOutAndLeave`. + * + * The rule is stated as a WHITELIST of one, which is the only form that holds: + * the previous version listed the spellings it refused, so `onClick={() => + * logout()}` beside a legitimate `signOutAndLeave(logout)` in the same file + * passed both of its checks. A control has exactly one legitimate thing to do + * with the sign-out โ€” hand it to the journey โ€” so anything else is a finding, + * and nobody has to predict the next spelling. + */ +function straySignOutUses(path: string): string[] { + const source = code(path); + const stray: string[] = []; + for (const name of signOutBindings(source)) { + const remaining = source + // the binding is not a use of itself + .replace(/const\s*\{[^}]*\}\s*=\s*useAuth\(\)/g, "") + .replace(/const\s+[A-Za-z_$][\w$]*\s*=\s*useAuth\(\)/g, "") + // the one permitted use + .split(`signOutAndLeave(${name}`) + .join("signOutAndLeave("); + const uses = remaining.match( + new RegExp(`(^|[^\\w$.])${escapeForRegExp(name)}\\b`, "g"), + ); + for (const use of uses ?? []) stray.push(`${name}: ${use.trim()}`); + } + return stray; +} + describe("sign-out controls", () => { // The population itself is asserted, because a derived sweep that found // NOTHING would pass every test below having checked no file at all โ€” the @@ -87,26 +161,41 @@ describe("sign-out controls", () => { expect(code(path)).toContain("signOutAndLeave"); }); - // A control must not sign out for itself, in ANY of the spellings. The list - // is every way a control can reach `logout` without the journey deciding - // where the person goes โ€” including handing the function straight to a - // component, which is how the account menu did it and which the previous - // `(?:await|void)\s+logout\(\)` regex could not see. - it.each(controls())("%s does not sign out for itself", (path: string) => { - const source = code(path); - const direct = [ - // `await logout()`, `void logout()`, `return logout()`. - /(?:await|void|return)\s+logout\(\)/g, - // `logout().then(...)`, `logout().catch(...)`. - /\blogout\(\)\s*\./g, - // The function handed to a prop or a callback, never called here: - // `onClick={logout}`, `onSelect: logout`, `onPress={logout}`. - /(?:=\{|:\s*)logout\s*[},)]/g, - ].flatMap((pattern) => source.match(pattern) ?? []); - expect(direct).toEqual([]); + it.each(controls())("%s does nothing else with the sign-out", (path: string) => { + expect(straySignOutUses(path)).toEqual([]); + }); + + // The sweep is held to the spellings it was blind to, as source rather than + // as a claim: each of these is a control that would have passed the previous + // version, and each must be reported now. + it.each([ + [ + "an arrow calling it beside a legitimate journey call", + `const { logout } = useAuth(); + onClick={() => void signOutAndLeave(logout)} + onClick={() => logout()}`, + ], + [ + "a member access off the whole context", + `const auth = useAuth(); + onClick={() => { void auth.logout(); window.location.replace("/auth/login"); }}`, + ], + [ + "a renamed binding", + `const { logout: signOut } = useAuth(); + onClick={async () => { await signOut(); }}`, + ], + ])("reports %s", (_name: string, source: string) => { + const probe = `${sweepProbeDirectory}/probe.tsx`; + writeFileSync(probe, source); + try { + expect(straySignOutUses(probe)).not.toEqual([]); + } finally { + rmSync(probe, { force: true }); + } }); - // The provider DEFINES `logout`, so it is the one file that calls it for + // The provider DEFINES the sign-out, so it is the one file that calls it for // itself โ€” and its own callers must still read the outcome. A discarded // result there is the same defect one layer down: exitImpersonation tore the // credentials down and told the operator to sign in again without ever diff --git a/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx b/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx index df8cb7740..073d8e041 100644 --- a/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx +++ b/module/services/frontend/code/src/lib/__tests__/impersonation-session.test.tsx @@ -62,12 +62,14 @@ let refreshQueue: Array<() => Response>; let logoutCalls: number; let stopCalls: Array; let stopResponse: () => Response; +let logoutResponse: () => Response; async function renderAsAdmin() { refreshQueue = []; logoutCalls = 0; stopCalls = []; stopResponse = () => HttpResponse.json({ durationSeconds: "42" }); + logoutResponse = () => HttpResponse.json({}); server.use( http.post("/v1/auth/refresh", () => { const next = refreshQueue.shift(); @@ -75,7 +77,7 @@ async function renderAsAdmin() { }), http.post("/v1/auth/logout", () => { logoutCalls += 1; - return HttpResponse.json({}); + return logoutResponse(); }), // The bearer is recorded, not just the call: the stop has to be made by // the impersonation session itself for the server to derive which window @@ -299,6 +301,53 @@ describe("Impersonation session journey", () => { expect(logoutCalls).toBe(1); }); + // THE SIGN-OUT'S OWN OUTCOME, read rather than discarded. + // + // Exiting tears the credentials down and tells the operator to sign in again. + // If the sign-out itself was not confirmed โ€” the server answers 503 "logout + // incomplete, retry" โ€” an already-issued access token stays valid until it + // expires, and local state is cleared either way, so saying nothing presents + // an unfinished sign-out as a finished one. That was the shape of the defect + // in every user-facing sign-out control; this is the same defect one layer + // down, and it is asserted on the message the operator is actually given. + it("reports an unconfirmed sign-out when leaving an impersonated session", async () => { + await renderAsAdmin(); + act(() => enter(impersonationToken)); + await waitFor(() => + expect(screen.getByTestId("state").textContent).toBe(`authed/${TARGET}`), + ); + + // The teardown path is the one that signs out: a restore that SUCCEEDS + // keeps the admin's own session and never calls it. So the restore fails + // here, as in the test below, and the only difference between the two is + // whether the sign-out itself was confirmed. + logoutResponse = () => new HttpResponse(null, { status: 503 }); + refreshQueue.push(() => new HttpResponse(null, { status: 503 })); + await act(async () => { + await expect(exit()).rejects.toThrow(/not confirmed/i); + }); + + expect(logoutCalls).toBe(1); + expect(getToken()).toBeNull(); + }); + + // THE CONTROL: the same teardown with a CONFIRMED sign-out reports the + // restore failure instead, which is the message that path always gave. So + // the one above is about the sign-out's outcome and not about tearing down. + it("reports only the restore failure when the sign-out is confirmed", async () => { + await renderAsAdmin(); + act(() => enter(impersonationToken)); + await waitFor(() => + expect(screen.getByTestId("state").textContent).toBe(`authed/${TARGET}`), + ); + + refreshQueue.push(() => new HttpResponse(null, { status: 503 })); + await act(async () => { + await expect(exit()).rejects.toThrow(/sign in again/i); + }); + expect(logoutCalls).toBe(1); + }); + // An expired or already-revoked window is refused by the server, and there // was nothing left to close. Reporting a failed stop there would cry wolf on // the one message that has to be trusted.