Files
netbird/proxy/target_middleware_revision_test.go
T
Maycon Santos 1161616c02 [proxy] Enforce per-target HTTP access actions (#8115)
Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware.

Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
2026-10-12 12:31:57 +02:00

116 lines
5.6 KiB
Go

package proxy
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
protobuf "google.golang.org/protobuf/proto"
"github.com/netbirdio/netbird/proxy/internal/middleware"
guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestUpdateMappingBindsMiddlewareRevisionAcrossPolicyUpdates(t *testing.T) {
oldUpstream := &targetAccessUpstream{}
oldBackend := httptest.NewServer(oldUpstream)
t.Cleanup(oldBackend.Close)
newUpstream := &targetAccessUpstream{}
newBackend := httptest.NewServer(newUpstream)
t.Cleanup(newBackend.Close)
runtime, _ := newTargetAccessRuntime(t)
registry := middleware.NewRegistry()
require.NoError(t, registry.Register(guardrail.Factory{}))
manager := middleware.NewManager(0, nil, runtime.Logger)
manager.SetResolver(middleware.NewResolver(registry))
manager.SetLiveServiceCheck(runtime.isLiveService)
t.Cleanup(manager.InvalidateAll)
runtime.middlewareRegistry = registry
runtime.middlewareManager = manager
runtime.proxy = internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, runtime.Logger,
internalproxy.WithMiddlewareManager(manager))
handler := runtime.auth.Protect(runtime.proxy)
mapping := &proto.ProxyMapping{
Id: "middleware-revision-service", AccountId: targetAccessAccountID, Domain: targetAccessDomain,
Path: []*proto.PathMapping{{
Path: "/", Target: oldBackend.URL,
Options: &proto.PathTargetOptions{
AgentNetwork: true,
DirectUpstream: true,
Middlewares: []*proto.MiddlewareConfig{{
Id: guardrail.ID,
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST,
Enabled: true,
ConfigJson: []byte(`{"provider_allowlists":{"provider-1":["allowed-model"]}}`),
}},
},
}},
}
applyTargetAccessMapping(t, runtime, mapping)
initial := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusForbidden, initial.Code,
"the original guardrail must reject a request with no known model")
require.Empty(t, oldUpstream.snapshot(), "the original policy must prevent upstream forwarding")
// Hold the request at the auth/proxy boundary so updateMapping runs after
// authorization has pinned the route but before its middleware is selected.
captureAuthorizedRequest := func() *http.Request {
var pinned *http.Request
capture := runtime.auth.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pinned = r
w.WriteHeader(http.StatusNoContent)
}))
response := targetAccessRequestTo(capture, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusNoContent, response.Code, "auth must pass the resolved request to the proxy")
require.NotNil(t, pinned, "the request must be captured after auth target resolution")
return pinned
}
staleRequest := captureAuthorizedRequest()
oldChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, oldChain, "updateMapping must install the original guardrail chain")
replacement := protobuf.Clone(mapping).(*proto.ProxyMapping)
replacement.Path[0].Target = newBackend.URL
replacement.Path[0].Options.Middlewares[0].ConfigJson = []byte(`{}`)
applyTargetAccessMapping(t, runtime, replacement)
newChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, newChain, "updateMapping must install the replacement guardrail chain")
require.NotSame(t, oldChain, newChain, "a policy update must replace the middleware chain")
staleResponse := httptest.NewRecorder()
runtime.proxy.ServeHTTP(staleResponse, staleRequest)
assert.Equal(t, http.StatusServiceUnavailable, staleResponse.Code,
"a request authorized against the old target must not execute the replacement policy")
assert.Equal(t, "no-store", staleResponse.Header().Get("Cache-Control"), "stale policy failures must not be cached")
assert.Empty(t, oldUpstream.snapshot(), "a replacement policy must never authorize the old upstream")
assert.Empty(t, newUpstream.snapshot(), "a stale request must not forward to the replacement upstream")
fresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusOK, fresh.Code, "fresh auth resolution must use the replacement middleware revision")
require.Len(t, newUpstream.snapshot(), 1, "the replacement target must receive the fresh request")
// Provider changes rebuild middleware instances without replacing the
// service mapping, so requests pinned to its current revision stay valid.
currentRequest := captureAuthorizedRequest()
manager.InvalidateMiddleware(guardrail.ID)
refreshedChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, refreshedChain, "a live service must retain its chain after provider refresh")
require.NotSame(t, newChain, refreshedChain, "provider refresh must rebuild the concrete guardrail chain")
currentResponse := httptest.NewRecorder()
runtime.proxy.ServeHTTP(currentResponse, currentRequest)
assert.Equal(t, http.StatusOK, currentResponse.Code, "provider refresh must preserve the current request's revision")
assert.Len(t, newUpstream.snapshot(), 2, "the current request must reach the replacement target after refresh")
afterRefresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
assert.Equal(t, http.StatusOK, afterRefresh.Code, "new requests must continue after provider refresh")
assert.Len(t, newUpstream.snapshot(), 3, "fresh requests must use the rebuilt chain and current target")
assert.Empty(t, oldUpstream.snapshot(), "no request may reach the retired target")
}