diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 6c2e53f412..8ff6fd2e0e 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -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 @@ -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 diff --git a/go/adk/pkg/mcp/passthrough_test.go b/go/adk/pkg/mcp/passthrough_test.go index 6b98dbd211..6cb1199ba6 100644 --- a/go/adk/pkg/mcp/passthrough_test.go +++ b/go/adk/pkg/mcp/passthrough_test.go @@ -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 { diff --git a/go/core/internal/controller/grant_admission_test.go b/go/core/internal/controller/grant_admission_test.go index 4e7c0a4d56..69604eb2cf 100644 --- a/go/core/internal/controller/grant_admission_test.go +++ b/go/core/internal/controller/grant_admission_test.go @@ -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) { @@ -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") diff --git a/go/core/internal/controller/preparation_test.go b/go/core/internal/controller/preparation_test.go index 30f41a2feb..9f383336dd 100644 --- a/go/core/internal/controller/preparation_test.go +++ b/go/core/internal/controller/preparation_test.go @@ -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{} @@ -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), diff --git a/go/core/internal/controller/reconciler.go b/go/core/internal/controller/reconciler.go index 9911b09a27..c8906559ea 100644 --- a/go/core/internal/controller/reconciler.go +++ b/go/core/internal/controller/reconciler.go @@ -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() @@ -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{ @@ -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 diff --git a/go/core/internal/database/models.go b/go/core/internal/database/models.go index dfd35f7a9d..208c5f76e8 100644 --- a/go/core/internal/database/models.go +++ b/go/core/internal/database/models.go @@ -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 { @@ -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 diff --git a/go/core/internal/database/runtime_egress.go b/go/core/internal/database/runtime_egress.go index 91eaf0e12b..ef0d415227 100644 --- a/go/core/internal/database/runtime_egress.go +++ b/go/core/internal/database/runtime_egress.go @@ -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. @@ -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 @@ -52,14 +53,7 @@ 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) } @@ -67,7 +61,7 @@ func (c *Client) GetRuntimeEgressPolicy(ctx context.Context, revision string) (e 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) diff --git a/go/core/internal/database/runtime_egress_test.go b/go/core/internal/database/runtime_egress_test.go index c53d23093f..68d1118e33 100644 --- a/go/core/internal/database/runtime_egress_test.go +++ b/go/core/internal/database/runtime_egress_test.go @@ -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})) @@ -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) @@ -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) @@ -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)) @@ -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") } @@ -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) { diff --git a/go/core/internal/database/runtime_revisions.go b/go/core/internal/database/runtime_revisions.go index a577d59af0..21d9092fbc 100644 --- a/go/core/internal/database/runtime_revisions.go +++ b/go/core/internal/database/runtime_revisions.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) // UpsertAgentDefinition records the desired runtime revision for a @@ -127,7 +128,7 @@ func (c *Client) RecordRuntimeRevision(ctx context.Context, revision RuntimeRevi if err := recordRuntimeRevision(ctx, tx, runtimeRevisionRecord{ RuntimeArtifact: RuntimeArtifact{Revision: revision.Revision, Kind: runtimeKindAgent, Namespace: revision.Namespace, ActorTemplateAtespace: revision.ActorTemplateAtespace, ActorTemplateName: revision.ActorTemplateName, ActorTemplateUID: revision.ActorTemplateUID}, - SourceSnapshot: revision.SourceSnapshot, EgressDestinations: revision.EgressDestinations, Credentials: revision.Credentials, TokenPassthroughOrigins: revision.TokenPassthroughOrigins, + SourceSnapshot: revision.SourceSnapshot, EgressDestinations: revision.EgressDestinations, Credentials: revision.Credentials, TokenPassthroughTargets: revision.TokenPassthroughTargets, }); err != nil { return err } @@ -159,7 +160,7 @@ func (c *Client) RecordRuntimeRevision(ctx context.Context, revision RuntimeRevi func (c *Client) GetRuntimeRevision(ctx context.Context, revision string) (*RuntimeRevision, error) { row, err := queryOne(ctx, c.db, ` SELECT revision, namespace, agent_name, agent_uid, - source_snapshot, egress_destinations, token_passthrough_origins, credentials, actor_template_atespace, actor_template_name, actor_template_uid, + source_snapshot, egress_destinations, token_passthrough_targets, credentials, actor_template_atespace, actor_template_name, actor_template_uid, agent_card, deleted_at FROM agent_runtime_revision WHERE revision = $1 `, pgx.RowToStructByName[runtimeRevisionRow], revision) if err != nil { @@ -179,12 +180,16 @@ func toRuntimeRevision(row runtimeRevisionRow) (*RuntimeRevision, error) { if err != nil { return nil, fmt.Errorf("decode runtime revision %s credentials: %w", row.Revision, err) } + targets, err := egress.CanonicalGatewayTargets(row.TokenPassthroughTargets) + if err != nil { + return nil, fmt.Errorf("decode runtime revision %s tokenPassthrough targets: %w", row.Revision, err) + } return &RuntimeRevision{ Revision: row.Revision, Namespace: row.Namespace, AgentName: row.AgentName, AgentUID: row.AgentUID, SourceSnapshot: row.SourceSnapshot, AgentCard: card, EgressDestinations: row.EgressDestinations, - TokenPassthroughOrigins: row.TokenPassthroughOrigins, + TokenPassthroughTargets: targets, Credentials: credentials, ActorTemplateAtespace: row.ActorTemplateAtespace, ActorTemplateName: row.ActorTemplateName, ActorTemplateUID: row.ActorTemplateUID, @@ -233,7 +238,7 @@ func getAvailableRuntimeRevisionForUpdate(ctx context.Context, tx pgx.Tx, revisi } return queryOne(ctx, tx, ` SELECT revision, namespace, agent_name, agent_uid, - source_snapshot, egress_destinations, token_passthrough_origins, credentials, actor_template_atespace, actor_template_name, actor_template_uid, + source_snapshot, egress_destinations, token_passthrough_targets, credentials, actor_template_atespace, actor_template_name, actor_template_uid, agent_card, deleted_at FROM agent_runtime_revision WHERE revision = $1 `, pgx.RowToStructByName[runtimeRevisionRow], revision) } @@ -258,7 +263,7 @@ type runtimeRevisionRecord struct { RuntimeArtifact SourceSnapshot json.RawMessage EgressDestinations []string - TokenPassthroughOrigins []string + TokenPassthroughTargets []egresspolicy.GatewayTarget Credentials []egress.Credential } @@ -271,17 +276,17 @@ func recordRuntimeRevision(ctx context.Context, tx pgx.Tx, revision runtimeRevis if revision.EgressDestinations == nil { revision.EgressDestinations = []string{} } - if revision.TokenPassthroughOrigins == nil { - revision.TokenPassthroughOrigins = []string{} + if revision.TokenPassthroughTargets == nil { + revision.TokenPassthroughTargets = []egresspolicy.GatewayTarget{} } result, err := tx.Exec(ctx, ` INSERT INTO runtime_revision (revision, kind, namespace, source_snapshot, egress_destinations, - actor_template_atespace, actor_template_name, actor_template_uid, credentials, token_passthrough_origins) + actor_template_atespace, actor_template_name, actor_template_uid, credentials, token_passthrough_targets) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (revision) DO UPDATE SET actor_template_uid = EXCLUDED.actor_template_uid, updated_at = NOW() WHERE runtime_revision.deleted_at IS NULL AND runtime_revision.kind = EXCLUDED.kind `, revision.Revision, revision.Kind, revision.Namespace, revision.SourceSnapshot, revision.EgressDestinations, - revision.ActorTemplateAtespace, revision.ActorTemplateName, revision.ActorTemplateUID, revision.Credentials, revision.TokenPassthroughOrigins) + revision.ActorTemplateAtespace, revision.ActorTemplateName, revision.ActorTemplateUID, revision.Credentials, revision.TokenPassthroughTargets) if err != nil { return fmt.Errorf("record runtime revision %s: %w", revision.Revision, err) } @@ -375,7 +380,7 @@ type runtimeRevisionRow struct { AgentUID string SourceSnapshot []byte EgressDestinations []string - TokenPassthroughOrigins []string + TokenPassthroughTargets []egresspolicy.GatewayTarget Credentials []egress.Credential ActorTemplateAtespace string ActorTemplateName string diff --git a/go/core/internal/database/runtime_revisions_test.go b/go/core/internal/database/runtime_revisions_test.go index a88cdcbf22..d27ec9ee2d 100644 --- a/go/core/internal/database/runtime_revisions_test.go +++ b/go/core/internal/database/runtime_revisions_test.go @@ -17,6 +17,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" ) func TestRetireAgentIdentities(t *testing.T) { @@ -564,11 +565,13 @@ func TestRecordReadyRuntimeRevisionOnlyCurrentActivePair(t *testing.T) { func TestRuntimeRevisionPersistsCredentialBindings(t *testing.T) { client := NewClient(setupTestDB(t), "public") - revision := RuntimeRevision{Revision: "credential-revision", Namespace: "team", AgentName: "agent", AgentUID: "agent", SourceSnapshot: []byte("{}"), AgentCard: &a2apb.AgentCard{}, EgressDestinations: []string{"https://api.example.com:443"}, ActorTemplateAtespace: "team", ActorTemplateName: "runtime", Credentials: []egress.Credential{{Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://k8s.io/default/team/auth/token"}}} + revision := RuntimeRevision{Revision: "credential-revision", Namespace: "team", AgentName: "agent", AgentUID: "agent", SourceSnapshot: []byte("{}"), AgentCard: &a2apb.AgentCard{}, EgressDestinations: []string{"https://api.example.com:443"}, ActorTemplateAtespace: "team", ActorTemplateName: "runtime", Credentials: []egress.Credential{{Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://k8s.io/default/team/auth/token"}}, + TokenPassthroughTargets: []egresspolicy.GatewayTarget{{Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}}} require.NoError(t, client.RecordRuntimeRevision(t.Context(), revision, RecordRevisionOptions{})) got, err := client.GetRuntimeRevision(t.Context(), revision.Revision) require.NoError(t, err) require.Equal(t, revision.Credentials, got.Credentials) + require.Equal(t, revision.TokenPassthroughTargets, got.TokenPassthroughTargets) revision.Credentials[0].URI = "ate-secret://k8s.io/default/team/other/token" require.NoError(t, client.RecordRuntimeRevision(t.Context(), revision, RecordRevisionOptions{})) unchanged, err := client.GetRuntimeRevision(t.Context(), revision.Revision) @@ -588,6 +591,15 @@ func TestRuntimeRevisionRejectsMalformedStoredCredentials(t *testing.T) { require.Nil(t, revision) } +func TestRuntimeRevisionRejectsMalformedStoredTokenPassthroughTargets(t *testing.T) { + revision, err := toRuntimeRevision(runtimeRevisionRow{ + Revision: "bad-revision", + TokenPassthroughTargets: []egresspolicy.GatewayTarget{{Origin: "https://*.example.com", Header: "authorization"}}, + }) + require.ErrorContains(t, err, "decode runtime revision bad-revision tokenPassthrough targets") + require.Nil(t, revision) +} + func TestRecordReadyRevisionRollsBackOnPromotionFailure(t *testing.T) { for _, kind := range []string{"agent", "sandbox"} { for _, existing := range []bool{false, true} { diff --git a/go/core/internal/egress/credentials.go b/go/core/internal/egress/credentials.go index f226807c19..e0ec443fec 100644 --- a/go/core/internal/egress/credentials.go +++ b/go/core/internal/egress/credentials.go @@ -8,9 +8,11 @@ import ( "golang.org/x/net/http/httpguts" "k8s.io/apimachinery/pkg/util/validation" + + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) -// Credential binds a Secret reference to an exact destination and HTTP header. +// Credential binds a provider reference to an exact destination and HTTP header. // Values are fetched by the gateway and never enter a runtime revision. type Credential struct { Origin string `json:"origin"` @@ -19,6 +21,40 @@ type Credential struct { URI string `json:"uri"` } +// CanonicalGatewayTargets validates, orders and deduplicates targets. One +// origin and header cannot carry two prefixes. +func CanonicalGatewayTargets(targets []egresspolicy.GatewayTarget) ([]egresspolicy.GatewayTarget, error) { + if len(targets) == 0 { + return nil, nil + } + result := slices.Clone(targets) + for i := range result { + t := &result[i] + u, err := ParseOrigin(t.Origin) + if err != nil || strings.Contains(t.Origin, "*") { + return nil, fmt.Errorf("gateway target requires an exact HTTP(S) DNS origin, got %q", t.Origin) + } + t.Origin = u.String() + t.Header = strings.ToLower(t.Header) + if !validCredentialHeader(t.Header) { + return nil, fmt.Errorf("invalid gateway target header %q", t.Header) + } + if !httpguts.ValidHeaderFieldValue(t.Prefix) { + return nil, fmt.Errorf("invalid gateway target prefix %q", t.Prefix) + } + } + slices.SortFunc(result, func(a, b egresspolicy.GatewayTarget) int { + return strings.Compare(a.Origin+"\x00"+a.Header+"\x00"+a.Prefix, b.Origin+"\x00"+b.Header+"\x00"+b.Prefix) + }) + result = slices.Compact(result) + for i := 1; i < len(result); i++ { + if a, b := result[i-1], result[i]; a.Origin == b.Origin && a.Header == b.Header { + return nil, fmt.Errorf("conflicting gateway targets for %s header %s", a.Origin, a.Header) + } + } + return result, nil +} + // CanonicalCredentials validates and orders bindings by complete origin and header. func CanonicalCredentials(bindings []Credential) ([]Credential, error) { result := slices.Clone(bindings) @@ -33,13 +69,8 @@ func CanonicalCredentials(bindings []Credential) ([]Credential, error) { if !validCredentialHeader(c.Header) || !httpguts.ValidHeaderFieldValue(c.Prefix) { return nil, fmt.Errorf("invalid credential injection header %q", c.Header) } - u, err = url.Parse(c.URI) - if err != nil || u.Scheme != "ate-secret" || u.Host != "k8s.io" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - return nil, fmt.Errorf("invalid Kubernetes credential URI") - } - parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") - if len(parts) != 4 || parts[0] != "default" || len(validation.IsDNS1123Label(parts[1])) != 0 || len(validation.IsDNS1123Subdomain(parts[2])) != 0 || parts[3] == "" || len(validation.IsConfigMapKey(parts[3])) != 0 { - return nil, fmt.Errorf("credential URI must identify the default locator, namespace, Secret and key") + if err := validCredentialURI(c.URI); err != nil { + return nil, err } } slices.SortFunc(result, func(a, b Credential) int { @@ -62,6 +93,37 @@ func CanonicalCredentials(bindings []Credential) ([]Credential, error) { return result, nil } +// validCredentialURI accepts ate-secret:///. Provider names are +// lowercase DNS names without ports. k8s.io paths name one Secret key. +func validCredentialURI(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "ate-secret" || u.Opaque != "" { + return fmt.Errorf("invalid credential URI") + } + if u.User != nil || u.ForceQuery || u.RawQuery != "" || strings.Contains(raw, "#") { + return fmt.Errorf("credential URI cannot carry user information, a query or a fragment") + } + if u.Host != u.Hostname() || len(validation.IsDNS1123Subdomain(u.Host)) != 0 { + return fmt.Errorf("credential provider must be a lowercase DNS name without a port") + } + // Substrate requires at least two nonempty, unescaped path segments. + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + if u.EscapedPath() != u.Path || !strings.HasPrefix(u.Path, "/") || len(parts) < 2 || slices.Contains(parts, "") { + return fmt.Errorf("credential URI must have at least two nonempty path segments without percent encoding") + } + if u.Host != "k8s.io" { + return nil + } + if len(parts) != 4 || parts[0] != "default" { + return fmt.Errorf("credential URI must identify the default locator, namespace, Secret and key") + } + namespace, secret, key := parts[1], parts[2], parts[3] + if len(validation.IsDNS1123Label(namespace)) != 0 || len(validation.IsDNS1123Subdomain(secret)) != 0 || len(validation.IsConfigMapKey(key)) != 0 { + return fmt.Errorf("credential URI must identify the default locator, namespace, Secret and key") + } + return nil +} + // validCredentialHeader excludes routing, framing, hop-by-hop and cookie headers. func validCredentialHeader(header string) bool { if len(header) > 256 || !httpguts.ValidHeaderFieldName(header) { diff --git a/go/core/internal/egress/credentials_test.go b/go/core/internal/egress/credentials_test.go index 453d663065..90781b2139 100644 --- a/go/core/internal/egress/credentials_test.go +++ b/go/core/internal/egress/credentials_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) func TestCanonicalCredentials(t *testing.T) { @@ -22,7 +24,33 @@ func TestCanonicalCredentials(t *testing.T) { {"prefix", func(c *Credential) { c.Prefix = "Bearer\n" }}, {"provider", func(c *Credential) { c.URI = "https://example.com/secret" }}, {"missing key", func(c *Credential) { c.URI = "ate-secret://k8s.io/default/team/auth" }}, - {"old provider", func(c *Credential) { c.URI = "ate-secret://kubernetes.io/team/auth/token" }}, + {"empty key", func(c *Credential) { c.URI = "ate-secret://k8s.io/default/team/auth/" }}, + {"mixed case Kubernetes provider", func(c *Credential) { c.URI = "ate-secret://K8s.io/default/team/auth/token" }}, + {"Kubernetes provider port", func(c *Credential) { c.URI = "ate-secret://k8s.io:443/default/team/auth/token" }}, + {"mixed case external provider", func(c *Credential) { c.URI = "ate-secret://Provider.example/default/token" }}, + {"external provider port", func(c *Credential) { c.URI = "ate-secret://provider.example:443/default/token" }}, + {"empty provider port", func(c *Credential) { c.URI = "ate-secret://provider.example:/default/token" }}, + {"IPv6 provider", func(c *Credential) { c.URI = "ate-secret://[2001:db8::1]/default/token" }}, + {"missing provider", func(c *Credential) { c.URI = "ate-secret:///team/auth/token" }}, + {"userinfo", func(c *Credential) { c.URI = "ate-secret://user@provider.example/default/token" }}, + {"opaque", func(c *Credential) { c.URI = "ate-secret:provider.example/token" }}, + {"empty query", func(c *Credential) { c.URI += "?" }}, + {"fragment", func(c *Credential) { c.URI += "#token" }}, + {"empty fragment", func(c *Credential) { c.URI += "#" }}, + {"invalid escape", func(c *Credential) { c.URI = "ate-secret://provider.example/%xx" }}, + {"invalid provider DNS name", func(c *Credential) { c.URI = "ate-secret://provider_name/default/token" }}, + {"provider trailing dot", func(c *Credential) { c.URI = "ate-secret://provider.example./default/token" }}, + {"missing path", func(c *Credential) { c.URI = "ate-secret://provider.example" }}, + {"single path segment", func(c *Credential) { c.URI = "ate-secret://provider.example/token" }}, + {"empty path segment", func(c *Credential) { c.URI = "ate-secret://provider.example/default//token" }}, + {"empty first path segment", func(c *Credential) { c.URI = "ate-secret://provider.example//default/token" }}, + {"trailing slash", func(c *Credential) { c.URI = "ate-secret://provider.example/default/token/" }}, + {"encoded path", func(c *Credential) { + c.URI = "ate-secret://provider.example/default/https%3A%2F%2Fapi.example.com%3A8443" + }}, + {"encoded unreserved character", func(c *Credential) { c.URI = "ate-secret://provider.example/default/%74oken" }}, + {"unescaped space", func(c *Credential) { c.URI = "ate-secret://provider.example/default/session token" }}, + {"encoded Kubernetes key", func(c *Credential) { c.URI = "ate-secret://k8s.io/default/team/auth/%74oken" }}, {"missing locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/team/auth/token" }}, {"unsupported locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/remote/team/auth/token" }}, {"query", func(c *Credential) { c.URI += "?key=other" }}, @@ -39,3 +67,49 @@ func TestCanonicalCredentials(t *testing.T) { _, err = CanonicalCredentials([]Credential{base, other}) require.ErrorContains(t, err, "conflicting credentials") } + +func TestCanonicalGatewayTargets(t *testing.T) { + target := egresspolicy.GatewayTarget{Origin: "https://API.Example.com.:0443", Header: "Authorization", Prefix: "Bearer "} + got, err := CanonicalGatewayTargets([]egresspolicy.GatewayTarget{target, target, {Origin: "http://api.example.com", Header: "x-api-key"}}) + require.NoError(t, err) + require.Equal(t, []egresspolicy.GatewayTarget{ + {Origin: "http://api.example.com:80", Header: "x-api-key"}, + {Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer "}, + }, got) + for _, test := range []struct { + name string + change func(*egresspolicy.GatewayTarget) + }{ + {"wildcard", func(g *egresspolicy.GatewayTarget) { g.Origin = "https://*.example.com" }}, + {"path", func(g *egresspolicy.GatewayTarget) { g.Origin = "https://api.example.com/mcp" }}, + {"header", func(g *egresspolicy.GatewayTarget) { g.Header = "Authorization\r\nInjected" }}, + {"prefix", func(g *egresspolicy.GatewayTarget) { g.Prefix = "Bearer\n" }}, + } { + t.Run(test.name, func(t *testing.T) { + invalid := target + test.change(&invalid) + _, err := CanonicalGatewayTargets([]egresspolicy.GatewayTarget{invalid}) + require.Error(t, err) + }) + } + other := target + other.Prefix = "" + _, err = CanonicalGatewayTargets([]egresspolicy.GatewayTarget{target, other}) + require.ErrorContains(t, err, "conflicting gateway targets") +} + +func TestCanonicalCredentialsExternalProvider(t *testing.T) { + for _, uri := range []string{ + "ate-secret://provider.example/default/session-token", + "ate-secret://caller-token.kagent.dev/default/token", + "ate-secret://provider.example/default/api.example.com:8443", + "ate-secret://caller-token.example/v1/https/api.example.com:8443", + } { + t.Run(uri, func(t *testing.T) { + binding := Credential{Origin: "https://api.example.com:8443", Header: "authorization", Prefix: "Bearer ", URI: uri} + got, err := CanonicalCredentials([]Credential{binding}) + require.NoError(t, err) + require.Equal(t, []Credential{binding}, got) + }) + } +} diff --git a/go/core/internal/egress/destination.go b/go/core/internal/egress/destination.go index eebe18251b..484a047460 100644 --- a/go/core/internal/egress/destination.go +++ b/go/core/internal/egress/destination.go @@ -10,6 +10,8 @@ import ( "strings" "k8s.io/apimachinery/pkg/util/validation" + + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) // Origin retains the protocol and destination port needed by Substrate's @@ -64,11 +66,13 @@ func ParseOrigin(raw string) (*url.URL, error) { type Access struct { Destinations []string `json:"destinations,omitempty"` Credentials []Credential `json:"credentials,omitempty"` + // TokenPassthroughTargets come from the revision; live grants never set them. + TokenPassthroughTargets []egresspolicy.GatewayTarget `json:"tokenPassthroughTargets,omitempty" db:"-"` } -// CanonicalAccess merges equivalent origins and validates destinations and -// credentials, including conflicts with canonical caller-token origins. -func CanonicalAccess(destinations []string, credentials []Credential, tokenPassthroughOrigins []string) (Access, error) { +// CanonicalAccess merges equivalent origins and validates destinations, +// credentials and tokenPassthrough targets. Credentials cannot use a target's origin. +func CanonicalAccess(destinations []string, credentials []Credential, targets []egresspolicy.GatewayTarget) (Access, error) { result := Access{} protocols := map[string]string{} allowed := map[string]bool{} @@ -95,11 +99,19 @@ func CanonicalAccess(destinations []string, credentials []Credential, tokenPasst if err != nil { return Access{}, err } + result.TokenPassthroughTargets, err = CanonicalGatewayTargets(targets) + if err != nil { + return Access{}, err + } + reserved := map[string]bool{} + for _, target := range result.TokenPassthroughTargets { + reserved[target.Origin] = true + } for _, binding := range result.Credentials { if !allowed[binding.Origin] { return Access{}, fmt.Errorf("credential destination %q is not explicitly allowed", binding.Origin) } - if slices.Contains(tokenPassthroughOrigins, binding.Origin) { + if reserved[binding.Origin] { return Access{}, fmt.Errorf("destination %q cannot combine caller-token passthrough with gateway credentials", binding.Origin) } } diff --git a/go/core/internal/egress/destination_test.go b/go/core/internal/egress/destination_test.go index 20abd0d001..e6fab1817c 100644 --- a/go/core/internal/egress/destination_test.go +++ b/go/core/internal/egress/destination_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) func TestCanonicalAccess(t *testing.T) { @@ -15,9 +17,9 @@ func TestCanonicalAccess(t *testing.T) { require.Equal(t, []string{"http://api.example.com:80", "https://*.example.com:443", "https://api.example.com:443", "https://api.example.com:8443"}, access.Destinations) require.Len(t, access.Credentials, 1) require.Equal(t, "https://api.example.com:443", access.Credentials[0].Origin) - _, err = CanonicalAccess([]string{credential.Origin}, []Credential{credential}, []string{"https://api.example.com:443"}) + _, err = CanonicalAccess([]string{credential.Origin}, []Credential{credential}, []egresspolicy.GatewayTarget{{Origin: "https://API.example.com", Header: "authorization"}}) require.ErrorContains(t, err, "cannot combine caller-token passthrough with gateway credentials") - _, err = CanonicalAccess([]string{credential.Origin}, []Credential{credential}, []string{"https://api.example.com:8443", "http://api.example.com:443"}) + _, err = CanonicalAccess([]string{credential.Origin}, []Credential{credential}, []egresspolicy.GatewayTarget{{Origin: "https://api.example.com:8443", Header: "authorization"}, {Origin: "http://api.example.com:443", Header: "authorization"}}) require.NoError(t, err, "caller tokens on different origins do not conflict") for _, raw := range []string{"https://*", "https://*.com", "https://foo.*.com", "https://192.0.2.1", "https://[::1]", "https://user@api.example.com", "https://api.example.com/", "https://api.example.com?", "https://api.example.com#", "https://api.example.com:", "https://api.example.com:0", "https://api.example.com:65536", "ftp://api.example.com"} { t.Run(raw, func(t *testing.T) { diff --git a/go/core/internal/egress/live.go b/go/core/internal/egress/live.go index fa223e0721..daf7407585 100644 --- a/go/core/internal/egress/live.go +++ b/go/core/internal/egress/live.go @@ -2,6 +2,7 @@ package egress import ( "context" + "fmt" "slices" ) @@ -13,15 +14,18 @@ type PolicyStore interface { // EffectiveAccess retains the base and all grants compatible with this revision. // Stored grants can conflict with an older actor's pinned base. -func EffectiveAccess(base, grants Access, tokenPassthroughOrigins []string) (Access, error) { - filtered, _ := FilterConflicts(base, grants, tokenPassthroughOrigins) - return MergeAccess(base, filtered, tokenPassthroughOrigins) +func EffectiveAccess(base, grants Access) (Access, error) { + filtered, _ := FilterConflicts(base, grants) + return MergeAccess(base, filtered) } // MergeAccess validates live grants against the pinned runtime's credentials and -// caller-token origins. Inputs are never modified. -func MergeAccess(base, grants Access, tokenPassthroughOrigins []string) (Access, error) { - return CanonicalAccess(append(slices.Clone(base.Destinations), grants.Destinations...), append(slices.Clone(base.Credentials), grants.Credentials...), tokenPassthroughOrigins) +// tokenPassthrough targets. Inputs are never modified. +func MergeAccess(base, grants Access) (Access, error) { + if len(grants.TokenPassthroughTargets) != 0 { + return Access{}, fmt.Errorf("egress grants cannot carry tokenPassthrough targets") + } + return CanonicalAccess(append(slices.Clone(base.Destinations), grants.Destinations...), append(slices.Clone(base.Credentials), grants.Credentials...), base.TokenPassthroughTargets) } // IntersectAccess retains only destinations and complete credential bindings @@ -47,15 +51,15 @@ func IntersectAccess(a, b Access) Access { // sorted grant order determines which rules fit when the combined limits are // exceeded. A conflicting credential does not remove its network destination. // Neither input is modified; dropped origins are sorted and deduplicated. -func FilterConflicts(base, grants Access, tokenOrigins []string) (Access, []string) { - if _, err := MergeAccess(base, grants, tokenOrigins); err == nil { +func FilterConflicts(base, grants Access) (Access, []string) { + if _, err := MergeAccess(base, grants); err == nil { return grants, nil } var result Access var dropped []string for _, origin := range grants.Destinations { candidate := Access{Destinations: append(slices.Clone(result.Destinations), origin)} - if _, err := MergeAccess(base, candidate, tokenOrigins); err != nil { + if _, err := MergeAccess(base, candidate); err != nil { dropped = append(dropped, origin) } else { result = candidate @@ -69,7 +73,7 @@ func FilterConflicts(base, grants Access, tokenOrigins []string) (Access, []stri Destinations: result.Destinations, Credentials: append(slices.Clone(result.Credentials), credential), } - if _, err := MergeAccess(base, candidate, tokenOrigins); err != nil { + if _, err := MergeAccess(base, candidate); err != nil { dropped = append(dropped, credential.Origin) } else { result = candidate diff --git a/go/core/internal/egress/live_test.go b/go/core/internal/egress/live_test.go index e1899f8920..716476a52a 100644 --- a/go/core/internal/egress/live_test.go +++ b/go/core/internal/egress/live_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) func canonicalTestAccess(t *testing.T, destinations []string, credentials ...Credential) Access { @@ -16,7 +18,7 @@ func canonicalTestAccess(t *testing.T, destinations []string, credentials ...Cre } func cloneAccess(a Access) Access { - return Access{Destinations: slices.Clone(a.Destinations), Credentials: slices.Clone(a.Credentials)} + return Access{Destinations: slices.Clone(a.Destinations), Credentials: slices.Clone(a.Credentials), TokenPassthroughTargets: slices.Clone(a.TokenPassthroughTargets)} } func TestIntersectAccess(t *testing.T) { @@ -40,10 +42,14 @@ func TestFilterConflicts(t *testing.T) { conflict := binding conflict.URI = "ate-secret://k8s.io/default/team/other/token" const extra = "https://tickets.example.com:443" + tokenBase, err := CanonicalAccess([]string{binding.Origin}, nil, []egresspolicy.GatewayTarget{{Origin: binding.Origin, Header: "authorization", Prefix: "Bearer "}}) + require.NoError(t, err) + _, err = MergeAccess(Access{}, tokenBase) + require.ErrorContains(t, err, "grants cannot carry tokenPassthrough targets") for _, test := range []struct { name string base, grants, want Access - tokens, dropped []string + dropped []string }{ {name: "additive", base: base, grants: canonicalTestAccess(t, []string{extra}), want: canonicalTestAccess(t, []string{extra})}, {name: "duplicate", base: base, grants: base, want: base}, @@ -51,17 +57,17 @@ func TestFilterConflicts(t *testing.T) { want: canonicalTestAccess(t, []string{binding.Origin, extra}), dropped: []string{binding.Origin}}, {name: "protocol conflict keeps other grants", base: base, grants: canonicalTestAccess(t, []string{"http://model.example.com:443", extra}), want: canonicalTestAccess(t, []string{extra}), dropped: []string{"http://model.example.com:443"}}, - {name: "token passthrough", base: canonicalTestAccess(t, []string{binding.Origin}), tokens: []string{binding.Origin}, + {name: "token passthrough", base: tokenBase, grants: canonicalTestAccess(t, []string{binding.Origin, extra}, binding), want: canonicalTestAccess(t, []string{binding.Origin, extra}), dropped: []string{binding.Origin}}, } { t.Run(test.name, func(t *testing.T) { beforeBase, beforeGrants := cloneAccess(test.base), cloneAccess(test.grants) - filtered, dropped := FilterConflicts(test.base, test.grants, test.tokens) + filtered, dropped := FilterConflicts(test.base, test.grants) require.Equal(t, test.want, filtered) require.Equal(t, test.dropped, dropped) - effective, err := EffectiveAccess(test.base, test.grants, test.tokens) + effective, err := EffectiveAccess(test.base, test.grants) require.NoError(t, err) - want, err := MergeAccess(test.base, test.want, test.tokens) + want, err := MergeAccess(test.base, test.want) require.NoError(t, err) require.Equal(t, want, effective) require.Equal(t, beforeBase, test.base) @@ -80,7 +86,7 @@ func TestFilterConflictsLimits(t *testing.T) { {"https://z.example.com", "https://a.example.com"}, {"https://a.example.com", "https://z.example.com"}, } { - filtered, dropped := FilterConflicts(base, canonicalTestAccess(t, input), nil) + filtered, dropped := FilterConflicts(base, canonicalTestAccess(t, input)) require.Equal(t, []string{"https://a.example.com:443"}, filtered.Destinations) require.Equal(t, []string{"https://z.example.com:443"}, dropped) } @@ -92,7 +98,7 @@ func TestFilterConflictsLimits(t *testing.T) { extra := credentials[0] extra.Header = "x-new" grants := canonicalTestAccess(t, base.Destinations, extra) - filtered, dropped := FilterConflicts(base, grants, nil) + filtered, dropped := FilterConflicts(base, grants) require.Empty(t, filtered.Credentials) require.Equal(t, base.Destinations, filtered.Destinations) require.Equal(t, base.Destinations, dropped) diff --git a/go/core/internal/substrate/config.go b/go/core/internal/substrate/config.go index 8699c200c3..40cf9d4c7a 100644 --- a/go/core/internal/substrate/config.go +++ b/go/core/internal/substrate/config.go @@ -4,11 +4,14 @@ import ( "time" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) // Config holds connection settings for Agent Substrate ate-api. type Config struct { EgressPolicies egress.PolicyStore + // EgressPolicyCompilers extend each actor's EgressPolicy in order. + EgressPolicyCompilers []egresspolicy.Compiler // AteAPIEndpoint is a gRPC target (e.g. dns:///api.ate-system.svc:443). AteAPIEndpoint string // CAFile contains the CA certificates used to verify ate-api. diff --git a/go/core/internal/substrate/egress.go b/go/core/internal/substrate/egress.go index d0aabcc843..bf6db73e7c 100644 --- a/go/core/internal/substrate/egress.go +++ b/go/core/internal/substrate/egress.go @@ -32,9 +32,10 @@ func (c *Client) EnsureActorEgressPolicy(ctx context.Context, atespace, name, re if err != nil { return fmt.Errorf("load intended actor egress: %w", err) } - policy, err := ActorEgressPolicy(atespace, access.Destinations, access.Credentials) - if err != nil { - return err + policy, chainErr := compileActorEgressPolicy(ctx, atespace, access, c.cfg.EgressPolicyCompilers) + if chainErr != nil { + // No rules denies all; extensions may have removed built-in access. + policy, _ = ActorEgressPolicy(atespace, nil, nil) } if existing == nil { _, err = c.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{Actor: actor, EgressPolicy: policy}) @@ -50,6 +51,9 @@ func (c *Client) EnsureActorEgressPolicy(ctx context.Context, atespace, name, re if err != nil { return err } + if chainErr != nil { + return fmt.Errorf("denied actor egress after extension failure: %w", chainErr) + } // A source update may have discovered this instance before its Actor // existed. Recheck after applying so creation cannot miss that update. latest, err := c.cfg.EgressPolicies.GetRuntimeEgressPolicy(ctx, revision) diff --git a/go/core/internal/substrate/egress_test.go b/go/core/internal/substrate/egress_test.go index 9738445022..7a31b724c4 100644 --- a/go/core/internal/substrate/egress_test.go +++ b/go/core/internal/substrate/egress_test.go @@ -3,6 +3,7 @@ package substrate import ( "context" "errors" + "slices" "testing" "github.com/stretchr/testify/require" @@ -13,6 +14,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) type egressPolicyStoreFunc func(context.Context, string) (egress.Access, error) @@ -76,6 +78,33 @@ func TestEnsureActorEgressPolicyReloadsAfterConflict(t *testing.T) { require.Zero(t, fake.updated) } +func TestEnsureActorEgressPolicyDeniesWhenExtensionFails(t *testing.T) { + const kept, denied = "https://mcp.example.com:443", "https://denied.example.com:443" + fake := &egressPolicyFake{} + access := egress.Access{ + Destinations: []string{kept, denied}, + TokenPassthroughTargets: []egresspolicy.GatewayTarget{{Origin: kept, Header: "authorization", Prefix: "Bearer "}}, + } + store := egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return access, nil }) + var failure error + extension := compilerFunc(func(_ context.Context, _ egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + policy.Rules = slices.DeleteFunc(policy.Rules, func(rule *ateapipb.EgressRule) bool { + return slices.Contains(rule.GetHttps().GetHostnames(), "denied.example.com") + }) + policy.Rules[0].GetHttps().Effects = &ateapipb.HttpRuleEffects{ReplaceHeaders: []*ateapipb.CredentialHeader{{Header: "authorization", Prefix: "Bearer ", CredentialUri: "ate-secret://k8s.io/default/team-a/token/value"}}} + return failure + }) + client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: store, EgressPolicyCompilers: []egresspolicy.Compiler{extension}}} + reconcile := func() error { return client.EnsureActorEgressPolicy(t.Context(), "team-a", "actor", "revision") } + + require.NoError(t, reconcile()) + require.Len(t, fake.policy.GetRules(), 1, "the extension removed a destination") + failure = errors.New("provider unavailable") + require.ErrorContains(t, reconcile(), "provider unavailable") + require.NotNil(t, fake.policy) + require.Empty(t, fake.policy.GetRules(), "a failed extension denies all egress instead of restoring the built-in policy") +} + type egressPolicyFake struct { ateapipb.ControlClient policy *ateapipb.EgressPolicy diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index 7f084fea55..396d85f526 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -1,11 +1,18 @@ package substrate import ( + "context" + "fmt" + "net" + "net/url" + "slices" "strconv" + "strings" "go.ate.dev/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) // ActorEgressPolicy compiles HTTP(S) origins into an actor's default allowlist. @@ -37,3 +44,77 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr } return policy, nil } + +// defaultEgressPolicyCompiler adds the allowlist and Secret credentials. It runs first. +type defaultEgressPolicyCompiler struct{ access egress.Access } + +func (c defaultEgressPolicyCompiler) Compile(_ context.Context, input egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + base, err := ActorEgressPolicy(input.Atespace, c.access.Destinations, c.access.Credentials) + if err != nil { + return err + } + policy.Rules = append(policy.Rules, base.Rules...) + return nil +} + +// compileActorEgressPolicy runs the built-in compiler, then each extension. +// Extensions may change anything; the result must still validate, and with +// any extension every tokenPassthrough target must end bound. +func compileActorEgressPolicy(ctx context.Context, atespace string, access egress.Access, compilers []egresspolicy.Compiler) (*ateapipb.EgressPolicy, error) { + policy := &ateapipb.EgressPolicy{Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: "default"}} + chain := append([]egresspolicy.Compiler{defaultEgressPolicyCompiler{access: access}}, compilers...) + for _, compiler := range chain { + input := egresspolicy.Input{Atespace: atespace, GatewayTargets: slices.Clone(access.TokenPassthroughTargets)} + if err := compiler.Compile(ctx, input, policy); err != nil { + return nil, fmt.Errorf("compile egress policy with %T: %w", compiler, err) + } + } + headers, err := policyHeaders(policy) + if err != nil { + return nil, err + } + var bindings []egress.Credential + for key, header := range headers { + if header.GetActorJwt() != nil { + continue + } + origin, _, _ := strings.Cut(key, "\x00") + bindings = append(bindings, egress.Credential{Origin: origin, Header: header.GetHeader(), Prefix: header.GetPrefix(), URI: header.GetCredentialUri()}) + } + if _, err := egress.CanonicalCredentials(bindings); err != nil { + return nil, err + } + for _, target := range access.TokenPassthroughTargets { + if len(compilers) != 0 && headers[target.Origin+"\x00"+target.Header] == nil { + return nil, fmt.Errorf("gateway target %s header %s has no credential", target.Origin, target.Header) + } + } + return policy, nil +} + +// policyHeaders returns credential headers keyed by origin and lowercase header. +func policyHeaders(policy *ateapipb.EgressPolicy) (map[string]*ateapipb.CredentialHeader, error) { + headers := map[string]*ateapipb.CredentialHeader{} + for _, rule := range policy.GetRules() { + scheme, hostnames, ports, effects := "https", rule.GetHttps().GetHostnames(), rule.GetHttps().GetPorts(), rule.GetHttps().GetEffects() + if rule.GetHttp() != nil { + scheme, hostnames, ports, effects = "http", rule.GetHttp().GetHostnames(), rule.GetHttp().GetPorts(), rule.GetHttp().GetEffects() + } + if len(effects.GetReplaceHeaders()) == 0 { + continue + } + if len(hostnames) != 1 || len(ports.GetNumbers()) != 1 { + return nil, fmt.Errorf("credential headers require a rule with one hostname and port") + } + u := url.URL{Scheme: scheme, Host: net.JoinHostPort(hostnames[0], strconv.Itoa(int(ports.GetNumbers()[0])))} + origin := egress.Origin(&u) + for _, header := range effects.GetReplaceHeaders() { + key := origin + "\x00" + strings.ToLower(header.GetHeader()) + if headers[key] != nil { + return nil, fmt.Errorf("duplicate credential header %s on %s", header.GetHeader(), origin) + } + headers[key] = header + } + } + return headers, nil +} diff --git a/go/core/internal/substrate/policy_test.go b/go/core/internal/substrate/policy_test.go index 525efb3b89..94ad9e1135 100644 --- a/go/core/internal/substrate/policy_test.go +++ b/go/core/internal/substrate/policy_test.go @@ -1,11 +1,15 @@ package substrate import ( + "context" + "errors" "testing" "github.com/stretchr/testify/require" + "go.ate.dev/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) func TestActorPolicyKeepsCredentialsOnExactOrigin(t *testing.T) { @@ -27,3 +31,81 @@ func TestActorPolicyKeepsCredentialsOnExactOrigin(t *testing.T) { require.Equal(t, []int32{443}, policy.Rules[2].GetHttps().GetPorts().GetNumbers()) require.Equal(t, []int32{8443}, policy.Rules[3].GetHttps().GetPorts().GetNumbers()) } + +type compilerFunc func(context.Context, egresspolicy.Input, *ateapipb.EgressPolicy) error + +func (f compilerFunc) Compile(ctx context.Context, input egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + return f(ctx, input, policy) +} + +func TestCompileActorEgressPolicyExtensions(t *testing.T) { + secret := egress.Credential{Origin: "https://other.example.com:443", Header: "x-api-key", URI: "ate-secret://k8s.io/default/team/auth/token"} + target := egresspolicy.GatewayTarget{Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer "} + access := egress.Access{Destinations: []string{"https://api.example.com:443", "https://other.example.com:443"}, Credentials: []egress.Credential{secret}, TokenPassthroughTargets: []egresspolicy.GatewayTarget{target}} + addHeader := func(host, header, prefix, uri string) compilerFunc { + return func(_ context.Context, _ egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + for _, rule := range policy.GetRules() { + if https := rule.GetHttps(); https != nil && https.GetHostnames()[0] == host { + if https.Effects == nil { + https.Effects = &ateapipb.HttpRuleEffects{} + } + https.Effects.ReplaceHeaders = append(https.Effects.ReplaceHeaders, &ateapipb.CredentialHeader{Header: header, Prefix: prefix, CredentialUri: uri}) + } + } + return nil + } + } + const callerURI = "ate-secret://caller-token.example/v1/https/api.example.com:443" + bind := addHeader("api.example.com", "Authorization", "Bearer ", callerURI) + for _, test := range []struct { + name string + compilers []egresspolicy.Compiler + wantErr string + }{ + {name: "binds the target", compilers: []egresspolicy.Compiler{bind}}, + {name: "extensions run after the built-in policy with their own input", compilers: []egresspolicy.Compiler{ + compilerFunc(func(_ context.Context, input egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + input.GatewayTargets[0].Header = "changed" + if policy.GetMetadata().GetAtespace() != "team" || len(policy.GetRules()) != 2 { + return errors.New("built-in policy missing") + } + return nil + }), + compilerFunc(func(ctx context.Context, input egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + if input.GatewayTargets[0] != target { + return errors.New("input shared between compilers") + } + return bind(ctx, input, policy) + }), + }}, + {name: "unbound target", compilers: []egresspolicy.Compiler{compilerFunc(func(context.Context, egresspolicy.Input, *ateapipb.EgressPolicy) error { return nil })}, wantErr: "has no credential"}, + {name: "compiler error", compilers: []egresspolicy.Compiler{compilerFunc(func(context.Context, egresspolicy.Input, *ateapipb.EgressPolicy) error { + return errors.New("denied") + })}, wantErr: "denied"}, + {name: "invalid URI", compilers: []egresspolicy.Compiler{addHeader("api.example.com", "authorization", "Bearer ", "https://caller-token.example/token")}, wantErr: "credential"}, + {name: "rebinding the target", compilers: []egresspolicy.Compiler{bind, bind}, wantErr: "duplicate credential header"}, + {name: "extensions may change earlier output", compilers: []egresspolicy.Compiler{bind, compilerFunc(func(_ context.Context, _ egresspolicy.Input, policy *ateapipb.EgressPolicy) error { + policy.GetRules()[1].GetHttps().GetEffects().GetReplaceHeaders()[0] = &ateapipb.CredentialHeader{Header: "x-api-key", ActorJwt: &ateapipb.ActorJwtSource{}} + policy.Rules = append(policy.Rules, &ateapipb.EgressRule{Https: &ateapipb.HttpsRule{Hostnames: []string{"*"}}}) + return nil + })}}, + } { + t.Run(test.name, func(t *testing.T) { + policy, err := compileActorEgressPolicy(t.Context(), "team", access, test.compilers) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + require.Nil(t, policy) + return + } + require.NoError(t, err) + require.Equal(t, callerURI, policy.GetRules()[0].GetHttps().GetEffects().GetReplaceHeaders()[0].GetCredentialUri()) + }) + } +} + +func TestCompileActorEgressPolicyWithoutExtensionsLeavesTargetsUnbound(t *testing.T) { + target := egresspolicy.GatewayTarget{Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer "} + policy, err := compileActorEgressPolicy(t.Context(), "team", egress.Access{Destinations: []string{target.Origin}, TokenPassthroughTargets: []egresspolicy.GatewayTarget{target}}, nil) + require.NoError(t, err, "runtimes forward the caller token without an extension") + require.Empty(t, policy.GetRules()[0].GetHttps().GetEffects().GetReplaceHeaders()) +} diff --git a/go/core/internal/translator/byo/compiler.go b/go/core/internal/translator/byo/compiler.go index 53f90f7fb9..3c6eb56289 100644 --- a/go/core/internal/translator/byo/compiler.go +++ b/go/core/internal/translator/byo/compiler.go @@ -70,7 +70,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) Environment: environment, ConfigJSON: configJSON, AgentCard: card, SkillImages: compiled.SkillImages, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - TokenPassthroughOrigins: tokenPassthroughOrigins, Credentials: credentials, Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), + TokenPassthroughTargets: tokenPassthroughTargets, Credentials: credentials, Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), }}, nil } diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index 0ae31bb582..1313694ce3 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -110,7 +110,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Claude revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment) if err != nil { return nil, err } @@ -129,7 +129,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) ConfigJSON: configJSON, AgentCard: card, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - TokenPassthroughOrigins: tokenPassthroughOrigins, Credentials: credentials, Provenance: provenance, EgressDestinations: egress, + TokenPassthroughTargets: tokenPassthroughTargets, Credentials: credentials, Provenance: provenance, EgressDestinations: egress, }, Warnings: mcp.warnings, }, nil @@ -261,7 +261,7 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ return []corev1.EnvVar{ {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), - }, []string{"https://" + vertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil + }, []string{"https://" + translator.VertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil default: return nil, nil, translator.NewValidationError("Claude does not support ModelConfig provider %q", model.Spec.Provider) } @@ -338,17 +338,6 @@ func secretEnvironment(environmentName, secretName, key string) corev1.EnvVar { }}} } -func vertexHostname(location string) string { - switch location { - case "global": - return "aiplatform.googleapis.com" - case "us", "eu": - return "aiplatform." + location + ".rep.googleapis.com" - default: - return location + "-aiplatform.googleapis.com" - } -} - type provenanceEntry struct { APIVersion string `json:"apiVersion"` Kind string `json:"kind"` diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index c8370aefc6..370a0f4338 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -130,7 +130,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Codex revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, nil, environment) if err != nil { return nil, err } @@ -146,7 +146,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) SkillImages: skills.Images, Environment: environment, ConfigJSON: configJSON, AgentCard: card, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - TokenPassthroughOrigins: tokenPassthroughOrigins, Credentials: credentials, Provenance: provenance, EgressDestinations: egress, + TokenPassthroughTargets: tokenPassthroughTargets, Credentials: credentials, Provenance: provenance, EgressDestinations: egress, }, Warnings: mcp.warnings, }, nil diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index cf5ac41053..bbd34ca02b 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -26,6 +26,7 @@ import ( claudetranslator "github.com/kagent-dev/kagent/go/core/internal/translator/claude" codextranslator "github.com/kagent-dev/kagent/go/core/internal/translator/codex" kagenttranslator "github.com/kagent-dev/kagent/go/core/internal/translator/kagent" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" "github.com/kagent-dev/kagent/go/pkg/tracing" ) @@ -1023,3 +1024,18 @@ func TestResolveModelConfigMistral(t *testing.T) { func testRuntimeDefaults() *configv1.RuntimeDefaultsConfig { return &configv1.RuntimeDefaultsConfig{Cpu: new("1"), Memory: new("1Gi"), ControlPlaneUrl: new("http://kagent-controller.kagent:8083"), GatewayUrl: new("http://kagent-controller.kagent:8083"), Logging: &configv1.LoggingConfig{Level: new("info")}} } + +func TestRevisionDigestIncludesTokenPassthroughTargets(t *testing.T) { + revision := translator.Revision{AgentName: "agent"} + digest := func() translator.RevisionID { + id, err := revision.Digest() + require.NoError(t, err) + return id + } + without := digest() + revision.TokenPassthroughTargets = []egresspolicy.GatewayTarget{{Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}} + bearer := digest() + revision.TokenPassthroughTargets[0].Header = "x-api-key" + require.NotEqual(t, without, bearer) + require.NotEqual(t, bearer, digest(), "the header is part of revision identity") +} diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 00ec89fa49..44a67275f1 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -2,7 +2,9 @@ package translator import ( "fmt" + "maps" "net/url" + "slices" "strings" corev1 "k8s.io/api/core/v1" @@ -10,6 +12,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/models" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" "github.com/kagent-dev/kagent/go/core/pkg/env" ) @@ -17,15 +20,76 @@ import ( // The gateway overwrites the corresponding header with the current Secret. const CredentialPlaceholder = "kagent-credential-injected" +// passthroughClient sends the caller's token; an empty header means no target. +type passthroughClient struct { + client, endpoint, header, prefix string + // explicit lists the headers the client sets itself. + explicit []string +} + +// passthroughTargets records each client's token header. The origin must not +// serve clients that did not opt in. +func passthroughTargets(clients []passthroughClient, directEndpoints []string) ([]egresspolicy.GatewayTarget, error) { + direct := map[string]bool{} + if len(clients) != 0 { + for _, endpoint := range directEndpoints { + // IP literals fail to parse and cannot match a DNS-only target. + if origin, err := canonicalOrigin(endpoint); err == nil { + direct[origin.String()] = true + } + } + } + var targets []egresspolicy.GatewayTarget + explicit := map[string]bool{} + for _, client := range clients { + if client.header == "" { + return nil, NewValidationError("%s tokenPassthrough has no credential target", client.client) + } + origin, err := canonicalOrigin(client.endpoint) + if err != nil { + return nil, err + } + if direct[origin.String()] { + return nil, NewValidationError("destination %q mixes tokenPassthrough with clients that do not use it", origin) + } + for _, header := range client.explicit { + explicit[origin.String()+"\x00"+strings.ToLower(header)] = true + } + targets = append(targets, egresspolicy.GatewayTarget{Origin: origin.String(), Header: client.header, Prefix: client.prefix}) + } + for _, target := range targets { + if explicit[target.Origin+"\x00"+target.Header] { + return nil, NewValidationError("destination %q has an explicit %q header that tokenPassthrough replaces", target.Origin, target.Header) + } + } + targets, err := egress.CanonicalGatewayTargets(targets) + if err != nil { + return nil, NewValidationError("%v", err) + } + return targets, nil +} + +func canonicalOrigin(endpoint string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return nil, NewValidationError("invalid passthrough credential destination") + } + origin, err := egress.ParseOrigin(egress.Origin(u)) + if err != nil { + return nil, NewValidationError("invalid passthrough credential destination: %v", err) + } + return origin, nil +} + // CompileCredentials replaces secret-backed environment values with inert SDK // placeholders and compiles their destination-scoped gateway bindings. Models // outside the agent tree (such as memory embeddings) are supplied separately. -func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar) ([]corev1.EnvVar, []egress.Credential, []string, error) { +func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar) ([]corev1.EnvVar, []egress.Credential, []egresspolicy.GatewayTarget, error) { var bindings []egress.Credential - var tokenPassthroughOrigins []string boundModels := map[string]bool{} boundMCP := map[string]bool{} - var passthroughEndpoints []string + var passthrough []passthroughClient + var directEndpoints []string bind := func(rawURL, header, prefix, namespace, name, key string) error { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { @@ -43,7 +107,16 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } for _, tool := range agent.MCPTools { if tool.Server.Spec.TokenPassthrough { - passthroughEndpoints = append(passthroughEndpoints, tool.Server.Spec.URL) + var explicit []string + for _, ref := range tool.Server.Spec.HeadersFrom { + explicit = append(explicit, ref.Name) + } + passthrough = append(passthrough, passthroughClient{ + client: fmt.Sprintf("%s %q", tool.Binding.Server.Kind, tool.Server.Name), endpoint: tool.Server.Spec.URL, + header: "authorization", prefix: "Bearer ", explicit: explicit, + }) + } else { + directEndpoints = append(directEndpoints, tool.Server.Spec.URL) } for _, ref := range tool.Server.Spec.HeadersFrom { if ref.ValueFrom != nil && ref.ValueFrom.Type == v1alpha3.SecretValueSource { @@ -92,28 +165,26 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, boundModels[name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true } for _, resolved := range models { - if !resolved.Config.Spec.TokenPassthrough { + spec := resolved.Config.Spec + if !spec.TokenPassthrough { + directEndpoints = append(directEndpoints, modelEndpoints(resolved, environment)...) continue } - _, endpoint, _, _ := modelCredentialTarget(resolved) - passthroughEndpoints = append(passthroughEndpoints, endpoint) + _, endpoint, header, prefix := modelCredentialTarget(resolved) + passthrough = append(passthrough, passthroughClient{ + client: fmt.Sprintf("ModelConfig %q", resolved.Config.Name), endpoint: endpoint, header: header, prefix: prefix, + explicit: slices.Collect(maps.Keys(spec.DefaultHeaders)), + }) } - for _, endpoint := range passthroughEndpoints { - u, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil { - return nil, nil, nil, NewValidationError("invalid passthrough credential destination") - } - origin, err := egress.ParseOrigin(egress.Origin(u)) - if err != nil { - return nil, nil, nil, NewValidationError("invalid passthrough credential destination: %v", err) - } - tokenPassthroughOrigins = append(tokenPassthroughOrigins, origin.String()) + targets, err := passthroughTargets(passthrough, directEndpoints) + if err != nil { + return nil, nil, nil, err } var destinations []string for _, binding := range bindings { destinations = append(destinations, binding.Origin) } - access, err := egress.CanonicalAccess(destinations, bindings, tokenPassthroughOrigins) + access, err := egress.CanonicalAccess(destinations, bindings, targets) if err != nil { return nil, nil, nil, NewValidationError("%v", err) } @@ -130,7 +201,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } result[i].Value, result[i].ValueFrom = CredentialPlaceholder, nil } - return result, access.Credentials, tokenPassthroughOrigins, nil + return result, access.Credentials, access.TokenPassthroughTargets, nil } func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, header, prefix string) { @@ -192,3 +263,61 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade } return name, endpoint, header, prefix } + +// modelEndpoints returns every endpoint a model client calls, keyed or not. +func modelEndpoints(resolved *ResolvedModelConfig, environment []corev1.EnvVar) []string { + spec := resolved.Config.Spec + if spec.Provider == v1alpha3.ModelProviderOllama { + // Go ADK Ollama clients share one host and key across models. + var host string + var hasCredential bool + for _, variable := range environment { + switch variable.Name { + case env.OllamaAPIBase.Name(): + host = variable.Value + case env.OllamaAPIKey.Name(): + hasCredential = variable.Value != "" || variable.ValueFrom != nil + } + } + if host != "" { + return []string{host} + } + if models.OllamaReachesCloud(spec.Model, host, hasCredential) { + return []string{"https://api.ollama.com"} + } + return nil + } + var endpoints []string + if _, endpoint, _, _ := modelCredentialTarget(resolved); endpoint != "" { + endpoints = append(endpoints, endpoint) + } + if spec.Provider == v1alpha3.ModelProviderSAPAICore && spec.SAPAICore != nil { + endpoints = append(endpoints, spec.SAPAICore.BaseURL) + if spec.SAPAICore.AuthURL != "" { + endpoints = append(endpoints, spec.SAPAICore.AuthURL) + } + } + var vertex *v1alpha3.BaseVertexAIConfig + switch { + case spec.Provider == v1alpha3.ModelProviderGeminiVertexAI && spec.GeminiVertexAI != nil: + vertex = &spec.GeminiVertexAI.BaseVertexAIConfig + case spec.Provider == v1alpha3.ModelProviderAnthropicVertexAI && spec.AnthropicVertexAI != nil: + vertex = &spec.AnthropicVertexAI.BaseVertexAIConfig + } + if vertex != nil { + endpoints = append(endpoints, "https://"+VertexHostname(vertex.Location), "https://oauth2.googleapis.com") + } + return endpoints +} + +// VertexHostname returns the Vertex AI API host for a location. +func VertexHostname(location string) string { + switch location { + case "global": + return "aiplatform.googleapis.com" + case "us", "eu": + return "aiplatform." + location + ".rep.googleapis.com" + default: + return location + "-aiplatform.googleapis.com" + } +} diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index d130a02b31..f3c9f462af 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -8,6 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) func TestCompileCredentialDestinations(t *testing.T) { @@ -79,7 +80,7 @@ func TestCompileCredentialsPreservesPassthrough(t *testing.T) { Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "auth", APIKeySecretKey: "token", }).Root}} environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}) - require.ErrorContains(t, err, "cannot combine caller-token passthrough") + require.ErrorContains(t, err, "mixes tokenPassthrough with clients that do not use it") require.Nil(t, environment) } @@ -176,11 +177,11 @@ func TestModelCredentialTargetOllamaCloud(t *testing.T) { func TestPassthroughCredentialUsesCanonicalOrigin(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true, OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://API.OPENAI.COM.:0443/v1"}}) - environment, credentials, origins, err := CompileCredentials(input, nil, nil) + environment, credentials, targets, err := CompileCredentials(input, nil, nil) require.NoError(t, err) require.Empty(t, environment) require.Empty(t, credentials) - require.Equal(t, []string{"https://api.openai.com:443"}, origins) + require.Equal(t, []egresspolicy.GatewayTarget{{Origin: "https://api.openai.com:443", Header: "authorization", Prefix: "Bearer "}}, targets) } func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { @@ -189,9 +190,9 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }} input := credentialInput(v1alpha3.ModelConfigSpec{}) input.Root.MCPTools = []ResolvedMCPTool{{Server: passthrough}} - _, _, origins, err := CompileCredentials(input, nil, nil) + _, _, targets, err := CompileCredentials(input, nil, nil) require.NoError(t, err) - require.Equal(t, []string{"https://mcp.example.com:443"}, origins) + require.Equal(t, []egresspolicy.GatewayTarget{{Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}}, targets) // A gateway credential on the same origin would overwrite the caller's token. keyed := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "keyed", Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ @@ -202,6 +203,115 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }} input.Root.MCPTools = append(input.Root.MCPTools, ResolvedMCPTool{Server: keyed}) environment, _, _, err := CompileCredentials(input, nil, nil) - require.ErrorContains(t, err, "cannot combine caller-token passthrough") + require.ErrorContains(t, err, "mixes tokenPassthrough with clients that do not use it") require.Nil(t, environment) } + +func TestCompileCredentialsTokenPassthroughTargets(t *testing.T) { + server := func(name, url string, passthrough bool) ResolvedMCPTool { + return ResolvedMCPTool{Server: &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{URL: url, TokenPassthrough: passthrough}}} + } + model := func(spec v1alpha3.ModelConfigSpec) *ResolvedModelConfig { + return credentialInput(spec).Root.ResolvedModelConfig + } + passthroughOpenAI := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true} + for _, test := range []struct { + name string + spec v1alpha3.ModelConfigSpec + tools []ResolvedMCPTool + extra []*ResolvedModelConfig + environment []corev1.EnvVar + want []egresspolicy.GatewayTarget + wantErr string + }{ + { + name: "MCP and model", spec: passthroughOpenAI, tools: []ResolvedMCPTool{server("user", "https://MCP.Example.com.:0443/mcp", true)}, + want: []egresspolicy.GatewayTarget{ + {Origin: "https://api.openai.com:443", Header: "authorization", Prefix: "Bearer "}, + {Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}, + }, + }, + { + name: "auxiliary model", extra: []*ResolvedModelConfig{model(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderMistral, TokenPassthrough: true})}, + want: []egresspolicy.GatewayTarget{{Origin: "https://api.mistral.ai:443", Header: "authorization", Prefix: "Bearer "}}, + }, + { + name: "Bedrock", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, TokenPassthrough: true, Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1"}}, + want: []egresspolicy.GatewayTarget{{Origin: "https://bedrock-runtime.us-east-1.amazonaws.com:443", Header: "authorization", Prefix: "Bearer "}}, + }, + { + name: "Secret header on the passthrough origin", + tools: []ResolvedMCPTool{{Server: &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "user", Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.com/mcp", TokenPassthrough: true, + HeadersFrom: []v1alpha3.ValueRef{{Name: "X-Tenant-Key", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "mcp", Key: "token"}}}, + }}}}, + environment: []corev1.EnvVar{credentialEnv("KAGENT_CREDENTIAL_TENANT", "mcp", "token")}, + wantErr: "cannot combine caller-token passthrough", + }, + {name: "HTTP origin", tools: []ResolvedMCPTool{server("user", "http://mcp.example.com/mcp", true)}, want: []egresspolicy.GatewayTarget{{Origin: "http://mcp.example.com:80", Header: "authorization", Prefix: "Bearer "}}}, + {name: "no gateway target", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderSAPAICore, TokenPassthrough: true, SAPAICore: &v1alpha3.SAPAICoreConfig{BaseURL: "https://sap.example.com"}}, wantErr: "has no credential target"}, + { + name: "MCP without passthrough", tools: []ResolvedMCPTool{server("user", "https://mcp.example.com/mcp", true), server("shared", "https://mcp.example.com/other", false)}, + wantErr: "mixes tokenPassthrough", + }, + { + name: "keyless custom Ollama", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOllama, Model: "llama3", Ollama: &v1alpha3.OllamaConfig{Host: "https://mcp.example.com"}}, + environment: []corev1.EnvVar{{Name: "OLLAMA_API_BASE", Value: "https://mcp.example.com"}}, + tools: []ResolvedMCPTool{server("user", "https://mcp.example.com/mcp", true)}, wantErr: "mixes tokenPassthrough", + }, + { + name: "keyless Vertex model", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-flash", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-central1"}}}, + tools: []ResolvedMCPTool{server("user", "https://us-central1-aiplatform.googleapis.com/mcp", true)}, wantErr: "mixes tokenPassthrough", + }, + { + name: "Vertex token endpoint", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"}}}, + tools: []ResolvedMCPTool{server("user", "https://oauth2.googleapis.com/mcp", true)}, wantErr: "mixes tokenPassthrough", + }, + { + name: "SAP auth endpoint", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderSAPAICore, SAPAICore: &v1alpha3.SAPAICoreConfig{BaseURL: "https://sap.example.com", AuthURL: "https://mcp.example.com/oauth/token"}}, + tools: []ResolvedMCPTool{server("user", "https://mcp.example.com/mcp", true)}, wantErr: "mixes tokenPassthrough", + }, + { + name: "keyless auxiliary model", tools: []ResolvedMCPTool{server("user", "https://mcp.example.com/mcp", true)}, + extra: []*ResolvedModelConfig{model(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://mcp.example.com/v1"}})}, wantErr: "mixes tokenPassthrough", + }, + { + name: "IP destination beside a target", tools: []ResolvedMCPTool{server("user", "https://mcp.example.com/mcp", true), server("ip", "https://192.0.2.1/mcp", false)}, + want: []egresspolicy.GatewayTarget{{Origin: "https://mcp.example.com:443", Header: "authorization", Prefix: "Bearer "}}, + }, + { + name: "explicit target header", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true, DefaultHeaders: map[string]string{"Authorization": "Bearer static"}}, + wantErr: "explicit \"authorization\" header", + }, + { + name: "explicit header on another client", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, TokenPassthrough: true, Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "https://same.example.com"}}, + tools: []ResolvedMCPTool{{Server: &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "user", Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://SAME.example.com.:0443/mcp", TokenPassthrough: true, HeadersFrom: []v1alpha3.ValueRef{{Name: "X-API-Key", Value: "static"}}, + }}}}, + wantErr: "explicit \"x-api-key\" header", + }, + { + name: "keyless Ollama stays local beside cloud MCP", spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOllama, Model: "minimax-m3:cloud", Ollama: &v1alpha3.OllamaConfig{}}, + tools: []ResolvedMCPTool{server("user", "https://api.ollama.com/mcp", true)}, + want: []egresspolicy.GatewayTarget{{Origin: "https://api.ollama.com:443", Header: "authorization", Prefix: "Bearer "}}, + }, + } { + t.Run(test.name, func(t *testing.T) { + input := credentialInput(test.spec) + input.Root.MCPTools = test.tools + extra := test.extra + if extra != nil { + extra = append(extra, input.Root.ResolvedModelConfig) + } + _, _, targets, err := CompileCredentials(input, extra, test.environment) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + var validation *ValidationError + require.ErrorAs(t, err, &validation) + return + } + require.NoError(t, err) + require.Equal(t, test.want, targets) + }) + } +} diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index 3593770b7e..25f6f8ae4b 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -63,7 +63,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughTargets, err := translator.CompileCredentials(input, compiled.Models, environment) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) Environment: environment, ConfigJSON: configJSON, AgentCard: card, SkillImages: compiled.SkillImages, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - TokenPassthroughOrigins: tokenPassthroughOrigins, Credentials: credentials, Provenance: provenance, EgressDestinations: compiled.Egress, + TokenPassthroughTargets: tokenPassthroughTargets, Credentials: credentials, Provenance: provenance, EgressDestinations: compiled.Egress, }}, nil } diff --git a/go/core/internal/translator/revision.go b/go/core/internal/translator/revision.go index 6fd52592e9..c50085176a 100644 --- a/go/core/internal/translator/revision.go +++ b/go/core/internal/translator/revision.go @@ -13,6 +13,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/kagent-dev/kagent/go/core/internal/egress" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) const shortRevisionBytes = 6 @@ -67,8 +68,8 @@ type Revision struct { Provenance json.RawMessage // Credentials contains references resolved by the egress gateway. Credentials []egress.Credential - // TokenPassthroughOrigins cannot receive gateway credentials, including live grants. - TokenPassthroughOrigins []string + // TokenPassthroughTargets reserve their origins against Secret credentials. + TokenPassthroughTargets []egresspolicy.GatewayTarget // EgressDestinations is the HTTP(S) origin allowlist required by this revision. EgressDestinations []string } @@ -95,23 +96,23 @@ func (r *Revision) Digest() (RevisionID, error) { return RevisionID{}, fmt.Errorf("unsupported sandbox class %q", sandboxClass) } raw, err := json.Marshal(struct { - AgentName string `json:"agentName"` - AgentUID string `json:"agentUID"` - Namespace string `json:"namespace"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - Image string `json:"image"` - Command []string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - Environment []corev1.EnvVar `json:"environment"` - ConfigJSON json.RawMessage `json:"config"` - SkillImages []SkillImage `json:"skillImages,omitempty"` - WorkerPoolName string `json:"workerPoolName"` - SnapshotLocation string `json:"snapshotLocation"` - Credentials []egress.Credential `json:"credentials,omitempty"` - TokenPassthroughOrigins []string `json:"tokenPassthroughOrigins,omitempty"` - EgressDestinations []string `json:"egressDestinations"` - SandboxClass atev1alpha1.SandboxClass `json:"sandboxClass"` + AgentName string `json:"agentName"` + AgentUID string `json:"agentUID"` + Namespace string `json:"namespace"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Image string `json:"image"` + Command []string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Environment []corev1.EnvVar `json:"environment"` + ConfigJSON json.RawMessage `json:"config"` + SkillImages []SkillImage `json:"skillImages,omitempty"` + WorkerPoolName string `json:"workerPoolName"` + SnapshotLocation string `json:"snapshotLocation"` + Credentials []egress.Credential `json:"credentials,omitempty"` + TokenPassthroughTargets []egresspolicy.GatewayTarget `json:"tokenPassthroughTargets,omitempty"` + EgressDestinations []string `json:"egressDestinations"` + SandboxClass atev1alpha1.SandboxClass `json:"sandboxClass"` // Placement now matches the pool's namespace as well as its name. // Hash it explicitly so existing immutable templates do not conflict after upgrades. @@ -122,7 +123,7 @@ func (r *Revision) Digest() (RevisionID, error) { Image: r.Image, Command: r.Command, Args: r.Args, Environment: r.Environment, ConfigJSON: r.ConfigJSON, SkillImages: r.SkillImages, WorkerPoolName: r.WorkerPoolName, SnapshotLocation: r.SnapshotLocation, - TokenPassthroughOrigins: r.TokenPassthroughOrigins, Credentials: r.Credentials, EgressDestinations: r.EgressDestinations, + TokenPassthroughTargets: r.TokenPassthroughTargets, Credentials: r.Credentials, EgressDestinations: r.EgressDestinations, SandboxClass: sandboxClass, WorkerPoolNamespace: r.Namespace, }) if err != nil { diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 2e84773734..6e78d5658b 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -68,6 +68,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/version" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/kagent-dev/kagent/go/core/pkg/config" + "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" "github.com/kagent-dev/kagent/go/core/pkg/migrations" "github.com/kagent-dev/kagent/go/core/pkg/postgres" "github.com/kagent-dev/kagent/go/pkg/logging" @@ -118,6 +119,9 @@ type Options struct { // its own surface here; letting it reclassify core's would turn a config // field into a way to make an authenticated method public. MethodPolicies map[string]auth.AccessMode + // EgressPolicyCompilers bind tokenPassthrough targets; with any registered, + // all must be bound. Authenticator must not forward the caller token. + EgressPolicyCompilers []egresspolicy.Compiler } // resolve substitutes core's defaults for whichever components the caller left @@ -277,11 +281,12 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er return err } actors, err := substrate.Dial(ctx, substrate.Config{ - EgressPolicies: store, - AteAPIEndpoint: cfg.GetSubstrate().GetApiEndpoint(), - CAFile: cfg.GetSubstrate().GetCaFile(), - ClientCertFile: cfg.GetSubstrate().GetClientCertFile(), - CallTimeout: 30 * time.Second, + EgressPolicies: store, + EgressPolicyCompilers: opts.EgressPolicyCompilers, + AteAPIEndpoint: cfg.GetSubstrate().GetApiEndpoint(), + CAFile: cfg.GetSubstrate().GetCaFile(), + ClientCertFile: cfg.GetSubstrate().GetClientCertFile(), + CallTimeout: 30 * time.Second, }) if err != nil { return err diff --git a/go/core/pkg/egresspolicy/egresspolicy.go b/go/core/pkg/egresspolicy/egresspolicy.go new file mode 100644 index 0000000000..0f51b22ca0 --- /dev/null +++ b/go/core/pkg/egresspolicy/egresspolicy.go @@ -0,0 +1,28 @@ +// Package egresspolicy lets an embedding controller extend actor EgressPolicies. +package egresspolicy + +import ( + "context" + + "go.ate.dev/substrate/pkg/proto/ateapipb" +) + +// GatewayTarget is the header a tokenPassthrough client sends the caller's +// token in. Origins are canonical (https://host:443) and headers lowercase. +type GatewayTarget struct { + Origin string `json:"origin"` + Header string `json:"header"` + Prefix string `json:"prefix,omitempty"` +} + +// Input is the persisted state a Compiler reads for one actor. +type Input struct { + Atespace string + GatewayTargets []GatewayTarget +} + +// Compiler extends the built-in policy and may change anything in it. Compilers +// run in order; afterwards every target must have a header on its origin. +type Compiler interface { + Compile(context.Context, Input, *ateapipb.EgressPolicy) error +} diff --git a/go/core/pkg/migrations/core/000001_initial.sql b/go/core/pkg/migrations/core/000001_initial.sql index 8859a1eb9b..16cb6312c9 100644 --- a/go/core/pkg/migrations/core/000001_initial.sql +++ b/go/core/pkg/migrations/core/000001_initial.sql @@ -34,7 +34,7 @@ CREATE TABLE runtime_revision ( source_snapshot JSONB NOT NULL, egress_destinations TEXT[] NOT NULL DEFAULT '{}', credentials JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(credentials) = 'array'), - token_passthrough_origins TEXT[] NOT NULL DEFAULT '{}', + token_passthrough_targets JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(token_passthrough_targets) = 'array'), actor_template_atespace TEXT CONSTRAINT runtime_revision_actor_template_namespace_not_null NOT NULL, actor_template_name TEXT NOT NULL, actor_template_uid TEXT NOT NULL DEFAULT '', diff --git a/go/core/pkg/migrations/runner_test.go b/go/core/pkg/migrations/runner_test.go index 019b448a56..6cc9564c6d 100644 --- a/go/core/pkg/migrations/runner_test.go +++ b/go/core/pkg/migrations/runner_test.go @@ -323,8 +323,8 @@ func TestBuiltinMigrationsRoundTrip(t *testing.T) { t.Fatalf("%s versions = %v, want %v", source.Name, versions, expected) } } - if !testColumnExists(t, dsn, "runtime_revision", "token_passthrough_origins") || !testColumnExists(t, dsn, "agent_runtime_revision", "token_passthrough_origins") { - t.Fatal("runtime and agent revisions must expose passthrough origins") + if !testColumnExists(t, dsn, "runtime_revision", "token_passthrough_targets") || !testColumnExists(t, dsn, "agent_runtime_revision", "token_passthrough_targets") { + t.Fatal("runtime and agent revisions must expose passthrough targets") } for _, table := range []string{"agent_definition", "sandbox_template_definition"} { if !testColumnExists(t, dsn, table, "grant_destinations") || !testColumnExists(t, dsn, table, "grant_credentials") {