From 647612345cccade384d91a8a72a41709db2a163c Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Wed, 25 Feb 2026 22:20:35 -0600 Subject: [PATCH] Deprecate URLs with "team" and "query" terminology (#40520) **Related issue:** Resolves #40519 # Details This PR adds a new system for registering deprecated URLs separately from the main URLs (i.e. not clogging up `handler.go` with a bunch of `.WithAltPaths()` or similar. It uses a registry that's shared between all the different endpointer, which is then iterated over and a new handler is created for the deprecated endpoint which stores info about the deprecation (the old and new URLs) in the context. A new middleware looks for that context info and, if found, logs a deprecation warning (if the topic is enabled). # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. no need for a changelog as we are not logging the warnings by default ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually * Verified that going to `/teams` with `--logging_enable_topics=deprecated-field-names` got me this log: ``` deprecated_path=/api/_version_/fleet/teams deprecation_warning="API `/api/_version_/fleet/teams` is deprecated, use `/api/_version_/fleet/fleets` instead ``` * Going to `/fleets` with that flag enabled resulted in no deprecation log * Going to `/teams` _without_ the flag enabled resulted in no deprecation log --- server/platform/endpointer/endpoint_utils.go | 95 +++++++- .../endpointer/endpoint_utils_test.go | 103 ++++++++ server/service/handler.go | 99 ++++---- server/service/handler_deprecated_paths.go | 223 ++++++++++++++++++ server/service/integration_logger_test.go | 4 +- 5 files changed, 469 insertions(+), 55 deletions(-) create mode 100644 server/service/handler_deprecated_paths.go diff --git a/server/platform/endpointer/endpoint_utils.go b/server/platform/endpointer/endpoint_utils.go index ee2b752879..dc4065bec8 100644 --- a/server/platform/endpointer/endpoint_utils.go +++ b/server/platform/endpointer/endpoint_utils.go @@ -744,6 +744,87 @@ func WriteBrowserSecurityHeaders(w http.ResponseWriter) { w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") } +// handlerKey identifies a registered handler by HTTP method and unversioned path template. +type handlerKey struct { + method string + path string // unversioned path template, e.g. "/api/_version_/fleet/fleets" +} + +// HandlerRegistry stores HTTP handlers by method+path during endpoint registration, +// enabling lookup for deprecated path alias registration. +type HandlerRegistry struct { + handlers map[handlerKey]http.Handler +} + +// NewHandlerRegistry creates an empty HandlerRegistry. +func NewHandlerRegistry() *HandlerRegistry { + return &HandlerRegistry{handlers: make(map[handlerKey]http.Handler)} +} + +// DeprecatedPathAlias maps a primary (canonical) path to one or more deprecated +// paths that should serve the same handler. +type DeprecatedPathAlias struct { + Method string + PrimaryPath string // canonical path (must already be registered) + DeprecatedPaths []string // old paths to alias +} + +// deprecatedPathInfoKey is the context key for deprecated URL path info. +type deprecatedPathInfoKey struct{} + +// deprecatedPathInfo holds the deprecated and canonical paths for logging. +type deprecatedPathInfo struct { + deprecatedPath string + primaryPath string +} + +// LogDeprecatedPathAlias is a kithttp.RequestFunc (ServerBefore function) +// that checks if the request is using a deprecated URL path alias and, if so, +// elevates the log level to Warn and adds deprecation info to the request log. +// It must run after the LoggingContext is created (i.e. after SetRequestsContexts). +func LogDeprecatedPathAlias(ctx context.Context, _ *http.Request) context.Context { + if !platform_logging.TopicEnabled(platform_logging.DeprecatedFieldTopic) { + return ctx + } + info, ok := ctx.Value(deprecatedPathInfoKey{}).(deprecatedPathInfo) + if !ok { + return ctx + } + logging.WithLevel(ctx, slog.LevelWarn) + logging.WithExtras(ctx, + "deprecated_path", info.deprecatedPath, + "deprecation_warning", fmt.Sprintf("API `%s` is deprecated, use `%s` instead", info.deprecatedPath, info.primaryPath), + ) + return ctx +} + +// RegisterDeprecatedPathAliases registers deprecated URL path aliases that point +// to the same handler as the canonical path, and wraps them in a handler that +// can log deprecation warnings. +func RegisterDeprecatedPathAliases(r *mux.Router, versions []string, registry *HandlerRegistry, aliases []DeprecatedPathAlias) { + allVersions := append(append([]string{}, versions...), "latest") + versionRegex := strings.Join(allVersions, "|") + for _, alias := range aliases { + handler := registry.handlers[handlerKey{alias.Method, alias.PrimaryPath}] + if handler == nil { + panic(fmt.Sprintf("deprecated alias: no handler registered for %s %s", alias.Method, alias.PrimaryPath)) + } + for _, path := range alias.DeprecatedPaths { + // Replace the version placeholder in the deprecated path with a regex that matches all versions, + // so that the same handler can be used for all versions of the deprecated path. + pathForHandler := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", versionRegex), 1) + info := deprecatedPathInfo{deprecatedPath: path, primaryPath: alias.PrimaryPath} + // Wrap the handler to inject deprecation info into the context for logging. + wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), deprecatedPathInfoKey{}, info) + handler.ServeHTTP(w, r.WithContext(ctx)) + }) + nameAndVerb := getNameFromPathAndVerb(alias.Method, path, "") + r.Handle(pathForHandler, wrappedHandler).Name(nameAndVerb).Methods(alias.Method) + } + } +} + type CommonEndpointer[H any] struct { EP Endpointer[H] MakeDecoderFn func(iface any, requestBodyLimit int64) kithttp.DecodeRequestFunc @@ -760,6 +841,12 @@ type CommonEndpointer[H any] struct { // CustomMiddlewareAfterAuth are middlewares that run after authentication. CustomMiddlewareAfterAuth []endpoint.Middleware + // HandlerRegistry, if set, records handlers by method+path for deprecated + // path alias lookup. The pointer is shared across shallow copies (created + // by builder methods like WithAltPaths) so all registrations land in the + // same map. + HandlerRegistry *HandlerRegistry + startingAtVersion string endingAtVersion string alternativePaths []string @@ -971,10 +1058,14 @@ func (e *CommonEndpointer[H]) HandlePathHandler(path string, pathHandler func(pa versionedPath := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1) nameAndVerb := getNameFromPathAndVerb(verb, path, e.startingAtVersion) + handler := pathHandler(versionedPath) if e.usePathPrefix { - e.Router.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) + e.Router.PathPrefix(versionedPath).Handler(handler).Name(nameAndVerb).Methods(verb) } else { - e.Router.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) + e.Router.Handle(versionedPath, handler).Name(nameAndVerb).Methods(verb) + } + if e.HandlerRegistry != nil { + e.HandlerRegistry.handlers[handlerKey{verb, path}] = handler } for _, alias := range e.alternativePaths { nameAndVerb := getNameFromPathAndVerb(verb, alias, e.startingAtVersion) diff --git a/server/platform/endpointer/endpoint_utils_test.go b/server/platform/endpointer/endpoint_utils_test.go index 09c08464a0..9811cce643 100644 --- a/server/platform/endpointer/endpoint_utils_test.go +++ b/server/platform/endpointer/endpoint_utils_test.go @@ -3,11 +3,13 @@ package endpointer import ( "context" "fmt" + "log/slog" "net/http" "net/http/httptest" "testing" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/logging" platform_http "github.com/fleetdm/fleet/v4/server/platform/http" "github.com/go-kit/kit/endpoint" kithttp "github.com/go-kit/kit/transport/http" @@ -123,3 +125,104 @@ func (n nopEP) CallHandlerFunc(f testHandlerFunc, ctx context.Context, request a func (n nopEP) Service() any { return nil } + +func TestRegisterDeprecatedPathAliases(t *testing.T) { + // Set up a router and register a primary endpoint via CommonEndpointer. + r := mux.NewRouter() + registry := NewHandlerRegistry() + versions := []string{"v1", "2022-04"} + + authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req any) (any, error) { + if authctx, ok := authz_ctx.FromContext(ctx); ok { + authctx.SetChecked() + } + return next(ctx, req) + } + } + + ce := &CommonEndpointer[testHandlerFunc]{ + EP: nopEP{}, + MakeDecoderFn: func(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc { + return func(ctx context.Context, r *http.Request) (request any, err error) { + return nopRequest{}, nil + } + }, + EncodeFn: func(ctx context.Context, w http.ResponseWriter, i any) error { + w.WriteHeader(http.StatusOK) + return nil + }, + AuthMiddleware: authMiddleware, + Router: r, + Versions: versions, + HandlerRegistry: registry, + } + + // Register the primary endpoint. + ce.GET("/api/_version_/fleet/fleets", func(ctx context.Context, request any) (platform_http.Errorer, error) { + return nopResponse{}, nil + }, nil) + + // Register a deprecated alias for it. + RegisterDeprecatedPathAliases(r, versions, registry, []DeprecatedPathAlias{ + { + Method: "GET", + PrimaryPath: "/api/_version_/fleet/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/teams"}, + }, + }) + + s := httptest.NewServer(r) + t.Cleanup(s.Close) + + // Both the primary and deprecated paths should return 200. + for _, path := range []string{"/api/v1/fleet/fleets", "/api/v1/fleet/teams", "/api/latest/fleet/teams"} { + resp, err := http.Get(s.URL + path) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "path %s should return 200", path) + } +} + +func TestLogDeprecatedPathAlias(t *testing.T) { + // Without deprecated path info in context, LogDeprecatedPathAlias is a no-op. + lc := &logging.LoggingContext{} + ctx := logging.NewContext(context.Background(), lc) + ctx2 := LogDeprecatedPathAlias(ctx, nil) + require.Equal(t, ctx, ctx2, "should return same context when no deprecated path info") + require.Empty(t, lc.Extras) + + // With deprecated path info, it should set warn level and extras. + ctx = context.WithValue(ctx, deprecatedPathInfoKey{}, deprecatedPathInfo{ + deprecatedPath: "/api/_version_/fleet/teams", + primaryPath: "/api/_version_/fleet/fleets", + }) + LogDeprecatedPathAlias(ctx, nil) + + // Extras is a flat []interface{} of key-value pairs. + require.Len(t, lc.Extras, 4) // "deprecated_path", value, "deprecation_warning", value + require.Equal(t, "deprecated_path", lc.Extras[0]) + require.Equal(t, "/api/_version_/fleet/teams", lc.Extras[1]) + require.Equal(t, "deprecation_warning", lc.Extras[2]) + require.Contains(t, lc.Extras[3], "deprecated") + + // ForceLevel should be set to Warn. + require.NotNil(t, lc.ForceLevel) + require.Equal(t, slog.LevelWarn, *lc.ForceLevel) +} + +func TestRegisterDeprecatedPathAliasesPanicsOnMissing(t *testing.T) { + r := mux.NewRouter() + registry := NewHandlerRegistry() + versions := []string{"v1"} + + require.Panics(t, func() { + RegisterDeprecatedPathAliases(r, versions, registry, []DeprecatedPathAlias{ + { + Method: "GET", + PrimaryPath: "/api/_version_/fleet/nonexistent", + DeprecatedPaths: []string{"/api/_version_/fleet/old"}, + }, + }) + }) +} diff --git a/server/service/handler.go b/server/service/handler.go index a5cfda1420..78c89fe124 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -125,6 +125,7 @@ func MakeHandler( kithttp.ServerBefore( kithttp.PopulateRequestContext, // populate the request context with common fields auth.SetRequestsContexts(svc), + endpointer.LogDeprecatedPathAlias, // log deprecation warning for deprecated URL path aliases setCarveStoreInRequestContext(carveStore), ), kithttp.ServerErrorHandler(&endpointer.ErrorHandler{Logger: logger.SlogLogger()}), @@ -282,9 +283,11 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC extra extraHandlerOpts, ) { apiVersions := []string{"v1", "2022-04"} + registry := endpointer.NewHandlerRegistry() // user-authenticated endpoints ue := newUserAuthenticatedEndpointer(svc, opts, r, apiVersions...) + ue.HandlerRegistry = registry ue.POST("/api/_version_/fleet/trigger", triggerEndpoint, triggerRequest{}) @@ -301,18 +304,18 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/users/roles/spec", applyUserRoleSpecsEndpoint, applyUserRoleSpecsRequest{}) ue.POST("/api/_version_/fleet/translate", translatorEndpoint, translatorRequest{}) - ue.WithAltPaths("/api/_version_/fleet/spec/teams").WithRequestBodySizeLimit(5*units.MiB).POST("/api/_version_/fleet/spec/fleets", applyTeamSpecsEndpoint, applyTeamSpecsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{fleet_id:[0-9]+}/secrets").PATCH("/api/_version_/fleet/fleets/{fleet_id:[0-9]+}/secrets", modifyTeamEnrollSecretsEndpoint, modifyTeamEnrollSecretsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams").POST("/api/_version_/fleet/fleets", createTeamEndpoint, createTeamRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams").GET("/api/_version_/fleet/fleets", listTeamsEndpoint, listTeamsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}").GET("/api/_version_/fleet/fleets/{id:[0-9]+}", getTeamEndpoint, getTeamRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}").PATCH("/api/_version_/fleet/fleets/{id:[0-9]+}", modifyTeamEndpoint, modifyTeamRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}").DELETE("/api/_version_/fleet/fleets/{id:[0-9]+}", deleteTeamEndpoint, deleteTeamRequest{}) - ue.WithRequestBodySizeLimit(2*units.MiB).WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}/agent_options").POST("/api/_version_/fleet/fleets/{id:[0-9]+}/agent_options", modifyTeamAgentOptionsEndpoint, modifyTeamAgentOptionsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}/users").GET("/api/_version_/fleet/fleets/{id:[0-9]+}/users", listTeamUsersEndpoint, listTeamUsersRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}/users").PATCH("/api/_version_/fleet/fleets/{id:[0-9]+}/users", addTeamUsersEndpoint, modifyTeamUsersRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}/users").DELETE("/api/_version_/fleet/fleets/{id:[0-9]+}/users", deleteTeamUsersEndpoint, modifyTeamUsersRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{id:[0-9]+}/secrets").GET("/api/_version_/fleet/fleets/{id:[0-9]+}/secrets", teamEnrollSecretsEndpoint, teamEnrollSecretsRequest{}) + ue.WithRequestBodySizeLimit(5*units.MiB).POST("/api/_version_/fleet/spec/fleets", applyTeamSpecsEndpoint, applyTeamSpecsRequest{}) + ue.PATCH("/api/_version_/fleet/fleets/{fleet_id:[0-9]+}/secrets", modifyTeamEnrollSecretsEndpoint, modifyTeamEnrollSecretsRequest{}) + ue.POST("/api/_version_/fleet/fleets", createTeamEndpoint, createTeamRequest{}) + ue.GET("/api/_version_/fleet/fleets", listTeamsEndpoint, listTeamsRequest{}) + ue.GET("/api/_version_/fleet/fleets/{id:[0-9]+}", getTeamEndpoint, getTeamRequest{}) + ue.PATCH("/api/_version_/fleet/fleets/{id:[0-9]+}", modifyTeamEndpoint, modifyTeamRequest{}) + ue.DELETE("/api/_version_/fleet/fleets/{id:[0-9]+}", deleteTeamEndpoint, deleteTeamRequest{}) + ue.WithRequestBodySizeLimit(2*units.MiB).POST("/api/_version_/fleet/fleets/{id:[0-9]+}/agent_options", modifyTeamAgentOptionsEndpoint, modifyTeamAgentOptionsRequest{}) + ue.GET("/api/_version_/fleet/fleets/{id:[0-9]+}/users", listTeamUsersEndpoint, listTeamUsersRequest{}) + ue.PATCH("/api/_version_/fleet/fleets/{id:[0-9]+}/users", addTeamUsersEndpoint, modifyTeamUsersRequest{}) + ue.DELETE("/api/_version_/fleet/fleets/{id:[0-9]+}/users", deleteTeamUsersEndpoint, modifyTeamUsersRequest{}) + ue.GET("/api/_version_/fleet/fleets/{id:[0-9]+}/secrets", teamEnrollSecretsEndpoint, teamEnrollSecretsRequest{}) ue.GET("/api/_version_/fleet/users", listUsersEndpoint, listUsersRequest{}) ue.POST("/api/_version_/fleet/users/admin", createUserEndpoint, createUserRequest{}) @@ -347,18 +350,12 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.StartingAtVersion("2022-04").PATCH("/api/_version_/fleet/policies/{policy_id}", modifyGlobalPolicyEndpoint, modifyGlobalPolicyRequest{}) ue.POST("/api/_version_/fleet/automations/reset", resetAutomationEndpoint, resetAutomationRequest{}) - // Alias /api/_version_/fleet/team/ -> /api/_version_/fleet/teams/ - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/policies", "/api/_version_/fleet/teams/{fleet_id}/policies"). - POST("/api/_version_/fleet/fleets/{fleet_id}/policies", teamPolicyEndpoint, teamPolicyRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/policies", "/api/_version_/fleet/teams/{fleet_id}/policies"). - GET("/api/_version_/fleet/fleets/{fleet_id}/policies", listTeamPoliciesEndpoint, listTeamPoliciesRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/policies/count", "/api/_version_/fleet/teams/{fleet_id}/policies/count"). - GET("/api/_version_/fleet/fleets/{fleet_id}/policies/count", countTeamPoliciesEndpoint, countTeamPoliciesRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/policies/{policy_id}", "/api/_version_/fleet/teams/{fleet_id}/policies/{policy_id}"). - GET("/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", getTeamPolicyByIDEndpoint, getTeamPolicyByIDRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/policies/delete", "/api/_version_/fleet/teams/{fleet_id}/policies/delete"). - POST("/api/_version_/fleet/fleets/{fleet_id}/policies/delete", deleteTeamPoliciesEndpoint, deleteTeamPoliciesRequest{}) - ue.WithAltPaths("/api/_version_/fleet/teams/{fleet_id}/policies/{policy_id}").PATCH("/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", modifyTeamPolicyEndpoint, modifyTeamPolicyRequest{}) + ue.POST("/api/_version_/fleet/fleets/{fleet_id}/policies", teamPolicyEndpoint, teamPolicyRequest{}) + ue.GET("/api/_version_/fleet/fleets/{fleet_id}/policies", listTeamPoliciesEndpoint, listTeamPoliciesRequest{}) + ue.GET("/api/_version_/fleet/fleets/{fleet_id}/policies/count", countTeamPoliciesEndpoint, countTeamPoliciesRequest{}) + ue.GET("/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", getTeamPolicyByIDEndpoint, getTeamPolicyByIDRequest{}) + ue.POST("/api/_version_/fleet/fleets/{fleet_id}/policies/delete", deleteTeamPoliciesEndpoint, deleteTeamPoliciesRequest{}) + ue.PATCH("/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", modifyTeamPolicyEndpoint, modifyTeamPolicyRequest{}) ue.WithRequestBodySizeLimit(fleet.MaxSpecSize).POST("/api/_version_/fleet/spec/policies", applyPolicySpecsEndpoint, applyPolicySpecsRequest{}) ue.POST("/api/_version_/fleet/certificates", createCertificateTemplateEndpoint, createCertificateTemplateRequest{}) @@ -368,17 +365,17 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/spec/certificates", applyCertificateTemplateSpecsEndpoint, applyCertificateTemplateSpecsRequest{}) ue.DELETE("/api/_version_/fleet/spec/certificates", deleteCertificateTemplateSpecsEndpoint, deleteCertificateTemplateSpecsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/{id:[0-9]+}").GET("/api/_version_/fleet/reports/{id:[0-9]+}", getQueryEndpoint, getQueryRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries").GET("/api/_version_/fleet/reports", listQueriesEndpoint, listQueriesRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/{id:[0-9]+}/report").GET("/api/_version_/fleet/reports/{id:[0-9]+}/report", getQueryReportEndpoint, getQueryReportRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries").POST("/api/_version_/fleet/reports", createQueryEndpoint, createQueryRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/{id:[0-9]+}").PATCH("/api/_version_/fleet/reports/{id:[0-9]+}", modifyQueryEndpoint, modifyQueryRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/{name}").DELETE("/api/_version_/fleet/reports/{name}", deleteQueryEndpoint, deleteQueryRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/id/{id:[0-9]+}").DELETE("/api/_version_/fleet/reports/id/{id:[0-9]+}", deleteQueryByIDEndpoint, deleteQueryByIDRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/delete").POST("/api/_version_/fleet/reports/delete", deleteQueriesEndpoint, deleteQueriesRequest{}) - ue.WithAltPaths("/api/_version_/fleet/spec/queries").WithRequestBodySizeLimit(fleet.MaxSpecSize).POST("/api/_version_/fleet/spec/reports", applyQuerySpecsEndpoint, applyQuerySpecsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/spec/queries").GET("/api/_version_/fleet/spec/reports", getQuerySpecsEndpoint, getQuerySpecsRequest{}) - ue.WithAltPaths("/api/_version_/fleet/spec/queries/{name}").GET("/api/_version_/fleet/spec/reports/{name}", getQuerySpecEndpoint, getQuerySpecRequest{}) + ue.GET("/api/_version_/fleet/reports/{id:[0-9]+}", getQueryEndpoint, getQueryRequest{}) + ue.GET("/api/_version_/fleet/reports", listQueriesEndpoint, listQueriesRequest{}) + ue.GET("/api/_version_/fleet/reports/{id:[0-9]+}/report", getQueryReportEndpoint, getQueryReportRequest{}) + ue.POST("/api/_version_/fleet/reports", createQueryEndpoint, createQueryRequest{}) + ue.PATCH("/api/_version_/fleet/reports/{id:[0-9]+}", modifyQueryEndpoint, modifyQueryRequest{}) + ue.DELETE("/api/_version_/fleet/reports/{name}", deleteQueryEndpoint, deleteQueryRequest{}) + ue.DELETE("/api/_version_/fleet/reports/id/{id:[0-9]+}", deleteQueryByIDEndpoint, deleteQueryByIDRequest{}) + ue.POST("/api/_version_/fleet/reports/delete", deleteQueriesEndpoint, deleteQueriesRequest{}) + ue.WithRequestBodySizeLimit(fleet.MaxSpecSize).POST("/api/_version_/fleet/spec/reports", applyQuerySpecsEndpoint, applyQuerySpecsRequest{}) + ue.GET("/api/_version_/fleet/spec/reports", getQuerySpecsEndpoint, getQuerySpecsRequest{}) + ue.GET("/api/_version_/fleet/spec/reports/{name}", getQuerySpecEndpoint, getQuerySpecRequest{}) ue.GET("/api/_version_/fleet/packs/{id:[0-9]+}", getPackEndpoint, getPackRequest{}) ue.POST("/api/_version_/fleet/packs", createPackEndpoint, createPackRequest{}) @@ -475,7 +472,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) ue.GET("/api/_version_/fleet/os_versions/{id:[0-9]+}", getOSVersionEndpoint, getOSVersionRequest{}) - ue.WithAltPaths("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{report_id:[0-9]+}").GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{}) ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) @@ -498,16 +495,16 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/spec/labels/{name}", getLabelSpecEndpoint, getGenericSpecRequest{}) // This endpoint runs live queries synchronously (with a configured timeout). - ue.WithAltPaths("/api/_version_/fleet/queries/{id:[0-9]+}/run").POST("/api/_version_/fleet/reports/{id:[0-9]+}/run", runOneLiveQueryEndpoint, runOneLiveQueryRequest{}) + ue.POST("/api/_version_/fleet/reports/{id:[0-9]+}/run", runOneLiveQueryEndpoint, runOneLiveQueryRequest{}) // Old endpoint, removed from docs. This GET endpoint runs live queries synchronously (with a configured timeout). - ue.WithAltPaths("/api/_version_/fleet/queries/run").GET("/api/_version_/fleet/reports/run", runLiveQueryEndpoint, runLiveQueryRequest{}) + ue.GET("/api/_version_/fleet/reports/run", runLiveQueryEndpoint, runLiveQueryRequest{}) // The following two POST APIs are the asynchronous way to run live queries. // The live queries are created with these two endpoints and their results can be queried via // websockets via the `GET /api/_version_/fleet/results/` endpoint. - ue.WithAltPaths("/api/_version_/fleet/queries/run").POST("/api/_version_/fleet/reports/run", createDistributedQueryCampaignEndpoint, createDistributedQueryCampaignRequest{}) - ue.WithAltPaths("/api/_version_/fleet/queries/run_by_identifiers").POST("/api/_version_/fleet/reports/run_by_identifiers", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{}) + ue.POST("/api/_version_/fleet/reports/run", createDistributedQueryCampaignEndpoint, createDistributedQueryCampaignRequest{}) + ue.POST("/api/_version_/fleet/reports/run_by_identifiers", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{}) // This endpoint is deprecated and maintained for backwards compatibility. This and above endpoint are functionally equivalent - ue.WithAltPaths("/api/_version_/fleet/queries/run_by_names").POST("/api/_version_/fleet/reports/run_by_names", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{}) + ue.POST("/api/_version_/fleet/reports/run_by_names", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{}) ue.GET("/api/_version_/fleet/packs/{id:[0-9]+}/scheduled", getScheduledQueriesInPackEndpoint, getScheduledQueriesInPackRequest{}) ue.EndingAtVersion("v1").POST("/api/_version_/fleet/schedule", scheduleQueryEndpoint, scheduleQueryRequest{}) @@ -527,15 +524,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.EndingAtVersion("v1").DELETE("/api/_version_/fleet/global/schedule/{id:[0-9]+}", deleteGlobalScheduleEndpoint, deleteGlobalScheduleRequest{}) ue.StartingAtVersion("2022-04").DELETE("/api/_version_/fleet/schedule/{id:[0-9]+}", deleteGlobalScheduleEndpoint, deleteGlobalScheduleRequest{}) - // Alias /api/_version_/fleet/team/ -> /api/_version_/fleet/teams/ - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/schedule", "/api/_version_/fleet/teams/{fleet_id}/schedule"). - GET("/api/_version_/fleet/fleets/{fleet_id}/schedule", getTeamScheduleEndpoint, getTeamScheduleRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/schedule", "/api/_version_/fleet/teams/{fleet_id}/schedule"). - POST("/api/_version_/fleet/fleets/{fleet_id}/schedule", teamScheduleQueryEndpoint, teamScheduleQueryRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/schedule/{report_id}", "/api/_version_/fleet/teams/{fleet_id}/schedule/{report_id}"). - PATCH("/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", modifyTeamScheduleEndpoint, modifyTeamScheduleRequest{}) - ue.WithAltPaths("/api/_version_/fleet/team/{fleet_id}/schedule/{report_id}", "/api/_version_/fleet/teams/{fleet_id}/schedule/{report_id}"). - DELETE("/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", deleteTeamScheduleEndpoint, deleteTeamScheduleRequest{}) + ue.GET("/api/_version_/fleet/fleets/{fleet_id}/schedule", getTeamScheduleEndpoint, getTeamScheduleRequest{}) + ue.POST("/api/_version_/fleet/fleets/{fleet_id}/schedule", teamScheduleQueryEndpoint, teamScheduleQueryRequest{}) + ue.PATCH("/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", modifyTeamScheduleEndpoint, modifyTeamScheduleRequest{}) + ue.DELETE("/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", deleteTeamScheduleEndpoint, deleteTeamScheduleRequest{}) ue.GET("/api/_version_/fleet/carves", listCarvesEndpoint, listCarvesRequest{}) ue.GET("/api/_version_/fleet/carves/{id:[0-9]+}", getCarveEndpoint, getCarveRequest{}) @@ -829,7 +821,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.DELETE("/api/_version_/fleet/abm_tokens/{id:[0-9]+}", deleteABMTokenEndpoint, deleteABMTokenRequest{}) ue.GET("/api/_version_/fleet/abm_tokens", listABMTokensEndpoint, nil) ue.GET("/api/_version_/fleet/abm_tokens/count", countABMTokensEndpoint, nil) - ue.WithAltPaths("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/teams").PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/fleets", updateABMTokenTeamsEndpoint, updateABMTokenTeamsRequest{}) + ue.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/fleets", updateABMTokenTeamsEndpoint, updateABMTokenTeamsRequest{}) ue.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/renew", renewABMTokenEndpoint, renewABMTokenRequest{}) ue.GET("/api/_version_/fleet/mdm/apple/request_csr", getMDMAppleCSREndpoint, getMDMAppleCSRRequest{}) @@ -839,7 +831,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // VPP Tokens ue.GET("/api/_version_/fleet/vpp_tokens", getVPPTokens, getVPPTokensRequest{}) ue.POST("/api/_version_/fleet/vpp_tokens", uploadVPPTokenEndpoint, uploadVPPTokenRequest{}) - ue.WithAltPaths("/api/_version_/fleet/vpp_tokens/{id}/teams").PATCH("/api/_version_/fleet/vpp_tokens/{id}/fleets", patchVPPTokensTeams, patchVPPTokensTeamsRequest{}) + ue.PATCH("/api/_version_/fleet/vpp_tokens/{id}/fleets", patchVPPTokensTeams, patchVPPTokensTeamsRequest{}) ue.PATCH("/api/_version_/fleet/vpp_tokens/{id}/renew", patchVPPTokenRenewEndpoint, patchVPPTokenRenewRequest{}) ue.DELETE("/api/_version_/fleet/vpp_tokens/{id}", deleteVPPToken, deleteVPPTokenRequest{}) @@ -1103,6 +1095,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC POST("/api/_version_/fleet/mdm/sso", initiateMDMSSOEndpoint, initiateMDMSSORequest{}) ne.WithCustomMiddleware(mdmSsoLimiter). POST("/api/_version_/fleet/mdm/sso/callback", callbackMDMSSOEndpoint, callbackMDMSSORequest{}) + + // Register all deprecated URL path aliases from the declarative table. + endpointer.RegisterDeprecatedPathAliases(r, apiVersions, registry, deprecatedPathAliases) } // WithSetup is an http middleware that checks if setup procedures have been completed. diff --git a/server/service/handler_deprecated_paths.go b/server/service/handler_deprecated_paths.go new file mode 100644 index 0000000000..677b9a6b26 --- /dev/null +++ b/server/service/handler_deprecated_paths.go @@ -0,0 +1,223 @@ +package service + +import ( + eu "github.com/fleetdm/fleet/v4/server/platform/endpointer" +) + +// deprecatedPathAliases defines deprecated URL path aliases that map old +// (deprecated) paths to their canonical (primary) paths. Each entry causes +// the deprecated path(s) to serve the same handler as the primary path. +// +// These are organized by category: +// - teams → fleets: team CRUD, secrets, agent_options, users, spec +// - team/teams → fleets: policies, schedule (both singular and plural deprecated) +// - queries → reports: query CRUD, spec, report data +// - host queries → reports +// - live queries → reports: run, run_by_identifiers, run_by_names +// - ABM/VPP token teams → fleets +var deprecatedPathAliases = []eu.DeprecatedPathAlias{ + // ---- teams → fleets ---- + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/spec/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/spec/teams"}, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id:[0-9]+}/secrets", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{fleet_id:[0-9]+}/secrets"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/teams"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/teams"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}"}, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}"}, + }, + { + Method: "DELETE", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}/agent_options", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}/agent_options"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}/users", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}/users"}, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}/users", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}/users"}, + }, + { + Method: "DELETE", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}/users", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}/users"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{id:[0-9]+}/secrets", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{id:[0-9]+}/secrets"}, + }, + + // ---- team/teams → fleets (policies) ---- + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/policies", + "/api/_version_/fleet/teams/{fleet_id}/policies", + }, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/policies", + "/api/_version_/fleet/teams/{fleet_id}/policies", + }, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies/count", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/policies/count", + "/api/_version_/fleet/teams/{fleet_id}/policies/count", + }, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/policies/{policy_id}", + "/api/_version_/fleet/teams/{fleet_id}/policies/{policy_id}", + }, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies/delete", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/policies/delete", + "/api/_version_/fleet/teams/{fleet_id}/policies/delete", + }, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/policies/{policy_id}", + DeprecatedPaths: []string{"/api/_version_/fleet/teams/{fleet_id}/policies/{policy_id}"}, + }, + + // ---- queries → reports ---- + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/reports/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/{id:[0-9]+}"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/reports", + DeprecatedPaths: []string{"/api/_version_/fleet/queries"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/reports/{id:[0-9]+}/report", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/{id:[0-9]+}/report"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports", + DeprecatedPaths: []string{"/api/_version_/fleet/queries"}, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/reports/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/{id:[0-9]+}"}, + }, + { + Method: "DELETE", PrimaryPath: "/api/_version_/fleet/reports/{name}", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/{name}"}, + }, + { + Method: "DELETE", PrimaryPath: "/api/_version_/fleet/reports/id/{id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/id/{id:[0-9]+}"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports/delete", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/delete"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/spec/reports", + DeprecatedPaths: []string{"/api/_version_/fleet/spec/queries"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/spec/reports", + DeprecatedPaths: []string{"/api/_version_/fleet/spec/queries"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/spec/reports/{name}", + DeprecatedPaths: []string{"/api/_version_/fleet/spec/queries/{name}"}, + }, + + // ---- host queries → reports ---- + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}", + DeprecatedPaths: []string{"/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{report_id:[0-9]+}"}, + }, + + // ---- live queries → reports ---- + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports/{id:[0-9]+}/run", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/{id:[0-9]+}/run"}, + }, + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/reports/run", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/run"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports/run", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/run"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports/run_by_identifiers", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/run_by_identifiers"}, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/reports/run_by_names", + DeprecatedPaths: []string{"/api/_version_/fleet/queries/run_by_names"}, + }, + + // ---- team/teams → fleets (schedule) ---- + { + Method: "GET", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/schedule", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/schedule", + "/api/_version_/fleet/teams/{fleet_id}/schedule", + }, + }, + { + Method: "POST", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/schedule", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/schedule", + "/api/_version_/fleet/teams/{fleet_id}/schedule", + }, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/schedule/{report_id}", + "/api/_version_/fleet/teams/{fleet_id}/schedule/{report_id}", + }, + }, + { + Method: "DELETE", PrimaryPath: "/api/_version_/fleet/fleets/{fleet_id}/schedule/{report_id}", + DeprecatedPaths: []string{ + "/api/_version_/fleet/team/{fleet_id}/schedule/{report_id}", + "/api/_version_/fleet/teams/{fleet_id}/schedule/{report_id}", + }, + }, + + // ---- ABM/VPP token teams → fleets ---- + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/abm_tokens/{id:[0-9]+}/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/abm_tokens/{id:[0-9]+}/teams"}, + }, + { + Method: "PATCH", PrimaryPath: "/api/_version_/fleet/vpp_tokens/{id}/fleets", + DeprecatedPaths: []string{"/api/_version_/fleet/vpp_tokens/{id}/teams"}, + }, +} diff --git a/server/service/integration_logger_test.go b/server/service/integration_logger_test.go index 4512045bbe..052b9a59f7 100644 --- a/server/service/integration_logger_test.go +++ b/server/service/integration_logger_test.go @@ -95,12 +95,14 @@ func (s *integrationLoggerTestSuite) TestLogger() { assert.Equal(t, "/api/latest/fleet/config", attrs["uri"]) assert.Equal(t, "admin1@example.com", attrs["user"]) case 2: - assert.Equal(t, slog.LevelDebug, rec.Level) + assert.Equal(t, slog.LevelWarn, rec.Level) // Warn because /queries is a deprecated path assert.Equal(t, "POST", attrs["method"]) assert.Equal(t, "/api/latest/fleet/queries", attrs["uri"]) assert.Equal(t, "admin1@example.com", attrs["user"]) assert.Equal(t, "somequery", attrs["name"]) assert.Equal(t, "select 1 from osquery;", attrs["sql"]) + assert.Equal(t, "/api/_version_/fleet/queries", attrs["deprecated_path"]) + assert.Contains(t, attrs["deprecation_warning"], "deprecated") default: t.Fail() }