Implement GET /api/v1/fleet/rest_api (#42883)

**Related issue:** Resolves #42883 

Added a new premium GET /api/_version_/fleet/rest_api endpoint that
returns the contents of the embedded `api_endpoints.yml` artifact.
This commit is contained in:
Juan Fernandez 2026-04-10 12:12:38 -03:00 committed by GitHub
parent d8bd213e4c
commit 1bc32467a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1211 additions and 238 deletions

View file

@ -0,0 +1 @@
* Added a new premium GET /api/_version_/fleet/rest_api endpoint that returns the contents of the embedded `api_endpoints.yml` artifact.

View file

@ -41,6 +41,7 @@ import (
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints"
"github.com/fleetdm/fleet/v4/server/authz"
configpkg "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -1483,8 +1484,8 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore, redisPool, carveStore,
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes, acmeRoutes}, extra...)
if err := service.ValidateAPIEndpoints(apiHandler); err != nil {
panic(fmt.Sprintf("invalid api_endpoints.yml: %v", err))
if err := apiendpoints.Init(apiHandler); err != nil {
panic(fmt.Sprintf("error initializing API endpoints: %v", err))
}
if serveCSP {

View file

@ -0,0 +1,16 @@
package service
import (
"context"
apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func (svc *Service) ListAPIEndpoints(ctx context.Context) ([]fleet.APIEndpoint, error) {
if err := svc.authz.Authorize(ctx, &fleet.APIEndpoint{}, fleet.ActionRead); err != nil {
return nil, ctxerr.Wrap(ctx, err, "authorize list API endpoints")
}
return apiendpoints.GetAPIEndpoints(), nil
}

View file

@ -0,0 +1,85 @@
package apiendpoints
import (
_ "embed"
"fmt"
"net/http"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/ghodss/yaml"
"github.com/gorilla/mux"
)
//go:embed api_endpoints.yml
var apiEndpointsYAML []byte
var apiEndpoints []fleet.APIEndpoint
// GetAPIEndpoints returns a copy of the embedded API endpoints slice.
func GetAPIEndpoints() []fleet.APIEndpoint {
result := make([]fleet.APIEndpoint, len(apiEndpoints))
copy(result, apiEndpoints)
return result
}
func Init(h http.Handler) error {
r, ok := h.(*mux.Router)
if !ok {
return fmt.Errorf("expected *mux.Router, got %T", h)
}
registered := make(map[string]struct{})
_ = r.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error {
tpl, err := route.GetPathTemplate()
if err != nil {
return nil
}
meths, err := route.GetMethods()
if err != nil || len(meths) == 0 {
return nil
}
for _, m := range meths {
val := fleet.NewAPIEndpointFromTpl(m, tpl)
registered[val.Fingerprint()] = struct{}{}
}
return nil
})
loadedApiEndpoints, err := loadGetAPIEndpoints()
if err != nil {
return err
}
var missing []string
for _, e := range loadedApiEndpoints {
if _, ok := registered[e.Fingerprint()]; !ok {
missing = append(missing, e.Method+" "+e.Path)
}
}
if len(missing) > 0 {
return fmt.Errorf("the following API endpoints are unknown: %v", missing)
}
apiEndpoints = loadedApiEndpoints
return nil
}
func loadGetAPIEndpoints() ([]fleet.APIEndpoint, error) {
endpoints := make([]fleet.APIEndpoint, 0)
if err := yaml.Unmarshal(apiEndpointsYAML, &endpoints); err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(endpoints))
for _, e := range endpoints {
fp := e.Fingerprint()
if _, ok := seen[fp]; ok {
panic(fmt.Errorf("duplicate entry (%s, %s)", e.Method, e.Path))
}
seen[fp] = struct{}{}
}
return endpoints, nil
}

View file

@ -0,0 +1,668 @@
- method: "POST"
path: "/api/v1/fleet/trigger"
display_name: "Trigger scheduled tasks"
- method: "GET"
path: "/api/v1/fleet/me"
display_name: "Get current user"
- method: "GET"
path: "/api/v1/fleet/sessions/:id"
display_name: "Get session info"
- method: "DELETE"
path: "/api/v1/fleet/sessions/:id"
display_name: "Delete session"
- method: "GET"
path: "/api/v1/fleet/config/certificate"
display_name: "Get config certificate"
- method: "GET"
path: "/api/v1/fleet/config"
display_name: "Get app config"
- method: "PATCH"
path: "/api/v1/fleet/config"
display_name: "Modify app config"
- method: "POST"
path: "/api/v1/fleet/spec/enroll_secret"
display_name: "Apply enroll secret spec"
- method: "GET"
path: "/api/v1/fleet/spec/enroll_secret"
display_name: "Get enroll secret spec"
- method: "GET"
path: "/api/v1/fleet/version"
display_name: "Get version"
- method: "POST"
path: "/api/v1/fleet/translate"
display_name: "Translate"
- method: "POST"
path: "/api/v1/fleet/spec/fleets"
display_name: "Apply fleet specs"
- method: "PATCH"
path: "/api/v1/fleet/fleets/:fleet_id/secrets"
display_name: "Modify fleet enroll secrets"
- method: "POST"
path: "/api/v1/fleet/fleets"
display_name: "Create fleet"
- method: "GET"
path: "/api/v1/fleet/fleets"
display_name: "List fleets"
- method: "GET"
path: "/api/v1/fleet/fleets/:id"
display_name: "Get fleet"
- method: "PATCH"
path: "/api/v1/fleet/fleets/:id"
display_name: "Modify fleet"
- method: "DELETE"
path: "/api/v1/fleet/fleets/:id"
display_name: "Delete fleet"
- method: "POST"
path: "/api/v1/fleet/fleets/:id/agent_options"
display_name: "Modify fleet agent options"
- method: "GET"
path: "/api/v1/fleet/fleets/:id/users"
display_name: "List fleet users"
- method: "GET"
path: "/api/v1/fleet/users"
display_name: "List users"
- method: "GET"
path: "/api/v1/fleet/users/:id"
display_name: "Get user"
- method: "POST"
path: "/api/v1/fleet/targets"
display_name: "Search targets"
deprecated: true
- method: "POST"
path: "/api/v1/fleet/targets/count"
display_name: "Count targets"
- method: "POST"
path: "/api/v1/fleet/invites"
display_name: "Create invite"
- method: "GET"
path: "/api/v1/fleet/invites"
display_name: "List invites"
- method: "DELETE"
path: "/api/v1/fleet/invites/:id"
display_name: "Delete invite"
- method: "PATCH"
path: "/api/v1/fleet/invites/:id"
display_name: "Update invite"
- method: "POST"
path: "/api/v1/fleet/global/policies"
display_name: "Create global policy"
deprecated: true
- method: "POST"
path: "/api/v1/fleet/policies"
display_name: "Create policy"
- method: "GET"
path: "/api/v1/fleet/global/policies"
display_name: "List global policies"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/policies"
display_name: "List policies"
- method: "GET"
path: "/api/v1/fleet/policies/count"
display_name: "Count policies"
- method: "GET"
path: "/api/v1/fleet/global/policies/:policy_id"
display_name: "Get global policy"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/policies/:policy_id"
display_name: "Get policy"
- method: "POST"
path: "/api/v1/fleet/global/policies/delete"
display_name: "Delete global policies"
deprecated: true
- method: "POST"
path: "/api/v1/fleet/policies/delete"
display_name: "Delete policies"
- method: "PATCH"
path: "/api/v1/fleet/global/policies/:policy_id"
display_name: "Modify global policy"
deprecated: true
- method: "PATCH"
path: "/api/v1/fleet/policies/:policy_id"
display_name: "Modify policy"
- method: "POST"
path: "/api/v1/fleet/automations/reset"
display_name: "Reset automations"
- method: "POST"
path: "/api/v1/fleet/fleets/:fleet_id/policies"
display_name: "Create fleet policy"
- method: "GET"
path: "/api/v1/fleet/fleets/:fleet_id/policies"
display_name: "List fleet policies"
- method: "GET"
path: "/api/v1/fleet/fleets/:fleet_id/policies/count"
display_name: "Count fleet policies"
- method: "GET"
path: "/api/v1/fleet/fleets/:fleet_id/policies/:policy_id"
display_name: "Get fleet policy"
- method: "POST"
path: "/api/v1/fleet/fleets/:fleet_id/policies/delete"
display_name: "Delete fleet policies"
- method: "PATCH"
path: "/api/v1/fleet/fleets/:fleet_id/policies/:policy_id"
display_name: "Modify fleet policy"
- method: "POST"
path: "/api/v1/fleet/spec/policies"
display_name: "Apply policy specs"
- method: "POST"
path: "/api/v1/fleet/certificates"
display_name: "Create certificate template"
- method: "GET"
path: "/api/v1/fleet/certificates"
display_name: "List certificate templates"
- method: "GET"
path: "/api/v1/fleet/certificates/:id"
display_name: "Get certificate template"
- method: "DELETE"
path: "/api/v1/fleet/certificates/:id"
display_name: "Delete certificate template"
- method: "POST"
path: "/api/v1/fleet/spec/certificates"
display_name: "Apply certificate template specs"
- method: "DELETE"
path: "/api/v1/fleet/spec/certificates"
display_name: "Delete certificate template specs"
- method: "GET"
path: "/api/v1/fleet/reports/:id"
display_name: "Get report"
- method: "GET"
path: "/api/v1/fleet/reports"
display_name: "List reports"
- method: "GET"
path: "/api/v1/fleet/reports/:id/report"
display_name: "Get report results"
- method: "POST"
path: "/api/v1/fleet/reports"
display_name: "Create report"
- method: "PATCH"
path: "/api/v1/fleet/reports/:id"
display_name: "Modify report"
- method: "DELETE"
path: "/api/v1/fleet/reports/:name"
display_name: "Delete report"
- method: "DELETE"
path: "/api/v1/fleet/reports/id/:id"
display_name: "Delete report by ID"
- method: "POST"
path: "/api/v1/fleet/reports/delete"
display_name: "Delete reports"
- method: "POST"
path: "/api/v1/fleet/spec/reports"
display_name: "Apply report specs"
- method: "GET"
path: "/api/v1/fleet/spec/reports"
display_name: "Get report specs"
- method: "GET"
path: "/api/v1/fleet/spec/reports/:name"
display_name: "Get report spec"
- method: "GET"
path: "/api/v1/fleet/packs/:id"
display_name: "Get pack"
- method: "POST"
path: "/api/v1/fleet/packs"
display_name: "Create pack"
- method: "PATCH"
path: "/api/v1/fleet/packs/:id"
display_name: "Modify pack"
- method: "GET"
path: "/api/v1/fleet/packs"
display_name: "List packs"
- method: "DELETE"
path: "/api/v1/fleet/packs/:name"
display_name: "Delete pack"
- method: "DELETE"
path: "/api/v1/fleet/packs/id/:id"
display_name: "Delete pack by ID"
- method: "POST"
path: "/api/v1/fleet/spec/packs"
display_name: "Apply pack specs"
- method: "GET"
path: "/api/v1/fleet/spec/packs"
display_name: "Get pack specs"
- method: "GET"
path: "/api/v1/fleet/spec/packs/:name"
display_name: "Get pack spec"
- method: "GET"
path: "/api/v1/fleet/software/versions"
display_name: "List software versions"
- method: "GET"
path: "/api/v1/fleet/software/versions/:id"
display_name: "Get software version"
- method: "GET"
path: "/api/v1/fleet/software"
display_name: "List software"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/software/:id"
display_name: "Get software"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/software/count"
display_name: "Count software"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/software/titles"
display_name: "List software titles"
- method: "GET"
path: "/api/v1/fleet/software/titles/:id"
display_name: "Get software title"
- method: "POST"
path: "/api/v1/fleet/hosts/:host_id/software/:software_title_id/install"
display_name: "Install software"
- method: "POST"
path: "/api/v1/fleet/hosts/:host_id/software/:software_title_id/uninstall"
display_name: "Uninstall software"
- method: "GET"
path: "/api/v1/fleet/software/titles/:title_id/package"
display_name: "Get software installer"
- method: "POST"
path: "/api/v1/fleet/software/titles/:title_id/package/token"
display_name: "Get software installer token"
- method: "POST"
path: "/api/v1/fleet/software/package"
display_name: "Upload software installer"
- method: "PATCH"
path: "/api/v1/fleet/software/titles/:id/name"
display_name: "Update software name"
- method: "PATCH"
path: "/api/v1/fleet/software/titles/:id/package"
display_name: "Update software installer"
- method: "DELETE"
path: "/api/v1/fleet/software/titles/:title_id/available_for_install"
display_name: "Delete software installer"
- method: "GET"
path: "/api/v1/fleet/software/install/:install_uuid/results"
display_name: "Get software install results"
- method: "POST"
path: "/api/v1/fleet/software/batch"
display_name: "Batch set software installers"
- method: "GET"
path: "/api/v1/fleet/software/batch/:request_uuid"
display_name: "Get batch software installers result"
- method: "GET"
path: "/api/v1/fleet/software/titles/:title_id/icon"
display_name: "Get software title icon"
- method: "PUT"
path: "/api/v1/fleet/software/titles/:title_id/icon"
display_name: "Set software title icon"
- method: "DELETE"
path: "/api/v1/fleet/software/titles/:title_id/icon"
display_name: "Delete software title icon"
- method: "GET"
path: "/api/v1/fleet/software/app_store_apps"
display_name: "Get app store apps"
- method: "POST"
path: "/api/v1/fleet/software/app_store_apps"
display_name: "Add app store app"
- method: "PATCH"
path: "/api/v1/fleet/software/titles/:title_id/app_store_app"
display_name: "Update app store app"
- method: "PUT"
path: "/api/v1/fleet/setup_experience/software"
display_name: "Set setup experience software"
- method: "GET"
path: "/api/v1/fleet/setup_experience/software"
display_name: "Get setup experience software"
- method: "GET"
path: "/api/v1/fleet/setup_experience/script"
display_name: "Get setup experience script"
- method: "POST"
path: "/api/v1/fleet/setup_experience/script"
display_name: "Set setup experience script"
- method: "DELETE"
path: "/api/v1/fleet/setup_experience/script"
display_name: "Delete setup experience script"
- method: "POST"
path: "/api/v1/fleet/software/fleet_maintained_apps"
display_name: "Add fleet-maintained app"
- method: "GET"
path: "/api/v1/fleet/software/fleet_maintained_apps"
display_name: "List fleet-maintained apps"
- method: "GET"
path: "/api/v1/fleet/software/fleet_maintained_apps/:app_id"
display_name: "Get fleet-maintained app"
- method: "GET"
path: "/api/v1/fleet/vulnerabilities"
display_name: "List vulnerabilities"
- method: "GET"
path: "/api/v1/fleet/vulnerabilities/:cve"
display_name: "Get vulnerability"
- method: "GET"
path: "/api/v1/fleet/host_summary"
display_name: "Get host summary"
- method: "GET"
path: "/api/v1/fleet/hosts"
display_name: "List hosts"
- method: "POST"
path: "/api/v1/fleet/hosts/delete"
display_name: "Delete hosts"
- method: "GET"
path: "/api/v1/fleet/hosts/:id"
display_name: "Get host"
- method: "GET"
path: "/api/v1/fleet/hosts/count"
display_name: "Count hosts"
- method: "POST"
path: "/api/v1/fleet/hosts/search"
display_name: "Search hosts"
- method: "GET"
path: "/api/v1/fleet/hosts/identifier/:identifier"
display_name: "Get host by identifier"
- method: "POST"
path: "/api/v1/fleet/hosts/identifier/:identifier/query"
display_name: "Run live query on host by identifier"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/query"
display_name: "Run live query on host"
- method: "DELETE"
path: "/api/v1/fleet/hosts/:id"
display_name: "Delete host"
- method: "POST"
path: "/api/v1/fleet/hosts/transfer"
display_name: "Transfer hosts to a team"
- method: "POST"
path: "/api/v1/fleet/hosts/transfer/filter"
display_name: "Transfer hosts to a team by filter"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/refetch"
display_name: "Refetch host"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/device_mapping"
display_name: "List host device mapping"
deprecated: true
- method: "PUT"
path: "/api/v1/fleet/hosts/:id/device_mapping"
display_name: "Set host device mapping"
- method: "DELETE"
path: "/api/v1/fleet/hosts/:id/device_mapping/idp"
display_name: "Delete host IDP mapping"
- method: "GET"
path: "/api/v1/fleet/hosts/report"
display_name: "Get hosts report"
- method: "GET"
path: "/api/v1/fleet/os_versions"
display_name: "List OS versions"
- method: "GET"
path: "/api/v1/fleet/os_versions/:id"
display_name: "Get OS version"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/reports/:report_id"
display_name: "Get host report"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/reports"
display_name: "List host reports"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/queries"
display_name: "List host queries"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/hosts/:id/health"
display_name: "Get host health"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/labels"
display_name: "Add labels to host"
- method: "DELETE"
path: "/api/v1/fleet/hosts/:id/labels"
display_name: "Remove labels from host"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/software"
display_name: "Get host software"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/certificates"
display_name: "List host certificates"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/certificates/:template_id/resend"
display_name: "Resend host certificate"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/recovery_lock_password"
display_name: "Get host recovery lock password"
- method: "GET"
path: "/api/v1/fleet/hosts/summary/mdm"
display_name: "Get host MDM summary"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/mdm"
display_name: "Get host MDM"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/dep_assignment"
display_name: "Get host DEP assignment"
- method: "POST"
path: "/api/v1/fleet/labels"
display_name: "Create label"
- method: "PATCH"
path: "/api/v1/fleet/labels/:id"
display_name: "Modify label"
- method: "GET"
path: "/api/v1/fleet/labels/:id"
display_name: "Get label"
- method: "GET"
path: "/api/v1/fleet/labels"
display_name: "List labels"
- method: "GET"
path: "/api/v1/fleet/labels/summary"
display_name: "Get labels summary"
- method: "GET"
path: "/api/v1/fleet/labels/:id/hosts"
display_name: "List hosts in label"
- method: "DELETE"
path: "/api/v1/fleet/labels/:name"
display_name: "Delete label"
- method: "DELETE"
path: "/api/v1/fleet/labels/id/:id"
display_name: "Delete label by ID"
- method: "POST"
path: "/api/v1/fleet/spec/labels"
display_name: "Apply label specs"
- method: "GET"
path: "/api/v1/fleet/spec/labels"
display_name: "Get label specs"
- method: "GET"
path: "/api/v1/fleet/spec/labels/:name"
display_name: "Get label spec"
- method: "POST"
path: "/api/v1/fleet/reports/:id/run"
display_name: "Run live query"
- method: "GET"
path: "/api/v1/fleet/reports/run"
display_name: "Run live query"
deprecated: true
- method: "POST"
path: "/api/v1/fleet/reports/run"
display_name: "Create distributed query campaign"
- method: "POST"
path: "/api/v1/fleet/reports/run_by_identifiers"
display_name: "Create distributed query campaign by identifier"
- method: "POST"
path: "/api/v1/fleet/reports/run_by_names"
display_name: "Create distributed query campaign by names"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/packs/:id/scheduled"
display_name: "Get scheduled queries in pack"
- method: "POST"
path: "/api/v1/fleet/packs/schedule"
display_name: "Schedule query"
- method: "GET"
path: "/api/v1/fleet/schedule/:id"
display_name: "Get scheduled query"
- method: "PATCH"
path: "/api/v1/fleet/packs/schedule/:id"
display_name: "Modify scheduled query"
- method: "DELETE"
path: "/api/v1/fleet/packs/schedule/:id"
display_name: "Delete scheduled query"
- method: "GET"
path: "/api/v1/fleet/schedule"
display_name: "Get global schedule"
- method: "POST"
path: "/api/v1/fleet/schedule"
display_name: "Add query to global schedule"
- method: "PATCH"
path: "/api/v1/fleet/schedule/:id"
display_name: "Modify global scheduled query"
- method: "DELETE"
path: "/api/v1/fleet/schedule/:id"
display_name: "Delete global scheduled query"
- method: "GET"
path: "/api/v1/fleet/global/schedule"
display_name: "Get global schedule"
deprecated: true
- method: "POST"
path: "/api/v1/fleet/global/schedule"
display_name: "Add query to global schedule"
deprecated: true
- method: "PATCH"
path: "/api/v1/fleet/global/schedule/:id"
display_name: "Modify global scheduled query"
deprecated: true
- method: "DELETE"
path: "/api/v1/fleet/global/schedule/:id"
display_name: "Delete global scheduled query"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/fleets/:fleet_id/schedule"
display_name: "Get fleet schedule"
- method: "POST"
path: "/api/v1/fleet/fleets/:fleet_id/schedule"
display_name: "Add query to fleet schedule"
- method: "PATCH"
path: "/api/v1/fleet/fleets/:fleet_id/schedule/:report_id"
display_name: "Modify fleet scheduled query"
- method: "DELETE"
path: "/api/v1/fleet/fleets/:fleet_id/schedule/:report_id"
display_name: "Delete fleet scheduled query"
- method: "GET"
path: "/api/v1/fleet/carves"
display_name: "List carves"
- method: "GET"
path: "/api/v1/fleet/carves/:id"
display_name: "Get carve"
- method: "GET"
path: "/api/v1/fleet/carves/:id/block/:block_id"
display_name: "Get carve block"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/macadmins"
display_name: "Get host macadmins data"
- method: "GET"
path: "/api/v1/fleet/macadmins"
display_name: "Get aggregated macadmins data"
- method: "GET"
path: "/api/v1/fleet/status/result_store"
display_name: "Get result store status"
- method: "GET"
path: "/api/v1/fleet/status/live_query"
display_name: "Get live query status"
- method: "POST"
path: "/api/v1/fleet/scripts/run"
display_name: "Run script"
- method: "POST"
path: "/api/v1/fleet/scripts/run/sync"
display_name: "Run script sync"
- method: "POST"
path: "/api/v1/fleet/scripts/run/batch"
display_name: "Batch run scripts"
- method: "GET"
path: "/api/v1/fleet/scripts/results/:execution_id"
display_name: "Get script results"
- method: "POST"
path: "/api/v1/fleet/scripts"
display_name: "Create script"
- method: "GET"
path: "/api/v1/fleet/scripts"
display_name: "List scripts"
- method: "GET"
path: "/api/v1/fleet/scripts/:script_id"
display_name: "Get script"
- method: "PATCH"
path: "/api/v1/fleet/scripts/:script_id"
display_name: "Update script"
- method: "DELETE"
path: "/api/v1/fleet/scripts/:script_id"
display_name: "Delete script"
- method: "POST"
path: "/api/v1/fleet/scripts/batch"
display_name: "Batch set scripts"
- method: "POST"
path: "/api/v1/fleet/scripts/batch/:batch_execution_id/cancel"
display_name: "Cancel batch script execution"
- method: "GET"
path: "/api/v1/fleet/scripts/batch/summary/:batch_execution_id"
display_name: "Get batch script execution summary"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/scripts/batch/:batch_execution_id/host_results"
display_name: "Get batch script host results"
- method: "GET"
path: "/api/v1/fleet/scripts/batch/:batch_execution_id/host-results"
display_name: "Get batch script host results"
deprecated: true
- method: "GET"
path: "/api/v1/fleet/scripts/batch/:batch_execution_id"
display_name: "Get batch script execution status"
- method: "GET"
path: "/api/v1/fleet/scripts/batch"
display_name: "List batch script executions"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/scripts"
display_name: "Get host script details"
- method: "GET"
path: "/api/v1/fleet/hosts/:id/activities/upcoming"
display_name: "List host upcoming activities"
- method: "DELETE"
path: "/api/v1/fleet/hosts/:id/activities/upcoming/:activity_id"
display_name: "Cancel host upcoming activity"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/lock"
display_name: "Lock host"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/unlock"
display_name: "Unlock host"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/wipe"
display_name: "Wipe host"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/clear_passcode"
display_name: "Clear host passcode"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/recovery_lock_password/rotate"
display_name: "Rotate host recovery lock password"
- method: "POST"
path: "/api/v1/fleet/autofill/policy"
display_name: "Autofill policy"
- method: "PUT"
path: "/api/v1/fleet/spec/secret_variables"
display_name: "Create secret variables"
- method: "POST"
path: "/api/v1/fleet/custom_variables"
display_name: "Create custom variable"
- method: "GET"
path: "/api/v1/fleet/custom_variables"
display_name: "List custom variables"
- method: "DELETE"
path: "/api/v1/fleet/custom_variables/:id"
display_name: "Delete custom variable"
- method: "GET"
path: "/api/v1/fleet/scim/details"
display_name: "Get SCIM details"
- method: "POST"
path: "/api/v1/fleet/conditional-access/microsoft"
display_name: "Create Microsoft conditional access"
- method: "POST"
path: "/api/v1/fleet/conditional-access/microsoft/confirm"
display_name: "Confirm Microsoft conditional access"
- method: "DELETE"
path: "/api/v1/fleet/conditional-access/microsoft"
display_name: "Delete Microsoft conditional access"
- method: "GET"
path: "/api/v1/fleet/conditional_access/idp/signing_cert"
display_name: "Get IdP signing certificate"
- method: "GET"
path: "/api/v1/fleet/conditional_access/idp/apple/profile"
display_name: "Get IdP Apple profile"
- method: "PATCH"
path: "/api/v1/fleet/mdm/apple/setup"
display_name: "Update MDM Apple setup"
deprecated: true
- method: "PATCH"
path: "/api/v1/fleet/setup_experience"
display_name: "Update setup experience"

View file

@ -0,0 +1,65 @@
package apiendpoints
import (
"net/http"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
func TestValidateAPIEndpoints(t *testing.T) {
originalYAML := apiEndpointsYAML
t.Cleanup(func() {
apiEndpointsYAML = originalYAML
})
endpoints := []fleet.APIEndpoint{
fleet.NewAPIEndpointFromTpl("GET", "/api/v1/fleet/hosts"),
fleet.NewAPIEndpointFromTpl("POST", "/api/v1/fleet/hosts/:id/refetch"),
}
apiEndpointsYAML = []byte(`
- method: "GET"
path: "/api/v1/fleet/hosts"
display_name: "Route 1"
- method: "POST"
path: "/api/v1/fleet/hosts/:id/refetch"
display_name: "Route 2"`)
routerWithEndpoints := func(endpoints []fleet.APIEndpoint) *mux.Router {
r := mux.NewRouter()
for _, e := range endpoints {
path := strings.Replace(e.Path, "/_version_/", "/{fleetversion:(?:v1|latest)}/", 1)
r.Handle(path, http.NotFoundHandler()).Methods(e.Method)
}
return r
}
t.Run("all routes present", func(t *testing.T) {
err := Init(routerWithEndpoints(endpoints))
require.NoError(t, err)
})
t.Run("missing route returns error", func(t *testing.T) {
err := Init(routerWithEndpoints(endpoints[:1]))
require.ErrorContains(t, err, endpoints[1].Method+" "+endpoints[1].Path)
})
t.Run("no routes registered returns error listing all missing", func(t *testing.T) {
err := Init(mux.NewRouter())
require.ErrorContains(t, err, "the following API endpoints are unknown")
})
t.Run("non-mux handler returns error", func(t *testing.T) {
err := Init(http.NewServeMux())
require.ErrorContains(t, err, "expected *mux.Router")
})
t.Run("empty endpoint list always passes", func(t *testing.T) {
apiEndpointsYAML = []byte(``)
err := Init(mux.NewRouter())
require.NoError(t, err)
})
}

View file

@ -1298,3 +1298,21 @@ allow {
team_role(subject, object.team_id) == [admin, maintainer, gitops][_]
action == [read, write][_]
}
##
# API Endpoints
##
# Global admins can read API endpoints.
allow {
object.type == "api_endpoint"
subject.global_role == admin
action == read
}
# Any team admin can read API endpoints.
allow {
object.type == "api_endpoint"
team_role(subject, subject.teams[_].id) == admin
action == read
}

View file

@ -3009,3 +3009,31 @@ func TestAuthorizeSecretVariables(t *testing.T) {
{user: test.UserTeamTechnicianTeam1, object: secretVariable, action: write, allow: false},
})
}
func TestAuthorizeAPIEndpoint(t *testing.T) {
t.Parallel()
endpoint := &fleet.APIEndpoint{}
runTestCases(t, []authTestCase{
// Unauthenticated always denied.
{user: nil, object: endpoint, action: read, allow: false},
// Global roles: only admin is allowed.
{user: test.UserNoRoles, object: endpoint, action: read, allow: false},
{user: test.UserObserver, object: endpoint, action: read, allow: false},
{user: test.UserObserverPlus, object: endpoint, action: read, allow: false},
{user: test.UserMaintainer, object: endpoint, action: read, allow: false},
{user: test.UserTechnician, object: endpoint, action: read, allow: false},
{user: test.UserGitOps, object: endpoint, action: read, allow: false},
{user: test.UserAdmin, object: endpoint, action: read, allow: true},
// Team roles: only team admins are allowed.
{user: test.UserTeamObserverTeam1, object: endpoint, action: read, allow: false},
{user: test.UserTeamObserverPlusTeam1, object: endpoint, action: read, allow: false},
{user: test.UserTeamMaintainerTeam1, object: endpoint, action: read, allow: false},
{user: test.UserTeamTechnicianTeam1, object: endpoint, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: endpoint, action: read, allow: false},
{user: test.UserTeamAdminTeam1, object: endpoint, action: read, allow: true},
{user: test.UserTeamAdminTeam2, object: endpoint, action: read, allow: true},
})
}

View file

@ -17,13 +17,12 @@ func Up_20260409153714(tx *sql.Tx) error {
path VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
method VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
is_allowed BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
created_by_id INT UNSIGNED,
author_id INT UNSIGNED,
PRIMARY KEY (user_id, path, method),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_id) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
)
`)
if err != nil {

View file

@ -2999,13 +2999,12 @@ CREATE TABLE `user_api_endpoints` (
`user_id` int unsigned NOT NULL,
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`is_allowed` tinyint(1) NOT NULL DEFAULT '1',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by_id` int unsigned DEFAULT NULL,
`author_id` int unsigned DEFAULT NULL,
PRIMARY KEY (`user_id`,`path`,`method`),
KEY `created_by_id` (`created_by_id`),
KEY `author_id` (`author_id`),
CONSTRAINT `user_api_endpoints_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `user_api_endpoints_ibfk_2` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
CONSTRAINT `user_api_endpoints_ibfk_2` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;

View file

@ -0,0 +1,99 @@
package fleet
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
)
// APIEndpoint represents an API endpoint that we can attach permissions to
type APIEndpoint struct {
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
NormalizedPath string `json:"-"`
DisplayName string `json:"display_name" yaml:"display_name"`
Deprecated bool `json:"deprecated" yaml:"deprecated"`
}
// AuthzType implements authz.AuthzTyper.
func (e *APIEndpoint) AuthzType() string {
return "api_endpoint"
}
var validHTTPMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
}
// versionSegmentRe matches the gorilla/mux version segment that attachFleetAPIRoutes
// inserts in place of /_version_/ (e.g. /{fleetversion:(?:v1|2022-04|latest)}/).
var versionSegmentRe = regexp.MustCompile(`/\{fleetversion:[^}]+\}/`)
// NewAPIEndpointFromTpl creates a new APIEndpoint from the provided params.
// tpl is meant to be a route template as usually defined in the mux router,
// or a path using the /_version_/ placeholder convention.
func NewAPIEndpointFromTpl(method string, tpl string) APIEndpoint {
path := versionSegmentRe.ReplaceAllString(tpl, "/v1/")
path = strings.ReplaceAll(path, "/_version_/", "/v1/")
val := APIEndpoint{
Method: method,
Path: path,
}
val.normalize()
return val
}
// normalize method and path properties
func (e *APIEndpoint) normalize() {
e.Method = strings.ToUpper(e.Method)
segments := strings.Split(e.Path, "/")
n := 0
for i, seg := range segments {
if strings.HasPrefix(seg, ":") ||
(strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}")) {
n++
segments[i] = fmt.Sprintf(":placeholder_%d", n)
}
}
e.NormalizedPath = strings.ToLower(strings.Join(segments, "/"))
}
// Fingerprint return a string that uniquely identifies
// the APIEndpoint
func (e APIEndpoint) Fingerprint() string {
return fmt.Sprintf("|%s|%s|", e.Method, e.NormalizedPath)
}
func (e APIEndpoint) validate() error {
if strings.TrimSpace(e.DisplayName) == "" {
return errors.New("display_name is required")
}
if _, ok := validHTTPMethods[e.Method]; !ok {
return fmt.Errorf("invalid HTTP method %q", e.Method)
}
if strings.TrimSpace(e.Path) == "" {
return errors.New("path is required")
}
return nil
}
func (e *APIEndpoint) UnmarshalJSON(data []byte) error {
// Use an alias to prevent infinite recursion.
type Alias APIEndpoint
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}
*e = APIEndpoint(alias)
e.normalize()
return e.validate()
}

View file

@ -0,0 +1,114 @@
package fleet
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIdIsConstructedCorrectly(t *testing.T) {
tests := []struct {
method string
path string
want string
}{
{
method: "GET",
path: "/api/v1/fleet/trigger",
want: "|GET|/api/v1/fleet/trigger|",
},
{
method: "GET",
path: "/software/titles/:title_id/icon/:id",
want: "|GET|/software/titles/:placeholder_1/icon/:placeholder_2|",
},
{
method: "GET",
path: "/api/v1/fleet/hosts/:id",
want: "|GET|/api/v1/fleet/hosts/:placeholder_1|",
},
{
method: "post",
path: "/a/:b/:c/:d",
want: "|POST|/a/:placeholder_1/:placeholder_2/:placeholder_3|",
},
{
method: "pAtCh",
path: "/no/placeholders/here",
want: "|PATCH|/no/placeholders/here|",
},
{
method: "GET",
path: "/:single",
want: "|GET|/:placeholder_1|",
},
{
method: "GET",
path: "/UPPER/CASE",
want: "|GET|/upper/case|",
},
// gorilla/mux brace-style placeholders
{
method: "post",
path: "/api/v1/fleet/hosts/{id:[0-9]+}",
want: "|POST|/api/v1/fleet/hosts/:placeholder_1|",
},
{
method: "get",
path: "/api/v1/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}",
want: "|GET|/api/v1/fleet/hosts/:placeholder_1/reports/:placeholder_2|",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
sut := NewAPIEndpointFromTpl(tt.method, tt.path)
require.Equal(t, tt.want, sut.Fingerprint())
})
}
}
func TestAPIEndpointValidate(t *testing.T) {
base := APIEndpoint{Method: "GET", Path: "/api/v1/fleet/foo", DisplayName: "foo"}
tests := []struct {
name string
modify func(APIEndpoint) APIEndpoint
wantErr string
}{
{
name: "valid endpoint",
modify: func(e APIEndpoint) APIEndpoint { return e },
},
{
name: "missing display_name",
modify: func(e APIEndpoint) APIEndpoint { e.DisplayName = ""; return e },
wantErr: "display_name is required",
},
{
name: "whitespace display_name",
modify: func(e APIEndpoint) APIEndpoint { e.DisplayName = " "; return e },
wantErr: "display_name is required",
},
{
name: "invalid method",
modify: func(e APIEndpoint) APIEndpoint { e.Method = "GTE"; return e },
wantErr: "invalid HTTP method",
},
{
name: "empty path",
modify: func(e APIEndpoint) APIEndpoint { e.Path = " "; return e },
wantErr: "path is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.modify(base).validate()
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
})
}
}

View file

@ -1426,6 +1426,9 @@ type Service interface {
// Returns a NotFoundError error if there's no secret variable with such ID.
DeleteSecretVariable(ctx context.Context, id uint) error
// ListAPIEndpoints returns all API endpoints
ListAPIEndpoints(ctx context.Context) (endpoints []APIEndpoint, err error)
// /////////////////////////////////////////////////////////////////////////////
// SCIM

View file

@ -876,6 +876,8 @@ type ListSecretVariablesFunc func(ctx context.Context, opts fleet.ListOptions) (
type DeleteSecretVariableFunc func(ctx context.Context, id uint) error
type ListAPIEndpointsFunc func(ctx context.Context) (endpoints []fleet.APIEndpoint, err error)
type ScimDetailsFunc func(ctx context.Context) (fleet.ScimDetails, error)
type ConditionalAccessMicrosoftCreateIntegrationFunc func(ctx context.Context, tenantID string) (adminConsentURL string, err error)
@ -2193,6 +2195,9 @@ type Service struct {
DeleteSecretVariableFunc DeleteSecretVariableFunc
DeleteSecretVariableFuncInvoked bool
ListAPIEndpointsFunc ListAPIEndpointsFunc
ListAPIEndpointsFuncInvoked bool
ScimDetailsFunc ScimDetailsFunc
ScimDetailsFuncInvoked bool
@ -5240,6 +5245,13 @@ func (s *Service) DeleteSecretVariable(ctx context.Context, id uint) error {
return s.DeleteSecretVariableFunc(ctx, id)
}
func (s *Service) ListAPIEndpoints(ctx context.Context) (endpoints []fleet.APIEndpoint, err error) {
s.mu.Lock()
s.ListAPIEndpointsFuncInvoked = true
s.mu.Unlock()
return s.ListAPIEndpointsFunc(ctx)
}
func (s *Service) ScimDetails(ctx context.Context) (fleet.ScimDetails, error) {
s.mu.Lock()
s.ScimDetailsFuncInvoked = true

View file

@ -1,111 +1,34 @@
package service
import (
_ "embed"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"context"
"github.com/gorilla/mux"
"gopkg.in/yaml.v2"
"github.com/fleetdm/fleet/v4/server/fleet"
)
//go:embed api_endpoints.yml
var apiEndpointsYAML []byte
//////////////////////////////////////////////////////////////////////////////////
// List API endpoints
//////////////////////////////////////////////////////////////////////////////////
var apiEndpoints = mustParseAPIEndpoints()
type listAPIEndpointsRequest struct{}
// APIEndpoint represents an API endpoint that we can attach permissions to.
type APIEndpoint struct {
Method string `yaml:"method"`
Path string `yaml:"path"`
Name string `yaml:"name"`
Deprecated bool `yaml:"deprecated"`
type listAPIEndpointsResponse struct {
APIEndpoints []fleet.APIEndpoint `json:"api_endpoints"`
Err error `json:"error,omitempty"`
}
var validHTTPMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
func (r listAPIEndpointsResponse) Error() error { return r.Err }
func listAPIEndpointsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
endpoints, err := svc.ListAPIEndpoints(ctx)
return listAPIEndpointsResponse{
APIEndpoints: endpoints,
Err: err,
}, nil
}
func (e APIEndpoint) validate() error {
if strings.TrimSpace(e.Name) == "" {
return errors.New("name is required")
}
if _, ok := validHTTPMethods[strings.ToUpper(e.Method)]; !ok {
return fmt.Errorf("invalid HTTP method %q", e.Method)
}
if strings.TrimSpace(e.Path) == "" {
return errors.New("path is required")
}
return nil
}
func mustParseAPIEndpoints() []APIEndpoint {
var routes []APIEndpoint
if err := yaml.Unmarshal(apiEndpointsYAML, &routes); err != nil {
panic(fmt.Sprintf("api_endpoints.yml: failed to parse: %v", err))
}
for i, r := range routes {
if err := r.validate(); err != nil {
panic(fmt.Sprintf("api_endpoints.yml: entry %d: %v", i, err))
}
// Normalise method to upper-case so callers don't have to.
routes[i].Method = strings.ToUpper(r.Method)
}
return routes
}
// GetAPIEndpoints returns all routes defined in api_endpoints.yml.
func GetAPIEndpoints() []APIEndpoint {
return apiEndpoints
}
// versionSegmentRe matches the gorilla/mux version segment that attachFleetAPIRoutes
// inserts in place of /_version_/ (e.g. /{fleetversion:(?:v1|2022-04|latest)}/).
var versionSegmentRe = regexp.MustCompile(`/\{fleetversion:[^}]+\}/`)
// ValidateAPIEndpoints checks that every route declared in api_endpoints.yml is
// registered in h.
func ValidateAPIEndpoints(h http.Handler) error {
r, ok := h.(*mux.Router)
if !ok {
return fmt.Errorf("expected *mux.Router, got %T", h)
}
registered := make(map[string]struct{})
_ = r.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error {
tpl, err := route.GetPathTemplate()
if err != nil {
return nil
}
meths, err := route.GetMethods()
if err != nil || len(meths) == 0 {
return nil
}
normalized := versionSegmentRe.ReplaceAllString(tpl, "/_version_/")
for _, m := range meths {
registered[m+":"+normalized] = struct{}{}
}
return nil
})
var missing []string
for _, route := range GetAPIEndpoints() {
key := route.Method + ":" + route.Path
if _, ok := registered[key]; !ok {
missing = append(missing, route.Method+" "+route.Path)
}
}
if len(missing) > 0 {
return fmt.Errorf("the following API endpoints are missing: %v", missing)
}
return nil
func (svc *Service) ListAPIEndpoints(ctx context.Context) ([]fleet.APIEndpoint, error) {
// skipauth: No authorization check, this is a premium feature only
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}

View file

@ -1,4 +0,0 @@
- method: POST
path: "/api/_version_/fleet/trigger"
name: "Some wild description goes here"
deprecated: false

View file

@ -1,127 +0,0 @@
package service
import (
"net/http"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
func TestGetAPIEndpoints(t *testing.T) {
routes := GetAPIEndpoints()
require.NotEmpty(t, routes)
for _, r := range routes {
require.NotEmpty(t, r.Method, "route method should not be empty")
require.NotEmpty(t, r.Path, "route path should not be empty")
require.NotEmpty(t, r.Name, "route name should not be empty")
require.True(t, strings.HasPrefix(r.Path, "/"), "route path should start with /")
_, validMethod := validHTTPMethods[r.Method]
require.True(t, validMethod, "route method %q should be a valid HTTP method", r.Method)
}
}
func TestAPIEndpointValidate(t *testing.T) {
base := APIEndpoint{Method: "GET", Path: "/api/_version_/fleet/foo", Name: "foo"}
tests := []struct {
name string
modify func(APIEndpoint) APIEndpoint
wantErr string
}{
{
name: "valid endpoint",
modify: func(e APIEndpoint) APIEndpoint { return e },
},
{
name: "missing name",
modify: func(e APIEndpoint) APIEndpoint { e.Name = ""; return e },
wantErr: "name is required",
},
{
name: "whitespace name",
modify: func(e APIEndpoint) APIEndpoint { e.Name = " "; return e },
wantErr: "name is required",
},
{
name: "invalid method",
modify: func(e APIEndpoint) APIEndpoint { e.Method = "GTE"; return e },
wantErr: "invalid HTTP method",
},
{
name: "empty path",
modify: func(e APIEndpoint) APIEndpoint { e.Path = " "; return e },
wantErr: "path is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.modify(base).validate()
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
})
}
}
func TestValidateAPIEndpoints(t *testing.T) {
allRoutes := GetAPIEndpoints()
routerWithRoutes := func(routes []APIEndpoint) *mux.Router {
r := mux.NewRouter()
for _, route := range routes {
path := strings.Replace(route.Path, "/_version_/", "/{fleetversion:(?:v1|latest)}/", 1)
r.Handle(path, http.NotFoundHandler()).Methods(route.Method)
}
return r
}
tests := []struct {
name string
handler http.Handler
wantErr string
}{
{
name: "all routes present",
handler: routerWithRoutes(allRoutes),
},
{
name: "no routes registered",
handler: mux.NewRouter(),
wantErr: "the following API endpoints are missing",
},
{
name: "non-mux handler returns error",
handler: http.NewServeMux(),
wantErr: "expected *mux.Router, got *http.ServeMux",
},
}
if len(allRoutes) >= 2 {
last := allRoutes[len(allRoutes)-1]
tests = append(tests, struct {
name string
handler http.Handler
wantErr string
}{
name: "last route missing",
handler: routerWithRoutes(allRoutes[:len(allRoutes)-1]),
wantErr: last.Method + " " + last.Path,
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAPIEndpoints(tt.handler)
if tt.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantErr)
}
})
}
}

View file

@ -579,6 +579,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/custom_variables", listSecretVariablesEndpoint, listSecretVariablesRequest{})
ue.DELETE("/api/_version_/fleet/custom_variables/{id:[0-9]+}", deleteSecretVariableEndpoint, deleteSecretVariableRequest{})
// API end-points
ue.GET("/api/_version_/fleet/rest_api", listAPIEndpointsEndpoint, listAPIEndpointsRequest{})
// Scim details
ue.GET("/api/_version_/fleet/scim/details", getScimDetailsEndpoint, nil)

View file

@ -7587,6 +7587,10 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
}`), http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "missing or invalid license")
// list API endpoints requires premium license
var listAPIEndpointsResp listAPIEndpointsResponse
s.DoJSON("GET", "/api/latest/fleet/rest_api", nil, http.StatusPaymentRequired, &listAPIEndpointsResp)
}
func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() {

View file

@ -28256,3 +28256,65 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersDeletesOb
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), fleet.ListTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0")
require.Empty(t, listPolResp.Policies)
}
func (s *integrationEnterpriseTestSuite) TestListAPIEndpoints() {
t := s.T()
defer func() { s.token = s.getTestAdminToken() }()
// unauthenticated request is rejected
s.DoRawNoAuth("GET", "/api/latest/fleet/rest_api", nil, http.StatusUnauthorized)
// non-admin global roles are forbidden
for _, role := range []string{fleet.RoleObserver, fleet.RoleObserverPlus, fleet.RoleMaintainer, fleet.RoleGitOps, fleet.RoleTechnician} {
u := &fleet.User{
Name: "test " + role,
Email: role + "-api-endpoints@example.com",
GlobalRole: ptr.String(role),
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.token = s.getTestToken(u.Email, test.GoodPassword)
s.Do("GET", "/api/latest/fleet/rest_api", nil, http.StatusForbidden)
}
// non-admin team roles are forbidden
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "api-endpoints-test-team"})
require.NoError(t, err)
for _, role := range []string{fleet.RoleObserver, fleet.RoleObserverPlus, fleet.RoleMaintainer, fleet.RoleGitOps, fleet.RoleTechnician} {
u := &fleet.User{
Name: "team " + role,
Email: "team-" + role + "-api-endpoints@example.com",
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team.ID}, Role: role}},
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.token = s.getTestToken(u.Email, test.GoodPassword)
s.Do("GET", "/api/latest/fleet/rest_api", nil, http.StatusForbidden)
}
// team admin is allowed
teamAdmin := &fleet.User{
Name: "team admin",
Email: "team-admin-api-endpoints@example.com",
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team.ID}, Role: fleet.RoleAdmin}},
}
require.NoError(t, teamAdmin.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), teamAdmin)
require.NoError(t, err)
s.token = s.getTestToken(teamAdmin.Email, test.GoodPassword)
s.Do("GET", "/api/latest/fleet/rest_api", nil, http.StatusOK)
// restore admin token
s.token = s.getTestAdminToken()
// global admin can list API endpoints
var resp listAPIEndpointsResponse
s.DoJSON("GET", "/api/latest/fleet/rest_api", nil, http.StatusOK, &resp)
require.NotEmpty(t, resp.APIEndpoints)
require.NoError(t, resp.Err)
require.NotEmpty(t, resp.APIEndpoints[0].Method)
require.NotEmpty(t, resp.APIEndpoints[0].Path)
require.NotEmpty(t, resp.APIEndpoints[0].DisplayName)
}

View file

@ -37,6 +37,7 @@ import (
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -628,6 +629,9 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
}
var carveStore fleet.CarveStore = ds // In tests, we use MySQL as storage for carves.
apiHandler := MakeHandler(svc, cfg, logger, limitStore, redisPool, carveStore, featureRoutes, extra...)
if err := apiendpoints.Init(apiHandler); err != nil {
t.Fatalf("error initializing API endpoints: %v", err)
}
rootMux.Handle("/api/", apiHandler)
var errHandler *errorstore.Handler
ctxErrHandler := ctxerr.FromContext(ctx)