mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-04 04:29:06 +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)
|
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.
|
// gateway that does serve the lookup get a working answer.
|
||||||
func isBedrockModelLessPath(reqPath string) bool {
|
func isBedrockModelLessPath(reqPath string) bool {
|
||||||
native, _ := splitBedrockNamespace(reqPath)
|
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
|
// 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
|
var eligible func(ProviderRoute) bool
|
||||||
switch {
|
switch {
|
||||||
case isBedrockModelLessPath(reqPath):
|
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):
|
case isModelLessPath(reqPath):
|
||||||
// Vertex/Bedrock are path-routed and don't serve OpenAI-style
|
// Vertex/Bedrock are path-routed and don't serve OpenAI-style
|
||||||
// model-listing endpoints; including them here could rewrite a
|
// model-listing endpoints; including them here could rewrite a
|
||||||
|
|||||||
Reference in New Issue
Block a user