@@ -29188,3 +29188,118 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetInstallersScriptByHash() {
2918829188 })
2918929189 require.Equal(t, string(scriptBytes), installScript)
2919029190}
29191+
29192+ // TestAPIOnlyUserCanReachGitOpsEndpoints verifies that the API endpoint
29193+ // catalog includes every route invoked by fleetctl gitops and
29194+ // fleetctl generate-gitops. The test runs as an api-only admin user so any
29195+ // 403 observed here comes from the api_only middleware (catalog miss), never
29196+ // from service-level authz. Regression test for #44279.
29197+ func (s *integrationEnterpriseTestSuite) TestAPIOnlyUserCanReachGitOpsEndpoints() {
29198+ t := s.T()
29199+ defer func() { s.token = s.getTestAdminToken() }()
29200+
29201+ assertNot403 := func(verb, path string, body any) {
29202+ t.Helper()
29203+ var raw []byte
29204+ if body != nil {
29205+ j, err := json.Marshal(body)
29206+ require.NoError(t, err)
29207+ raw = j
29208+ }
29209+ req, err := http.NewRequest(verb, s.server.URL+path, bytes.NewReader(raw))
29210+ require.NoError(t, err)
29211+ req.Header.Set("Authorization", "Bearer "+s.token)
29212+ resp, err := http.DefaultClient.Do(req)
29213+ require.NoError(t, err)
29214+ defer resp.Body.Close()
29215+ require.NotEqualf(t, http.StatusForbidden, resp.StatusCode,
29216+ "%s %s returned 403 for an api-only admin user; the route is likely missing from server/api_endpoints/api_endpoints.yml",
29217+ verb, path)
29218+ }
29219+
29220+ // Create an api-only admin (no endpoint restrictions). Admins pass every
29221+ // service-level authz check, so the only remaining source of 403 is the
29222+ // api_only middleware's catalog check — exactly what we want to verify.
29223+ var createResp struct {
29224+ Token string `json:"token"`
29225+ }
29226+ s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{
29227+ "name": "api-only-admin-gitops-catalog",
29228+ "global_role": fleet.RoleAdmin,
29229+ }, http.StatusOK, &createResp)
29230+ require.NotEmpty(t, createResp.Token)
29231+ s.token = createResp.Token
29232+
29233+ // Endpoints invoked by fleetctl gitops / generate-gitops. Status codes vary
29234+ // per endpoint based on payload validity; we only care that 403 never comes
29235+ // from the catalog check.
29236+ emptySpecs := map[string]any{"specs": []any{}}
29237+ assertNot403("GET", "/api/latest/fleet/me", nil)
29238+ assertNot403("GET", "/api/latest/fleet/config", nil)
29239+ assertNot403("GET", "/api/latest/fleet/version", nil)
29240+ assertNot403("GET", "/api/latest/fleet/fleets", nil)
29241+ assertNot403("GET", "/api/latest/fleet/spec/labels", nil)
29242+ assertNot403("POST", "/api/latest/fleet/spec/labels", emptySpecs)
29243+ assertNot403("GET", "/api/latest/fleet/spec/enroll_secret", nil)
29244+ assertNot403("GET", "/api/latest/fleet/spec/certificate_authorities", nil)
29245+ assertNot403("POST", "/api/latest/fleet/spec/certificate_authorities", emptySpecs)
29246+ assertNot403("GET", "/api/latest/fleet/abm_tokens/count", nil)
29247+ assertNot403("POST", "/api/latest/fleet/spec/policies", emptySpecs)
29248+ assertNot403("POST", "/api/latest/fleet/spec/reports", emptySpecs)
29249+ assertNot403("POST", "/api/latest/fleet/spec/fleets", emptySpecs)
29250+ assertNot403("PUT", "/api/latest/fleet/spec/secret_variables", map[string]any{"secret_variables": []any{}})
29251+ assertNot403("GET", "/api/latest/fleet/policies", nil)
29252+ assertNot403("GET", "/api/latest/fleet/configuration_profiles", nil)
29253+ assertNot403("GET", "/api/latest/fleet/scripts", nil)
29254+ assertNot403("GET", "/api/latest/fleet/software/titles", nil)
29255+ assertNot403("GET", "/api/latest/fleet/software/fleet_maintained_apps", nil)
29256+ assertNot403("GET", "/api/latest/fleet/setup_experience/script", nil)
29257+ assertNot403("GET", "/api/latest/fleet/vpp_tokens", nil)
29258+ assertNot403("GET", "/api/latest/fleet/certificates", nil)
29259+ }
29260+
29261+ func (s *integrationEnterpriseTestSuite) TestAPIOnlyGitOpsUserWithEndpointRestrictions() {
29262+ t := s.T()
29263+ defer func() { s.token = s.getTestAdminToken() }()
29264+
29265+ allowedEndpoints := []map[string]any{
29266+ {"method": "GET", "path": "/api/v1/fleet/me"},
29267+ {"method": "GET", "path": "/api/v1/fleet/config"},
29268+ {"method": "GET", "path": "/api/v1/fleet/spec/labels"},
29269+ {"method": "GET", "path": "/api/v1/fleet/abm_tokens/count"},
29270+ {"method": "POST", "path": "/api/v1/fleet/spec/policies"},
29271+ }
29272+
29273+ var createResp struct {
29274+ Token string `json:"token"`
29275+ }
29276+ s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{
29277+ "name": "api-only-gitops-restricted",
29278+ "global_role": fleet.RoleGitOps,
29279+ "api_endpoints": allowedEndpoints,
29280+ }, http.StatusOK, &createResp)
29281+ require.NotEmpty(t, createResp.Token)
29282+ s.token = createResp.Token
29283+
29284+ // Allowed endpoints reach the handler.
29285+ s.Do("GET", "/api/latest/fleet/me", nil, http.StatusOK)
29286+ s.Do("GET", "/api/latest/fleet/config", nil, http.StatusOK)
29287+ s.Do("GET", "/api/latest/fleet/spec/labels", nil, http.StatusOK)
29288+ s.Do("GET", "/api/latest/fleet/abm_tokens/count", nil, http.StatusOK)
29289+ // Apply a real policy spec; an empty specs list short-circuits before authz
29290+ // in checkPolicySpecAuthorization and surfaces as 500, which would mask the
29291+ // middleware behavior we want to verify.
29292+ s.Do("POST", "/api/latest/fleet/spec/policies", map[string]any{
29293+ "specs": []map[string]any{
29294+ {"name": t.Name() + "-policy", "query": "SELECT 1;", "platform": "darwin"},
29295+ },
29296+ }, http.StatusOK)
29297+
29298+ // Other gitops endpoints are in the catalog but not in the allow list, so
29299+ // the middleware rejects them with 403.
29300+ s.Do("GET", "/api/latest/fleet/version", nil, http.StatusForbidden)
29301+ s.Do("GET", "/api/latest/fleet/fleets", nil, http.StatusForbidden)
29302+ s.Do("POST", "/api/latest/fleet/spec/labels", map[string]any{"specs": []any{}}, http.StatusForbidden)
29303+ s.Do("POST", "/api/latest/fleet/spec/reports", map[string]any{"specs": []any{}}, http.StatusForbidden)
29304+ s.Do("POST", "/api/latest/fleet/spec/fleets", map[string]any{"specs": []any{}}, http.StatusForbidden)
29305+ }
0 commit comments