Repository navigation
feat(accounts): a local runtime may list loopback stubs a callable source can reach - #1089
Merged
antoinetoussaint-byte merged 6 commits intoOct 12, 2026
Conversation
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>
…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
force-pushed
the
issue-1087-callable-sources-a-local-composition-cannot-reach-a
branch
from
October 12, 2026 20:39
935a550 to
97d13b0
Compare
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
deleted the
issue-1087-callable-sources-a-local-composition-cannot-reach-a
branch
October 25, 2026 17:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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
module/configurations/local/datasource-oauth.envsetslocal_egress=(empty). An empty value changes nothing anywhere.requireStartupConfiguration, so no other startup refusal can answer in its place. The value is refused, never ignored.ip:port, ahead ofguardDial. Any address it does not list goes toguardDialunchanged. With no allowance installed, the dialer'sControlisguardDialitself.Where "is this local" is answered
Both of these facts must hold, and the host already reads both:
local(codefly.IsLocal()). This is the environment identity every other local-only exemption inwork.goreads: the dev/fixture identity provider, the ephemeral signing key, the insecure refresh cookie and the shipped-placeholder tolerance.infra.InCluster(), new). That means noKUBERNETES_SERVICE_HOST, and no projected service-account token or CA at/var/run/secrets/kubernetes.io/serviceaccount/. These are the fixed paths and variable thatinfra.NewKubernetesClientalready treats as the host's in-cluster identity.InClusterreuses 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 ownsolution_host_local_profile.goalready 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 bycodefly runhas 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 runhas no cluster, so it cannot answer here.Built on #1088 (the shared classifier)
This branch is rebased onto
mainafter #1088, which routed every guarded egress path throughauth.IsPublicDestination.apisource/client.gokeeps fix(accounts): every host egress guard refuses every non-global address (#1086) #1088'sdialControlseam (a variable whose default isguardDial) untouched. The dialer isControl: withLocalEgress(dialControl).withLocalEgress(next)returnsnextitself when no allowance is installed. Otherwise it admits an exact listedip:portand hands every other address tonextunchanged, soguardDialandauth.IsPublicDestinationDialAddressstill judge it.TestLocalEgress_EveryUnlistedAddressReachesTheGuardpins this with a recording guard. fix(accounts): every host egress guard refuses every non-global address (#1086) #1088'sTestEveryOutboundDialIsGuardedstill passes.100.100.100.200) is back inTestLocalEgress_AdmitsOnlyTheListedAddress, along with NAT64-embedded metadata (64:ff9b::a9fe:a9fe).client.go's package comment and the Transport paragraph inCALLABLE_SOURCES.md) keep fix(accounts): every host egress guard refuses every non-global address (#1086) #1088's wording, with the one-sentence local-egress exception appended.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:
pkg/auth/oidc/discovery.go,validator.go,exchanger.go). No RPC exposesConfigureOrgIdentityProvider, so no tenant names their destination today.The changed files are
CALLABLE_SOURCES.md,WEBHOOKS.md,MODULE.md, and comments inauth/public_address.go,auth/egresstest/vectors.go,business/webhook_security.go, and the apisource, crawler and object-storeclient.go. No OIDC code changed.Mutation proof of the rebased wrapper
nextTestLocalEgress_AdmitsOnlyTheListedAddress,TestLocalEgress_EveryUnlistedAddressReachesTheGuard,TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuardnet.IPpredicates instead ofnextTestLocalEgress_AdmitsOnlyTheListedAddress(CGNAT, NAT64),TestLocalEgress_EveryUnlistedAddressReachesTheGuardguardDialis returned instead of the wrappednext(this drops the seam)TestLocalEgress_NoAllowanceIsThePublicGuard; across the package also #1088'sTestEveryOutboundDialIsGuardedControl: dialControl)TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuardTests
pkg/datasource/apisource/local_egress_test.go(pure, no network beyond loopback):TestLocalEgress_RefusesAnythingButAnExplicitLoopbackAddressAndPort: one named subtest per refused spelling.TestLocalEgress_RefusedOutsideALocalRuntimeTestLocalEgress_AdmitsOnlyTheListedAddress: covers the next port, a neighbouring loopback address, the other loopback family, private and link-local addresses.TestLocalEgress_NoAllowanceIsThePublicGuardTestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard: anhttptestprovider. The realClientCredentialsexchange and the realNew(...).Fetchgo 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 pathmaincalls):TestStartupRefusesLocalEgressOutsideTheLocalEnvironment:requireStartupConfiguration(ctx, false)refuses withErrLocalEgressOutsideLocal, plus the environment fact on its own.TestLocalEgressRefusedInAPodThatNamesItselfLocal:isLocal=truein a pod is refused, including throughrequireStartupConfigurationwithKUBERNETES_SERVICE_HOSTset.pkg/business/datasource_local_egress_integration_test.go(DB-backed,!pure):TestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowanceuses the real Postgres store, the real Vault envelope, a realclient_credentialsAPI source and the real SDK receipt guard. Neither the token exchange nor the operation client is substituted.InvokeSourceOperationcommits: oneclient_credentialsexchange carrying the client id and secret, then one operation call carrying the exchanged bearer token.Mutation proof
Each mutant was applied, the named tests run, and the file restored:
TestLocalEgress_RefusedOutsideALocalRuntime; on the main pathTestStartupRefusesLocalEgressOutsideTheLocalEnvironment,TestLocalEgressRefusedInAPodThatNamesItselfLocalisLocal && !inCluster→!inCluster)TestStartupRefusesLocalEgressOutsideTheLocalEnvironmentisLocal)TestLocalEgressRefusedInAPodThatNamesItselfLocalrequireStartupConfigurationTestStartupRefusesLocalEgressOutsideTheLocalEnvironment,TestLocalEgressRefusedInAPodThatNamesItselfLocalInClusteralways falseTestLocalEgressRefusedInAPodThatNamesItselfLocalTestLocalEgress_AdmitsOnlyTheListedAddress,TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard.../private,/public,/link-local_metadata,/unspecified_v4,/unspecified_v6,/valid_entry_beside_invalid.../port_zero.../leading-zero_port.../ipv4-mapped_loopback,.../zoned_loopbackControl: guardDial(bypass the allowance)TestLocalEgress_ClientCredentialsReachALocalStubThroughTheRealGuard; the DB testTestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowanceTestClientCredentialsSourceReachesALocalStubThroughTheEgressAllowanceReview 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 isKUBERNETES_SERVICE_HOSTalone.CALLABLE_SOURCES.md,work.goandInCluster's doc comment now state the real requirement. Defeating both checks takes pod-spec control:CODEFLY__ENVIRONMENT=localplus a container-env override that clears the variable. A configuration value cannot do it.inClusternow takes its facts as arguments, and the fixed paths sit in a test-settable variable.F2.
TestDoWorkRefusesLocalEgressBeforeAnyOtherStartupCheckdrives the refusal throughdoWorkwith 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.inCluster: drop the projected-file branch (the review's survivor)TestInClusterAnswersYesOnEachKubeletFactAlone/{projected_token_only,projected_CA_only,uncheckable_path},TestInClusterReadsTheKubeletCarriersInCluster: pass no projected pathsTestInClusterReadsTheKubeletCarriersInCluster: check the CA path onlyTestInClusterReadsTheKubeletCarriersinCluster: an uncheckable path counts as absentTestInClusterAnswersYesOnEachKubeletFactAlone/uncheckable_pathTestDoWorkRefusesLocalEgressBeforeAnyOtherStartupCheck,TestStartupRefusesLocalEgressOutsideTheLocalEnvironmentRun locally (Go 1.27.2, CI's pin)
go test -tags pure ./...inaccounts/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-vthat 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-adminbooted vault, store, cache, telemetry, accounts and auth-gateway. A stub provider and API built onhttptestlistened on127.0.0.1:18187, andlocal_egress=127.0.0.1:18187was set in the localdatasource-oauth.envfor that run only (not committed). Then, all through the gateway's Connect routes:Authenticatewith the dev-admin fixture token.AddSource: an API source withcredential_kind: OAUTH2andgrant: GRANT_CLIENT_CREDENTIALS, with bothtoken_urlandbase_urlon the stub.CreateRoleandGrantScopeon the source's boundary, thenDeclareSourceOperations(one read-onlyGET /v1/items).InvokeSourceOperation.Results:
(configureLocalEgress) callable sources may dial these loopback addresses past the public-address guard (local runtime only).InvokeSourceOperationreturned 200 with receiptstatus: committed,providerStatus: 200and the stub's output.POST /token grant_type="client_credentials" client_id="stub-client"carrying the client secret, then oneGET /v1/itemswithAuthorization: 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
codefly run --env local-dogfood(not local: core'sMatchis exact apart from case and-/_) with the allowance set. The run never reached accounts: telemetry refused first withno configuration found for observability, because that environment needs operator-supplied groups the repo ships only as.example. The refusal is proven wheremaincalls it:requireStartupConfiguration, the first call indoWork, inTestStartupRefusesLocalEgressOutsideTheLocalEnvironmentandTestLocalEgressRefusedInAPodThatNamesItselfLocal.InClusterwas not run inside a pod. Its file branch is now driven by unit tests with injected paths (TestInClusterAnswersYesOnEachKubeletFactAlone,TestInClusterReadsTheKubeletCarriers).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: thedatasource-oauthtable gainslocal_egress, with the rules.module/configurations/local/datasource-oauth.env: shipslocal_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