mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
43a7aeaae1
commit
2b35eabd5d
12 changed files with 566 additions and 7 deletions
1
changes/42885-api-only-endpoints-middleware
Normal file
1
changes/42885-api-only-endpoints-middleware
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
42
server/platform/endpointer/route_template.go
Normal file
42
server/platform/endpointer/route_template.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
79
server/service/middleware/auth/api_only.go
Normal file
79
server/service/middleware/auth/api_only.go
Normal 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")
|
||||
}
|
||||
337
server/service/middleware/auth/api_only_test.go
Normal file
337
server/service/middleware/auth/api_only_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue