From 7aea2e3fde11de4af84c69461209d13057744967 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Tue, 28 Apr 2026 15:04:05 -0400 Subject: [PATCH] Add gitops endpoints to api_endpoints catalog (#44291) Resolves #44279 Add gitops endpoints to api_endpoints catalog --- server/api_endpoints/api_endpoints.yml | 87 ++++++++++++- server/service/integration_enterprise_test.go | 115 ++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/server/api_endpoints/api_endpoints.yml b/server/api_endpoints/api_endpoints.yml index 47de3440ca..4815d57289 100644 --- a/server/api_endpoints/api_endpoints.yml +++ b/server/api_endpoints/api_endpoints.yml @@ -40,6 +40,9 @@ - method: "GET" path: "/api/v1/fleet/config/certificate" display_name: "Get Fleet certificate" +- method: "PATCH" + path: "/api/v1/fleet/config" + display_name: "Updates Fleet configuration" - method: "GET" path: "/api/v1/fleet/config" display_name: "Get Fleet configuration" @@ -406,6 +409,9 @@ - method: "GET" path: "/api/v1/fleet/scripts/results/:execution_id" display_name: "Get script result" +- method: "POST" + path: "/api/v1/fleet/scripts/batch" + display_name: "Batch-run script" - method: "POST" path: "/api/v1/fleet/scripts/run/batch" display_name: "Batch-run script" @@ -510,4 +516,83 @@ display_name: "Delete custom variable" - method: "GET" path: "/api/v1/fleet/configuration_profiles/:profile_uuid/status" - display_name: "Get OS setting (configuration profile) status" \ No newline at end of file + display_name: "Get OS setting (configuration profile) status" +- method: "GET" + path: "/api/v1/fleet/me" + display_name: "Get current user" +- method: "GET" + path: "/api/v1/fleet/spec/labels" + display_name: "Get labels spec" +- method: "POST" + path: "/api/v1/fleet/spec/labels" + display_name: "Apply labels spec" +- method: "GET" + path: "/api/v1/fleet/abm_tokens/count" + display_name: "Count Apple Business Manager (ABM) tokens" +- method: "GET" + path: "/api/v1/fleet/spec/certificate_authorities" + display_name: "Get certificate authorities spec" +- method: "POST" + path: "/api/v1/fleet/spec/certificate_authorities" + display_name: "Batch-apply certificate authorities" +- method: "POST" + path: "/api/v1/fleet/spec/certificates" + display_name: "Apply certificate templates spec" +- method: "DELETE" + path: "/api/v1/fleet/spec/certificates" + display_name: "Delete certificate templates spec" +- method: "PUT" + path: "/api/v1/fleet/spec/secret_variables" + display_name: "Save secret variables" +- method: "POST" + path: "/api/v1/fleet/spec/reports" + display_name: "Apply reports spec" +- method: "POST" + path: "/api/v1/fleet/spec/policies" + display_name: "Apply policies spec" +- method: "POST" + path: "/api/v1/fleet/spec/fleets" + display_name: "Batch-apply fleets spec" +- method: "GET" + path: "/api/v1/fleet/policies" + display_name: "List policies" +- method: "POST" + path: "/api/v1/fleet/policies/delete" + display_name: "Delete policies" +- method: "POST" + path: "/api/v1/fleet/mdm/profiles/batch" + display_name: "Batch-apply MDM configuration profiles" +- method: "GET" + path: "/api/v1/fleet/mdm/profiles/:profile_uuid" + display_name: "Download MDM configuration profile" + deprecated: true +- method: "GET" + path: "/api/v1/fleet/mdm/bootstrap/:fleet_id/metadata" + display_name: "Get MDM bootstrap package metadata" +- method: "DELETE" + path: "/api/v1/fleet/mdm/bootstrap/:fleet_id" + display_name: "Delete MDM bootstrap package" +- method: "POST" + path: "/api/v1/fleet/software/batch" + display_name: "Batch-apply software installers" +- method: "GET" + path: "/api/v1/fleet/software/batch/:request_uuid" + display_name: "Get batch software installers result" +- method: "POST" + path: "/api/v1/fleet/software/app_store_apps/batch" + display_name: "Batch-associate App Store apps" +- 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/software/titles/:title_id/icon" + display_name: "Get software title icon" +- method: "PUT" + path: "/api/v1/fleet/software/titles/:title_id/icon" + display_name: "Upload or update software title icon" +- method: "DELETE" + path: "/api/v1/fleet/software/titles/:title_id/icon" + display_name: "Delete software title icon" \ No newline at end of file diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index dae6f17600..31ef24b8b2 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -29188,3 +29188,118 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetInstallersScriptByHash() { }) require.Equal(t, string(scriptBytes), installScript) } + +// TestAPIOnlyUserCanReachGitOpsEndpoints verifies that the API endpoint +// catalog includes every route invoked by fleetctl gitops and +// fleetctl generate-gitops. The test runs as an api-only admin user so any +// 403 observed here comes from the api_only middleware (catalog miss), never +// from service-level authz. Regression test for #44279. +func (s *integrationEnterpriseTestSuite) TestAPIOnlyUserCanReachGitOpsEndpoints() { + t := s.T() + defer func() { s.token = s.getTestAdminToken() }() + + assertNot403 := func(verb, path string, body any) { + t.Helper() + var raw []byte + if body != nil { + j, err := json.Marshal(body) + require.NoError(t, err) + raw = j + } + req, err := http.NewRequest(verb, s.server.URL+path, bytes.NewReader(raw)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+s.token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.NotEqualf(t, http.StatusForbidden, resp.StatusCode, + "%s %s returned 403 for an api-only admin user; the route is likely missing from server/api_endpoints/api_endpoints.yml", + verb, path) + } + + // Create an api-only admin (no endpoint restrictions). Admins pass every + // service-level authz check, so the only remaining source of 403 is the + // api_only middleware's catalog check — exactly what we want to verify. + var createResp struct { + Token string `json:"token"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "api-only-admin-gitops-catalog", + "global_role": fleet.RoleAdmin, + }, http.StatusOK, &createResp) + require.NotEmpty(t, createResp.Token) + s.token = createResp.Token + + // Endpoints invoked by fleetctl gitops / generate-gitops. Status codes vary + // per endpoint based on payload validity; we only care that 403 never comes + // from the catalog check. + emptySpecs := map[string]any{"specs": []any{}} + assertNot403("GET", "/api/latest/fleet/me", nil) + assertNot403("GET", "/api/latest/fleet/config", nil) + assertNot403("GET", "/api/latest/fleet/version", nil) + assertNot403("GET", "/api/latest/fleet/fleets", nil) + assertNot403("GET", "/api/latest/fleet/spec/labels", nil) + assertNot403("POST", "/api/latest/fleet/spec/labels", emptySpecs) + assertNot403("GET", "/api/latest/fleet/spec/enroll_secret", nil) + assertNot403("GET", "/api/latest/fleet/spec/certificate_authorities", nil) + assertNot403("POST", "/api/latest/fleet/spec/certificate_authorities", emptySpecs) + assertNot403("GET", "/api/latest/fleet/abm_tokens/count", nil) + assertNot403("POST", "/api/latest/fleet/spec/policies", emptySpecs) + assertNot403("POST", "/api/latest/fleet/spec/reports", emptySpecs) + assertNot403("POST", "/api/latest/fleet/spec/fleets", emptySpecs) + assertNot403("PUT", "/api/latest/fleet/spec/secret_variables", map[string]any{"secret_variables": []any{}}) + assertNot403("GET", "/api/latest/fleet/policies", nil) + assertNot403("GET", "/api/latest/fleet/configuration_profiles", nil) + assertNot403("GET", "/api/latest/fleet/scripts", nil) + assertNot403("GET", "/api/latest/fleet/software/titles", nil) + assertNot403("GET", "/api/latest/fleet/software/fleet_maintained_apps", nil) + assertNot403("GET", "/api/latest/fleet/setup_experience/script", nil) + assertNot403("GET", "/api/latest/fleet/vpp_tokens", nil) + assertNot403("GET", "/api/latest/fleet/certificates", nil) +} + +func (s *integrationEnterpriseTestSuite) TestAPIOnlyGitOpsUserWithEndpointRestrictions() { + t := s.T() + defer func() { s.token = s.getTestAdminToken() }() + + allowedEndpoints := []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/me"}, + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/spec/labels"}, + {"method": "GET", "path": "/api/v1/fleet/abm_tokens/count"}, + {"method": "POST", "path": "/api/v1/fleet/spec/policies"}, + } + + var createResp struct { + Token string `json:"token"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "api-only-gitops-restricted", + "global_role": fleet.RoleGitOps, + "api_endpoints": allowedEndpoints, + }, http.StatusOK, &createResp) + require.NotEmpty(t, createResp.Token) + s.token = createResp.Token + + // Allowed endpoints reach the handler. + s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/spec/labels", nil, http.StatusOK) + s.Do("GET", "/api/latest/fleet/abm_tokens/count", nil, http.StatusOK) + // Apply a real policy spec; an empty specs list short-circuits before authz + // in checkPolicySpecAuthorization and surfaces as 500, which would mask the + // middleware behavior we want to verify. + s.Do("POST", "/api/latest/fleet/spec/policies", map[string]any{ + "specs": []map[string]any{ + {"name": t.Name() + "-policy", "query": "SELECT 1;", "platform": "darwin"}, + }, + }, http.StatusOK) + + // Other gitops endpoints are in the catalog but not in the allow list, so + // the middleware rejects them with 403. + s.Do("GET", "/api/latest/fleet/version", nil, http.StatusForbidden) + s.Do("GET", "/api/latest/fleet/fleets", nil, http.StatusForbidden) + s.Do("POST", "/api/latest/fleet/spec/labels", map[string]any{"specs": []any{}}, http.StatusForbidden) + s.Do("POST", "/api/latest/fleet/spec/reports", map[string]any{"specs": []any{}}, http.StatusForbidden) + s.Do("POST", "/api/latest/fleet/spec/fleets", map[string]any{"specs": []any{}}, http.StatusForbidden) +}