Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/architecture/credential-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ an origin with static gateway credentials, which would override the caller's
authentication. A passthrough RemoteMCPServer cannot set `Authorization` in
`headersFrom`.

Compilation records each passthrough client as a target {origin, header,
prefix} in the revision. It rejects a client with no credential target (SAP AI
Core), a client that shares its origin with an MCP server or model that did not
opt in, and an explicit header that matches a target header.

RemoteMCPServer passthrough is implemented only by the Go kagent runtime.
Codex and Claude reject it at compilation, and the Python runtime fails at
startup. A request without a caller token fails instead of connecting
Expand All @@ -97,6 +102,23 @@ require one should set the `kagent.dev/discovery: disabled` label. The runtime
shares one MCP session per server. The standalone SSE stream and session
teardown therefore carry the token of the caller that opened the session.

### Gateway token passthrough

Without `app.Options.EgressPolicyCompilers`, runtimes forward the caller token
as described above. To keep it out of runtimes, an embedding program registers
compilers and its `Authenticator.UpstreamAuth` must replace the caller's
`Authorization` with `Bearer kagent-credential-injected`; registering compilers
alone changes nothing, and a forwarding authenticator still exposes the token.
The runtime forwards the placeholder as usual. Compilers run in order after the
built-in EgressPolicy compiler and may change anything it produced. Kagent then
validates the result, and every target must have a header. If a compiler or
this validation fails, the actor gets no egress until a retry succeeds. Kagent
never names a credential provider.

This covers Go ADK MCP servers and models, OpenAI, Azure and Foundry memory
embeddings, and Python OpenAI and Anthropic models. Python MCP, Claude and
Codex still do not support passthrough.

## Deferred API fields

The source retains commented declarations for Harness and SandboxTemplate
Expand Down
2 changes: 2 additions & 0 deletions go/adk/pkg/mcp/passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func TestTokenPassthroughRoundTripper(t *testing.T) {
wantErr bool
}{
{name: "caller token replaces every other Authorization source", token: "caller-token", wantAuth: "Bearer caller-token"},
// The egress gateway replaces this placeholder with the caller's token.
{name: "gateway placeholder is forwarded unchanged", token: "kagent-credential-injected", wantAuth: "Bearer kagent-credential-injected"},
{name: "missing caller token fails without dialing", wantErr: true},
}
for _, tt := range tests {
Expand Down
35 changes: 35 additions & 0 deletions go/core/internal/controller/grant_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
v1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3"
"github.com/kagent-dev/kagent/go/core/internal/egress"
"github.com/kagent-dev/kagent/go/core/internal/substrate"
"github.com/kagent-dev/kagent/go/core/pkg/egresspolicy"
)

func TestGrantAdmissionAcrossSources(t *testing.T) {
Expand Down Expand Up @@ -117,6 +118,40 @@ func TestGrantAdmissionAcrossSources(t *testing.T) {
}
}

func TestAgentGrantAdmissionReservesTokenPassthroughOrigins(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const origin = "https://mcp.example.com:443"
server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "user"}, Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://mcp.example.com/mcp", TokenPassthrough: true}}
template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{
Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"lookup"}}}},
}}
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "auth"}, Data: map[string][]byte{"token": []byte("value")}}
collections, _ := newPreparationTestCollectionsFor(t, "gvisor", template, server, secret)
initial := collections.Reconciliations.List()[0]
target := egresspolicy.GatewayTarget{Origin: origin, Header: "authorization", Prefix: "Bearer "}
require.Equal(t, []egresspolicy.GatewayTarget{target}, initial.Target.Revision.TokenPassthroughTargets)
agent := initial.Agent.DeepCopy()
agent.Spec.EgressPolicyRefs = []corev1.LocalObjectReference{{Name: "keys"}}
collections.Agents.(krt.StaticCollection[*v1alpha3.Agent]).UpdateObject(agent)
prepared := proto.CloneOf(initial.Target.ActorTemplate)
prepared.Metadata.Uid, prepared.Status = "prepared", sandboxGoldenStatus()
store := &fakeRuntimeRevisionStore{}
r := &Reconciler{collections: collections, templates: &fakeActorTemplates{template: prepared}, store: store}
policies := collections.EgressPolicies.(krt.StaticCollection[*v1alpha3.EgressPolicy])
// Any Secret header on a passthrough origin conflicts, not just the target's.
for _, header := range []string{"Authorization", "X-API-Key"} {
policies.UpdateObject(&v1alpha3.EgressPolicy{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "keys"}, Spec: v1alpha3.EgressPolicySpec{Destinations: []v1alpha3.EgressDestination{{
Origin: origin, Authentication: &v1alpha3.EgressAuthentication{Header: header, SecretKeyRef: corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "auth"}, Key: "token"}},
}}}})
synctest.Wait()
require.NotEmpty(t, collections.Reconciliations.GetKey(initial.ResourceName()).EgressConflict, header)
require.NoError(t, r.reconcileAgent(t.Context(), initial.ResourceName()))
require.Empty(t, store.pair.EgressGrant.Credentials, header)
}
require.Equal(t, []egresspolicy.GatewayTarget{target}, store.revision.TokenPassthroughTargets)
})
}

func TestAgentGrantAdmissionWithAndWithoutCompiledBase(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
collections, harnesses := newPreparationTestCollections(t, "gvisor")
Expand Down
13 changes: 10 additions & 3 deletions go/core/internal/controller/preparation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,15 @@ func TestInvalidActorTemplateHasNoCompiledTarget(t *testing.T) {

func newPreparationTestCollections(t *testing.T, workerPool string) (Collections, krt.StaticCollection[*kagentv1alpha3.Harness]) {
t.Helper()
opts := krt.NewOptionsBuilder(t.Context().Done(), "test-preparation", nil)
template := &kagentv1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid"}}
return newPreparationTestCollectionsFor(t, workerPool, template)
}

// newPreparationTestCollectionsFor prepares template on a BYO Harness with extra
// mock objects, such as the RemoteMCPServers the template references.
func newPreparationTestCollectionsFor(t *testing.T, workerPool string, template *kagentv1alpha3.AgentTemplate, extra ...any) (Collections, krt.StaticCollection[*kagentv1alpha3.Harness]) {
t.Helper()
opts := krt.NewOptionsBuilder(t.Context().Done(), "test-preparation", nil)
runtimeHarness := harness("team-a", "byo", nil)
runtimeHarness.UID = "harness-uid"
runtimeHarness.Spec.BYO = &kagentv1alpha3.BYOHarness{}
Expand All @@ -523,11 +530,11 @@ func newPreparationTestCollections(t *testing.T, workerPool string) (Collections
WorkerPoolRef: corev1.LocalObjectReference{Name: workerPool}, SnapshotPolicy: kagentv1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"},
}
harnesses := krt.NewStaticCollection(nil, []*kagentv1alpha3.Harness{runtimeHarness}, opts.WithName("Harnesses")...)
mock := krttest.NewMock(t, []any{
mock := krttest.NewMock(t, append([]any{
template,
&atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "gvisor"}, Spec: atev1alpha1.WorkerPoolSpec{SandboxClasses: []atev1alpha1.WorkerPoolSandboxClass{{Name: atev1alpha1.SandboxClassGvisor}}}},
&atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "microvm"}, Spec: atev1alpha1.WorkerPoolSpec{SandboxClasses: []atev1alpha1.WorkerPoolSandboxClass{{Name: atev1alpha1.SandboxClassMicroVM}}}},
})
}, extra...))
collections := Collections{
EgressPolicies: krt.NewStaticCollection[*kagentv1alpha3.EgressPolicy](nil, nil, opts.WithName("EgressPolicies")...),
AgentTemplates: krttest.GetMockCollection[*kagentv1alpha3.AgentTemplate](mock),
Expand Down
10 changes: 5 additions & 5 deletions go/core/internal/controller/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ func newAgentReconciliations(
_, err = egress.CanonicalAccess(state.EgressGrant.Destinations, state.EgressGrant.Credentials, nil)
} else {
revision := &state.Target.Revision
base := egress.Access{Destinations: revision.EgressDestinations, Credentials: revision.Credentials}
_, err = egress.MergeAccess(base, state.EgressGrant, revision.TokenPassthroughOrigins)
base := egress.Access{Destinations: revision.EgressDestinations, Credentials: revision.Credentials, TokenPassthroughTargets: revision.TokenPassthroughTargets}
_, err = egress.MergeAccess(base, state.EgressGrant)
}
if err != nil {
state.EgressConflict = err.Error()
Expand Down Expand Up @@ -384,8 +384,8 @@ func (r *Reconciler) reconcileAgent(ctx context.Context, key string) error {
grants = egress.IntersectAccess(previous, grants)
if state.Target != nil {
revision := &state.Target.Revision
base := egress.Access{Destinations: revision.EgressDestinations, Credentials: revision.Credentials}
grants, _ = egress.FilterConflicts(base, grants, revision.TokenPassthroughOrigins)
base := egress.Access{Destinations: revision.EgressDestinations, Credentials: revision.Credentials, TokenPassthroughTargets: revision.TokenPassthroughTargets}
grants, _ = egress.FilterConflicts(base, grants)
}
}
definition := database.AgentDefinition{
Expand Down Expand Up @@ -445,7 +445,7 @@ func (r *Reconciler) reconcileAgent(ctx context.Context, key string) error {
SourceSnapshot: target.Revision.Provenance, AgentCard: target.Revision.AgentCard,
EgressDestinations: target.Revision.EgressDestinations,
Credentials: target.Revision.Credentials,
TokenPassthroughOrigins: target.Revision.TokenPassthroughOrigins,
TokenPassthroughTargets: target.Revision.TokenPassthroughTargets,
ActorTemplateAtespace: observed.GetMetadata().GetAtespace(), ActorTemplateName: observed.GetMetadata().GetName(), ActorTemplateUID: observed.GetMetadata().GetUid(),
}
ready := observed.GetStatus().GetGoldenSnapshotStatus().GetGoldenTag() != nil
Expand Down
3 changes: 2 additions & 1 deletion go/core/internal/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1"
"github.com/kagent-dev/kagent/go/core/internal/egress"
"github.com/kagent-dev/kagent/go/core/pkg/egresspolicy"
)

type Tool struct {
Expand Down Expand Up @@ -68,7 +69,7 @@ type RuntimeRevision struct {
AgentCard *a2apb.AgentCard
Credentials []egress.Credential
EgressDestinations []string
TokenPassthroughOrigins []string
TokenPassthroughTargets []egresspolicy.GatewayTarget
ActorTemplateAtespace string
ActorTemplateName string
ActorTemplateUID string
Expand Down
16 changes: 5 additions & 11 deletions go/core/internal/database/runtime_egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5"

"github.com/kagent-dev/kagent/go/core/internal/egress"
"github.com/kagent-dev/kagent/go/core/pkg/egresspolicy"
)

// canonicalStoredGrants validates and normalizes source grants for NOT NULL storage.
Expand All @@ -33,12 +34,12 @@ func (c *Client) GetRuntimeEgressPolicy(ctx context.Context, revision string) (e
type policyInputs struct {
EgressDestinations []string
Credentials []egress.Credential
TokenPassthroughOrigins []string
TokenPassthroughTargets []egresspolicy.GatewayTarget
GrantDestinations []string
GrantCredentials []egress.Credential
}
row, err := queryOne(ctx, c.db, `
SELECT r.egress_destinations, r.credentials, r.token_passthrough_origins,
SELECT r.egress_destinations, r.credentials, r.token_passthrough_targets,
COALESCE(ad.grant_destinations, sd.grant_destinations, '{}') AS grant_destinations,
COALESCE(ad.grant_credentials, sd.grant_credentials, '[]') AS grant_credentials
FROM runtime_revision r
Expand All @@ -52,22 +53,15 @@ func (c *Client) GetRuntimeEgressPolicy(ctx context.Context, revision string) (e
if err != nil {
return egress.Access{}, fmt.Errorf("get runtime egress policy %s: %w", revision, notFoundOr(err))
}
for i, raw := range row.TokenPassthroughOrigins {
origin, err := egress.ParseOrigin(raw)
if err != nil {
return egress.Access{}, fmt.Errorf("decode runtime token origins %s: %w", revision, err)
}
row.TokenPassthroughOrigins[i] = origin.String()
}
base, err := egress.CanonicalAccess(row.EgressDestinations, row.Credentials, row.TokenPassthroughOrigins)
base, err := egress.CanonicalAccess(row.EgressDestinations, row.Credentials, row.TokenPassthroughTargets)
if err != nil {
return egress.Access{}, fmt.Errorf("decode runtime egress base %s: %w", revision, err)
}
grants, err := egress.CanonicalAccess(row.GrantDestinations, row.GrantCredentials, nil)
if err != nil {
return egress.Access{}, fmt.Errorf("decode runtime egress grants %s: %w", revision, err)
}
return egress.EffectiveAccess(base, grants, row.TokenPassthroughOrigins)
return egress.EffectiveAccess(base, grants)
}

var _ egress.PolicyStore = (*Client)(nil)
Expand Down
17 changes: 11 additions & 6 deletions go/core/internal/database/runtime_egress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import (

apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1"
"github.com/kagent-dev/kagent/go/core/internal/egress"
"github.com/kagent-dev/kagent/go/core/pkg/egresspolicy"
)

func TestRuntimeEgressRetainsSourceAndListsSuspendedActors(t *testing.T) {
client := NewClient(setupTestDB(t), "public")
ctx := t.Context()
revision := RuntimeRevision{Revision: "agent", Namespace: "team-a", AgentName: "scratch", AgentUID: "old-uid", SourceSnapshot: []byte(`{}`), AgentCard: &a2apb.AgentCard{}, ActorTemplateAtespace: "team-a", ActorTemplateName: "runtime", TokenPassthroughOrigins: []string{"https://model.example.com:443"}, EgressDestinations: []string{"https://model.example.com:443"}}
revision := RuntimeRevision{Revision: "agent", Namespace: "team-a", AgentName: "scratch", AgentUID: "old-uid", SourceSnapshot: []byte(`{}`), AgentCard: &a2apb.AgentCard{}, ActorTemplateAtespace: "team-a", ActorTemplateName: "runtime", TokenPassthroughTargets: []egresspolicy.GatewayTarget{{Origin: "https://model.example.com:443", Header: "authorization"}}, EgressDestinations: []string{"https://model.example.com:443"}}
upsertErr := client.UpsertAgentDefinition(ctx, AgentDefinition{Namespace: "team-a", AgentName: "scratch", AgentUID: "old-uid", DesiredRevision: "agent"})
require.NoError(t, upsertErr)
require.NoError(t, client.RecordRuntimeRevision(ctx, revision, RecordRevisionOptions{Ready: true}))
Expand Down Expand Up @@ -127,10 +128,14 @@ func TestRuntimeEgressTokenOriginsAreNotGrants(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
client := NewClient(setupTestDB(t), "public")
ctx := t.Context()
var targets []egresspolicy.GatewayTarget
for _, origin := range test.origins {
targets = append(targets, egresspolicy.GatewayTarget{Origin: origin, Header: "authorization"})
}
require.NoError(t, client.RecordRuntimeRevision(ctx, RuntimeRevision{
Revision: "revision", Namespace: "team", AgentName: "source", AgentUID: "uid",
SourceSnapshot: []byte(`{}`), AgentCard: &a2apb.AgentCard{}, ActorTemplateAtespace: "team", ActorTemplateName: "runtime",
TokenPassthroughOrigins: test.origins,
TokenPassthroughTargets: targets,
}, RecordRevisionOptions{}))
policy, err := client.GetRuntimeEgressPolicy(ctx, "revision")
require.NoError(t, err)
Expand All @@ -150,7 +155,7 @@ func TestRuntimeEgressCanonicalTokenOriginWithholdsCredentials(t *testing.T) {
require.NoError(t, client.RecordRuntimeRevision(ctx, RuntimeRevision{
Revision: "revision", Namespace: "team", AgentName: "source", AgentUID: "uid",
SourceSnapshot: []byte(`{}`), AgentCard: &a2apb.AgentCard{}, ActorTemplateAtespace: "team", ActorTemplateName: "runtime",
TokenPassthroughOrigins: []string{"https://MODEL.example.com."},
TokenPassthroughTargets: []egresspolicy.GatewayTarget{{Origin: "https://MODEL.example.com.", Header: "Authorization"}},
}, RecordRevisionOptions{}))
policy, err := client.GetRuntimeEgressPolicy(ctx, "revision")
require.NoError(t, err)
Expand Down Expand Up @@ -191,7 +196,7 @@ func TestSourceGrantsDetermineEffectivePolicy(t *testing.T) {
t.Helper()
policy, err := client.GetRuntimeEgressPolicy(ctx, "revision")
require.NoError(t, err)
expected, err := egress.MergeAccess(base, grants, nil)
expected, err := egress.MergeAccess(base, grants)
require.NoError(t, err)
require.True(t, slices.Equal(expected.Destinations, policy.Destinations))
require.True(t, slices.Equal(expected.Credentials, policy.Credentials))
Expand All @@ -206,7 +211,7 @@ func TestSourceGrantsDetermineEffectivePolicy(t *testing.T) {
require.NoError(t, err)
policy, err := client.GetRuntimeEgressPolicy(ctx, "revision")
require.NoError(t, err)
expected, err := egress.MergeAccess(base, canonical, nil)
expected, err := egress.MergeAccess(base, canonical)
require.NoError(t, err)
require.Equal(t, expected, policy, "older revisions retain nonconflicting grants")
}
Expand Down Expand Up @@ -255,7 +260,7 @@ func TestRuntimeEgressMalformedInputsFailClosed(t *testing.T) {
{"grant credentials", `UPDATE agent_definition SET grant_credentials = '[{"hostname":"legacy.example.com"}]'`},
{"grant origin", `UPDATE agent_definition SET grant_destinations = ARRAY['invalid']`},
{"pinned credentials", `UPDATE runtime_revision SET credentials = '[{"hostname":"legacy.example.com"}]'`},
{"token origin", `UPDATE runtime_revision SET token_passthrough_origins = ARRAY['invalid']`},
{"token target", `UPDATE runtime_revision SET token_passthrough_targets = '[{"origin":"invalid","header":"authorization"}]'`},
{"missing typed revision", `DELETE FROM agent_revision`},
} {
t.Run(test.name, func(t *testing.T) {
Expand Down
Loading
Loading