diff --git a/README.md b/README.md index 917d532..6ae4afc 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ See `convert/testdata/*/input.yaml` for worked examples. |---|---| | Model | One **route per (provider endpoint, capability)** under a single shared `ai-gateway` Service, with the path derived from the model's `formats[0].type` (llm_format) + capability via the endpoint table. Each route gets an `ai-proxy-advanced` plugin (`route:` FK) — models that resolve to the same endpoint share one route, contributing one `targets[]` entry each. Body-model routes also get an `ai-model-selector` plugin. One `ai-models` entry (`name` + `alias`) is emitted per model. | | Provider | Not a standalone entity. Its `type` and `config.auth` populate each referencing target's `model.provider`, `model.options`, and `auth`. | -| MCP Server | Service + Route + `ai-mcp-proxy` (`config.mode` = source type). Server ACLs / per-tool ACLs are written into the plugin config (`default_acl`, `tools[].acl`), not Kong `acl` plugins. `access.identity_providers` + `access.metadata` (openid-connect) add an `ai-mcp-oauth2` plugin and append `metadata.endpoint` to the route (listener / conversion-listener / passthrough-listener only); a `key-auth` provider adds a `key-auth` plugin (and is rejected if `metadata` is set). | -| Agent (`a2a`) | Service (`config.url`) + Route + `ai-a2a-proxy` plugin (logging). | +| MCP Server | Service + Route + `ai-mcp-proxy` (`config.mode` = source type). Server ACLs / per-tool ACLs are written into the plugin config (`default_acl`, `tools[].acl`), not Kong `acl` plugins. `access.identity_providers` + `access.metadata` (openid-connect) add an `ai-mcp-oauth2` plugin and append `metadata.endpoint` to the route (listener / conversion-listener / passthrough-listener only); a `key-auth` provider adds a `key-auth` plugin (and is rejected if `metadata` is set). `config.upstream.auth` (AWS SigV4) lowers to the plugin's `auth` record. | +| Agent (`a2a`) | Service (`config.url`) + Route + `ai-a2a-proxy` plugin (logging). `config.upstream.auth` (AWS SigV4) lowers to the plugin's `auth` record, and `config.proxy` to `proxy_config`. | | Agent (`http`) | Service (`config.url`) + Route, no AI plugin. | | Policy | Kong plugin (`name` = policy `type`, config passed through). `global: true` -> one top-level plugin; otherwise instantiated per referencing entity. | | Consumer | Consumer (`username` = name, `custom_id`), `groups` membership, nested `keyauth_credentials`, scoped policy plugins. | @@ -171,6 +171,11 @@ Lossy by design (the forward direction never emits them): `display_name`, credential types are warned about and skipped. - **MCP upstream.** Passthrough MCP servers without an `upstream_url` get a placeholder host and a warning. +- **Upstream auth.** Agents and MCP Servers carry `config.upstream.auth` (AWS + SigV4, `type: aws`), which maps to the `ai-a2a-proxy` / `ai-mcp-proxy` `auth` + record (`provider: aws_iam`, nested `aws_iam` options). Unsupported auth types + are warned about and dropped. The plugin's `aws_iam.bearer_token` has no AI + Gateway representation, so the reverse direction warns and drops it. - **MCP OAuth2.** MCP `access.identity_providers` / `access.metadata` round-trips in both directions. An openid-connect provider lowers its client credentials plus the identically-typed / unambiguous fields onto the `ai-mcp-oauth2` diff --git a/convert/agent.go b/convert/agent.go index e62d51d..dcc644e 100644 --- a/convert/agent.go +++ b/convert/agent.go @@ -1,6 +1,8 @@ package convert import ( + "fmt" + "github.com/Kong/ai-deck-converter/internal/aigw" "github.com/Kong/ai-deck-converter/internal/aimap" "github.com/Kong/ai-deck-converter/internal/kong" @@ -33,8 +35,14 @@ func (c *Converter) convertAgents() error { switch a.Type { case "a2a": - plugin := a2aPlugin(a) - plugin.Source = source("agent", a.Name, "config") + plugin, err := c.a2aPlugin(a) + if err != nil { + return err + } + plugin.Source = source("agent", a.Name, "config", + kong.FieldMapping{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"}, + kong.FieldMapping{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"}, + ) route.Plugins = append(route.Plugins, plugin) case "http": // plain HTTP proxy: Service + Route only @@ -67,7 +75,7 @@ func (c *Converter) convertAgents() error { return nil } -func a2aPlugin(a *aigw.Agent) kong.Plugin { +func (c *Converter) a2aPlugin(a *aigw.Agent) (kong.Plugin, error) { cfg := map[string]any{} if logging := loggingBlock(withLoggingDefaults(a.Config.Logging, false, true)); logging != nil { // log_audits is an ai-mcp-proxy field; the ai-a2a-proxy schema has no @@ -80,7 +88,17 @@ func a2aPlugin(a *aigw.Agent) kong.Plugin { if a.Config.MaxRequestBodySize != nil { cfg["max_request_body_size"] = *a.Config.MaxRequestBodySize } - return kong.Plugin{Name: "ai-a2a-proxy", Config: cfg} + if pc := proxyConfigBlock(a.Config.Proxy); pc != nil { + cfg["proxy_config"] = pc + } + auth, err := c.upstreamAuthBlock(a.Config.Upstream, fmt.Sprintf("agent %q", a.Name)) + if err != nil { + return kong.Plugin{}, err + } + if auth != nil { + cfg["auth"] = auth + } + return kong.Plugin{Name: "ai-a2a-proxy", Config: cfg}, nil } // withLoggingDefaults returns a copy of l with statistics/payloads defaulted diff --git a/convert/convert_test.go b/convert/convert_test.go index 7a6120d..bad7d14 100644 --- a/convert/convert_test.go +++ b/convert/convert_test.go @@ -102,6 +102,7 @@ mcp_servers: {GeneratedPrefix: "config.mode", SourcePrefix: "type"}, {GeneratedPrefix: "config.tools", SourcePrefix: "tools"}, {GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"}, + {GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"}, {GeneratedPrefix: "config.default_acl", SourcePrefix: "access"}, {GeneratedPrefix: "config.acl_attribute_type", SourcePrefix: "access.acl_attribute_type"}, {GeneratedPrefix: "config.access_token_claim_field", SourcePrefix: "access.access_token_claim_field"}, @@ -146,6 +147,10 @@ agents: EntityType: "agent", EntityName: "booking-agent", FieldPrefix: "config", + FieldMappings: []FieldMapping{ + {GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"}, + {GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"}, + }, }}, metadata.Plugins) } @@ -936,8 +941,10 @@ func TestA2APluginDropsLogAudits(t *testing.T) { }, }, } - logging, ok := a2aPlugin(a).Config["logging"].(map[string]any) - require.True(t, ok, "expected logging block, got %v", a2aPlugin(a).Config["logging"]) + plugin, err := (&Converter{}).a2aPlugin(a) + require.NoError(t, err) + logging, ok := plugin.Config["logging"].(map[string]any) + require.True(t, ok, "expected logging block, got %v", plugin.Config["logging"]) require.NotContains(t, logging, "log_audits", "ai-a2a-proxy must not emit log_audits, got %v", logging) require.Equal(t, true, logging["log_statistics"], "expected log_statistics true") } diff --git a/convert/mcp.go b/convert/mcp.go index 800660c..91dc691 100644 --- a/convert/mcp.go +++ b/convert/mcp.go @@ -1,6 +1,8 @@ package convert import ( + "fmt" + "github.com/Kong/ai-deck-converter/internal/aigw" "github.com/Kong/ai-deck-converter/internal/kong" ) @@ -24,6 +26,7 @@ func (c *Converter) convertMCPServers() error { kong.FieldMapping{GeneratedPrefix: "config.mode", SourcePrefix: "type"}, kong.FieldMapping{GeneratedPrefix: "config.tools", SourcePrefix: "tools"}, kong.FieldMapping{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"}, + kong.FieldMapping{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"}, kong.FieldMapping{GeneratedPrefix: "config.default_acl", SourcePrefix: "access"}, kong.FieldMapping{GeneratedPrefix: "config.acl_attribute_type", SourcePrefix: "access.acl_attribute_type"}, kong.FieldMapping{ @@ -99,6 +102,15 @@ func (c *Converter) mcpPlugin(m *aigw.MCPServer) (kong.Plugin, error) { if pc := proxyConfigBlock(m.Config.Proxy); pc != nil { cfg["proxy_config"] = pc } + // Upstream authentication (e.g. AWS SigV4) lowers to the plugin's auth + // record; only emitted when set. + auth, err := c.upstreamAuthBlock(m.Config.Upstream, fmt.Sprintf("MCP server %q", m.Name)) + if err != nil { + return kong.Plugin{}, err + } + if auth != nil { + cfg["auth"] = auth + } // tools_cache_ttl_seconds is required by the plugin in upstream-server mode. if m.Config.ToolsCacheTTLSeconds != nil { cfg["tools_cache_ttl_seconds"] = *m.Config.ToolsCacheTTLSeconds diff --git a/convert/testdata/40_agent_upstream_auth_aws/expected.yaml b/convert/testdata/40_agent_upstream_auth_aws/expected.yaml new file mode 100644 index 0000000..ca6651a --- /dev/null +++ b/convert/testdata/40_agent_upstream_auth_aws/expected.yaml @@ -0,0 +1,30 @@ +_format_version: "3.0" +services: + - name: booking-agent + url: https://booking-agent.internal:8443/a2a + routes: + - name: booking-route + paths: + - /agents/booking + methods: + - POST + plugins: + - name: ai-a2a-proxy + config: + auth: + aws_iam: + assume_role_arn: arn:aws:iam::123456789012:role/agentcore + aws_access_key_id: AKIAIOSFODNN7EXAMPLE + aws_region: us-east-1 + aws_secret_access_key: '{vault://env/aws-secret}' + aws_session_token: '{vault://env/aws-session}' + role_session_name: kong-a2a + sts_endpoint_url: https://sts.us-east-1.amazonaws.com + provider: aws_iam + logging: + log_payloads: false + log_statistics: true + max_payload_size: 1048576 + proxy_config: + https_proxy_host: proxy.internal + https_proxy_port: 8080 diff --git a/convert/testdata/40_agent_upstream_auth_aws/input.yaml b/convert/testdata/40_agent_upstream_auth_aws/input.yaml new file mode 100644 index 0000000..79d4bf8 --- /dev/null +++ b/convert/testdata/40_agent_upstream_auth_aws/input.yaml @@ -0,0 +1,21 @@ +agents: + # a2a agent with AWS SigV4 upstream auth (e.g. an AgentCore agent) and an + # outbound HTTPS proxy. + - type: a2a + display_name: Booking Agent + name: booking-agent + config: + url: https://booking-agent.internal:8443/a2a + route: {name: booking-route, paths: [/agents/booking], methods: [POST]} + upstream: + auth: + type: aws + region: us-east-1 + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: "{vault://env/aws-secret}" + session_token: "{vault://env/aws-session}" + assume_role_arn: arn:aws:iam::123456789012:role/agentcore + role_session_name: kong-a2a + sts_endpoint_url: https://sts.us-east-1.amazonaws.com + proxy: + https_proxy: {host: proxy.internal, port: 8080} diff --git a/convert/testdata/41_mcp_upstream_auth_aws/expected.yaml b/convert/testdata/41_mcp_upstream_auth_aws/expected.yaml new file mode 100644 index 0000000..30df5e3 --- /dev/null +++ b/convert/testdata/41_mcp_upstream_auth_aws/expected.yaml @@ -0,0 +1,26 @@ +_format_version: "3.0" +services: + - name: secure-vendor-mcp + url: https://mcp.internal.kongair.com + routes: + - name: secure-vendor-mcp-route + paths: + - /mcp/secure-vendor + plugins: + - name: ai-mcp-proxy + config: + auth: + aws_iam: + aws_access_key_id: AKIAIOSFODNN7EXAMPLE + aws_region: us-west-2 + aws_secret_access_key: '{vault://env/aws-secret}' + provider: aws_iam + include_consumer_groups: true + logging: + log_audits: false + log_payloads: false + log_statistics: true + mode: passthrough-listener + tools: + - description: A vendor tool + name: vendorTool diff --git a/convert/testdata/41_mcp_upstream_auth_aws/input.yaml b/convert/testdata/41_mcp_upstream_auth_aws/input.yaml new file mode 100644 index 0000000..6fc08a4 --- /dev/null +++ b/convert/testdata/41_mcp_upstream_auth_aws/input.yaml @@ -0,0 +1,18 @@ +mcp_servers: + # passthrough MCP server fronting an AWS-signed upstream (AWS SigV4 upstream + # auth). + - type: passthrough-listener + display_name: Secure Vendor MCP + name: secure-vendor-mcp + upstream_url: https://mcp.internal.kongair.com + config: + route: {paths: [/mcp/secure-vendor]} + upstream: + auth: + type: aws + region: us-west-2 + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: "{vault://env/aws-secret}" + tools: + - name: vendorTool + description: A vendor tool diff --git a/convert/upstream.go b/convert/upstream.go new file mode 100644 index 0000000..3f74fcf --- /dev/null +++ b/convert/upstream.go @@ -0,0 +1,45 @@ +package convert + +import ( + "github.com/Kong/ai-deck-converter/internal/aigw" + "github.com/Kong/ai-deck-converter/internal/aimap" +) + +// upstreamAuthBlock lowers config.upstream into the `auth` record shared by the +// ai-a2a-proxy and ai-mcp-proxy plugins. It returns nil when no upstream auth +// is set, so the plugin's `provider: off` default stays implicit and the output +// remains minimal. Reversed by revert's upstreamFromConfig. +// +// entity is used only for warning messages (e.g. `agent "foo"`). +func (c *Converter) upstreamAuthBlock(u *aigw.UpstreamConfig, entity string) (map[string]any, error) { + if u == nil || u.Auth == nil { + return nil, nil + } + a := u.Auth + if a.Type != aimap.UpstreamAuthTypeAWS { + return nil, c.warn("%s: unsupported upstream auth type %q; only %q is supported", + entity, a.Type, aimap.UpstreamAuthTypeAWS) + } + + // Map the AI Gateway field names to their plugin aws_iam counterparts via + // the shared aimap table so forward and reverse can't drift. + values := map[string]string{ + "access_key_id": a.AccessKeyID, + "secret_access_key": a.SecretAccessKey, + "session_token": a.SessionToken, + "region": a.Region, + "assume_role_arn": a.AssumeRoleARN, + "role_session_name": a.RoleSessionName, + "sts_endpoint_url": a.STSEndpointURL, + } + awsIAM := map[string]any{} + for _, k := range aimap.AWSIAMAuthKeys { + setIfNotEmpty(awsIAM, k.Plugin, values[k.AIGW]) + } + + auth := map[string]any{"provider": aimap.UpstreamAuthProviderAWSIAM} + if len(awsIAM) > 0 { + auth["aws_iam"] = awsIAM + } + return auth, nil +} diff --git a/convert/upstream_test.go b/convert/upstream_test.go new file mode 100644 index 0000000..26a7609 --- /dev/null +++ b/convert/upstream_test.go @@ -0,0 +1,34 @@ +package convert + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// An unsupported upstream auth type warns (non-strict) and drops the auth +// block, and is fatal under -strict. +func TestConvertUpstreamAuthUnsupportedType(t *testing.T) { + src := []byte(` +agents: + - type: a2a + name: booking-agent + config: + url: https://booking-agent.internal/a2a + route: {paths: [/agents/booking]} + upstream: + auth: + type: gcp + region: us-east-1 +`) + + out, warnings, err := Convert(src, Options{}) + require.NoError(t, err) + require.NotEmpty(t, warnings) + require.Contains(t, strings.Join(warnings, "\n"), "unsupported upstream auth type") + require.NotContains(t, string(out), "auth:", "unsupported auth must not be emitted") + + _, _, err = Convert(src, Options{Strict: true}) + require.Error(t, err, "unsupported upstream auth type must be fatal in strict mode") +} diff --git a/internal/aigw/agent.go b/internal/aigw/agent.go index 87e8209..91a57f5 100644 --- a/internal/aigw/agent.go +++ b/internal/aigw/agent.go @@ -24,10 +24,16 @@ type AgentAccessConfig struct { IdentityProviders []string `yaml:"identity_providers,omitempty"` } -// AgentConfig holds the upstream URL, route, and logging configuration. +// AgentConfig holds the upstream URL, route, logging, upstream-auth, and proxy +// configuration. type AgentConfig struct { URL string `yaml:"url,omitempty"` Route RouteConfig `yaml:"route,omitempty"` MaxRequestBodySize *int `yaml:"max_request_body_size,omitempty"` Logging *Logging `yaml:"logging,omitempty"` + // Upstream lowers to the ai-a2a-proxy plugin's auth record (upstream + // authentication, e.g. AWS SigV4). + Upstream *UpstreamConfig `yaml:"upstream,omitempty"` + // Proxy lowers to the ai-a2a-proxy plugin's proxy_config. + Proxy *ProxyConfig `yaml:"proxy,omitempty"` } diff --git a/internal/aigw/mcp.go b/internal/aigw/mcp.go index 9e67c3a..940586c 100644 --- a/internal/aigw/mcp.go +++ b/internal/aigw/mcp.go @@ -64,6 +64,9 @@ type MCPServerConfig struct { // Proxy lowers to the ai-mcp-proxy plugin's proxy_config (only honored by // the plugin in passthrough-listener mode). Proxy *ProxyConfig `yaml:"proxy,omitempty"` + // Upstream lowers to the ai-mcp-proxy plugin's auth record (upstream + // authentication, e.g. AWS SigV4). + Upstream *UpstreamConfig `yaml:"upstream,omitempty"` // ToolsCacheTTLSeconds maps to the ai-mcp-proxy plugin's // tools_cache_ttl_seconds (required by the plugin in upstream-server mode). ToolsCacheTTLSeconds *int `yaml:"tools_cache_ttl_seconds,omitempty"` diff --git a/internal/aigw/upstream.go b/internal/aigw/upstream.go new file mode 100644 index 0000000..b5a8bba --- /dev/null +++ b/internal/aigw/upstream.go @@ -0,0 +1,23 @@ +package aigw + +// UpstreamConfig mirrors the AIGatewayUpstreamConfig schema: configuration +// applied when proxying to the upstream service, carried by Agents +// (config.upstream) and MCP Servers (config.upstream). It lowers to the `auth` +// record shared by the ai-a2a-proxy and ai-mcp-proxy plugins. +type UpstreamConfig struct { + Auth *UpstreamAuth `yaml:"auth,omitempty"` +} + +// UpstreamAuth is the authentication used when proxying to the upstream. It is +// a flat union discriminated by Type (currently only "aws" / AWS SigV4), +// matching the flat-union precedent used by ProviderAuth in provider.go. +type UpstreamAuth struct { + Type string `yaml:"type,omitempty"` // "aws" + AccessKeyID string `yaml:"access_key_id,omitempty"` + SecretAccessKey string `yaml:"secret_access_key,omitempty"` + SessionToken string `yaml:"session_token,omitempty"` + Region string `yaml:"region,omitempty"` + AssumeRoleARN string `yaml:"assume_role_arn,omitempty"` + RoleSessionName string `yaml:"role_session_name,omitempty"` + STSEndpointURL string `yaml:"sts_endpoint_url,omitempty"` +} diff --git a/internal/aimap/upstreamauth.go b/internal/aimap/upstreamauth.go new file mode 100644 index 0000000..c2e1aaa --- /dev/null +++ b/internal/aimap/upstreamauth.go @@ -0,0 +1,41 @@ +package aimap + +// Upstream-authentication mapping shared by both directions. The AI Gateway +// model carries upstream auth under `config.upstream.auth` (Agents and MCP +// Servers); it lowers to the `auth` record on the ai-a2a-proxy / ai-mcp-proxy +// plugins. Keep the type/provider constants and the field-name table here so +// the forward (convert) and reverse (revert) directions can never drift. +const ( + // UpstreamAuthTypeAWS is the AI Gateway upstream.auth.type discriminator + // for AWS SigV4 authentication. + UpstreamAuthTypeAWS = "aws" + // UpstreamAuthProviderAWSIAM is the ai-a2a-proxy / ai-mcp-proxy plugin + // auth.provider value for AWS SigV4 authentication. + UpstreamAuthProviderAWSIAM = "aws_iam" + // UpstreamAuthProviderOff is the plugin auth.provider default (no upstream + // authentication). The converter never emits it, relying on the plugin + // default instead. + UpstreamAuthProviderOff = "off" +) + +// AWSIAMAuthKey pairs an AI Gateway upstream.auth field name with its +// ai plugin auth.aws_iam counterpart. +type AWSIAMAuthKey struct { + AIGW string // AI Gateway upstream.auth field + Plugin string // plugin auth.aws_iam field +} + +// AWSIAMAuthKeys is the canonical field-name mapping between the AI Gateway +// upstream.auth block and the plugin auth.aws_iam block. Both convert and +// revert build their maps from this table so the two directions stay in sync. +// Note: the plugin's aws_iam block also carries a `bearer_token` field, which +// has no AI Gateway representation; revert warns and drops it. +var AWSIAMAuthKeys = []AWSIAMAuthKey{ + {AIGW: "access_key_id", Plugin: "aws_access_key_id"}, + {AIGW: "secret_access_key", Plugin: "aws_secret_access_key"}, + {AIGW: "session_token", Plugin: "aws_session_token"}, + {AIGW: "region", Plugin: "aws_region"}, + {AIGW: "assume_role_arn", Plugin: "assume_role_arn"}, + {AIGW: "role_session_name", Plugin: "role_session_name"}, + {AIGW: "sts_endpoint_url", Plugin: "sts_endpoint_url"}, +} diff --git a/revert/agent.go b/revert/agent.go index 2ae20a0..c164f1e 100644 --- a/revert/agent.go +++ b/revert/agent.go @@ -1,6 +1,8 @@ package revert import ( + "fmt" + "github.com/Kong/ai-deck-converter/internal/aigw" "github.com/Kong/ai-deck-converter/internal/kong" ) @@ -27,6 +29,12 @@ func (r *Reverter) revertAgent(svc *kong.Service, rt *kong.Route, name string, s a.Type = "a2a" a.Config.Logging = loggingFromBlockWithDefaults(getMap(a2a.Config, "logging"), false, true) a.Config.MaxRequestBodySize = getInt(a2a.Config, "max_request_body_size") + a.Config.Proxy = proxyFromConfig(getMap(a2a.Config, "proxy_config")) + upstream, err := r.upstreamFromConfig(getMap(a2a.Config, "auth"), fmt.Sprintf("agent %q", name)) + if err != nil { + return err + } + a.Config.Upstream = upstream } a.Config.URL = serviceURL(svc) diff --git a/revert/mcp.go b/revert/mcp.go index 46c9a4a..77456a3 100644 --- a/revert/mcp.go +++ b/revert/mcp.go @@ -1,6 +1,8 @@ package revert import ( + "fmt" + "github.com/Kong/ai-deck-converter/internal/aigw" "github.com/Kong/ai-deck-converter/internal/kong" ) @@ -49,6 +51,11 @@ func (r *Reverter) revertMCPServer(svc *kong.Service, rt *kong.Route, plugins, s m.Config.Logging = loggingFromBlockWithDefaults(getMap(cfg, "logging"), true, false) m.Config.Server = mcpServerConfigForAIGateway(getMap(cfg, "server")) m.Config.Proxy = proxyFromConfig(getMap(cfg, "proxy_config")) + upstream, err := r.upstreamFromConfig(getMap(cfg, "auth"), fmt.Sprintf("MCP server %q", svc.Name)) + if err != nil { + return err + } + m.Config.Upstream = upstream m.Config.ToolsCacheTTLSeconds = getInt(cfg, "tools_cache_ttl_seconds") // Access: the ACL attribute config and default_acl live in the plugin diff --git a/revert/testdata/26_agent_upstream_auth_aws/expected.yaml b/revert/testdata/26_agent_upstream_auth_aws/expected.yaml new file mode 100644 index 0000000..d4e39ab --- /dev/null +++ b/revert/testdata/26_agent_upstream_auth_aws/expected.yaml @@ -0,0 +1,29 @@ +agents: + - type: a2a + name: booking-agent + config: + url: https://booking-agent.internal:8443/a2a + route: + name: booking-route + paths: + - /agents/booking + methods: + - POST + logging: + payloads: false + statistics: true + max_payload_size: 1048576 + upstream: + auth: + type: aws + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: '{vault://env/aws-secret}' + session_token: '{vault://env/aws-session}' + region: us-east-1 + assume_role_arn: arn:aws:iam::123456789012:role/agentcore + role_session_name: kong-a2a + sts_endpoint_url: https://sts.us-east-1.amazonaws.com + proxy: + https_proxy: + host: proxy.internal + port: 8080 diff --git a/revert/testdata/26_agent_upstream_auth_aws/input.yaml b/revert/testdata/26_agent_upstream_auth_aws/input.yaml new file mode 100644 index 0000000..ca6651a --- /dev/null +++ b/revert/testdata/26_agent_upstream_auth_aws/input.yaml @@ -0,0 +1,30 @@ +_format_version: "3.0" +services: + - name: booking-agent + url: https://booking-agent.internal:8443/a2a + routes: + - name: booking-route + paths: + - /agents/booking + methods: + - POST + plugins: + - name: ai-a2a-proxy + config: + auth: + aws_iam: + assume_role_arn: arn:aws:iam::123456789012:role/agentcore + aws_access_key_id: AKIAIOSFODNN7EXAMPLE + aws_region: us-east-1 + aws_secret_access_key: '{vault://env/aws-secret}' + aws_session_token: '{vault://env/aws-session}' + role_session_name: kong-a2a + sts_endpoint_url: https://sts.us-east-1.amazonaws.com + provider: aws_iam + logging: + log_payloads: false + log_statistics: true + max_payload_size: 1048576 + proxy_config: + https_proxy_host: proxy.internal + https_proxy_port: 8080 diff --git a/revert/testdata/27_mcp_upstream_auth_aws/expected.yaml b/revert/testdata/27_mcp_upstream_auth_aws/expected.yaml new file mode 100644 index 0000000..9aac0e4 --- /dev/null +++ b/revert/testdata/27_mcp_upstream_auth_aws/expected.yaml @@ -0,0 +1,21 @@ +mcp_servers: + - type: passthrough-listener + name: secure-vendor-mcp + config: + route: + paths: + - /mcp/secure-vendor + logging: + payloads: false + statistics: true + audits: false + upstream: + auth: + type: aws + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: '{vault://env/aws-secret}' + region: us-west-2 + tools: + - name: vendorTool + description: A vendor tool + upstream_url: https://mcp.internal.kongair.com diff --git a/revert/testdata/27_mcp_upstream_auth_aws/input.yaml b/revert/testdata/27_mcp_upstream_auth_aws/input.yaml new file mode 100644 index 0000000..30df5e3 --- /dev/null +++ b/revert/testdata/27_mcp_upstream_auth_aws/input.yaml @@ -0,0 +1,26 @@ +_format_version: "3.0" +services: + - name: secure-vendor-mcp + url: https://mcp.internal.kongair.com + routes: + - name: secure-vendor-mcp-route + paths: + - /mcp/secure-vendor + plugins: + - name: ai-mcp-proxy + config: + auth: + aws_iam: + aws_access_key_id: AKIAIOSFODNN7EXAMPLE + aws_region: us-west-2 + aws_secret_access_key: '{vault://env/aws-secret}' + provider: aws_iam + include_consumer_groups: true + logging: + log_audits: false + log_payloads: false + log_statistics: true + mode: passthrough-listener + tools: + - description: A vendor tool + name: vendorTool diff --git a/revert/upstream.go b/revert/upstream.go new file mode 100644 index 0000000..f492c4a --- /dev/null +++ b/revert/upstream.go @@ -0,0 +1,57 @@ +package revert + +import ( + "github.com/Kong/ai-deck-converter/internal/aigw" + "github.com/Kong/ai-deck-converter/internal/aimap" +) + +// upstreamFromConfig lifts the `auth` record shared by the ai-a2a-proxy and +// ai-mcp-proxy plugins back into config.upstream. It returns nil when there is +// no upstream authentication (provider off or empty). It warns (the data is +// lossy) if the plugin carries a bearer_token — which has no AI Gateway +// representation — or an unrecognized provider. Reverses convert's +// upstreamAuthBlock. +// +// entity is used only for warning messages (e.g. `agent "foo"`). +func (r *Reverter) upstreamFromConfig(auth map[string]any, entity string) (*aigw.UpstreamConfig, error) { + if len(auth) == 0 { + return nil, nil + } + provider := getStr(auth, "provider") + switch provider { + case "", aimap.UpstreamAuthProviderOff: + return nil, nil + case aimap.UpstreamAuthProviderAWSIAM: + // handled below + default: + return nil, r.warn("%s: unrecognized upstream auth provider %q; dropping auth", entity, provider) + } + + awsIAM := getMap(auth, "aws_iam") + if bearer := getStr(awsIAM, "bearer_token"); bearer != "" { + if err := r.warn( + "%s: upstream auth bearer_token has no AI Gateway representation; dropping it", entity); err != nil { + return nil, err + } + } + + // Read plugin aws_iam fields back into AI Gateway names via the shared + // aimap table so forward and reverse can't drift. + values := map[string]string{} + for _, k := range aimap.AWSIAMAuthKeys { + if v := getStr(awsIAM, k.Plugin); v != "" { + values[k.AIGW] = v + } + } + a := &aigw.UpstreamAuth{ + Type: aimap.UpstreamAuthTypeAWS, + AccessKeyID: values["access_key_id"], + SecretAccessKey: values["secret_access_key"], + SessionToken: values["session_token"], + Region: values["region"], + AssumeRoleARN: values["assume_role_arn"], + RoleSessionName: values["role_session_name"], + STSEndpointURL: values["sts_endpoint_url"], + } + return &aigw.UpstreamConfig{Auth: a}, nil +} diff --git a/revert/upstream_test.go b/revert/upstream_test.go new file mode 100644 index 0000000..d59d047 --- /dev/null +++ b/revert/upstream_test.go @@ -0,0 +1,69 @@ +package revert + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A bearer_token in the plugin aws_iam block has no AI Gateway representation: +// revert warns and drops it, but still recovers the rest of the auth block. +func TestRevertUpstreamAuthBearerTokenWarns(t *testing.T) { + src := []byte(` +_format_version: "3.0" +services: + - name: secure-mcp + url: https://mcp.internal + routes: + - name: secure-mcp-route + paths: + - /mcp/secure + plugins: + - name: ai-mcp-proxy + config: + mode: passthrough-listener + auth: + provider: aws_iam + aws_iam: + aws_region: us-east-1 + bearer_token: "{vault://env/bedrock-bearer}" +`) + + out, warnings, err := Revert(src, Options{}) + require.NoError(t, err) + require.Contains(t, strings.Join(warnings, "\n"), "bearer_token") + require.Contains(t, string(out), "region: us-east-1", "the rest of the auth block must survive") + require.NotContains(t, string(out), "bearer_token", "bearer_token has no AI Gateway field") + + _, _, err = Revert(src, Options{Strict: true}) + require.Error(t, err, "dropping bearer_token must be fatal in strict mode") +} + +// An unrecognized upstream auth provider warns and drops the auth block. +func TestRevertUpstreamAuthUnknownProvider(t *testing.T) { + src := []byte(` +_format_version: "3.0" +services: + - name: secure-mcp + url: https://mcp.internal + routes: + - name: secure-mcp-route + paths: + - /mcp/secure + plugins: + - name: ai-mcp-proxy + config: + mode: passthrough-listener + auth: + provider: gcp_iam +`) + + out, warnings, err := Revert(src, Options{}) + require.NoError(t, err) + require.Contains(t, strings.Join(warnings, "\n"), "unrecognized upstream auth provider") + require.NotContains(t, string(out), "upstream:", "unknown provider must not produce an upstream block") + + _, _, err = Revert(src, Options{Strict: true}) + require.Error(t, err) +}