[proxy] Authorise a Bedrock profile lookup against the model table

GetInferenceProfile was routed by provider type alone, so any caller with a
Bedrock route could read the full configuration of every profile in the
account — name, ARN, and underlying models — including profiles its policy
never named. The listing beside it is bounded on the way back, but a detail
lookup answers with a single object no filter inspects, so nothing narrowed
it.

Authorise the named profile like any other per-model request, as the
/v1/models/{id} lookup already is. The identifier is normalised first: a
record may register the raw profile id AWS issues or the catalog key it
reduces to, and either spelling must resolve. The listing itself names no
profile and stays model-less.
This commit is contained in:
mlsmaycon
2026-08-23 12:19:00 +00:00
parent 27cde6a9d3
commit 84dda2ba8a
2 changed files with 83 additions and 2 deletions

View File

@@ -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)
}

View File

@@ -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