Added middleware for api-only users auth (#43772)

Fixes #42885

Added new middleware (APIOnlyEndpointCheck) that enforces 403 for
API-only users whose request either isn't in the API endpoint catalog or
falls outside their configured per-user endpoint restrictions.
This commit is contained in:
Juan Fernandez 2026-04-21 08:11:33 -03:00 committed by GitHub
parent 43a7aeaae1
commit 2b35eabd5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 566 additions and 7 deletions

View file

@ -0,0 +1 @@
- Added new middleware (APIOnlyEndpointCheck) that enforces 403 for API-only users whose request either isn't in the API endpoint catalog or falls outside their configured per-user endpoint restrictions.

View file

@ -1935,9 +1935,10 @@ func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBCon
activityACLAdapter,
logger,
)
// Create auth middleware for activity bounded context
// Makes sure that api_only users are subject to endpoint
// restrictions on activity routes.
activityAuthMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(svc, next)
return auth.AuthenticatedUser(svc, auth.APIOnlyEndpointCheck(next))
}
activityRoutes := activityRoutesFn(activityAuthMiddleware)
return activitySvc, activityRoutes

View file

@ -144,6 +144,12 @@ func (e *endpointer) Service() any {
func newUserAuthenticatedEndpointer(svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption, r *mux.Router,
versions ...string,
) *eu.CommonEndpointer[handlerFunc] {
// Append RouteTemplateRequestFunc so the api_only endpoint middleware
// can read the matched mux route template from context.
//
// Full-slice expression prevents aliasing into the caller's backing array
// if it happens to have spare capacity.
opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(eu.RouteTemplateRequestFunc))
return &eu.CommonEndpointer[handlerFunc]{
EP: &endpointer{
svc: svc,

View file

@ -15,6 +15,8 @@ var apiEndpointsYAML []byte
var apiEndpoints []fleet.APIEndpoint
var apiEndpointsSet map[string]struct{}
// GetAPIEndpoints returns a copy of the embedded API endpoints slice.
func GetAPIEndpoints() []fleet.APIEndpoint {
result := make([]fleet.APIEndpoint, len(apiEndpoints))
@ -22,6 +24,12 @@ func GetAPIEndpoints() []fleet.APIEndpoint {
return result
}
// IsInCatalog reports whether the given endpoint fingerprint is in the catalog.
func IsInCatalog(fingerprint string) bool {
_, ok := apiEndpointsSet[fingerprint]
return ok
}
func Init(h http.Handler) error {
r, ok := h.(*mux.Router)
if !ok {
@ -63,6 +71,12 @@ func Init(h http.Handler) error {
apiEndpoints = loadedApiEndpoints
set := make(map[string]struct{}, len(loadedApiEndpoints))
for _, e := range loadedApiEndpoints {
set[e.Fingerprint()] = struct{}{}
}
apiEndpointsSet = set
return nil
}

View file

@ -68,7 +68,7 @@ func (e *APIEndpoint) normalize() {
// Fingerprint return a string that uniquely identifies
// the APIEndpoint
func (e APIEndpoint) Fingerprint() string {
return fmt.Sprintf("|%s|%s|", e.Method, e.NormalizedPath)
return "|" + e.Method + "|" + e.NormalizedPath + "|"
}
func (e APIEndpoint) validate() error {

View file

@ -55,6 +55,9 @@ func (e *androidEndpointer) Service() any {
func newUserAuthenticatedEndpointer(fleetSvc fleet.Service, svc android.Service, opts []kithttp.ServerOption, r *mux.Router,
versions ...string,
) *eu.CommonEndpointer[handlerFunc] {
// Full-slice expression prevents aliasing into the caller's backing array
// if it happens to have spare capacity.
opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(auth.RouteTemplateRequestFunc))
return &eu.CommonEndpointer[handlerFunc]{
EP: &androidEndpointer{
svc: svc,
@ -63,7 +66,7 @@ func newUserAuthenticatedEndpointer(fleetSvc fleet.Service, svc android.Service,
EncodeFn: encodeResponse,
Opts: opts,
AuthMiddleware: func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(fleetSvc, next)
return auth.AuthenticatedUser(fleetSvc, auth.APIOnlyEndpointCheck(next))
},
Router: r,
Versions: versions,

View file

@ -0,0 +1,42 @@
package endpointer
import (
"context"
"net/http"
"github.com/gorilla/mux"
)
// contextKeyRouteTemplate is the context key type for the mux route template.
type contextKeyRouteTemplate struct{}
var routeTemplateKey = contextKeyRouteTemplate{}
// RouteTemplateRequestFunc captures the gorilla/mux route template for the
// matched request and stores it in the context.
func RouteTemplateRequestFunc(ctx context.Context, r *http.Request) context.Context {
route := mux.CurrentRoute(r)
if route == nil {
return ctx
}
tpl, err := route.GetPathTemplate()
if err != nil {
// Only happens when a route has no path, which Fleet never registers.
return ctx
}
return context.WithValue(ctx, routeTemplateKey, tpl)
}
// RouteTemplateFromContext returns the mux route template stored by
// RouteTemplateRequestFunc. Returns "" and false if no template is in context.
func RouteTemplateFromContext(ctx context.Context) (string, bool) {
tpl, ok := ctx.Value(routeTemplateKey).(string)
return tpl, ok
}
// WithRouteTemplate returns a new context with the given route template value.
// Intended for tests that need to simulate what RouteTemplateRequestFunc would
// have stored without running a real mux router.
func WithRouteTemplate(ctx context.Context, tpl string) context.Context {
return context.WithValue(ctx, routeTemplateKey, tpl)
}

View file

@ -136,6 +136,9 @@ func (e *fleetEndpointer) Service() any {
func newUserAuthenticatedEndpointer(svc fleet.Service, opts []kithttp.ServerOption, r *mux.Router,
versions ...string,
) *eu.CommonEndpointer[handlerFunc] {
// Full-slice expression prevents aliasing into the caller's backing array
// if it happens to have spare capacity.
opts = append(opts[:len(opts):len(opts)], kithttp.ServerBefore(auth.RouteTemplateRequestFunc))
return &eu.CommonEndpointer[handlerFunc]{
EP: &fleetEndpointer{
svc: svc,
@ -144,7 +147,7 @@ func newUserAuthenticatedEndpointer(svc fleet.Service, opts []kithttp.ServerOpti
EncodeFn: encodeResponse,
Opts: opts,
AuthMiddleware: func(next endpoint.Endpoint) endpoint.Endpoint {
return auth.AuthenticatedUser(svc, next)
return auth.AuthenticatedUser(svc, auth.APIOnlyEndpointCheck(next))
},
Router: r,
Versions: versions,

View file

@ -535,12 +535,15 @@ func (s *integrationTestSuite) TestModifyAPIOnlyUser() {
"name": "New Name",
}, http.StatusUnprocessableEntity)
// An API-only user cannot modify their own record via this endpoint.
// An API-only user cannot reach this admin endpoint: the api_only middleware
// rejects it at the catalog check (the user-management endpoint is not in the catalog).
//
// This is to protect against privilege escalation vulnerability.
s.token = apiUserToken
defer func() { s.token = s.getTestAdminToken() }()
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{
"name": "Self Update",
}, http.StatusUnprocessableEntity)
}, http.StatusForbidden)
s.token = s.getTestAdminToken()
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{

View file

@ -28985,3 +28985,73 @@ func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() {
require.False(t, foundRegular.APIOnly)
require.Empty(t, foundRegular.APIEndpoints)
}
func (s *integrationEnterpriseTestSuite) TestAPIOnlyUserEndpointMiddleware() {
t := s.T()
defer func() { s.token = s.getTestAdminToken() }()
createAPIOnlyUser := func(name string, endpoints []map[string]any) string {
prev := s.token
s.token = s.getTestAdminToken()
defer func() { s.token = prev }()
body := map[string]any{
"name": name,
"global_role": "observer",
}
if endpoints != nil {
body["api_endpoints"] = endpoints
}
var createResp struct {
Token string `json:"token"`
}
s.DoJSON("POST", "/api/latest/fleet/users/api_only", body, http.StatusOK, &createResp)
require.NotEmpty(t, createResp.Token)
return createResp.Token
}
// With no endpoint restrictions the user can reach any endpoint in the catalog.
t.Run("no restrictions allows all catalog endpoints", func(t *testing.T) {
s.token = createAPIOnlyUser("api-only-mw-no-restrictions", nil)
s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK)
s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK)
s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK)
})
// Paths not registered in the API endpoint catalog are always rejected for
// api-only users, regardless of whether they have endpoint restrictions.
t.Run("non-catalog path is rejected", func(t *testing.T) {
s.token = createAPIOnlyUser("api-only-mw-non-catalog-unrestricted", nil)
s.Do("PATCH", "/api/latest/fleet/users/api_only/1", map[string]any{"name": "x"}, http.StatusForbidden)
s.token = createAPIOnlyUser("api-only-mw-non-catalog-restricted", []map[string]any{
{"method": "GET", "path": "/api/v1/fleet/version"},
})
s.Do("PATCH", "/api/latest/fleet/users/api_only/1", map[string]any{"name": "x"}, http.StatusForbidden)
})
// With endpoint restrictions, only explicitly allowed endpoints are reachable.
t.Run("endpoint restrictions limit access to the allowed list", func(t *testing.T) {
s.token = createAPIOnlyUser("api-only-mw-restricted", []map[string]any{
{"method": "GET", "path": "/api/v1/fleet/version"},
})
// The only allowed endpoint returns 200.
s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK)
// These are in the catalog but not in the user's allow list.
s.Do("GET", "/api/latest/fleet/config", nil, http.StatusForbidden)
s.Do("GET", "/api/latest/fleet/me", nil, http.StatusForbidden)
})
// Non-api-only users must not be affected by the middleware at all.
t.Run("non-api-only user is unaffected", func(t *testing.T) {
s.token = s.getTestAdminToken()
s.Do("GET", "/api/latest/fleet/version", nil, http.StatusOK)
s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK)
s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK)
})
}

View file

@ -0,0 +1,79 @@
package auth
import (
"context"
apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints"
"github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
)
// RouteTemplateRequestFunc captures the gorilla/mux route template for the
// matched request and stores it in the context. Alias of the platform
// implementation, re-exported so callers that already import this package can
// continue to reference it here.
var RouteTemplateRequestFunc = eu.RouteTemplateRequestFunc
// APIOnlyEndpointCheck returns an endpoint.Endpoint middleware that enforces
// access control for API-only users (api_only=true). It must be wired inside
// AuthenticatedUser (so a Viewer is already in context when it runs) and the
// enclosing transport must register RouteTemplateRequestFunc as a ServerBefore
// option so the mux route template is available in context.
//
// For non-API-only users the check is skipped entirely. When there is no Viewer
// in context, the call passes through — AuthenticatedUser guarantees that any
// request that needs a Viewer has already been rejected before reaching here.
//
// For API-only users two checks are applied in order:
// 1. The requested route must appear in the API endpoint catalog. If not, a
// permission error (403) is returned.
// 2. If the user has configured endpoint restrictions (rows in
// user_api_endpoints), the route must match one of them. If not, a
// permission error (403) is returned. An empty restriction list grants
// full access to all catalog endpoints.
func APIOnlyEndpointCheck(next endpoint.Endpoint) endpoint.Endpoint {
return apiOnlyEndpointCheck(apiendpoints.IsInCatalog, next)
}
func apiOnlyEndpointCheck(isInCatalog func(string) bool, next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
v, ok := viewer.FromContext(ctx)
if !ok || v.User == nil || !v.User.APIOnly {
return next(ctx, request)
}
requestMethod, _ := ctx.Value(kithttp.ContextKeyRequestMethod).(string)
routeTemplate, _ := eu.RouteTemplateFromContext(ctx)
fp := fleet.NewAPIEndpointFromTpl(requestMethod, routeTemplate).Fingerprint()
if !isInCatalog(fp) {
return nil, permissionDenied(ctx)
}
// No endpoint restrictions: full access to all catalog endpoints.
if len(v.User.APIEndpoints) == 0 {
return next(ctx, request)
}
// Check whether the requested endpoint matches any of the user's allowed endpoints.
for _, ep := range v.User.APIEndpoints {
if fleet.NewAPIEndpointFromTpl(ep.Method, ep.Path).Fingerprint() == fp {
return next(ctx, request)
}
}
return nil, permissionDenied(ctx)
}
}
func permissionDenied(ctx context.Context) error {
if ac, ok := authz.FromContext(ctx); ok {
ac.SetChecked()
}
return fleet.NewPermissionError("forbidden")
}

View file

@ -0,0 +1,337 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
// muxVersionSegment is the gorilla/mux route template version segment that
// RouteTemplateRequestFunc would extract from a real mux router.
const muxVersionSegment = "/api/{fleetversion:(?:v1|2022-04|latest)}/"
// testCatalogEndpoints is the minimal set of endpoints used across tests.
var testCatalogEndpoints = []fleet.APIEndpoint{
fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts"),
fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts/:id"),
fleet.NewAPIEndpointFromTpl("POST", "/api/v1/fleet/scripts/run"),
}
// testIsInCatalog builds a fingerprint set from testCatalogEndpoints and
// returns an isInCatalog func suitable for injection into apiOnlyEndpointCheck.
func testIsInCatalog() func(string) bool {
set := make(map[string]struct{}, len(testCatalogEndpoints))
for _, ep := range testCatalogEndpoints {
set[ep.Fingerprint()] = struct{}{}
}
return func(fp string) bool {
_, ok := set[fp]
return ok
}
}
// muxTemplate returns a gorilla/mux route template for the given path suffix, simulating
// what RouteTemplateRequestFunc would extract from mux.CurrentRoute(r).GetPathTemplate().
func muxTemplate(pathSuffix string) string {
return muxVersionSegment + pathSuffix
}
func TestAPIOnlyEndpointCheck(t *testing.T) {
newNext := func() (func(context.Context, any) (any, error), *bool) {
called := false
fn := func(ctx context.Context, request any) (any, error) {
called = true
return nil, nil
}
return fn, &called
}
newEndpoint := func(next func(context.Context, any) (any, error)) func(context.Context, any) (any, error) {
return apiOnlyEndpointCheck(testIsInCatalog(), next)
}
ctxWithMethod := func(method, tpl string) context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, method)
ctx = eu.WithRouteTemplate(ctx, tpl)
return ctx
}
t.Run("non-api-only user always passes through", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: false}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("no viewer in context passes through", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts"))
// no viewer set
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user, endpoint in catalog, no restrictions", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: nil,
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user, empty APIEndpoints slice treated same as nil (no restrictions)", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{}, // empty, not nil
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user, endpoint with placeholder in catalog, no restrictions", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts/{id:[0-9]+}"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: nil,
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user, endpoint not in catalog", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/secret_admin_endpoint"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user, missing route template in context is rejected", func(t *testing.T) {
next, called := newNext()
// routeTemplateKey deliberately not set (simulates RouteTemplateRequestFunc failure).
ctx := context.Background()
ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, "GET")
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user, missing method and template are both rejected", func(t *testing.T) {
next, called := newNext()
// Neither method nor template set — empty fingerprint never matches catalog.
ctx := context.Background()
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user, method normalization is case-insensitive", func(t *testing.T) {
// Lower-case method must normalize to the same fingerprint as upper-case.
next, called := newNext()
ctx := ctxWithMethod("get", muxTemplate("fleet/hosts")) // lower-case
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: nil,
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user, rejection marks authz context as checked", func(t *testing.T) {
// Ensures authzcheck middleware does not emit a spurious "Missing
// authorization check" log when we deny an api_only user.
next, called := newNext()
ac := &authzctx.AuthorizationContext{}
ctx := authzctx.NewContext(context.Background(), ac)
ctx = context.WithValue(ctx, kithttp.ContextKeyRequestMethod, "GET")
ctx = eu.WithRouteTemplate(ctx, muxTemplate("fleet/secret_admin_endpoint"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{APIOnly: true}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
require.True(t, ac.Checked(), "authz context must be marked checked on denial")
})
t.Run("api-only user with restrictions, accessing allowed endpoint", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/hosts"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user with restrictions, accessing allowed placeholder endpoint", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/hosts/{id:[0-9]+}"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
// Stored path uses colon-prefix style as in the YAML catalog.
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/hosts/:id"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
t.Run("api-only user with restrictions, accessing disallowed endpoint", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("POST", muxTemplate("fleet/scripts/run"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/hosts"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user, allow-list entry for non-catalog endpoint is still denied", func(t *testing.T) {
// The catalog check runs before the allow-list check; an explicit allow entry
// must not grant access to an endpoint that is not in the catalog.
next, called := newNext()
ctx := ctxWithMethod("GET", muxTemplate("fleet/secret_admin_endpoint"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/secret_admin_endpoint"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user, wrong method for catalog endpoint is rejected at catalog step", func(t *testing.T) {
// POST /fleet/hosts is not in the catalog (only GET is), so the catalog check rejects it.
next, called := newNext()
ctx := ctxWithMethod("POST", muxTemplate("fleet/hosts"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/hosts"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.Error(t, err)
require.False(t, *called)
var permErr *fleet.PermissionError
require.ErrorAs(t, err, &permErr)
})
t.Run("api-only user with multiple allowed endpoints, accessing one of them", func(t *testing.T) {
next, called := newNext()
ctx := ctxWithMethod("POST", muxTemplate("fleet/scripts/run"))
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{
APIOnly: true,
APIEndpoints: []fleet.APIEndpointRef{
{Method: "GET", Path: "/api/v1/fleet/hosts"},
{Method: "POST", Path: "/api/v1/fleet/scripts/run"},
},
}})
_, err := newEndpoint(next)(ctx, nil)
require.NoError(t, err)
require.True(t, *called)
})
}
func TestRouteTemplateRequestFunc(t *testing.T) {
// Register a route and route the request through mux so mux.CurrentRoute
// returns a non-nil value, mirroring what happens in production.
newServedRequest := func(t *testing.T, routeTpl, reqPath string) (context.Context, bool) {
t.Helper()
var (
got context.Context
wasMatch bool
)
r := mux.NewRouter()
r.HandleFunc(routeTpl, func(_ http.ResponseWriter, req *http.Request) {
wasMatch = true
got = RouteTemplateRequestFunc(req.Context(), req)
}).Methods("GET")
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", reqPath, nil)
r.ServeHTTP(rec, req)
return got, wasMatch
}
t.Run("stores the matched route template", func(t *testing.T) {
ctx, matched := newServedRequest(t, "/api/v1/fleet/hosts/{id:[0-9]+}", "/api/v1/fleet/hosts/42")
require.True(t, matched, "expected route to be matched")
tpl, ok := eu.RouteTemplateFromContext(ctx)
require.True(t, ok, "route template must be stored in context")
require.Equal(t, "/api/v1/fleet/hosts/{id:[0-9]+}", tpl)
})
t.Run("no matched route leaves context unchanged", func(t *testing.T) {
// Call RouteTemplateRequestFunc directly with a request that never went
// through a mux router, so mux.CurrentRoute returns nil.
req := httptest.NewRequest("GET", "/whatever", nil)
ctx := context.Background()
got := RouteTemplateRequestFunc(ctx, req)
_, ok := eu.RouteTemplateFromContext(got)
require.False(t, ok, "no route template should be stored when no route is matched")
})
}