diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go index d50b06e92..d21e33c21 100644 --- a/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go @@ -131,3 +131,45 @@ func TestBedrockListingWithoutADiscoveryHostFallsThrough(t *testing.T) { assert.Equal(t, "bedrock.internal.example.com", out.Mutations.RewriteUpstream.Host) } + +// TestBedrockProfileDetailHonoursTheModelTable covers GetInferenceProfile, +// which the listing filter cannot help with: it answers for one profile with a +// single object, not a set, so nothing narrows it on the way back. Authorising +// it by provider type alone would let any caller with a Bedrock route read the +// full configuration of every profile in the account. +// +// Both registration spellings are exercised, because a record may carry the +// raw profile id AWS issues or the catalog key it reduces to. +func TestBedrockProfileDetailHonoursTheModelTable(t *testing.T) { + const permitted = "eu.anthropic.claude-sonnet-5-20260514-v1:0" + + for _, registered := range []string{permitted, "anthropic.claude-sonnet-5"} { + t.Run(registered, func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{registered}, nil)}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles/"+permitted)) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a profile the record registers must still resolve") + + denied, err := mw.Invoke(context.Background(), + getInput("/inference-profiles/eu.anthropic.claude-opus-5-20260514-v1:0")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, denied.Decision, + "a profile outside the record's models must not be readable") + }) + } +} + +// TestBedrockProfileListingStaysModelLess pins the other half: the listing +// names no profile, so it must not be judged against the model table. It is +// bounded by DiscoveryModels in the response instead, and denying it here +// would take model discovery away from exactly the records that enumerate +// their models. +func TestBedrockProfileListingStaysModelLess(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{"anthropic.claude-sonnet-5"}, nil)}}) + + out, err := mw.Invoke(context.Background(), getInput("/inference-profiles")) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision) +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 7afd7afd9..b8d4b001b 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -541,7 +541,30 @@ func modelDetailID(reqPath string) (string, bool) { // gateway that does serve the lookup get a working answer. func isBedrockModelLessPath(reqPath string) bool { native, _ := splitBedrockNamespace(reqPath) - return native == "/inference-profiles" || strings.HasPrefix(native, "/inference-profiles/") + return native == "/inference-profiles" || strings.HasPrefix(native, bedrockProfileDetailPrefix) +} + +// bedrockProfileDetailPrefix precedes the identifier in a GetInferenceProfile +// lookup, once any gateway namespace is off the front. +const bedrockProfileDetailPrefix = "/inference-profiles/" + +// bedrockProfileID returns the inference profile a "/inference-profiles/{id}" +// lookup names. The listing beside it names none, which is what separates the +// two: a listing is a set the response filter can bound, while this answers +// for one profile with a single object no filter inspects. +// +// The id arrives as AWS issues it — region prefix and version suffix included +// — because that is the only form that works at invoke time. +func bedrockProfileID(reqPath string) (string, bool) { + native, _ := splitBedrockNamespace(reqPath) + if !strings.HasPrefix(native, bedrockProfileDetailPrefix) { + return "", false + } + id := strings.TrimPrefix(native, bedrockProfileDetailPrefix) + if id == "" { + return "", false + } + return id, true } // isVertexPath reports whether reqPath is a Google Vertex AI publisher @@ -681,7 +704,23 @@ func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) var eligible func(ProviderRoute) bool switch { case isBedrockModelLessPath(reqPath): - eligible = func(r ProviderRoute) bool { return r.Bedrock } + if profile, isDetail := bedrockProfileID(reqPath); isDetail { + // A detail lookup names one profile, so it is authorised like any + // other per-model request rather than by provider type alone. The + // listing beside it is bounded by DiscoveryModels on the way back, + // but this answers with a single object no filter inspects — so + // without the check here, a caller reads the full configuration of + // every profile in the account, including the ones its policy + // never named. + // + // The id is normalised first: a record may register the raw + // profile id or the catalog key it reduces to, and routeClaimsModel + // expects the normalised form an inference request would carry. + wanted := llm.NormalizeBedrockModel(profile) + eligible = func(r ProviderRoute) bool { return r.Bedrock && routeClaimsModel(r, wanted) } + } else { + eligible = func(r ProviderRoute) bool { return r.Bedrock } + } case isModelLessPath(reqPath): // Vertex/Bedrock are path-routed and don't serve OpenAI-style // model-listing endpoints; including them here could rewrite a