diff --git a/go.mod b/go.mod index e9a431585..1f62244f3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/agents/modules/saas-starter go 1.27.0 require ( - github.com/codefly-dev/core v0.17.2 + github.com/codefly-dev/core v0.18.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 4a1c1e04d..93c7d428e 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 76a180eba..6fc5c5dc3 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -48,11 +48,17 @@ depend on the database whose authority it witnesses. | `accounts` | `vault/http` | TCP 8200 | | `auth-gateway` | `accounts/connect`, `accounts/rest`, `accounts/grpc` | TCP 8080, 9090 | | `auth-gateway` | `cache/write` | TCP 6379 | -| `frontend` | `auth-gateway/rest` | TCP 8080 | +| `frontend` | `auth-gateway/rest` | Codefly-assigned HTTP port | The Codefly module interface exposes the public `frontend/http` and `marketing/http` endpoints; the auth-gateway gRPC ext-authz endpoint has module -visibility. Istio routes apex/`www`/docs hosts to `marketing/http` and `app` to +visibility. Its `rest` and `workload` endpoints are also module-visible; +`rest` serves plain HTTP and `workload` serves the same routes over TLS 1.3 with +required workload client certificates. Both ports are allocated by Codefly. +The gateway declares the platform-provided `workload-identity` configuration +group and refuses startup without its projected certificate, key, trust bundle, +caller allowlist and token files. Solution upstreams require mutual TLS and the +SPIFFE identity from applied presence. Istio routes apex/`www`/docs hosts to `marketing/http` and `app` to `frontend/http`. Accounts, frontend, marketing, and telemetry may reach public IP space only over TCP 443. The public rules exclude private, loopback, link-local, metadata, documentation, benchmark, multicast, and other diff --git a/module/INTERNAL_TRANSPORT.md b/module/INTERNAL_TRANSPORT.md index c05fec240..77e05a180 100644 --- a/module/INTERNAL_TRANSPORT.md +++ b/module/INTERNAL_TRANSPORT.md @@ -167,14 +167,17 @@ fail closed rather than fall back to another key when the set expires. ## The endpoint a composed module depends on: `accounts/authority` -The module interface exports four endpoints with `visibility: internal` and +The module interface exports five endpoints with `visibility: internal` and `allow-modules: ["*"]`. This explicitly preserves their reachability by every composed module; it grants neither public ingress nor application authority. -The retired `module` spelling is refused by current Codefly. Three do not +The retired `module` spelling is refused by current Codefly. Four do not carry this tier: `accounts/connect` and `auth-gateway/grpc` are tenant surfaces -whose tiering interceptor refuses internal methods, and `auth-gateway/rest` +whose tiering interceptor refuses internal methods, `auth-gateway/rest` carries the brokered bootstrap routes (registration, Work Context mints) and the -key set. The fourth, `accounts/authority`, is a gRPC endpoint with a listener of +key set, and `auth-gateway/workload` serves the same gateway routes over mutual +TLS to workloads that present a platform-issued SVID listed in its +allowed-callers file, where a module mint must also come from the SPIFFE ID its +delivered presence declares. The fifth, `accounts/authority`, is a gRPC endpoint with a listener of its own, and it is the internal tier **narrowed**: - **Only the module surface is served.** `business.ModuleAuthorityProcedures` diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 2dfb5848a..420f31681 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -559,6 +559,32 @@ twice under two names, which Core would refuse as a binding declared twice. ## 7b. The host's own control paths, and which part a consumer may hardcode +The current solution mint is `POST /modules/_work-context` on the gateway's +`workload` endpoint (`api: http`, transported over TLS 1.3). Codefly resolves its +address and port. A solution presents its X.509-SVID and projected ServiceAccount +token as `Authorization: Bearer`; it needs no shared internal token on this +listener. The gateway verifies the client chain and single SPIFFE URI SAN against +`workload-identity/TRUST_BUNDLE_FILE` and `ALLOWED_CALLERS_FILE`. Accounts' existing +TokenReview/BindExecution path requires that URI to equal both the reviewed +namespace/ServiceAccount identity and the SPIFFE ID in applied presence. A +mismatch returns 403, logs the mint refusal, and issues no credential. + +The `workload-identity` group supplies `CERT_FILE`, `KEY_FILE`, +`TRUST_BUNDLE_FILE`, `ALLOWED_CALLERS_FILE`, and `TOKEN_FILE` as readable paths; +missing files fail startup with the key named. The SDK certificate reloader +keeps the last good pair during rotation. Trust is reread for each handshake; +caller admission rereads the newline-separated, `#`-commented allowlist for each +handshake and request, so revocation also affects existing connections. + +Solution proxy traffic uses TLS 1.3, presents the gateway SVID, verifies the +server chain, and requires exactly the SPIFFE URI declared by the applied +presence. The registry read joins matching registration and applied-generation +records; a missing, inconsistent or ambiguous workload identity is not proxied. +Dial-time address revalidation still rejects off-mesh and metadata destinations. +The plain `rest` endpoint retains its existing routes, including module-authority +mints with the internal token and projected bearer. Those module mints and module +upstreams retain their existing in-cluster transport contract. + Three repositories independently invented the string `/platform/_credential` from a design note, and nothing failed at build time. This section exists so the next one reads a contract instead of guessing, and so the hardcoding question has @@ -791,6 +817,13 @@ brokering pattern is the same one `GET /solutions/_entitlements` already uses. ## 8. Local runs: one attestation path, not a local mode +Local solution traffic follows the same mutual-TLS contract as deployed traffic: +the platform projects rotating SVIDs, trust and peer files, and the gateway uses +its resolved `workload` endpoint for solution mints. Its solution proxy presents +the gateway SVID and checks applied presence for the destination's SPIFFE ID. +Plain-HTTP solution listeners and mints are not a supported local mode. Module +SDK mints continue to use the plain in-cluster endpoint described in ยง7b. + Binding a credential to an approved execution means reading what the workload actually runs: `TokenReview` on its projected service-account token, then the pod by the name that review returns, then the application container's `imageID`. That diff --git a/module/deployment/generated/service-topology.json b/module/deployment/generated/service-topology.json index f0d29f775..7e441d853 100644 --- a/module/deployment/generated/service-topology.json +++ b/module/deployment/generated/service-topology.json @@ -70,8 +70,12 @@ { "name": "rest", "api": "CODEFLY_API_REST", - "visibility": "ENDPOINT_VISIBILITY_MODULE", - "port": 8080 + "visibility": "ENDPOINT_VISIBILITY_MODULE" + }, + { + "name": "workload", + "api": "CODEFLY_API_HTTP", + "visibility": "ENDPOINT_VISIBILITY_MODULE" } ], "dependencies": [ @@ -210,6 +214,11 @@ "endpoint": "rest", "visibility": "ENDPOINT_VISIBILITY_MODULE" }, + { + "service": "auth-gateway", + "endpoint": "workload", + "visibility": "ENDPOINT_VISIBILITY_MODULE" + }, { "service": "frontend", "endpoint": "http", diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index cf4c64ec5..eb6579923 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -23,6 +23,9 @@ interface: - service: auth-gateway endpoint: rest visibility: internal + - service: auth-gateway + endpoint: workload + visibility: internal - service: frontend endpoint: http visibility: public diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 02bc9fbbd..db8125229 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -489,7 +489,15 @@ could not run is `ErrExecutionUnbound`, not one. API server has just authenticated. A longer one, or one whose lifetime cannot be read, is refused. -**Replay, stated precisely.** A projected ServiceAccount token is a BEARER +**Workload listener binding.** The gateway's TLS listener forwards its verified +SPIFFE peer on the authenticated internal mint RPC. `BindExecution` compares it +to the namespace and ServiceAccount returned by TokenReview and the full SPIFFE +ID retained from applied presence in `ModuleWorkload`. A mismatch follows the +existing refused-identity log path and is returned as 403 on the workload +endpoint, before signing or recording any credential. Plain module SDK mints +continue on their existing endpoint without this additional TLS attestation. + +**Replay on the plain module endpoint, stated precisely.** A projected ServiceAccount token is a BEARER credential. TokenReview proves the cluster issued it to a named pod and that the pod still exists; it does not prove the pod is the party presenting it. So: diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index d64e03519..e8fac74c3 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,7 +8,7 @@ require ( connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.17.2 + github.com/codefly-dev/core v0.18.2 github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/sdk-go v0.3.1 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 8404aa0e9..b41c75479 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -122,8 +122,8 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba h1:6nFaDYsLyA5x3WiGAk6pMI3c0naqxiPeaTHMqRPvbVs= diff --git a/module/services/accounts/code/pkg/adapters/module_capabilities_server.go b/module/services/accounts/code/pkg/adapters/module_capabilities_server.go index 3f533421e..f2a6fcbdf 100644 --- a/module/services/accounts/code/pkg/adapters/module_capabilities_server.go +++ b/module/services/accounts/code/pkg/adapters/module_capabilities_server.go @@ -72,6 +72,24 @@ func moduleIdentityError(err error, denied codes.Code, message string) error { return nil } +// moduleMintWorkloadPeer imports the attestation for the shared BindExecution +// check. An empty or ambiguous attestation is present and fails that check; +// absence preserves the plain module endpoint. +func moduleMintWorkloadPeer(ctx context.Context) context.Context { + // This RPC is on the internal tier. The gateway creates this attestation + // from its verified TLS connection, never from an HTTP request header. + if md, ok := metadata.FromIncomingContext(ctx); ok { + if peers := md.Get("x-codefly-workload-spiffe-id"); len(peers) != 0 { + peer := "" + if len(peers) == 1 { + peer = peers[0] + } + ctx = business.WithWorkloadPeer(ctx, peer) + } + } + return ctx +} + // MintModuleWorkContext issues that Work Context. It takes none itself โ€” this // is where a module obtains its identity โ€” so it authenticates with the pod's // projected ServiceAccount token: accounts reviews it, reads the pod it names @@ -88,6 +106,7 @@ func (s *ModuleCapabilitiesServer) MintModuleWorkContext(ctx context.Context, re if err := Validate(req); err != nil { return nil, err } + ctx = moduleMintWorkloadPeer(ctx) authority, err := service.ModuleAuthorizeWorkContext(ctx, req.GetPrefix(), req.GetServiceAccountToken(), req.GetOrganizationId()) if err != nil { if mapped := moduleIdentityError(err, codes.PermissionDenied, "module work context denied"); mapped != nil { @@ -156,6 +175,7 @@ func (s *ModuleCapabilitiesServer) MintModuleOperationContext(ctx context.Contex if err := Validate(req); err != nil { return nil, err } + ctx = moduleMintWorkloadPeer(ctx) authority, err := service.ModuleAuthorizeOperationContext(ctx, req.GetPrefix(), req.GetServiceAccountToken(), req.GetBinding(), req.GetOrganizationId()) if err != nil { if mapped := moduleIdentityError(err, codes.Unauthenticated, "module operation context denied"); mapped != nil { diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_test.go index 32bac9255..0acb556c0 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "crypto/rand" "errors" + "strings" "testing" "accounts/pkg/business" @@ -401,3 +402,111 @@ func TestModuleCallerReadsTheVerifiedPrincipalAndTenant(t *testing.T) { require.Equal(t, principalID, caller.PrincipalID) require.Equal(t, moduleWorkContextTenant, caller.BoundOrg) } + +// Exercise the real TokenReview/BindExecution path through the mint adapter. +// A refusal happens before signing or recording a credential. +func TestWorkloadPeerMintMatchesReviewedTokenAndDeliveredPresence(t *testing.T) { + const peer = "spiffe://acme.test/ns/acme-prod/sa/module" + declared := strings.Replace(documentsPrincipals, `"workload":{`, `"workload":{"spiffe_id":"`+peer+`",`, 1) + for _, test := range []struct { + name, peer, declaration string + allowed bool + }{ + {"matching", peer, declared, true}, + {"another service account", "spiffe://acme.test/ns/acme-prod/sa/other", declared, false}, + {"another namespace", "spiffe://acme.test/ns/other/sa/module", declared, false}, + {"another trust domain", "spiffe://other.test/ns/acme-prod/sa/module", declared, false}, + {"absent declared identity", peer, documentsPrincipals, false}, + {"empty attestation", "", declared, false}, + // TLS and presence agree, but the reviewed token names another SA. + {"presence differs from reviewed token", peer, strings.Replace(declared, `"service_account":"module"`, `"service_account":"other"`, 1), false}, + } { + t.Run(test.name, func(t *testing.T) { + store := installModuleWorkContextService(t, test.declaration) + installModuleWorkContextAuthority(t) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-workload-spiffe-id", test.peer)) + response, err := ModuleCapabilitiesSingleton().MintModuleWorkContext(ctx, &gen.ModuleMintWorkContextRequest{Prefix: "documents", ServiceAccountToken: moduleToken(t, "documents")}) + if test.allowed { + require.NoError(t, err) + require.NotEmpty(t, response.GetToken()) + require.Equal(t, 1, store.recordAttempts()) + } else { + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, response) + require.Zero(t, store.recordAttempts()) + } + }) + } +} + +func TestWorkloadPeerEveryMintBindsReviewedServiceAccount(t *testing.T) { + const peer = "spiffe://acme.test/ns/acme-prod/sa/module" + for _, kind := range []string{"work context", "operation context", "source delegation"} { + t.Run(kind, func(t *testing.T) { + for _, test := range []struct { + name string + peers []string + declaredPeer string + allowed bool + }{ + {"matching", []string{peer}, peer, true}, + {"another service account", []string{"spiffe://acme.test/ns/acme-prod/sa/other"}, peer, false}, + // Presence and TLS agree, but the reviewed token is for module, not other. + {"TLS and presence differ from token", []string{"spiffe://acme.test/ns/acme-prod/sa/other"}, "spiffe://acme.test/ns/acme-prod/sa/other", false}, + {"another trust domain", []string{"spiffe://other.test/ns/acme-prod/sa/module"}, peer, false}, + {"empty attestation", []string{""}, peer, false}, + {"ambiguous attestation", []string{peer, peer}, peer, false}, + {"plain endpoint", nil, peer, true}, + } { + t.Run(test.name, func(t *testing.T) { + prefix := "documents" + var audit *validatingAudit + switch kind { + case "source delegation": + _, audit = installSourceDelegationService(t) + prefix = "docstore" + default: + _, audit = installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + } + // Keep the reviewed namespace/ServiceAccount independent of the full + // SPIFFE ID delivered in presence, so agreement with both is required. + registry := moduleExecution.registry + grant := registry[business.ModulePrincipalID(prefix)] + grant.Workload.SPIFFEID = test.declaredPeer + registry[business.ModulePrincipalID(prefix)] = grant + service.SetModuleCapabilities(nil, nil, registry) + ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-codefly-workload-spiffe-id": test.peers}) + token := moduleToken(t, prefix) + var issued string + var err error + denied := codes.Unauthenticated + switch kind { + case "work context": + var response *gen.ModuleMintWorkContextResponse + response, err = ModuleCapabilitiesSingleton().MintModuleWorkContext(ctx, &gen.ModuleMintWorkContextRequest{Prefix: prefix, ServiceAccountToken: token}) + issued = response.GetToken() + denied = codes.PermissionDenied + case "operation context": + var response *gen.ModuleMintOperationContextResponse + response, err = ModuleCapabilitiesSingleton().MintModuleOperationContext(ctx, &gen.ModuleMintOperationContextRequest{Prefix: prefix, ServiceAccountToken: token, Binding: "model"}) + issued = response.GetToken() + case "source delegation": + var response *gen.ModuleMintSourceOperationContextResponse + response, err = ModuleCapabilitiesSingleton().MintSourceOperationContext(ctx, &gen.ModuleMintSourceOperationContextRequest{Prefix: prefix, ServiceAccountToken: token, Delegation: &gen.ModuleMintSourceOperationContextRequest_SourceId{SourceId: delegationSource}}) + issued = response.GetToken() + } + if test.allowed { + require.NoError(t, err) + require.NotEmpty(t, issued) + require.Len(t, audit.entries, 1) + } else { + require.Equal(t, denied, status.Code(err), "%v", err) + require.Empty(t, issued) + require.Empty(t, audit.entries, "a refused identity records no issuance") + } + }) + } + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/source_delegation.go b/module/services/accounts/code/pkg/adapters/source_delegation.go index bd89f9170..ac1836e0c 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation.go @@ -69,6 +69,7 @@ func (s *ModuleCapabilitiesServer) MintSourceOperationContext(ctx context.Contex if err := Validate(req); err != nil { return nil, err } + ctx = moduleMintWorkloadPeer(ctx) authority, err := service.AuthorizeSourceOperationContext(ctx, req.GetPrefix(), req.GetServiceAccountToken(), business.SourceDelegationRef{ DelegationID: req.GetDelegationId(), SourceID: req.GetSourceId(), diff --git a/module/services/accounts/code/pkg/business/module_capabilities.go b/module/services/accounts/code/pkg/business/module_capabilities.go index 57573b523..45e2164b9 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities.go +++ b/module/services/accounts/code/pkg/business/module_capabilities.go @@ -79,6 +79,7 @@ import ( // running image comes from the pod's status, and the approval comes from a // signed document. type ModuleWorkload struct { + SPIFFEID string `json:"spiffe_id"` ServiceAccount string `json:"service_account"` Namespace string `json:"namespace"` Container string `json:"container"` diff --git a/module/services/accounts/code/pkg/business/module_envelope.go b/module/services/accounts/code/pkg/business/module_envelope.go index ca77272bc..104585a52 100644 --- a/module/services/accounts/code/pkg/business/module_envelope.go +++ b/module/services/accounts/code/pkg/business/module_envelope.go @@ -128,7 +128,7 @@ func (w ModuleWorkloadIdentity) workload() ModuleWorkload { if err != nil { return ModuleWorkload{Container: w.Container} } - return ModuleWorkload{ServiceAccount: identity.ServiceAccount, Namespace: identity.Namespace, Container: w.Container} + return ModuleWorkload{SPIFFEID: w.SPIFFEID, ServiceAccount: identity.ServiceAccount, Namespace: identity.Namespace, Container: w.Container} } // ModuleEnvelopeRecord is one stored revision: the document as written, and who diff --git a/module/services/accounts/code/pkg/business/module_envelope_test.go b/module/services/accounts/code/pkg/business/module_envelope_test.go index 7da79e7d8..24ac631c3 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_test.go @@ -362,7 +362,7 @@ func TestOnlyAnApprovedWorkloadBindsThePrincipal(t *testing.T) { workload, err := approvedWorkload(entry, []ModuleWorkloadIdentity{{SPIFFEID: testSPIFFEID, Container: testContainer}}, document) require.NoError(t, err) - require.Equal(t, ModuleWorkload{ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, workload) + require.Equal(t, ModuleWorkload{SPIFFEID: testSPIFFEID, ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, workload) for name, retargeted := range map[string]ModuleWorkloadIdentity{ "another service account": {SPIFFEID: "spiffe://acme.test/ns/acme-prod/sa/intruder", Container: testContainer}, @@ -423,7 +423,7 @@ func TestSPIFFEIDsAreParsedStrictly(t *testing.T) { []any{map[string]any{"spiffe_id": "spiffe://ns/ns/acme-prod/sa/reports", "container": testContainer}} envelope, err := ParseModuleEnvelope(encodeEnvelope(t, document)) require.NoError(t, err) - require.Equal(t, ModuleWorkload{ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, + require.Equal(t, ModuleWorkload{SPIFFEID: "spiffe://ns/ns/acme-prod/sa/reports", ServiceAccount: "reports", Namespace: "acme-prod", Container: testContainer}, envelope.Principals[testModulePrefix].WorkloadIdentities[0].workload()) } diff --git a/module/services/accounts/code/pkg/business/module_execution_binding.go b/module/services/accounts/code/pkg/business/module_execution_binding.go index 39e989731..c1b94cfb5 100644 --- a/module/services/accounts/code/pkg/business/module_execution_binding.go +++ b/module/services/accounts/code/pkg/business/module_execution_binding.go @@ -310,6 +310,15 @@ var ErrExecutionAuthorityUnreconciled = errors.New( // audience is what makes the token a credential FOR this host. const executionTokenAudience = "accounts" +// WithWorkloadPeer carries the gateway's verified TLS identity through the +// internal mint. Absence is the existing plain module mint; a present but empty +// value is a refused attestation, never permission to omit the binding. +func WithWorkloadPeer(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, workloadPeerContextKey{}, id) +} + +type workloadPeerContextKey struct{} + // BindExecution establishes what a caller is running and holds it against what // the authority document approves. The caller passes its mint snapshot; both // the declared container and the workload-owner lookup use that same registry. @@ -359,6 +368,13 @@ func (s *Service) BindExecution( // and nothing about the caller has been decided. return nil, fmt.Errorf("%w: %w", ErrExecutionUnbound, err) } + if peer, presented := ctx.Value(workloadPeerContextKey{}).(string); presented { + identity, err := ParseKubernetesSPIFFEID(peer) + if err != nil || peer != grant.Workload.SPIFFEID || + identity.Namespace != reviewed.Namespace || identity.ServiceAccount != reviewed.ServiceAccount { + return nil, fmt.Errorf("%w: TLS workload identity differs from reviewed token or delivered presence", ErrExecutionIdentityMismatch) + } + } if reviewed.PodUID == "" || reviewed.PodName == "" { return nil, fmt.Errorf("%w: the token authenticates %s/%s but names no pod", ErrExecutionNotBoundToken, reviewed.Namespace, reviewed.ServiceAccount) @@ -366,8 +382,8 @@ func (s *Service) BindExecution( // THE TOKEN'S LIFETIME IS THE REPLAY WINDOW. A projected token is a bearer // credential: TokenReview proves the cluster issued it to this pod, never - // that the pod is the one presenting it. The host cannot close that here - // (see "Replay" in services/accounts/AGENTS.md); it can bound it, and it + // that the pod is the one presenting it. The workload listener additionally + // binds its TLS peer above. For plain module mints the host // bounds it to the shortest projection Kubernetes issues. if reviewed.IssuedAt.IsZero() || reviewed.ExpiresAt.IsZero() { return nil, fmt.Errorf("%w: the review reported no issue or expiry time", ErrExecutionTokenLifetime) diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go index 89d026716..03c14124e 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go @@ -72,19 +72,17 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.Equal(t, string(readFixture(t, "testdata/mesh-policy.golden.yaml")), string(first.MeshPolicy), "run: go generate ./pkg/cataloggen") require.Len(t, first.Catalog.GetServices(), 9) - require.Len(t, first.Catalog.GetInterfaceEndpoints(), 6) + require.Len(t, first.Catalog.GetInterfaceEndpoints(), 7) require.Len(t, first.Catalog.GetPublicEgress(), 5) endpointCount, dependencyCount := 0, 0 for _, service := range first.Catalog.GetServices() { endpointCount += len(service.GetEndpoints()) dependencyCount += len(service.GetDependencies()) } - require.Equal(t, 14, endpointCount) + require.Equal(t, 15, endpointCount) require.Equal(t, 8, dependencyCount) - // The accounts REST surface is reachable only through the gateway; the - // gateway's REST surface is module-visible because composed modules and - // solutions use it (Work Context minting, solution registration) and the - // interface exports it to them. + // Accounts REST stays private. Gateway REST is exported for module mints; + // solutions resolve its separately exported workload listener. restVisibility := map[string]catalogv1.EndpointVisibility{ "accounts": catalogv1.EndpointVisibility_ENDPOINT_VISIBILITY_PRIVATE, "auth-gateway": catalogv1.EndpointVisibility_ENDPOINT_VISIBILITY_MODULE, @@ -739,8 +737,9 @@ func TestDeploymentTopologyAuthorityPortIsAllocatedAtRender(t *testing.T) { require.NoError(t, err) for _, service := range artifacts.Catalog.GetServices() { for _, endpoint := range service.GetEndpoints() { - if service.GetName() == "accounts" && endpoint.GetName() == "authority" { - require.Zero(t, endpoint.GetPort(), "authority's pod port is allocated at render") + if (service.GetName() == "accounts" && endpoint.GetName() == "authority") || + (service.GetName() == "auth-gateway" && (endpoint.GetName() == "rest" || endpoint.GetName() == "workload")) { + require.Zero(t, endpoint.GetPort(), "%s/%s is allocated at render", service.GetName(), endpoint.GetName()) } else { require.NotZero(t, endpoint.GetPort(), "%s/%s declares its pod port", service.GetName(), endpoint.GetName()) } @@ -773,7 +772,7 @@ func TestDeploymentTopologyPodPortIsTheAllocationUnlessDeclared(t *testing.T) { } for endpoint, want := range map[string]uint32{ "accounts/connect": 8081, "accounts/grpc": 9090, "accounts/rest": 8080, "accounts/authority": 0, - "auth-gateway/grpc": 9090, "auth-gateway/rest": 8080, "telemetry/grpc": 9090, + "auth-gateway/grpc": 9090, "auth-gateway/rest": 0, "auth-gateway/workload": 0, "telemetry/grpc": 9090, "cache/read": 6379, "cache/write": 6379, "store/tcp": 5432, "vault/http": 8200, "frontend/http": 3000, "marketing/http": 3000, } { diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml index 3acdbfc7b..14cdf6b35 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml @@ -144,8 +144,6 @@ spec: matchLabels: app: frontend ports: - - protocol: TCP - port: 8080 --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -337,8 +335,6 @@ spec: matchLabels: app: auth-gateway ports: - - protocol: TCP - port: 8080 --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy diff --git a/module/services/accounts/code/pkg/infra/module_principal_registry_test.go b/module/services/accounts/code/pkg/infra/module_principal_registry_test.go index 427399229..3b5b60fca 100644 --- a/module/services/accounts/code/pkg/infra/module_principal_registry_test.go +++ b/module/services/accounts/code/pkg/infra/module_principal_registry_test.go @@ -378,7 +378,7 @@ func TestDeliveringAuthorityChangesTheRegistryWithNoRestart(t *testing.T) { grant, _ := s.entry() require.Equal(t, s.prefix, grant.Prefix, "the principal is the module's own name") require.Equal(t, s.tenant, grant.Tenant) - require.Equal(t, business.ModuleWorkload{ServiceAccount: "api", Namespace: s.primary.namespace, Container: "api"}, grant.Workload, + require.Equal(t, business.ModuleWorkload{SPIFFEID: s.primary.spiffeID(), ServiceAccount: "api", Namespace: s.primary.namespace, Container: "api"}, grant.Workload, "the workload is the applied presence's, and one the envelope allows") require.Equal(t, []string{"reports.render"}, grant.Queues) require.Equal(t, []string{s.resource}, grant.Resources) diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 90150ae93..9fd499aae 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -108,6 +108,43 @@ bearer: identity is stripped there and no ext_authz check runs, so the gateway has nothing to bind, and both halves say no rather than one promising what the other drops. +## Workload TLS endpoint and solution upstreams + +`workload` (`api: http`) serves the same mux over TLS 1.3 with required, verified +client certificates when the `workload-identity` workspace group supplies +`CERT_FILE`, `KEY_FILE`, `TRUST_BUNDLE_FILE`, and `ALLOWED_CALLERS_FILE`. If all +four values are absent, the gateway boots with only its existing plain endpoints, +does not resolve or open the workload endpoint, and logs once at info that no +workload identity is projected. Solution mints remain unreachable over mTLS and +solution proxying stays refused without a workload identity. Any partial set of +values or missing/unreadable supplied file fails boot naming its key. Keep the +`workload-identity` workspace dependency declared: renders use it to select +consumers of the projection. +The pinned SDK's `CertificateReloader` supplies both certificate callbacks. +Every handshake checks a single SPIFFE URI against the live caller file; every +request checks it again so keep-alive connections cannot outlive revocation. +The caller file must be published atomically, as the kubelet publishes a +ConfigMap volume (written whole, swapped behind one symlink); a mid-write mix +of two lists is indistinguishable from a complete list, so an in-place rewrite +is outside the contract. A truncated write is refused whole. + +On this endpoint every module mint (`/modules/_work-context`, +`/modules/_operation-context`, `/modules/_source-operation-context`) conveys +the verified SVID peer to accounts on the authenticated internal RPC. Accounts +binds it to TokenReview and the applied presence before signing. Caller-supplied +identity headers never attest a peer. Plain `rest` module mints still require +the internal token and keep their existing behavior. + +SPIFFE comparisons use the certificate's raw URI SAN, without URL normalization. +The live caller file must end in a newline; an incomplete or invalid snapshot +admits nobody, including on an existing connection. Entries trim only ASCII +space, tab and CR, with `#` introducing a comment. + +Solutions are proxied only over TLS 1.3 with the gateway SVID. The server's chain +and single SPIFFE URI must match the applied presence read alongside its registry +record. Missing or ambiguous identities fail closed. The existing DNS/address +guard remains the only dial path. Module upstream transport remains unchanged. + ## Brokering a module's Work Context `POST /modules/_work-context` takes the diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index e5e79fa1b..7e7aefba7 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -50,13 +50,11 @@ type Gateway struct { // clients is this replica's view of the registered-client registry: which // browser origins each first-party client speaks from (track 0016). clients *clientRegistryCache - // registeredTransport re-validates a runtime-registered upstream's resolved - // address at dial time (SSRF / DNS-rebinding defense). Both federated module - // and solution routes use it: a solution upstream is durable now (#534) but - // still names a host its registrant chose, and it receives forwarded user - // bearers. Catalog upstreams are static trusted config and keep the default - // transport. + // registeredTransport guards module upstream DNS/address resolution. + // Solutions use the same guarded dialer with per-destination workload TLS; + // catalog upstreams keep the default transport. registeredTransport http.RoundTripper + workload *workloadIdentity // solutionEntitlements answers what one viewer may use (#949). Set after // construction, like workContext: a nil client fails the entitlement surface // closed rather than answering an empty projection, which would retract every @@ -464,6 +462,7 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. // token. Never expose these capabilities to the frontend upstream. r.Header.Del("X-Codefly-Gateway-Token") r.Header.Del("X-Codefly-Internal-Token") + r.Header.Del(workloadSPIFFEHeader) r.Header.Del("X-Codefly-Public-Origin") if isAccountsRoute(entry) && g.authz != nil && g.authz.gatewayToken != "" { r.Header.Set("X-Codefly-Gateway-Token", g.authz.gatewayToken) @@ -483,6 +482,15 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. // transport. proxy.Transport = g.registeredTransport } + if entry != nil && strings.HasPrefix(entry.Service, solutionServicePrefix) { + transport, err := g.workload.upstreamTransport(entry.WorkloadSPIFFEID) + if err != nil || upstream.Scheme != "https" { + httpError(w, http.StatusBadGateway, "solution workload identity unavailable") + return + } + defer transport.CloseIdleConnections() + proxy.Transport = transport + } proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { httpError(w, http.StatusBadGateway, "upstream error: "+err.Error()) } diff --git a/module/services/auth-gateway/code/gateway_cors_test.go b/module/services/auth-gateway/code/gateway_cors_test.go index ed6c40f65..215f18132 100644 --- a/module/services/auth-gateway/code/gateway_cors_test.go +++ b/module/services/auth-gateway/code/gateway_cors_test.go @@ -218,7 +218,7 @@ func TestCORS_UpstreamGrantIsReplacedNotDuplicated(t *testing.T) { twoRegisteredClients(t, gw) permissive := &fakeUpstream{body: "solution-response"} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newSolutionTestServer(t, gw, "example", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Credentials", "true") permissive.ServeHTTP(w, r) @@ -355,7 +355,7 @@ func TestCORS_StreamedResponseCarriesTheGrantWithItsFirstChunk(t *testing.T) { twoRegisteredClients(t, gw) release := make(chan struct{}) - solution := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + solution := newSolutionTestServer(t, gw, "example", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: one\n\n")) http.NewResponseController(w).Flush() @@ -568,7 +568,7 @@ func TestCORS_VaryIsNotDuplicatedAgainstAnUpstreamThatSetsIt(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) twoRegisteredClients(t, gw) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + srv := newSolutionTestServer(t, gw, "example", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Add("Vary", "Origin") w.Header().Add("Vary", "Accept-Encoding") w.WriteHeader(http.StatusOK) diff --git a/module/services/auth-gateway/code/gateway_modules.go b/module/services/auth-gateway/code/gateway_modules.go index a3c5fa544..806a6eb44 100644 --- a/module/services/auth-gateway/code/gateway_modules.go +++ b/module/services/auth-gateway/code/gateway_modules.go @@ -172,7 +172,8 @@ func (g *Gateway) handleModuleWorkContext(w http.ResponseWriter, r *http.Request return true } - if g.authz == nil || !g.authz.acceptsInternalToken(r.Header.Get("X-Codefly-Internal-Token")) { + _, workloadRequest := workloadPeer(r.Context()) + if g.authz == nil || (!workloadRequest && !g.authz.acceptsInternalToken(r.Header.Get("X-Codefly-Internal-Token"))) { httpError(w, http.StatusUnauthorized, "unauthorized") return true } @@ -202,7 +203,11 @@ func (g *Gateway) handleModuleWorkContext(w http.ResponseWriter, r *http.Request logModuleExchangeRefusal("module-work-context", payload.Prefix, err) switch status.Code(err) { case codes.PermissionDenied: - httpError(w, http.StatusUnauthorized, "unauthorized") + if workloadRequest { + httpError(w, http.StatusForbidden, "forbidden") + } else { + httpError(w, http.StatusUnauthorized, "unauthorized") + } case codes.InvalidArgument: httpError(w, http.StatusBadRequest, "invalid request") case codes.Unavailable: @@ -330,13 +335,27 @@ func (g *Gateway) handleModuleOperationContext(w http.ResponseWriter, r *http.Re return true } +// moduleMintContext carries the listener attestation on every module mint. +func (s *ExtAuthz) moduleMintContext(ctx context.Context) context.Context { + ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) + // Replace, never append to, caller metadata. Only the verified listener + // context may attest a workload to accounts' internal mint. + md, _ := metadata.FromOutgoingContext(ctx) + md = md.Copy() + md.Delete(workloadSPIFFEHeader) + if id, ok := workloadPeer(ctx); ok { + md.Set(workloadSPIFFEHeader, id) + } + return metadata.NewOutgoingContext(ctx, md) +} + func (s *ExtAuthz) mintModuleWorkContext( ctx context.Context, prefix, serviceAccountToken, organizationID string, ) (*accountsv1.ModuleMintWorkContextResponse, error) { if s.backendConn == nil { return nil, fmt.Errorf("accounts connection not configured") } - ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) + ctx = s.moduleMintContext(ctx) response := &accountsv1.ModuleMintWorkContextResponse{} request := &accountsv1.ModuleMintWorkContextRequest{Prefix: prefix, ServiceAccountToken: serviceAccountToken, OrganizationId: organizationID} if err := s.backendConn.Invoke(ctx, mintModuleWorkContextMethod, request, response); err != nil { @@ -351,7 +370,7 @@ func (s *ExtAuthz) mintModuleOperationContext( if s.backendConn == nil { return nil, fmt.Errorf("accounts connection not configured") } - ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) + ctx = s.moduleMintContext(ctx) response := &accountsv1.ModuleMintOperationContextResponse{} request := &accountsv1.ModuleMintOperationContextRequest{Prefix: prefix, ServiceAccountToken: serviceAccountToken, Binding: binding, OrganizationId: organizationID} if err := s.backendConn.Invoke(ctx, mintModuleOperationContextMethod, request, response); err != nil { @@ -523,7 +542,7 @@ func (s *ExtAuthz) mintSourceOperationContext( if s.backendConn == nil { return nil, fmt.Errorf("accounts connection not configured") } - ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) + ctx = s.moduleMintContext(ctx) response := &accountsv1.ModuleMintSourceOperationContextResponse{} if err := s.backendConn.Invoke(ctx, mintSourceOperationContextMethod, request, response); err != nil { return nil, err diff --git a/module/services/auth-gateway/code/gateway_modules_test.go b/module/services/auth-gateway/code/gateway_modules_test.go index e6c7d7aaa..ca1efa567 100644 --- a/module/services/auth-gateway/code/gateway_modules_test.go +++ b/module/services/auth-gateway/code/gateway_modules_test.go @@ -188,6 +188,7 @@ func TestGateway_ModuleWorkContext_TakesOnlyTheProjectedTokenBearer(t *testing.T type fakeAccountsWorkContextMint struct { code codes.Code lastInternal string + lastPeer string lastPrefix string lastOrganization string lastToken string @@ -204,6 +205,7 @@ func (f *fakeAccountsWorkContextMint) handle(ctx context.Context, dec func(any) f.lastOrganization = req.GetOrganizationId() f.lastToken = req.GetServiceAccountToken() if md, ok := metadata.FromIncomingContext(ctx); ok { + f.lastPeer = strings.Join(md.Get(workloadSPIFFEHeader), ",") if values := md.Get("x-codefly-internal-token"); len(values) > 0 { f.lastInternal = values[0] } 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..fc165912f 100644 --- a/module/services/auth-gateway/code/gateway_solution_admission_test.go +++ b/module/services/auth-gateway/code/gateway_solution_admission_test.go @@ -527,7 +527,7 @@ func replacementArrivesMidRequest( t.Helper() predecessor = registerSolutionUpstream(t, gw, alias) replacement = &fakeUpstream{body: "replacement-response"} - replacementSrv := httptest.NewServer(replacement) + replacementSrv := newSolutionTestServer(t, gw, alias, replacement) t.Cleanup(replacementSrv.Close) registry := solutionRegistryFake(t, gw) diff --git a/module/services/auth-gateway/code/gateway_solution_registry.go b/module/services/auth-gateway/code/gateway_solution_registry.go index 81c7f8e03..3258d3de9 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry.go +++ b/module/services/auth-gateway/code/gateway_solution_registry.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" ) const ( @@ -38,11 +39,17 @@ const ( solutionRefreshFloor = 250 * time.Millisecond ) -// solutionRegistryClient is the registry surface the gateway consumes. The -// interface exists so the cache and the HTTP handlers can be exercised without -// an accounts process. +// solutionRegistrySnapshot joins only matching applied generations. The compact +// registration has no workload field; the host-binding read carries Core's +// complete presence document. No new wire schema duplicates that contract. +type solutionRegistrySnapshot struct { + *accountsv1.ListSolutionRegistrationsResponse + workloadIDs map[string]string +} + +// solutionRegistryClient is the registry surface used by the cache and handlers. type solutionRegistryClient interface { - List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*accountsv1.ListSolutionRegistrationsResponse, error) + List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*solutionRegistrySnapshot, error) } // accountsSolutionRegistry invokes the registry by method name over the @@ -62,9 +69,29 @@ func (c *accountsSolutionRegistry) invoke(ctx context.Context, method string, re return c.conn.Invoke(ctx, method, req, resp) } -func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*accountsv1.ListSolutionRegistrationsResponse, error) { +func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*solutionRegistrySnapshot, error) { resp := &accountsv1.ListSolutionRegistrationsResponse{} - return resp, c.invoke(ctx, listSolutionRegistrationsMethod, req, resp) + if err := c.invoke(ctx, listSolutionRegistrationsMethod, req, resp); err != nil { + return nil, err + } + bindings := &accountsv1.ListSolutionHostBindingsResponse{} + if err := c.invoke(ctx, "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", &accountsv1.ListSolutionHostBindingsRequest{}, bindings); err != nil { + return nil, err + } + out := &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: resp, workloadIDs: make(map[string]string)} + for _, record := range resp.GetRegistrations() { + for _, binding := range bindings.GetBindings() { + // These reads may straddle reconciliation. Never attach a successor's + // identity to its predecessor's address, even within one generation. + if !proto.Equal(record, binding.GetRegistration()) { + continue + } + if id := appliedWorkloadSPIFFEID(binding); id != "" { + out.workloadIDs[record.GetSolutionId()] = id + } + } + } + return out, nil } // solutionResolution is why a solution is or is not routable right now. The @@ -99,10 +126,11 @@ type solutionRegistryCache struct { // it at time.Now. now func() time.Time - mu sync.RWMutex - records map[string]*accountsv1.SolutionRegistration - revision int64 - loaded bool + mu sync.RWMutex + records map[string]*accountsv1.SolutionRegistration + workloadIDs map[string]string + revision int64 + loaded bool // attemptedAt is when a read was last ATTEMPTED, successfully or not. The // refresh floor is measured from it rather than from the last SUCCESS: a // failed read leaves the snapshot's age where it was, so measuring from that @@ -182,6 +210,7 @@ func (c *solutionRegistryCache) load(ctx context.Context) error { records[record.GetSolutionId()] = record } c.records = records + c.workloadIDs = resp.workloadIDs c.revision = resp.GetRegistryRevision() c.loaded = true c.lastErr = nil @@ -210,11 +239,11 @@ func (c *solutionRegistryCache) reconcile(ctx context.Context) { } // lookup reads the cached record without touching the network. -func (c *solutionRegistryCache) lookup(id string) (*accountsv1.SolutionRegistration, bool, bool) { +func (c *solutionRegistryCache) lookup(id string) (*accountsv1.SolutionRegistration, string, bool, bool) { c.mu.RLock() defer c.mu.RUnlock() record, ok := c.records[id] - return record, ok, c.loaded + return record, c.workloadIDs[id], ok, c.loaded } func (c *solutionRegistryCache) staleEnoughToRefresh() bool { @@ -227,8 +256,9 @@ func (c *solutionRegistryCache) staleEnoughToRefresh() bool { // a verdict must never be derived without: whether the registry could actually // be consulted about it. type solutionLookup struct { - record *accountsv1.SolutionRegistration - found bool + record *accountsv1.SolutionRegistration + workloadID string + found bool // loaded is false until a snapshot has ever loaded on this replica. loaded bool // undecidable is set when this alias had no conclusive record in the @@ -273,17 +303,18 @@ func (c *solutionRegistryCache) conclusive( func (c *solutionRegistryCache) lookupFresh( ctx context.Context, id string, surface routingSurface, ) solutionLookup { - record, found, loaded := c.lookup(id) + record, workloadID, found, loaded := c.lookup(id) if c.conclusive(record, found, surface) { - return solutionLookup{record: record, found: found, loaded: loaded} + return solutionLookup{record: record, workloadID: workloadID, found: found, loaded: loaded} } refreshErr := c.refreshIfStale(ctx) if refreshErr != nil { log.Printf("solution registry: on-demand refresh for %q failed: %v", id, refreshErr) } - record, found, loaded = c.lookup(id) + record, workloadID, found, loaded = c.lookup(id) return solutionLookup{ record: record, + workloadID: workloadID, found: found, loaded: loaded, undecidable: refreshErr != nil && !c.conclusive(record, found, surface), @@ -310,6 +341,7 @@ func (c *solutionRegistryCache) lookupFresh( // that must refuse it is the one holding the viewer, and it refuses it as a // verdict rather than as a missing route. type solutionRouting struct { + WorkloadSPIFFEID string // TargetID is the immutable solution target this alias resolves to, empty // for a registration no declaration opened a target for. TargetID string @@ -409,10 +441,14 @@ func (c *solutionRegistryCache) resolveRoutingOfKind( log.Printf("solution registry: record %q has an unusable upstream", id) return nil, solutionNotActive } + if surface == solutionSurface { + upstream.Scheme = "https" + } return &solutionRouting{ - TargetID: record.GetDeclared().GetTargetId(), - BindingID: record.GetDeclared().GetBindingId(), - Upstream: &url.URL{Scheme: upstream.Scheme, Host: upstream.Host}, + WorkloadSPIFFEID: read.workloadID, + TargetID: record.GetDeclared().GetTargetId(), + BindingID: record.GetDeclared().GetBindingId(), + Upstream: &url.URL{Scheme: upstream.Scheme, Host: upstream.Host}, }, solutionRoutable } diff --git a/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go b/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go index 28529428e..63e4791b9 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go +++ b/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go @@ -141,7 +141,7 @@ func (f *fakeSolutionRegistry) undeclare(alias string) { func (f *fakeSolutionRegistry) List( _ context.Context, req *accountsv1.ListSolutionRegistrationsRequest, -) (*accountsv1.ListSolutionRegistrationsResponse, error) { +) (*solutionRegistrySnapshot, error) { f.mu.Lock() defer f.mu.Unlock() f.listCalls++ @@ -157,7 +157,11 @@ func (f *fakeSolutionRegistry) List( listed.Status = fakeSolutionStatus(listed, time.Now()) out.Registrations = append(out.Registrations, listed) } - return out, nil + ids := make(map[string]string) + for _, record := range out.Registrations { + ids[record.GetSolutionId()] = "spiffe://acme.test/ns/acme-prod/sa/" + record.GetSolutionId() + } + return &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: out, workloadIDs: ids}, nil } // fakeSolutionStatus mirrors business.(*SolutionRegistration).Status, ordering diff --git a/module/services/auth-gateway/code/gateway_solution_workload.go b/module/services/auth-gateway/code/gateway_solution_workload.go new file mode 100644 index 000000000..546ced037 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_workload.go @@ -0,0 +1,36 @@ +package main + +import ( + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" +) + +// Read APPLIED presence only. Desired documents do not authorize traffic. The +// registration, applied generation and parsed document must name the same +// binding and generation. Presence can declare several workloads; until it +// identifies which serves an endpoint, only one distinct identity is routable. +func appliedWorkloadSPIFFEID(binding *accountsv1.SolutionHostBindingState) string { + applied := binding.GetApplied() + record := binding.GetRegistration() + declared := record.GetDeclared() + if applied == nil || applied.GetRemoved() || declared == nil || + declared.GetBindingId() != binding.GetBindingId() || + declared.GetGeneration() != applied.GetGeneration().GetGeneration() || + applied.GetSolutionId() != record.GetSolutionId() || record.GetTombstonedAt() != nil { + return "" + } + document, err := solutionhost.Parse([]byte(applied.GetGeneration().GetDocument())) + if err != nil || document.Binding != declared.GetBindingId() || document.Generation != declared.GetGeneration() { + return "" + } + id := "" + for _, workload := range document.Workloads { + candidate := workload.Identity.SPIFFEID + if !workloadSPIFFEPattern.MatchString(candidate) || (id != "" && id != candidate) { + return "" + } + id = candidate + } + return id +} diff --git a/module/services/auth-gateway/code/gateway_solution_workload_test.go b/module/services/auth-gateway/code/gateway_solution_workload_test.go new file mode 100644 index 000000000..106e338dc --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_workload_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestAppliedSolutionWorkloadIdentity(t *testing.T) { + var document *solutionhost.SolutionHostBinding + for _, fixture := range solutionhost.FixturesOf(solutionhost.DocumentTypePresence) { + parsed, err := solutionhost.Parse(fixture.Document) + if err == nil && len(parsed.Workloads) > 0 { + document = parsed + break + } + } + require.NotNil(t, document, "Core must ship a readable workload presence fixture") + body, err := json.Marshal(document) + require.NoError(t, err) + binding := &accountsv1.SolutionHostBindingState{ + BindingId: document.Binding, + Applied: &accountsv1.SolutionHostBindingAppliedGeneration{SolutionId: "example", Generation: &accountsv1.SolutionHostBindingGeneration{Generation: document.Generation, Document: string(body)}}, + Registration: &accountsv1.SolutionRegistration{SolutionId: "example", Declared: &accountsv1.SolutionDeclaredBinding{BindingId: document.Binding, Generation: document.Generation}}, + } + require.Equal(t, document.Workloads[0].Identity.SPIFFEID, appliedWorkloadSPIFFEID(binding)) + for _, test := range []struct { + name string + mutate func(*accountsv1.SolutionHostBindingState) + }{ + {"another applied generation", func(b *accountsv1.SolutionHostBindingState) { b.Registration.Declared.Generation++ }}, + {"another binding", func(b *accountsv1.SolutionHostBindingState) { b.BindingId += "-other" }}, + {"another registry alias", func(b *accountsv1.SolutionHostBindingState) { b.Applied.SolutionId = "other" }}, + {"tombstone", func(b *accountsv1.SolutionHostBindingState) { b.Applied.Removed = true }}, + {"desired only", func(b *accountsv1.SolutionHostBindingState) { b.Desired = b.Applied.Generation; b.Applied = nil }}, + {"malformed document", func(b *accountsv1.SolutionHostBindingState) { b.Applied.Generation.Document = "{}" }}, + {"no workload", func(b *accountsv1.SolutionHostBindingState) { + copy := *document + copy.Workloads = nil + body, err := json.Marshal(copy) + require.NoError(t, err) + b.Applied.Generation.Document = string(body) + }}, + {"ambiguous workloads", func(b *accountsv1.SolutionHostBindingState) { + copy := *document + second := copy.Workloads[0] + second.Name += "-other" + second.Identity.SPIFFEID = "spiffe://acme.test/ns/acme-prod/sa/other" + copy.Workloads = append(append([]solutionhost.Workload{}, copy.Workloads...), second) + body, err := json.Marshal(copy) + require.NoError(t, err) + b.Applied.Generation.Document = string(body) + }}, + } { + t.Run(test.name, func(t *testing.T) { + b := proto.Clone(binding).(*accountsv1.SolutionHostBindingState) + test.mutate(b) + require.Empty(t, appliedWorkloadSPIFFEID(b)) + }) + } +} + +// Exercises the actual reverse-proxy path and guarded network dial. Unlike the +// in-memory TLS tests this test requires permission to bind loopback. +func TestSolutionProxyWorkloadIdentity(t *testing.T) { + gateway := &Gateway{} + reached := 0 + server := newSolutionTestServer(t, gateway, "example", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reached++; _, _ = w.Write([]byte("solution")) })) + for _, test := range []struct { + id string + status int + }{ + {"spiffe://acme.test/ns/acme-prod/sa/example", http.StatusOK}, + {"spiffe://acme.test/ns/acme-prod/sa/other", http.StatusBadGateway}, + {"", http.StatusBadGateway}, + } { + recorder := httptest.NewRecorder() + gateway.proxyTo(recorder, httptest.NewRequest(http.MethodGet, "/assets/entry.js", nil), MustURL(server.URL), &RouteEntry{Service: "solution:example", WorkloadSPIFFEID: test.id}) + require.Equal(t, test.status, recorder.Code) + } + require.Equal(t, 1, reached) +} diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 4ff6d36d9..5d820758d 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -179,9 +179,10 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) } stripAllIdentityHeaders(r) entry := &RouteEntry{ - Service: "solution:" + id, - UpstreamPath: publicPath, - RateLimitClass: edgeRateLimitClassPublic, + Service: "solution:" + id, + WorkloadSPIFFEID: routing.WorkloadSPIFFEID, + UpstreamPath: publicPath, + RateLimitClass: edgeRateLimitClassPublic, } g.rateLimitThenProxy(w, r, routing.Upstream, entry) return true @@ -288,10 +289,11 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) class = edgeRateLimitClassStandardWrite } entry := &RouteEntry{ - Service: "solution:" + id, - UpstreamPath: "/" + path, - Protected: true, - RateLimitClass: class, + Service: "solution:" + id, + WorkloadSPIFFEID: routing.WorkloadSPIFFEID, + UpstreamPath: "/" + path, + Protected: true, + RateLimitClass: class, } // Metered FIRST, and the admission check runs inside the budget. // diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index a8e0e5143..f49d8442f 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -22,7 +22,7 @@ import ( func registerSolutionUpstream(t *testing.T, gw *Gateway, id string) *fakeUpstream { t.Helper() fake := &fakeUpstream{body: "solution-response"} - srv := httptest.NewServer(fake) + srv := newSolutionTestServer(t, gw, id, fake) t.Cleanup(srv.Close) registerSolutionHalves(t, gw, id, srv.URL) return fake diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 3b2ebccff..7996e8e01 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.17.2 + github.com/codefly-dev/core v0.18.2 github.com/codefly-dev/sdk-go v0.3.1 github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e github.com/envoyproxy/go-control-plane/envoy v1.39.0 diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 2f4297542..e99e8ab4b 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -44,8 +44,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index d239e12e5..55745498e 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "log/slog" "net" "net/http" "net/url" @@ -40,6 +41,31 @@ func main() { panic(fmt.Sprintf("Codefly REST endpoint is unavailable: %v", httpNetErr)) } httpPort = httpNet.Port + workload, err := loadWorkloadIdentity(func(key string) (string, error) { + return codefly.For(ctx).WorkspaceValue("workload-identity", key) + }) + if err != nil { + panic(err) + } + workloadListener, workloadTLS, err := workload.listen(func() (net.Listener, error) { + workloadNet, err := codefly.For(ctx).Endpoint("workload").API("http").ResolveNetworkInstance() + if err != nil || workloadNet == nil || workloadNet.Port == 0 { + return nil, fmt.Errorf("Codefly workload endpoint is unavailable: %v", err) + } + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", workloadNet.Port)) + if err != nil { + return nil, fmt.Errorf("workload listener: %w", err) + } + return listener, nil + }) + if err != nil { + panic(err) + } + if workloadListener != nil { + defer workloadListener.Close() + } else { + slog.Info("workload endpoint is off because no workload identity is projected") + } var otelProvider *wooltel.Provider var otelMetricProvider *otelMetrics if observabilityEnabled() { @@ -174,7 +200,7 @@ func main() { routeEntries := append(restEntries, connectEntries...) // Private HTTP API gateway listener behind the frontend product entry. - var httpServer *http.Server + var httpServer, workloadServer *http.Server var rateLimiter *RateLimiter if httpPort > 0 { // Build upstream map from codefly network mappings. @@ -233,6 +259,7 @@ func main() { // reach this replica without restarting it. clientRegistry := &accountsClientRegistry{conn: internalAPIConn, internalToken: authz.internalToken} gateway := NewGateway(authz, matcher, upstreams, rateLimiter, solutionRegistry, clientRegistry) + gateway.workload = workload // The per-viewer solution entitlement read (#949), over the same internal // connection. It is not a cache: the answer is per viewer and must not // outlive a revoke, so every request asks. @@ -261,6 +288,19 @@ func main() { IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, // 1 MiB } + if workloadListener != nil { + workloadServer = &http.Server{ + Handler: workload.handler(httpServer.Handler), TLSConfig: workloadTLS, + ReadHeaderTimeout: httpServer.ReadHeaderTimeout, ReadTimeout: httpServer.ReadTimeout, + IdleTimeout: httpServer.IdleTimeout, MaxHeaderBytes: httpServer.MaxHeaderBytes, + } + go func() { + if err := workloadServer.ServeTLS(workloadListener, "", ""); err != nil && err != http.ErrServerClosed { + log.Printf("workload server: %v", err) + stop() + } + }() + } go func() { fmt.Printf("auth-gateway API gateway (HTTP) listening on :%d (api: %s, routes: %d)\n", httpPort, apiHTTPURL, len(routeEntries)) @@ -286,6 +326,14 @@ func main() { drainCancel() } + if workloadServer != nil { + drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := workloadServer.Shutdown(drainCtx); err != nil { + log.Printf("shutdown: workload drain: %v", err) + } + drainCancel() + } + // 2. Gracefully stop gRPC server (finishes in-flight RPCs). log.Println("shutdown: stopping gRPC server...") grpcServer.GracefulStop() 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..c4835f0f5 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -53,12 +53,16 @@ var allowedPerimeterConfigDeps = map[string]bool{ // to require a token's resource audience to name it, so the origin a client // is sent to and the origin a token is accepted at are one operator-pinned // value rather than two headers that can disagree. - "application": true, - "gateway": true, - "gateway-trust": true, - "internal-auth": true, - "observability": true, - "security": true, + "application": true, + // workload-identity carries only file paths to this workload's own SVID, + // trust bundle and admitted-caller list, all projected by the platform. + // Reviewed for #1074: no external-IdP material and no provider endpoint. + "workload-identity": true, + "gateway": true, + "gateway-trust": true, + "internal-auth": true, + "observability": true, + "security": true, } // TestPerimeter_WorkspaceConfigExcludesIdentity asserts the ext_authz check's @@ -148,6 +152,10 @@ func fileImports(t *testing.T, path string) []string { var httpAllowedPerimeterFiles = map[string]bool{ "gateway.go": true, "gateway_solutions.go": true, + // workload_identity.go builds the gateway's mutual-TLS listener and the + // in-mesh transport it proxies to solutions with, holding each peer to a + // SPIFFE ID from delivered presence. It reaches no external IdP. + "workload_identity.go": true, // gateway_solution_credential.go serves the solution registration-token // exchange and verifies the resulting credential with local Ed25519 crypto // against this cluster's own published key set. Its only network call is the diff --git a/module/services/auth-gateway/code/routing.go b/module/services/auth-gateway/code/routing.go index 55744dbea..58878b5f4 100644 --- a/module/services/auth-gateway/code/routing.go +++ b/module/services/auth-gateway/code/routing.go @@ -39,6 +39,7 @@ type RouteExtension struct { // RouteEntry is the internal representation used by the gateway. type RouteEntry struct { + WorkloadSPIFFEID string // declared TLS peer for a solution upstream Service string // upstream service name (from directory path) Method string // HTTP method Path string // public HTTP path (may contain {param} segments) diff --git a/module/services/auth-gateway/code/workload_identity.go b/module/services/auth-gateway/code/workload_identity.go new file mode 100644 index 000000000..fea1de5d0 --- /dev/null +++ b/module/services/auth-gateway/code/workload_identity.go @@ -0,0 +1,289 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/asn1" + "fmt" + "net" + "net/http" + "os" + "regexp" + "strings" + + codefly "github.com/codefly-dev/sdk-go" +) + +const workloadSPIFFEHeader = "x-codefly-workload-spiffe-id" + +type workloadPeerKey struct{} + +// Only the workload listener supplies this context value. HTTP headers never +// attest a peer, including on the plain listener. +func workloadPeer(ctx context.Context) (string, bool) { + id, ok := ctx.Value(workloadPeerKey{}).(string) + return id, ok +} + +type workloadIdentity struct { + certificate *codefly.CertificateReloader + trustFile string + callersFile string +} + +// Resolve only the declared group: bare environment variables are not a second +// source of workload credentials. An entirely absent projection disables the +// workload endpoint; any supplied value requires the complete TLS identity. +func loadWorkloadIdentity(value func(string) (string, error)) (*workloadIdentity, error) { + paths := make(map[string]string) + keys := []string{"CERT_FILE", "KEY_FILE", "TRUST_BUNDLE_FILE", "ALLOWED_CALLERS_FILE"} + var missing error + for _, key := range keys { + path, err := value(key) + if err != nil || strings.TrimSpace(path) == "" { + if missing == nil { + missing = fmt.Errorf("workload-identity %s: missing path (%v)", key, err) + } + } + if strings.TrimSpace(path) != "" { + paths[key] = path + } + } + if len(paths) == 0 { + return nil, nil + } + if missing != nil { + return nil, missing + } + for _, key := range keys { + if _, err := os.ReadFile(paths[key]); err != nil { + return nil, fmt.Errorf("workload-identity %s: %w", key, err) + } + } + reloader, err := codefly.NewCertificateReloader(paths["CERT_FILE"], paths["KEY_FILE"]) + if err != nil { + return nil, fmt.Errorf("workload-identity CERT_FILE/KEY_FILE: %w", err) + } + w := &workloadIdentity{certificate: reloader, trustFile: paths["TRUST_BUNDLE_FILE"], callersFile: paths["ALLOWED_CALLERS_FILE"]} + if _, err := w.roots(); err != nil { + return nil, err + } + if _, err := w.allowedCallers(); err != nil { + return nil, err + } + if _, err := certificateSPIFFEID(reloader.Certificate().Leaf); err != nil { + return nil, fmt.Errorf("workload-identity CERT_FILE: %w", err) + } + return w, nil +} + +// Resolve and open the endpoint only when an identity is projected. Plain-only +// startup must not depend on workload endpoint discovery or bind its socket. +func (w *workloadIdentity) listen(listen func() (net.Listener, error)) (net.Listener, *tls.Config, error) { + if w == nil { + return nil, nil, nil + } + config, err := w.serverTLSConfig() + if err != nil { + return nil, nil, err + } + listener, err := listen() + if err != nil { + return nil, nil, err + } + return listener, config, nil +} + +var workloadSPIFFEPattern = regexp.MustCompile(`^spiffe://[a-z0-9._-]{1,255}(?:/[A-Za-z0-9._-]+)+$`) + +var subjectAltNameOID = asn1.ObjectIdentifier{2, 5, 29, 17} + +func certificateSPIFFEID(cert *x509.Certificate) (string, error) { + if cert == nil { + return "", fmt.Errorf("certificate required") + } + // cert.URIs has already passed through net/url, which lowercases schemes + // and drops empty fragments. Admission compares the original SAN bytes. + var id string + foundSAN := false + for _, extension := range cert.Extensions { + if !extension.Id.Equal(subjectAltNameOID) { + continue + } + if foundSAN { + return "", fmt.Errorf("duplicate subjectAltName extension") + } + foundSAN = true + var names []asn1.RawValue + rest, err := asn1.Unmarshal(extension.Value, &names) + if err != nil || len(rest) != 0 { + return "", fmt.Errorf("invalid subjectAltName extension") + } + for _, name := range names { + if name.Class != asn1.ClassContextSpecific { + return "", fmt.Errorf("invalid subjectAltName GeneralName") + } + if name.Tag != 6 { + continue + } + if id != "" || name.IsCompound || !workloadSPIFFEPattern.Match(name.Bytes) { + return "", fmt.Errorf("expected exactly one canonical SPIFFE URI SAN") + } + id = string(name.Bytes) + } + } + if id == "" { + return "", fmt.Errorf("expected exactly one canonical SPIFFE URI SAN") + } + return id, nil +} + +func (w *workloadIdentity) roots() (*x509.CertPool, error) { + pem, err := os.ReadFile(w.trustFile) + if err != nil { + return nil, fmt.Errorf("workload-identity TRUST_BUNDLE_FILE: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("workload-identity TRUST_BUNDLE_FILE: no certificates") + } + return roots, nil +} + +// Read on every admission, including requests on an existing connection. A +// removed identity, missing file or malformed replacement revokes immediately. +// +// The file must be published atomically โ€” written whole and swapped in, as the +// kubelet publishes a ConfigMap volume (a new ..data directory behind one +// symlink rename). No reader can tell a mid-write mix of an old and a new list +// from a complete one when both end in a newline, so an in-place rewrite is +// outside the contract, not a case this reader repairs. Only a regular file +// (reached directly or through that symlink) is read. +func (w *workloadIdentity) allowedCallers() (map[string]bool, error) { + info, err := os.Stat(w.callersFile) + if err != nil { + return nil, fmt.Errorf("workload-identity ALLOWED_CALLERS_FILE: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("workload-identity ALLOWED_CALLERS_FILE: not a regular file") + } + body, err := os.ReadFile(w.callersFile) + if err != nil { + return nil, fmt.Errorf("workload-identity ALLOWED_CALLERS_FILE: %w", err) + } + // A truncated write that has not reached its final newline is refused whole, + // including any complete entries before it. + if len(body) > 0 && body[len(body)-1] != '\n' { + return nil, fmt.Errorf("workload-identity ALLOWED_CALLERS_FILE: missing final newline") + } + allowed := make(map[string]bool) + for _, line := range strings.Split(string(body), "\n") { + line, _, _ = strings.Cut(line, "#") + id := strings.Trim(line, " \t\r") + if id == "" { + continue + } + if !workloadSPIFFEPattern.MatchString(id) { + return nil, fmt.Errorf("workload-identity ALLOWED_CALLERS_FILE: invalid SPIFFE ID") + } + allowed[id] = true + } + return allowed, nil +} + +func (w *workloadIdentity) admit(state tls.ConnectionState) (string, error) { + if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 { + return "", fmt.Errorf("verified client certificate required") + } + id, err := certificateSPIFFEID(state.PeerCertificates[0]) + if err != nil { + return "", err + } + allowed, err := w.allowedCallers() + if err != nil { + return "", err + } + if !allowed[id] { + return "", fmt.Errorf("workload caller is not allowed") + } + return id, nil +} + +func (w *workloadIdentity) serverTLSConfig() (*tls.Config, error) { + roots, err := w.roots() + if err != nil { + return nil, err + } + config := &tls.Config{ + MinVersion: tls.VersionTLS13, ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: roots, GetCertificate: w.certificate.GetCertificate, + // Revalidate the chain on every handshake, including after revocation. + SessionTicketsDisabled: true, + VerifyConnection: func(state tls.ConnectionState) error { _, err := w.admit(state); return err }, + } + config.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { + current := config.Clone() + current.GetConfigForClient = nil + roots, err := w.roots() + current.ClientCAs = roots + return current, err + } + return config, nil +} + +func (w *workloadIdentity) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + httpError(rw, http.StatusForbidden, "workload identity required") + return + } + id, err := w.admit(*r.TLS) + if err != nil { + httpError(rw, http.StatusForbidden, "workload identity refused") + return + } + next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), workloadPeerKey{}, id))) + }) +} + +func (w *workloadIdentity) upstreamTransport(expected string) (*http.Transport, error) { + if w == nil || !workloadSPIFFEPattern.MatchString(expected) { + return nil, fmt.Errorf("solution workload identity unavailable") + } + transport := newModuleUpstreamTransport(net.DefaultResolver) + // Each proxy exchange authenticates the current peer and projected leaf. + transport.DisableKeepAlives = true + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS13, GetClientCertificate: w.certificate.GetClientCertificate, + // SPIFFE authenticates a URI, not a DNS name. VerifyConnection performs + // the full chain/EKU verification below before comparing that URI. + InsecureSkipVerify: true, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("solution certificate required") + } + roots, err := w.roots() + if err != nil { + return err + } + intermediates := x509.NewCertPool() + for _, cert := range state.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + leaf := state.PeerCertificates[0] + if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return err + } + id, err := certificateSPIFFEID(leaf) + if err != nil { + return err + } + if id != expected { + return fmt.Errorf("solution SPIFFE ID mismatch") + } + return nil + }, + } + return transport, nil +} diff --git a/module/services/auth-gateway/code/workload_identity_test.go b/module/services/auth-gateway/code/workload_identity_test.go new file mode 100644 index 000000000..e1fc3646f --- /dev/null +++ b/module/services/auth-gateway/code/workload_identity_test.go @@ -0,0 +1,467 @@ +package main + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const gatewayTestSPIFFE = "spiffe://acme.test/ns/acme-prod/sa/gateway" +const callerTestSPIFFE = "spiffe://acme.test/ns/acme-prod/sa/worker" + +type workloadTestCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + pem []byte +} + +func newWorkloadTestCA(t *testing.T) *workloadTestCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + require.NoError(t, err) + tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "Example CA"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return &workloadTestCA{cert: cert, key: key, pem: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})} +} + +func (ca *workloadTestCA) leaf(t *testing.T, ids ...string) (tls.Certificate, []byte, []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + require.NoError(t, err) + tmpl := &x509.Certificate{SerialNumber: serial, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}} + // Encode the actual SAN bytes: url.Parse would normalize adversarial IDs + // before the certificate under test was even issued. + if len(ids) != 0 { + var names []asn1.RawValue + for _, id := range ids { + names = append(names, asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 6, Bytes: []byte(id)}) + } + san, err := asn1.Marshal(names) + require.NoError(t, err) + tmpl.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{2, 5, 29, 17}, Value: san}} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + require.NoError(t, err) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + return cert, certPEM, keyPEM +} + +func workloadTestIdentity(t *testing.T, ca *workloadTestCA, id, allowed string) (*workloadIdentity, map[string]string) { + t.Helper() + _, cert, key := ca.leaf(t, id) + paths := map[string]string{} + dir := t.TempDir() + for name, body := range map[string][]byte{"CERT_FILE": cert, "KEY_FILE": key, "TRUST_BUNDLE_FILE": ca.pem, "ALLOWED_CALLERS_FILE": []byte(allowed)} { + paths[name] = filepath.Join(dir, name) + require.NoError(t, os.WriteFile(paths[name], body, 0600)) + } + identity, err := loadWorkloadIdentity(func(key string) (string, error) { return paths[key], nil }) + require.NoError(t, err) + require.NotNil(t, identity) + return identity, paths +} + +func TestWorkloadIdentityAbsentServesOnlyPlainEndpoints(t *testing.T) { + for _, test := range []struct { + name string + value func(string) (string, error) + }{ + {"empty values", func(string) (string, error) { return "", nil }}, + {"blank values", func(string) (string, error) { return " \t\n", nil }}, + {"unprojected group", func(key string) (string, error) { + return "", fmt.Errorf("no workspace configuration value for workload-identity/%s", key) + }}, + } { + t.Run(test.name, func(t *testing.T) { + identity, err := loadWorkloadIdentity(test.value) + require.NoError(t, err) + require.Nil(t, identity) + listener, config, err := identity.listen(func() (net.Listener, error) { + t.Fatal("absent identity must not resolve or open the workload endpoint") + return nil, nil + }) + require.NoError(t, err) + require.Nil(t, listener) + require.Nil(t, config) + + gateway := NewGateway(&ExtAuthz{}, NewRouteMatcher([]*RouteEntry{ + {Service: "self", Method: http.MethodGet, Path: "/health"}, + }, nil), nil, nil, newFakeSolutionRegistry(), nil) + gateway.workload = identity + recorder := httptest.NewRecorder() + gateway.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + + // Even a declared solution with a valid SPIFFE identity cannot be + // reached when the gateway has no identity of its own. + registerSolutionHalves(t, gateway, "example", "https://solution") + recorder = httptest.NewRecorder() + gateway.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/solutions/example/assets/entry.js", nil)) + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.Contains(t, recorder.Body.String(), "solution workload identity unavailable") + }) + } +} + +// net.Pipe preserves the temporary deadline errors TLS requires when HTTP +// cancels a background read between keep-alive requests. bufconn's deadline +// error is permanent, causing TLS to close after the first request. +type workloadTestListener struct { + connections chan net.Conn + done chan struct{} + once sync.Once +} + +func (l *workloadTestListener) Accept() (net.Conn, error) { + select { + case conn := <-l.connections: + return conn, nil + case <-l.done: + return nil, net.ErrClosed + } +} +func (l *workloadTestListener) Close() error { l.once.Do(func() { close(l.done) }); return nil } +func (l *workloadTestListener) Addr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)} } +func (l *workloadTestListener) DialContext(ctx context.Context) (net.Conn, error) { + server, client := net.Pipe() + select { + case l.connections <- server: + return client, nil + case <-ctx.Done(): + _ = server.Close() + _ = client.Close() + return nil, ctx.Err() + case <-l.done: + _ = server.Close() + _ = client.Close() + return nil, net.ErrClosed + } +} + +// Real TLS and HTTP over an in-memory listener: these security checks also run +// where the sandbox forbids binding a loopback socket. +func workloadMemoryServer(t *testing.T, config *tls.Config, handler http.Handler) *workloadTestListener { + t.Helper() + listener := &workloadTestListener{connections: make(chan net.Conn), done: make(chan struct{})} + server := &http.Server{Handler: handler, TLSConfig: config} + go func() { _ = server.ServeTLS(listener, "", "") }() + t.Cleanup(func() { _ = server.Close(); _ = listener.Close() }) + return listener +} + +func workloadMemoryClient(t *testing.T, identity *workloadIdentity, listener *workloadTestListener, expected string) *http.Client { + t.Helper() + transport, err := identity.upstreamTransport(expected) + require.NoError(t, err) + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { return listener.DialContext(ctx) } + t.Cleanup(transport.CloseIdleConnections) + return &http.Client{Transport: transport, Timeout: 3 * time.Second} +} + +func TestWorkloadListenerAdmissionAndRevocation(t *testing.T) { + ca := newWorkloadTestCA(t) + gateway, paths := workloadTestIdentity(t, ca, gatewayTestSPIFFE, "# callers\n"+callerTestSPIFFE+"\n") + listener := &workloadTestListener{connections: make(chan net.Conn), done: make(chan struct{})} + opened, config, err := gateway.listen(func() (net.Listener, error) { return listener, nil }) + require.NoError(t, err) + require.Same(t, listener, opened) + server := &http.Server{TLSConfig: config, Handler: gateway.handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := workloadPeer(r.Context()) + require.True(t, ok) + require.Equal(t, callerTestSPIFFE, id) + w.WriteHeader(http.StatusNoContent) + }))} + go func() { _ = server.ServeTLS(opened, "", "") }() + t.Cleanup(func() { _ = server.Close(); _ = listener.Close() }) + for _, test := range []struct { + name string + ca *workloadTestCA + ids []string + noCert bool + allowed bool + }{ + {name: "no certificate", noCert: true}, + {name: "untrusted chain", ca: newWorkloadTestCA(t), ids: []string{callerTestSPIFFE}}, + {name: "unlisted identity", ca: ca, ids: []string{"spiffe://acme.test/ns/acme-prod/sa/intruder"}}, + {name: "two URI SANs", ca: ca, ids: []string{callerTestSPIFFE, callerTestSPIFFE}}, + {name: "uppercase raw scheme", ca: ca, ids: []string{strings.Replace(callerTestSPIFFE, "spiffe://", "SPIFFE://", 1)}}, + {name: "empty raw fragment", ca: ca, ids: []string{callerTestSPIFFE + "#"}}, + {name: "non SPIFFE URI", ca: ca, ids: []string{"https://example.com/worker"}}, + {name: "listed identity", ca: ca, ids: []string{callerTestSPIFFE}, allowed: true}, + } { + t.Run(test.name, func(t *testing.T) { + client := workloadMemoryClient(t, gateway, listener, gatewayTestSPIFFE) + cfg := client.Transport.(*http.Transport).TLSClientConfig + cfg.GetClientCertificate = nil + if !test.noCert { + leaf, _, _ := test.ca.leaf(t, test.ids...) + cfg.Certificates = []tls.Certificate{leaf} + } + response, err := client.Get("https://workload/health") + if !test.allowed { + require.Error(t, err) + return + } + require.NoError(t, err) + _ = response.Body.Close() + require.Equal(t, http.StatusNoContent, response.StatusCode) + }) + } + // Keep this connection alive across the revocation; the middleware must + // re-read the allowlist even when there is no new TLS handshake. + caller, _ := workloadTestIdentity(t, ca, callerTestSPIFFE, gatewayTestSPIFFE+"\n") + client := workloadMemoryClient(t, caller, listener, gatewayTestSPIFFE) + wire, err := listener.DialContext(context.Background()) + require.NoError(t, err) + connection := tls.Client(wire, client.Transport.(*http.Transport).TLSClientConfig) + t.Cleanup(func() { _ = connection.Close() }) + require.NoError(t, connection.SetDeadline(time.Now().Add(3*time.Second))) + reader := bufio.NewReader(connection) + request, err := http.NewRequest(http.MethodGet, "https://workload/health", nil) + require.NoError(t, err) + require.NoError(t, request.Write(connection)) + response, err := http.ReadResponse(reader, request) + require.NoError(t, err) + _ = response.Body.Close() + require.NoError(t, os.WriteFile(paths["ALLOWED_CALLERS_FILE"], []byte("# revoked\n"), 0600)) + require.NoError(t, request.Write(connection)) + response, err = http.ReadResponse(reader, request) + require.NoError(t, err) + _ = response.Body.Close() + require.Equal(t, http.StatusForbidden, response.StatusCode) + _, err = client.Get("https://workload/health") + require.Error(t, err) +} + +func TestWorkloadSolutionTLSIdentity(t *testing.T) { + ca := newWorkloadTestCA(t) + gateway, _ := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n") + solution, _ := workloadTestIdentity(t, ca, callerTestSPIFFE, gatewayTestSPIFFE+"\n") + config, err := solution.serverTLSConfig() + require.NoError(t, err) + listener := workloadMemoryServer(t, config, solution.handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := workloadPeer(r.Context()) + require.True(t, ok) + require.Equal(t, gatewayTestSPIFFE, id) + _, _ = io.WriteString(w, "solution") + }))) + for _, expected := range []string{callerTestSPIFFE, "spiffe://acme.test/ns/acme-prod/sa/different"} { + client := workloadMemoryClient(t, gateway, listener, expected) + response, err := client.Get("https://solution/assets/entry.js") + if expected != callerTestSPIFFE { + require.ErrorContains(t, err, "SPIFFE ID mismatch") + continue + } + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + _ = response.Body.Close() + require.Equal(t, "solution", string(body)) + } +} + +func TestWorkloadSolutionRejectsNoncanonicalRawSAN(t *testing.T) { + ca := newWorkloadTestCA(t) + gateway, _ := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n") + for _, id := range []string{ + strings.Replace(callerTestSPIFFE, "spiffe://", "SPIFFE://", 1), + callerTestSPIFFE + "#", + } { + t.Run(id, func(t *testing.T) { + leaf, _, _ := ca.leaf(t, id) + listener := workloadMemoryServer(t, &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{leaf}}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + client := workloadMemoryClient(t, gateway, listener, callerTestSPIFFE) + response, err := client.Get("https://solution/assets/entry.js") + if response != nil { + _ = response.Body.Close() + } + require.Error(t, err) + }) + } +} + +func TestWorkloadCertificateSPIFFEIDUsesCanonicalRawSAN(t *testing.T) { + ca := newWorkloadTestCA(t) + for _, test := range []struct { + name string + ids []string + allowed bool + }{ + {"Kubernetes identity", []string{callerTestSPIFFE}, true}, + {"generic path", []string{"spiffe://acme.test/Example_1/worker.v2-test"}, true}, + {"no URI", nil, false}, + {"duplicate URI", []string{callerTestSPIFFE, callerTestSPIFFE}, false}, + {"additional other URI", []string{callerTestSPIFFE, "https://example.com"}, false}, + {"uppercase scheme", []string{"SPIFFE://acme.test/ns/acme-prod/sa/worker"}, false}, + {"uppercase trust domain", []string{"spiffe://Acme.test/ns/acme-prod/sa/worker"}, false}, + {"empty path", []string{"spiffe://acme.test"}, false}, + {"empty path segment", []string{"spiffe://acme.test/ns//worker"}, false}, + {"trailing slash", []string{callerTestSPIFFE + "/"}, false}, + {"query", []string{callerTestSPIFFE + "?key=value"}, false}, + {"empty query", []string{callerTestSPIFFE + "?"}, false}, + {"fragment", []string{callerTestSPIFFE + "#fragment"}, false}, + {"empty fragment", []string{callerTestSPIFFE + "#"}, false}, + {"percent encoding", []string{"spiffe://acme.test/ns/acme-prod/sa/%77orker"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + leaf, _, _ := ca.leaf(t, test.ids...) + id, err := certificateSPIFFEID(leaf.Leaf) + if test.allowed { + require.NoError(t, err) + require.Equal(t, test.ids[0], id) + } else { + require.Error(t, err) + require.Empty(t, id) + } + }) + } +} + +func TestWorkloadAllowedCallersRequiresCompleteSnapshot(t *testing.T) { + ca := newWorkloadTestCA(t) + gateway, paths := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n") + leaf, _, _ := ca.leaf(t, callerTestSPIFFE) + state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf.Leaf}, VerifiedChains: [][]*x509.Certificate{{leaf.Leaf, ca.cert}}} + for _, test := range []struct { + name, body string + allowed bool + }{ + {"complete", callerTestSPIFFE + "\n", true}, + {"partial identity", callerTestSPIFFE, false}, + {"complete entry then partial identity", callerTestSPIFFE + "\nspiffe://acme.test/ns/acme-prod/sa/oth", false}, + {"complete replacement", callerTestSPIFFE + "-admin\n", false}, + {"empty", "", false}, + {"malformed", callerTestSPIFFE + "\ninvalid\n", false}, + {"ASCII whitespace and comments", " # callers\n\t " + callerTestSPIFFE + " \t\r# worker\r\n", true}, + {"Unicode whitespace", "\u00a0" + callerTestSPIFFE + "\n", false}, + {"restored", callerTestSPIFFE + "\n", true}, + } { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, os.WriteFile(paths["ALLOWED_CALLERS_FILE"], []byte(test.body), 0600)) + id, err := gateway.admit(state) + if test.allowed { + require.NoError(t, err) + require.Equal(t, callerTestSPIFFE, id) + } else { + require.Error(t, err) + require.Empty(t, id) + } + }) + } +} + +func TestWorkloadFilesRequiredAndRotation(t *testing.T) { + ca := newWorkloadTestCA(t) + identity, paths := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n") + for key := range paths { + t.Run(key, func(t *testing.T) { + _, err := loadWorkloadIdentity(func(k string) (string, error) { + if k == key { + return "", nil + } + return paths[k], nil + }) + require.ErrorContains(t, err, key) + _, err = loadWorkloadIdentity(func(k string) (string, error) { + if k == key { + return "", fmt.Errorf("no workspace configuration value") + } + return paths[k], nil + }) + require.ErrorContains(t, err, key) + _, err = loadWorkloadIdentity(func(k string) (string, error) { + if k == key { + return filepath.Join(t.TempDir(), "absent"), nil + } + return paths[k], nil + }) + require.ErrorContains(t, err, key) + _, err = loadWorkloadIdentity(func(k string) (string, error) { + if k == key { + return t.TempDir(), nil // A directory cannot be read as a file. + } + return paths[k], nil + }) + require.ErrorContains(t, err, key) + }) + } + first := identity.certificate.Certificate() + replacement, cert, key := ca.leaf(t, gatewayTestSPIFFE) + require.NoError(t, os.WriteFile(paths["CERT_FILE"], cert, 0600)) + future := time.Now().Add(time.Minute) + require.NoError(t, os.Chtimes(paths["CERT_FILE"], future, future)) + require.Equal(t, first, identity.certificate.Certificate(), "half-written rotation keeps the last good pair") + require.NoError(t, os.WriteFile(paths["KEY_FILE"], key, 0600)) + require.NoError(t, os.Chtimes(paths["KEY_FILE"], future, future)) + served, err := identity.certificate.GetCertificate(nil) + require.NoError(t, err) + presented, err := identity.certificate.GetClientCertificate(nil) + require.NoError(t, err) + require.Equal(t, replacement.Certificate, served.Certificate) + require.Equal(t, served, presented) +} + +// Existing solution routing tests use the same mTLS contract as production. +func newSolutionTestServer(t *testing.T, gateway *Gateway, id string, handler http.Handler) *httptest.Server { + t.Helper() + ca := newWorkloadTestCA(t) + if gateway.workload == nil { + gateway.workload, _ = workloadTestIdentity(t, ca, gatewayTestSPIFFE, "") + } + bundle, err := os.ReadFile(gateway.workload.trustFile) + require.NoError(t, err) + require.NoError(t, os.WriteFile(gateway.workload.trustFile, append(bundle, ca.pem...), 0600)) + solution, _ := workloadTestIdentity(t, ca, "spiffe://acme.test/ns/acme-prod/sa/"+id, gatewayTestSPIFFE+"\n") + solution.trustFile = gateway.workload.trustFile + config, err := solution.serverTLSConfig() + require.NoError(t, err) + server := httptest.NewUnstartedServer(solution.handler(handler)) + server.TLS = config + server.TLS.Certificates = []tls.Certificate{*solution.certificate.Certificate()} + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +func TestWorkloadProxyRequiresDeclaredIdentity(t *testing.T) { + gateway := &Gateway{} + recorder := httptest.NewRecorder() + gateway.proxyTo(recorder, httptest.NewRequest(http.MethodGet, "/assets/entry.js", nil), MustURL("https://solution"), &RouteEntry{Service: "solution:example"}) + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.NotContains(t, strings.ToLower(recorder.Body.String()), "credential") +} diff --git a/module/services/auth-gateway/code/workload_mint_test.go b/module/services/auth-gateway/code/workload_mint_test.go new file mode 100644 index 000000000..b60e419e2 --- /dev/null +++ b/module/services/auth-gateway/code/workload_mint_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "sync" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestWorkloadMintForwardsOnlyVerifiedPeerAndMapsRefusal(t *testing.T) { + mint := &fakeAccountsWorkContextMint{} + backend := grpc.NewServer() + backend.RegisterService(&grpc.ServiceDesc{ServiceName: "saas.accounts.v1.ModuleCapabilitiesService", HandlerType: (*any)(nil), Methods: []grpc.MethodDesc{{MethodName: "MintModuleWorkContext", Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + return mint.handle(ctx, dec) + }}}}, mint) + listener := bufconn.Listen(1 << 20) + go func() { _ = backend.Serve(listener) }() + t.Cleanup(backend.Stop) + conn, err := grpc.NewClient("passthrough:///accounts", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) })) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + gateway := NewGateway(&ExtAuthz{internalToken: "internal-perimeter", backendConn: conn}, NewRouteMatcher(nil, nil), nil, nil, newFakeSolutionRegistry(), nil) + ca := newWorkloadTestCA(t) + identity, _ := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n") + config, err := identity.serverTLSConfig() + require.NoError(t, err) + httpListener := workloadMemoryServer(t, config, identity.handler(gateway)) + caller, _ := workloadTestIdentity(t, ca, callerTestSPIFFE, gatewayTestSPIFFE+"\n") + client := workloadMemoryClient(t, caller, httpListener, gatewayTestSPIFFE) + for _, code := range []codes.Code{codes.OK, codes.PermissionDenied} { + mint.code = code + request, err := http.NewRequest(http.MethodPost, "https://workload/modules/_work-context", strings.NewReader(`{"prefix":"example"}`)) + require.NoError(t, err) + request.Header.Set("Authorization", "Bearer projected-token") + request.Header.Set(workloadSPIFFEHeader, "spiffe://acme.test/ns/acme-prod/sa/spoofed") + // No shared internal credential: this listener authenticated the caller. + response, err := client.Do(request) + require.NoError(t, err) + _ = response.Body.Close() + if code == codes.OK { + require.Equal(t, http.StatusOK, response.StatusCode) + } else { + require.Equal(t, http.StatusForbidden, response.StatusCode) + } + require.Equal(t, callerTestSPIFFE, mint.lastPeer) + require.Equal(t, "projected-token", mint.lastToken) + require.Equal(t, "internal-perimeter", mint.lastInternal) + } +} + +// Exercise each real HTTP route over TLS and the internal gRPC transport. The +// backend here checks the attestation; the accounts adapter tests exercise the +// real TokenReview binding and signer separately. +func TestWorkloadEveryMintForwardsAttestation(t *testing.T) { + for _, mint := range []struct { + path, method, body string + request proto.Message + response proto.Message + }{ + { + path: moduleWorkContextPath, method: "MintModuleWorkContext", body: `{"prefix":"example"}`, + request: &accountsv1.ModuleMintWorkContextRequest{}, + response: &accountsv1.ModuleMintWorkContextResponse{Token: "signed", ExpiresAt: timestamppb.New(time.Now().Add(time.Minute))}, + }, + { + path: moduleOperationContextPath, method: "MintModuleOperationContext", body: `{"prefix":"example","binding":"sync"}`, + request: &accountsv1.ModuleMintOperationContextRequest{}, + response: &accountsv1.ModuleMintOperationContextResponse{Token: "signed", ExpiresAt: timestamppb.New(time.Now().Add(time.Minute))}, + }, + { + path: moduleSourceOperationContextPath, method: "MintSourceOperationContext", body: `{"prefix":"example","source_id":"` + testSourceID + `"}`, + request: &accountsv1.ModuleMintSourceOperationContextRequest{}, + response: &accountsv1.ModuleMintSourceOperationContextResponse{Token: "signed", ExpiresAt: timestamppb.New(time.Now().Add(time.Minute))}, + }, + } { + t.Run(mint.method, func(t *testing.T) { + var mu sync.Mutex + var peers []string + calls := 0 + backend := grpc.NewServer() + backend.RegisterService(&grpc.ServiceDesc{ServiceName: "saas.accounts.v1.ModuleCapabilitiesService", HandlerType: (*any)(nil), Methods: []grpc.MethodDesc{{MethodName: mint.method, Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + req := proto.Clone(mint.request) + if err := dec(req); err != nil { + return nil, err + } + md, _ := metadata.FromIncomingContext(ctx) + if token := req.(interface{ GetServiceAccountToken() string }).GetServiceAccountToken(); token != "projected-token" { + return nil, status.Error(codes.Unauthenticated, "projected token missing") + } + if tokens := md.Get("x-codefly-internal-token"); len(tokens) != 1 || tokens[0] != "internal-perimeter" { + return nil, status.Error(codes.Unauthenticated, "internal credential missing") + } + mu.Lock() + peers = append([]string(nil), md.Get(workloadSPIFFEHeader)...) + calls++ + mu.Unlock() + if len(md.Get(workloadSPIFFEHeader)) != 0 && md.Get(workloadSPIFFEHeader)[0] != callerTestSPIFFE { + return nil, status.Error(codes.PermissionDenied, "workload mismatch") + } + return proto.Clone(mint.response), nil + }}}}, nil) + listener := bufconn.Listen(1 << 20) + go func() { _ = backend.Serve(listener) }() + t.Cleanup(backend.Stop) + conn, err := grpc.NewClient("passthrough:///accounts", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) })) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + gateway := NewGateway(&ExtAuthz{internalToken: "internal-perimeter", backendConn: conn}, NewRouteMatcher(nil, nil), nil, nil, newFakeSolutionRegistry(), nil) + ca := newWorkloadTestCA(t) + const otherPeer = "spiffe://acme.test/ns/acme-prod/sa/other" + identity, _ := workloadTestIdentity(t, ca, gatewayTestSPIFFE, callerTestSPIFFE+"\n"+otherPeer+"\n") + config, err := identity.serverTLSConfig() + require.NoError(t, err) + handler := identity.handler(gateway) + httpListener := workloadMemoryServer(t, config, handler) + request := func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "https://workload"+mint.path, strings.NewReader(mint.body)) + req.RequestURI = "" + req.Header.Set("Authorization", "Bearer projected-token") + req.Header.Set("X-Codefly-Internal-Token", "internal-perimeter") + req.Header.Set(workloadSPIFFEHeader, callerTestSPIFFE) + req.Header.Set("Grpc-Metadata-"+workloadSPIFFEHeader, callerTestSPIFFE) + return req + } + for _, peer := range []string{callerTestSPIFFE, otherPeer} { + caller, _ := workloadTestIdentity(t, ca, peer, gatewayTestSPIFFE+"\n") + client := workloadMemoryClient(t, caller, httpListener, gatewayTestSPIFFE) + response, err := client.Do(request()) + require.NoError(t, err) + _ = response.Body.Close() + want := http.StatusOK + if peer != callerTestSPIFFE { + want = http.StatusForbidden + } + require.Equal(t, want, response.StatusCode) + mu.Lock() + got := append([]string(nil), peers...) + mu.Unlock() + require.Equal(t, []string{peer}, got) + } + // Neither direct nor grpc-gateway-style HTTP headers attest a plain call. + // Even outgoing metadata already on the context must be replaced. + plain := request() + plain.TLS = nil + plain = plain.WithContext(metadata.NewOutgoingContext(plain.Context(), metadata.Pairs(workloadSPIFFEHeader, callerTestSPIFFE))) + rec := httptest.NewRecorder() + gateway.ServeHTTP(rec, plain) + require.Equal(t, http.StatusOK, rec.Code) + mu.Lock() + got := append([]string(nil), peers...) + before := calls + mu.Unlock() + require.Empty(t, got) + // The workload handler cannot fall back to the plain mint when TLS peer + // verification is absent, even with both bearer and internal credentials. + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, plain) + require.Equal(t, http.StatusForbidden, rec.Code) + mu.Lock() + after := calls + mu.Unlock() + require.Equal(t, before, after) + }) + } +} diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index 687ffa6bf..a9228d8d8 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -1,11 +1,12 @@ name: auth-gateway version: 0.0.0 description: | - Single ingress + auth brain. Hosts two listeners: + Single ingress + auth brain. Hosts three listeners: - gRPC ext_authz Check. Its answer carries identity but never the gateway credential, so an Envoy in front of accounts must stamp that credential itself. - private HTTP API gateway behind the frontend entry + - workload API gateway with TLS 1.3 and verified workload identities agent: kind: codefly:service name: go-grpc @@ -35,11 +36,15 @@ workspace-configuration-dependencies: - internal-auth - observability - security + - workload-identity endpoints: - name: grpc visibility: internal - name: rest visibility: internal + - name: workload + api: http + visibility: internal spec: hot-reload: true protocol-output-dirs: diff --git a/module/services/policy-log/code/go.mod b/module/services/policy-log/code/go.mod index 250f9ae08..5126e86f4 100644 --- a/module/services/policy-log/code/go.mod +++ b/module/services/policy-log/code/go.mod @@ -31,7 +31,7 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/codefly-dev/core v0.17.2 // indirect + github.com/codefly-dev/core v0.18.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect @@ -68,6 +68,7 @@ require ( github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/module/services/policy-log/code/go.sum b/module/services/policy-log/code/go.sum index e3870c77a..df345b4ca 100644 --- a/module/services/policy-log/code/go.sum +++ b/module/services/policy-log/code/go.sum @@ -58,8 +58,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= @@ -73,6 +73,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= diff --git a/module/services/store/code/go.mod b/module/services/store/code/go.mod index 760da7e31..780451672 100644 --- a/module/services/store/code/go.mod +++ b/module/services/store/code/go.mod @@ -16,7 +16,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.17.2 // indirect + github.com/codefly-dev/core v0.18.2 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect @@ -42,6 +42,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect diff --git a/module/services/store/code/go.sum b/module/services/store/code/go.sum index 297cb8d52..881557001 100644 --- a/module/services/store/code/go.sum +++ b/module/services/store/code/go.sum @@ -30,8 +30,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= @@ -46,6 +46,8 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= diff --git a/module/services/telemetry/code/go.mod b/module/services/telemetry/code/go.mod index 0d6f19ee5..393a34bd8 100644 --- a/module/services/telemetry/code/go.mod +++ b/module/services/telemetry/code/go.mod @@ -18,7 +18,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.17.2 // indirect + github.com/codefly-dev/core v0.18.2 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect @@ -45,6 +45,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect diff --git a/module/services/telemetry/code/go.sum b/module/services/telemetry/code/go.sum index 49e75054f..f1a817d21 100644 --- a/module/services/telemetry/code/go.sum +++ b/module/services/telemetry/code/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.3.1 h1:ljKwrfHIiESV+R/MXO3a7XTdKwFNaY6vdw0+8YAo9g8= @@ -42,6 +42,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= diff --git a/module/tools/go.mod b/module/tools/go.mod index bed1c1b83..057b9eec2 100644 --- a/module/tools/go.mod +++ b/module/tools/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( github.com/Masterminds/semver/v3 v3.5.0 - github.com/codefly-dev/core v0.17.2 + github.com/codefly-dev/core v0.18.2 github.com/yoheimuta/go-protoparser/v4 v4.14.2 golang.org/x/tools v0.49.0 google.golang.org/protobuf v1.36.12 @@ -43,6 +43,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect golang.org/x/mod v0.41.0 // indirect diff --git a/module/tools/go.sum b/module/tools/go.sum index 9f21b386d..22a395f43 100644 --- a/module/tools/go.sum +++ b/module/tools/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -40,6 +40,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= diff --git a/qualification/module-authority/go.mod b/qualification/module-authority/go.mod index 5fa212afe..99704f429 100644 --- a/qualification/module-authority/go.mod +++ b/qualification/module-authority/go.mod @@ -9,7 +9,7 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect - github.com/codefly-dev/core v0.17.2 // indirect + github.com/codefly-dev/core v0.18.2 // indirect github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/qualification/module-authority/go.sum b/qualification/module-authority/go.sum index c88cfb28c..efcdcf8f9 100644 --- a/qualification/module-authority/go.sum +++ b/qualification/module-authority/go.sum @@ -2,8 +2,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-202608252041 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -github.com/codefly-dev/core v0.17.2 h1:7jVf62OUuUZOFYBk4aGJ6Tr5VuIwXvw45XP2ZJMEtXA= -github.com/codefly-dev/core v0.17.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.18.2 h1:vVMA4LYLBfTE1seAfsIEfSzShbb+fVH4fZmtz5JaBvs= +github.com/codefly-dev/core v0.18.2/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/saas-sdk-go v0.0.3-0.20261011073356-ac003d53e6b1 h1:XyRDKFr1BYrnaduImuhvG0vmRnroFc2WmdDjRyoK0rA= github.com/codefly-dev/saas-sdk-go v0.0.3-0.20261011073356-ac003d53e6b1/go.mod h1:9mVR2e5xgkW6WXezMT3q8H9KMvX8NUFK4FIcv05ImEg= github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099 h1:MEzUboHy6HzBex1VoAOo3JqBgjTjSYs3jQGyqIwHOhQ=