mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-24 16:41:30 +02:00
[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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user