Skip to content

feat(accounts): a local runtime may list loopback stubs a callable source can reach - #1089

Merged
antoinetoussaint-byte merged 6 commits into
mainfrom
issue-1087-callable-sources-a-local-composition-cannot-reach-a
Oct 12, 2026
Merged

antoinetoussaint-byte merged 6 commits into
mainfrom
issue-1087-callable-sources-a-local-composition-cannot-reach-a

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1087

What this does

A composition booted on a developer's machine can now prove an OAuth2 REST source end to end against a stub provider and API running beside the host. It does this by listing the stubs' exact loopback addresses in a new configuration value, datasource-oauth/local_egress:

local_egress=127.0.0.1:18080,[::1]:18081

Before this change, the resolved-IP guard (apisource.guardDial) refused loopback for both the token exchange and the operation call, and nothing could change that. That stays the behaviour for every deployed host.

Security posture

  • Empty by default. The shipped module/configurations/local/datasource-oauth.env sets local_egress= (empty). An empty value changes nothing anywhere.
  • Refused at start outside a local runtime. A non-empty value refuses startup, naming the key. The check is the first one in requireStartupConfiguration, so no other startup refusal can answer in its place. The value is refused, never ignored.
  • Explicit host:port only. Each entry is one loopback IP literal and one port, written canonically. A hostname, range, CIDR, wildcard, zone, IPv4-mapped spelling, port 0 or non-loopback address refuses the whole value, so nothing partial is installed.
  • Loopback only. This is an extra narrowing beyond the issue's ask. Loopback never leaves the dialing process's network namespace. Even a host that somehow got past the runtime check could reach only its own namespace: never a metadata service, the cluster network or another tenant.
  • Never widens the public-address guard. At dial time the allowance is checked against the resolved ip:port, ahead of guardDial. Any address it does not list goes to guardDial unchanged. With no allowance installed, the dialer's Control is guardDial itself.
  • A value, never a file. It is a key in an existing configuration group, delivered by Codefly like every other value.

Where "is this local" is answered

Both of these facts must hold, and the host already reads both:

  1. The Codefly environment is local (codefly.IsLocal()). This is the environment identity every other local-only exemption in work.go reads: the dev/fixture identity provider, the ephemeral signing key, the insecure refresh cookie and the shipped-placeholder tolerance.
  2. The process is not in a Kubernetes pod (infra.InCluster(), new). That means no KUBERNETES_SERVICE_HOST, and no projected service-account token or CA at /var/run/secrets/kubernetes.io/serviceaccount/. These are the fixed paths and variable that infra.NewKubernetesClient already treats as the host's in-cluster identity. InCluster reuses those constants; it is not a new mechanism.

Why both. The environment is a single variable (CODEFLY__ENVIRONMENT), set by whatever renders the deployment. This repo's own solution_host_local_profile.go already rejects it as a sole trust root for that reason. The pod facts are put there by the kubelet. Removing all three takes control of the pod spec, not of a configuration value. A host started by codefly run has none of them, so the allowance works exactly where it is meant to.

The cost: a local Kubernetes cluster (environment local, in a pod) is refused too, by the same rule. A Docker-runtime local run reaches its stubs through a private address, so the loopback rule excludes it as well. Both are documented.

Not chosen: the cluster-CA-pinned local profile (#1063). It proves "this is the developer's cluster", but a native codefly run has no cluster, so it cannot answer here.

Built on #1088 (the shared classifier)

This branch is rebased onto main after #1088, which routed every guarded egress path through auth.IsPublicDestination.

Classifier coverage wording

The docs said the classifier covers "every tenant-directed egress in this host". That is not true, and they now name what it covers:

  • Covered: callable sources, the crawler, the object-store connector, outbound webhooks and the Slack notifier that shares their client, and Client ID Metadata Document fetches.
  • Not covered: the per-organization OIDC discovery, JWKS and token clients (pkg/auth/oidc/discovery.go, validator.go, exchanger.go). No RPC exposes ConfigureOrgIdentityProvider, so no tenant names their destination today.

The changed files are CALLABLE_SOURCES.md, WEBHOOKS.md, MODULE.md, and comments in auth/public_address.go, auth/egresstest/vectors.go, business/webhook_security.go, and the apisource, crawler and object-store client.go. No OIDC code changed.

Mutation proof of the rebased wrapper

Mutant Killed by
an unlisted address is admitted instead of handed to next TestLocalEgress_AdmitsOnlyTheListedAddress, TestLocalEgress_EveryUnlistedAddressReachesTheGuard, TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard
an unlisted address is judged by Go's net.IP predicates instead of next TestLocalEgress_AdmitsOnlyTheListedAddress (CGNAT, NAT64), TestLocalEgress_EveryUnlistedAddressReachesTheGuard
with no allowance, guardDial is returned instead of the wrapped next (this drops the seam) TestLocalEgress_NoAllowanceIsThePublicGuard; across the package also #1088's TestEveryOutboundDialIsGuarded
the dialer bypasses the wrapper (Control: dialControl) TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard

Tests

pkg/datasource/apisource/local_egress_test.go (pure, no network beyond loopback):

  • TestLocalEgress_RefusesAnythingButAnExplicitLoopbackAddressAndPort: one named subtest per refused spelling.
  • TestLocalEgress_RefusedOutsideALocalRuntime
  • TestLocalEgress_AdmitsOnlyTheListedAddress: covers the next port, a neighbouring loopback address, the other loopback family, private and link-local addresses.
  • TestLocalEgress_NoAllowanceIsThePublicGuard
  • TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard: an httptest provider. The real ClientCredentials exchange and the real New(...).Fetch go through the real guarded client, with no transport substituted. Both are refused with no allowance and with another port listed, and both succeed with the stub's address listed.

work_local_egress_test.go (main package, the path main calls):

  • TestStartupRefusesLocalEgressOutsideTheLocalEnvironment: requireStartupConfiguration(ctx, false) refuses with ErrLocalEgressOutsideLocal, plus the environment fact on its own.
  • TestLocalEgressRefusedInAPodThatNamesItselfLocal: isLocal=true in a pod is refused, including through requireStartupConfiguration with KUBERNETES_SERVICE_HOST set.
  • Positive control, empty-everywhere and malformed-entry cases.

pkg/business/datasource_local_egress_integration_test.go (DB-backed, !pure):

  • TestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowance uses the real Postgres store, the real Vault envelope, a real client_credentials API source and the real SDK receipt guard. Neither the token exchange nor the operation client is substituted.
  • With no allowance, token resolution fails and the stub sees nothing.
  • With the stub listed, InvokeSourceOperation commits: one client_credentials exchange carrying the client id and secret, then one operation call carrying the exchanged bearer token.
  • With the allowance withdrawn, a second source's exchange is refused before it is sent.

Mutation proof

Each mutant was applied, the named tests run, and the file restored:

Mutant Killed by
apisource: drop the not-local refusal TestLocalEgress_RefusedOutsideALocalRuntime; on the main path TestStartupRefusesLocalEgressOutsideTheLocalEnvironment, TestLocalEgressRefusedInAPodThatNamesItselfLocal
work.go: drop the environment factor (isLocal && !inCluster → !inCluster) TestStartupRefusesLocalEgressOutsideTheLocalEnvironment
work.go: drop the pod factor (→ isLocal) TestLocalEgressRefusedInAPodThatNamesItselfLocal
work.go: drop the call from requireStartupConfiguration TestStartupRefusesLocalEgressOutsideTheLocalEnvironment, TestLocalEgressRefusedInAPodThatNamesItselfLocal
infra: InCluster always false TestLocalEgressRefusedInAPodThatNamesItselfLocal
dial: admit any loopback instead of the listed entry TestLocalEgress_AdmitsOnlyTheListedAddress, TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard
dial: ignore the port the same two
dial: admit everything the same two
parse: drop loopback-only .../private, /public, /link-local_metadata, /unspecified_v4, /unspecified_v6, /valid_entry_beside_invalid
parse: drop port-0 .../port_zero
parse: drop canonical spelling .../leading-zero_port
parse: drop zone / IPv4-mapped .../ipv4-mapped_loopback, .../zoned_loopback
client.go: Control: guardDial (bypass the allowance) TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard; the DB test TestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowance
dial: admit everything with no allowance installed the DB test TestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowance

Review follow-up (F1, F2)

F1. A Codefly-rendered service pod mounts no service-account token by default (automountServiceAccountToken: false). So in such a pod the in-cluster fact is KUBERNETES_SERVICE_HOST alone.

  • CALLABLE_SOURCES.md, work.go and InCluster's doc comment now state the real requirement. Defeating both checks takes pod-spec control: CODEFLY__ENVIRONMENT=local plus a container-env override that clears the variable. A configuration value cannot do it.
  • They also say that loopback in a pod reaches every container in it, sidecars included.
  • inCluster now takes its facts as arguments, and the fixed paths sit in a test-settable variable.

F2. TestDoWorkRefusesLocalEgressBeforeAnyOtherStartupCheck drives the refusal through doWork with hosted carriers. A hosted boot with no key-service backend also refuses on that, so the test fails if the allowance's check stops being the first startup check.

Mutant Killed by
inCluster: drop the projected-file branch (the review's survivor) TestInClusterAnswersYesOnEachKubeletFactAlone/{projected_token_only,projected_CA_only,uncheckable_path}, TestInClusterReadsTheKubeletCarriers
InCluster: pass no projected paths TestInClusterReadsTheKubeletCarriers
InCluster: check the CA path only TestInClusterReadsTheKubeletCarriers
inCluster: an uncheckable path counts as absent TestInClusterAnswersYesOnEachKubeletFactAlone/uncheckable_path
startup: egress check after key-service selection TestDoWorkRefusesLocalEgressBeforeAnyOtherStartupCheck, TestStartupRefusesLocalEgressOutsideTheLocalEnvironment
startup: egress check last the same two

Run locally (Go 1.27.2, CI's pin)

  • go test -tags pure ./... in accounts/code: all ok.
  • go test . (main package): ok.
  • go test -p 1 -run 'TestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowance|TestClientCredentialsRealVaultAndConcurrentRowLock' ./pkg/business/: ok. Auto-booted store and Vault; confirmed with -v that the test ran rather than skipped.
  • GOWORK=off go test ./... at the repo root (module agent, incl. TestEveryDeclaredGroupShipsALocalDefault): ok.
  • node module/tools/naming-gate.mjs check: clean. scripts/check-boundaries.sh: clean.

On a locally booted host (codefly run, CLI 0.1.181, native runtime)

The host does boot locally on current main (sdk-go v0.3.1). codefly-dev/sdk-go#66 (fix PR codefly-dev/sdk-go#67 still open) did not block this run, so the done-criterion was exercised on the real graph, not only at the test level.

From module/services/auth-gateway, codefly run service --headless --fixture dev-admin booted vault, store, cache, telemetry, accounts and auth-gateway. A stub provider and API built on httptest listened on 127.0.0.1:18187, and local_egress=127.0.0.1:18187 was set in the local datasource-oauth.env for that run only (not committed). Then, all through the gateway's Connect routes:

  1. Authenticate with the dev-admin fixture token.
  2. AddSource: an API source with credential_kind: OAUTH2 and grant: GRANT_CLIENT_CREDENTIALS, with both token_url and base_url on the stub.
  3. CreateRole and GrantScope on the source's boundary, then DeclareSourceOperations (one read-only GET /v1/items).
  4. InvokeSourceOperation.

Results:

  • The accounts boot log shows (configureLocalEgress) callable sources may dial these loopback addresses past the public-address guard (local runtime only).
  • InvokeSourceOperation returned 200 with receipt status: committed, providerStatus: 200 and the stub's output.
  • The stub logged exactly one POST /token grant_type="client_credentials" client_id="stub-client" carrying the client secret, then one GET /v1/items with Authorization: Bearer <the exchanged token>.

Control: with the shipped empty value restored and the graph restarted, the identical flow on a fresh source returned 503 SOURCE_OPERATION_UNAVAILABLE, and the stub received no request.

What I did not verify

  • Refusal at start in a real non-local boot. I tried codefly run --env local-dogfood (not local: core's Match is exact apart from case and -/_) with the allowance set. The run never reached accounts: telemetry refused first with no configuration found for observability, because that environment needs operator-supplied groups the repo ships only as .example. The refusal is proven where main calls it: requireStartupConfiguration, the first call in doWork, in TestStartupRefusesLocalEgressOutsideTheLocalEnvironment and TestLocalEgressRefusedInAPodThatNamesItselfLocal.
  • A real pod. InCluster was not run inside a pod. Its file branch is now driven by unit tests with injected paths (TestInClusterAnswersYesOnEachKubeletFactAlone, TestInClusterReadsTheKubeletCarriers).
  • A Docker-runtime or k3d local run. Both are refused or excluded by design (documented) and were not run.
  • CI's pinned CLI. My local CLI is 0.1.181 and CI pins 0.1.185. No generated file changed, so no regeneration was involved.

Classification

This is a fix at the place that owns the behaviour. The host owns callable-source egress and its environment gates. The allowance is the host's own configuration value, refused where it does not apply. It is not a local substitute for a missing tool capability.

Docs changed

  • module/CALLABLE_SOURCES.md: a new "Local egress" section, the local-egress exception appended to fix(accounts): every host egress guard refuses every non-global address (#1086) #1088's Transport paragraph, and the classifier-coverage wording narrowed, with the OIDC exception.
  • module/WEBHOOKS.md, MODULE.md: the classifier-coverage wording narrowed.
  • module/configurations/README.md: the datasource-oauth table gains local_egress, with the rules.
  • module/configurations/local/datasource-oauth.env: ships local_egress= empty, with a comment.

Not merged, tagged or released. An independent security review runs on this head, and the coordinator merges and cuts the host release.

🤖 Generated with Claude Code

antoinetoussaint-byte added a commit that referenced this pull request Oct 12, 2026
…oth of its halves

Review of #1089 (F1, F2):

- A Codefly-rendered service pod mounts no service-account token by default
  (automountServiceAccountToken: false), so in such a pod the in-cluster fact
  is KUBERNETES_SERVICE_HOST alone. The docs said a deployment had to remove a
  variable AND two projected files. They now state the real requirement: pod-spec
  control (CODEFLY__ENVIRONMENT=local plus a container-env override clearing the
  variable), never a configuration value. They also say that loopback in a pod
  reaches every container in it, sidecars included.
- InCluster's projected-file branch was untested: deleting it passed
  everything. inCluster now takes its facts as arguments, the fixed paths sit in
  a test-settable variable, and tests drive each fact alone, an uncheckable
  path, and the wiring of the real carriers.
- The non-local refusal is now also driven through doWork with hosted carriers.
  A hosted boot with no key-service backend refuses on that too, so the test
  pins the allowance's check as the first startup check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
antoinetoussaint-byte and others added 6 commits October 12, 2026 16:22
…urce can reach

A composition booted on a developer's machine could not prove an OAuth2 REST
source end to end: the resolved-IP guard refuses loopback for both the token
exchange and the operation call, with no setting.

`datasource-oauth/local_egress` is a configuration value listing explicit
loopback ip:port entries. It is empty by default. Any non-empty value refuses
startup unless the Codefly environment is local AND the process is not in a
Kubernetes pod (no KUBERNETES_SERVICE_HOST, no projected service-account token
or CA): the environment alone is one variable whoever renders a deployment sets.
Entries admit exactly the listed address and port at dial time, ahead of the
public-address guard, which judges everything else unchanged. The allowance
lives in its own file so it and the guard are reviewable separately.

Closes #1087

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nstalls it

An operator reading a local host's boot sees that callable sources may dial
past the public-address guard, and which key said so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The SDK-boundary gate refuses a literal loopback address in production code,
and an example in an error message is one. The refusal now describes the
shape instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oth of its halves

Review of #1089 (F1, F2):

- A Codefly-rendered service pod mounts no service-account token by default
  (automountServiceAccountToken: false), so in such a pod the in-cluster fact
  is KUBERNETES_SERVICE_HOST alone. The docs said a deployment had to remove a
  variable AND two projected files. They now state the real requirement: pod-spec
  control (CODEFLY__ENVIRONMENT=local plus a container-env override clearing the
  variable), never a configuration value. They also say that loopback in a pod
  reaches every container in it, sidecars included.
- InCluster's projected-file branch was untested: deleting it passed
  everything. inCluster now takes its facts as arguments, the fixed paths sit in
  a test-settable variable, and tests drive each fact alone, an uncheckable
  path, and the wiring of the real carriers.
- The non-local refusal is now also driven through doWork with hosted carriers.
  A hosted boot with no key-service backend refuses on that too, so the test
  pins the allowance's check as the first startup check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…C clients it does not

The docs said auth.IsPublicDestination covers every tenant-directed egress in
this host. It covers callable sources, the crawler, the object-store connector,
outbound webhooks and the Slack notifier that shares their client, and Client
ID Metadata Document fetches. The per-organization OIDC discovery, JWKS and
token clients have no guard. No RPC exposes ConfigureOrgIdentityProvider, so no
tenant names their destination today. The wording now says exactly that, in
CALLABLE_SOURCES.md, WEBHOOKS.md, MODULE.md and the guards' own comments. No
code changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hen no allowance is installed

Asserted against a distinct guard rather than guardDial itself, so a wrapper
that hard-codes guardDial (and so drops the package's dial-control seam) fails
this test on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the issue-1087-callable-sources-a-local-composition-cannot-reach-a branch from 935a550 to 97d13b0 Compare October 12, 2026 20:39
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 7ee534a Oct 12, 2026
25 checks passed
antoinetoussaint-byte added a commit that referenced this pull request Oct 12, 2026
…ext cutover

The four conflicts were the accounts and auth-gateway go.mod/go.sum. Both
intents are kept: main's move to sdk-go v0.3.2 (#1095), and this branch's core
pin (core main's merge commit for core#783) and sdk-go/workcontext v0.1.0. The
require lines were combined by hand and go.sum regenerated with go mod tidy;
minimal version selection keeps core on the pinned commit.

No contract artefact conflicted, and a full pass of the pinned codefly 0.1.185
pipeline (proto, go generate, contracts, SDK) over the merged tree changes
nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte deleted the issue-1087-callable-sources-a-local-composition-cannot-reach-a branch October 25, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Callable sources: a local composition cannot reach a local OAuth2 provider

1 participant