diff --git a/changes/42885-api-only-endpoints-middleware b/changes/42885-api-only-endpoints-middleware new file mode 100644 index 0000000000..49aff381e5 --- /dev/null +++ b/changes/42885-api-only-endpoints-middleware @@ -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. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index d8a1d16e2b..864e1ead34 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -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 diff --git a/server/activity/internal/service/endpoint_utils.go b/server/activity/internal/service/endpoint_utils.go index ec69b85897..6cc8364e4f 100644 --- a/server/activity/internal/service/endpoint_utils.go +++ b/server/activity/internal/service/endpoint_utils.go @@ -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, diff --git a/server/api_endpoints/api_endpoints.go b/server/api_endpoints/api_endpoints.go index 30c857c61a..1b280a21bf 100644 --- a/server/api_endpoints/api_endpoints.go +++ b/server/api_endpoints/api_endpoints.go @@ -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 } diff --git a/server/fleet/api_endpoints.go b/server/fleet/api_endpoints.go index 0fbae9a280..5042b7c893 100644 --- a/server/fleet/api_endpoints.go +++ b/server/fleet/api_endpoints.go @@ -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 { diff --git a/server/mdm/android/service/endpoint_utils.go b/server/mdm/android/service/endpoint_utils.go index b919edeec1..73b18c102a 100644 --- a/server/mdm/android/service/endpoint_utils.go +++ b/server/mdm/android/service/endpoint_utils.go @@ -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, diff --git a/server/platform/endpointer/route_template.go b/server/platform/endpointer/route_template.go new file mode 100644 index 0000000000..4c01ab3710 --- /dev/null +++ b/server/platform/endpointer/route_template.go @@ -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) +} diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 68e8d35b1a..5de949eb9a 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -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, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 8158d6b5ed..e5950ae553 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -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{ diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4840d0ff2d..9a0b1486bb 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -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) + }) +} diff --git a/server/service/middleware/auth/api_only.go b/server/service/middleware/auth/api_only.go new file mode 100644 index 0000000000..d92c1f84a6 --- /dev/null +++ b/server/service/middleware/auth/api_only.go @@ -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") +} diff --git a/server/service/middleware/auth/api_only_test.go b/server/service/middleware/auth/api_only_test.go new file mode 100644 index 0000000000..853af0dd1d --- /dev/null +++ b/server/service/middleware/auth/api_only_test.go @@ -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") + }) +}