mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 22:39:09 +02:00
[proxy,management] Bound model discovery to the caller's own policies
The listing was narrowed by the provider record's enumerated models, which is the right bound only while one policy reaches a provider. Where two teams share a provider under different allowlists, every caller was offered the union: each model outside their own policy is a request the guardrail refuses a moment later, which is the empty-or-wrong picker this endpoint exists to avoid, moved one level up. A gateway record enumerating nothing was worse still — it offered the upstream's entire catalogue however narrow the policy. The synthesiser already knows which policies authorise a provider and which groups each binds, so the router can answer this at request time where it knows the caller's groups. Each route now carries one rule per authorising policy — its source groups and the models it permits — and the listing is bounded to the union across the rules matching the caller, intersected with what the provider serves. This is deliberately finer than the guardrail's own per-provider allowlist, which stays as it is: that list is a fail-closed backstop and cannot tell who is asking, so discovery is now narrower than the backstop rather than wider. A policy setting no allowlist lifts the restriction for the groups it binds, so nil and empty model lists stay distinct end to end — collapsing them would let a listing that should offer nothing fall open to everything.
This commit is contained in:
@@ -242,12 +242,13 @@ func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups
|
||||
if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix {
|
||||
stripBedrockNamespace(out)
|
||||
}
|
||||
// A route that enumerates its models bounds what the caller may use,
|
||||
// so the picker must not offer the rest: every entry outside the list
|
||||
// is a request the chain will deny.
|
||||
if reqPath == modelListingPath && len(route.Models) > 0 &&
|
||||
out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
out.Mutations.RewriteUpstream.DiscoveryModels = append([]string(nil), route.Models...)
|
||||
// What the caller may actually use bounds what the picker may offer:
|
||||
// every entry outside it is a request the chain will deny a moment
|
||||
// later.
|
||||
if reqPath == modelListingPath && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
if models, bounded := discoverableModels(route, userGroups); bounded {
|
||||
out.Mutations.RewriteUpstream.DiscoveryModels = models
|
||||
}
|
||||
}
|
||||
return out
|
||||
case matchOutcomeUnauthorised:
|
||||
@@ -271,6 +272,96 @@ func isNonInferenceMethod(method string) bool {
|
||||
return method == http.MethodGet || method == http.MethodHead
|
||||
}
|
||||
|
||||
// discoverableModels returns the model ids a caller in userGroups may actually
|
||||
// use on this route, and whether the listing should be bounded to them at all.
|
||||
//
|
||||
// Two things narrow a listing, and both must apply or the picker offers models
|
||||
// the very next request refuses:
|
||||
//
|
||||
// - the provider's own enumerated models, when it lists any (a gateway record
|
||||
// enumerates nothing and claims everything);
|
||||
// - the model allowlists of the policies that authorise THIS caller. A
|
||||
// provider reachable by two groups under different allowlists must not
|
||||
// offer either group the other's models, which is why the rules carry their
|
||||
// source groups rather than arriving pre-flattened.
|
||||
//
|
||||
// A policy that sets no allowlist lifts the restriction for the groups it
|
||||
// binds, so a caller holding one unrestricted policy sees the provider's full
|
||||
// list. bounded is false when nothing narrows the listing — an unrestricted
|
||||
// caller on a route that enumerates nothing — in which case the upstream's own
|
||||
// answer passes through untouched.
|
||||
func discoverableModels(route ProviderRoute, userGroups []string) ([]string, bool) {
|
||||
permitted, restricted := policyPermittedModels(route, userGroups)
|
||||
|
||||
switch {
|
||||
case !restricted && len(route.Models) == 0:
|
||||
return nil, false
|
||||
case !restricted:
|
||||
return append([]string(nil), route.Models...), true
|
||||
case len(route.Models) == 0:
|
||||
// A gateway record enumerates nothing, so the allowlist is the whole
|
||||
// bound — previously such a record offered the upstream's entire
|
||||
// catalogue however narrow the policy was.
|
||||
return sortedModels(permitted), true
|
||||
}
|
||||
|
||||
// Both bound: only what the provider serves and the policy permits.
|
||||
intersection := make(map[string]struct{}, len(route.Models))
|
||||
for _, m := range route.Models {
|
||||
if _, ok := permitted[m]; ok {
|
||||
intersection[m] = struct{}{}
|
||||
}
|
||||
}
|
||||
return sortedModels(intersection), true
|
||||
}
|
||||
|
||||
// policyPermittedModels folds the rules whose groups intersect the caller's
|
||||
// into the set of models they permit. restricted is false when the caller
|
||||
// holds at least one authorising policy that sets no allowlist, or when no
|
||||
// rule binds them at all.
|
||||
func policyPermittedModels(route ProviderRoute, userGroups []string) (map[string]struct{}, bool) {
|
||||
permitted := make(map[string]struct{})
|
||||
restricted := false
|
||||
for _, rule := range route.ModelPolicies {
|
||||
if !groupsIntersect(rule.GroupIDs, userGroups) {
|
||||
continue
|
||||
}
|
||||
if rule.Models == nil {
|
||||
// An unrestricted policy the caller holds lifts the restriction
|
||||
// entirely, whatever the others say.
|
||||
return nil, false
|
||||
}
|
||||
restricted = true
|
||||
for _, m := range rule.Models {
|
||||
permitted[m] = struct{}{}
|
||||
}
|
||||
}
|
||||
return permitted, restricted
|
||||
}
|
||||
|
||||
// groupsIntersect reports whether the two group-id sets share a member.
|
||||
func groupsIntersect(a, b []string) bool {
|
||||
for _, x := range a {
|
||||
for _, y := range b {
|
||||
if x == y {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortedModels flattens a model set into a stable slice so the bound the proxy
|
||||
// applies — and any test asserting on it — does not depend on map order.
|
||||
func sortedModels(set map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for m := range set {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// markNonInference tags an allow as a request that spends no tokens, so the
|
||||
// limit check skips the management pre-flight it would charge nothing against.
|
||||
func markNonInference(out *middleware.Output) {
|
||||
|
||||
Reference in New Issue
Block a user