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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 8 additions & 2 deletions module/DEPLOYMENT_TOPOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions module/INTERNAL_TRANSPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
33 changes: 33 additions & 0 deletions module/SOLUTION_REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions module/deployment/generated/service-topology.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -210,6 +214,11 @@
"endpoint": "rest",
"visibility": "ENDPOINT_VISIBILITY_MODULE"
},
{
"service": "auth-gateway",
"endpoint": "workload",
"visibility": "ENDPOINT_VISIBILITY_MODULE"
},
{
"service": "frontend",
"endpoint": "http",
Expand Down
3 changes: 3 additions & 0 deletions module/module.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion module/services/accounts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion module/services/accounts/code/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions module/services/accounts/code/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions module/services/accounts/code/pkg/adapters/module_work_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"crypto/rand"
"errors"
"strings"
"testing"

"accounts/pkg/business"
Expand Down Expand Up @@ -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")
}
})
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading