diff --git a/CODEOWNERS b/CODEOWNERS index 33a8c371f23d..bce1e230f613 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,9 +65,9 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/docs @rachaelshaw -/docs/REST\ API/rest-api.md @rachaelshaw # « REST API reference documentation -/docs/Contributing/API-for-contributors.md @rachaelshaw # « Advanced / contributors-only API reference documentation +/docs @rachaelshaw @noahtalerman +/docs/REST\ API/rest-api.md @rachaelshaw @noahtalerman # « REST API reference documentation +/docs/Contributing/API-for-contributors.md @rachaelshaw @noahtalerman # « Advanced / contributors-only API reference documentation /schema @eashaw # « Data tables (osquery/fleetd schema) documentation /render.yaml @edwardsb @@ -88,15 +88,15 @@ go.mod @fleetdm/go /handbook/README.md @mikermcneil /handbook/company/open-positions.yml @sampfluger88 #/handbook/company/product-groups.md 🤡 Covered in custom.js -/handbook/finance/README.md @sampfluger88 -/handbook/finance/finance.rituals.yml @sampfluger88 +/handbook/finance/README.md @sampfluger88 +/handbook/finance/finance.rituals.yml @sampfluger88 /handbook/digital-experience/security.md @sampfluger88 -/handbook/digital-experience @sampfluger88 -/handbook/customer-success @sampfluger88 +/handbook/digital-experience @sampfluger88 +/handbook/customer-success @sampfluger88 /handbook/demand @sampfluger88 #/handbook/engineering 🤡 Covered in custom.js /handbook/sales @sampfluger88 -#/handbook/product-design 🤡 Covered in custom.js +#/handbook/product-design 🤡 Covered in custom.js ############################################################################################## # 🌐 GitHub issue templates diff --git a/changes/24288-mdm-gitops-role b/changes/24288-mdm-gitops-role new file mode 100644 index 000000000000..2d04811311b2 --- /dev/null +++ b/changes/24288-mdm-gitops-role @@ -0,0 +1 @@ +Fixed breaking with gitops user role running `fleetctl gitops` command when MDM is enabled. diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index 30efe0a513a7..5389065913ac 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -299,12 +299,12 @@ func checkABMTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage) } - abmToks, err := fleetClient.ListABMTokens() + abmToks, err := fleetClient.CountABMTokens() if err != nil { return nil, false, false, err } - if hasLegacyConfig && len(abmToks) > 1 { + if hasLegacyConfig && abmToks > 1 { return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage) } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 7abc95dc6376..ad11066b2aa5 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -1217,6 +1217,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { + return 0, nil + } ds.DeleteSetupExperienceScriptFunc = func(ctx context.Context, teamID *uint) error { return nil } @@ -1815,6 +1818,9 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { + return 0, nil + } apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() require.NoError(t, err) @@ -2854,6 +2860,9 @@ software: } return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil } + ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { + return len(tt.tokens), nil + } ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { var res []*fleet.TeamSummary @@ -3177,6 +3186,9 @@ software: ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil } + ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { + return 1, nil + } ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { var res []*fleet.TeamSummary diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 674329d9e493..2de82b708025 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1274,6 +1274,22 @@ func (svc *Service) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error return tokens, nil } +func (svc *Service) CountABMTokens(ctx context.Context) (int, error) { + // Authorizing using the more general AppConfig object because: + // - this service method returns a count, which is not sensitive information + // - gitops role, which needs this info, is not authorized for AppleBM access (as of 2024/12/02) + if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil { + return 0, err + } + + tokens, err := svc.ds.GetABMTokenCount(ctx) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "count ABM tokens") + } + + return tokens, nil +} + func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID *uint) (*fleet.ABMToken, error) { if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionWrite); err != nil { return nil, err diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index 21cc3d21d939..5162f20f4dcb 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -6,12 +6,15 @@ import ( "strings" "testing" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -148,3 +151,46 @@ b1xn1jGQd/o0xFf9ojpDNy6vNojidQGHh6E3h0GYvxbnQmVNq5U= // prevent static analysis tools from raising issues due to detection of // private key in code. func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +func TestCountABMTokensAuth(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + ctx := context.Background() + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + svc := Service{ds: ds, authz: authorizer} + + ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { + return 5, nil + } + + t.Run("CountABMTokens", func(t *testing.T) { + cases := []struct { + desc string + user *fleet.User + shoudFailWithAuth bool + }{ + {"no role", test.UserNoRoles, true}, + {"gitops can read", test.UserGitOps, false}, + {"maintainer can read", test.UserMaintainer, false}, + {"observer can read", test.UserObserver, false}, + {"observer+ can read", test.UserObserverPlus, false}, + {"admin can read", test.UserAdmin, false}, + {"tm1 gitops cannot read", test.UserTeamGitOpsTeam1, true}, + {"tm1 maintainer can read", test.UserTeamMaintainerTeam1, false}, + {"tm1 observer can read", test.UserTeamObserverTeam1, false}, + {"tm1 observer+ can read", test.UserTeamObserverPlusTeam1, false}, + {"tm1 admin can read", test.UserTeamAdminTeam1, false}, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + ctx = test.UserContext(ctx, c.user) + count, err := svc.CountABMTokens(ctx) + checkAuthErr(t, c.shoudFailWithAuth, err) + if !c.shoudFailWithAuth { + assert.EqualValues(t, 5, count) + } + }) + } + }) +} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index ea7f92ec8940..ef458048c8d2 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -6536,6 +6536,14 @@ func testMDMAppleGetAndUpdateABMToken(t *testing.T, ds *Datastore) { tm3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) require.NoError(t, err) + toks, err := ds.ListABMTokens(ctx) + require.NoError(t, err) + require.Empty(t, toks) + + tokCount, err := ds.GetABMTokenCount(ctx) + require.NoError(t, err) + assert.EqualValues(t, 0, tokCount) + // create a token with an empty name and no team set, and another that will be unused encTok := uuid.NewString() @@ -6546,10 +6554,14 @@ func testMDMAppleGetAndUpdateABMToken(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotEmpty(t, t2.ID) - toks, err := ds.ListABMTokens(ctx) + toks, err = ds.ListABMTokens(ctx) require.NoError(t, err) require.Len(t, toks, 2) + tokCount, err = ds.GetABMTokenCount(ctx) + require.NoError(t, err) + assert.EqualValues(t, 2, tokCount) + // get that token tok, err = ds.GetABMTokenByOrgName(ctx, "") require.NoError(t, err) @@ -6645,6 +6657,10 @@ func testMDMAppleGetAndUpdateABMToken(t *testing.T, ds *Datastore) { require.Equal(t, uint(0), expTok.MacOSTeam.ID) require.Equal(t, tm2.Name, expTok.IOSTeamName) require.Equal(t, tm3.Name, expTok.IPadOSTeamName) + + tokCount, err = ds.GetABMTokenCount(ctx) + require.NoError(t, err) + assert.EqualValues(t, 1, tokCount) } func testMDMAppleABMTokensTermsExpired(t *testing.T, ds *Datastore) { diff --git a/server/fleet/service.go b/server/fleet/service.go index 7e9f7c973cbb..db7fa3b11040 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -851,6 +851,9 @@ type Service interface { // ListABMTokens lists all the ABM tokens in Fleet. ListABMTokens(ctx context.Context) ([]*ABMToken, error) + // CountABMTokens counts the ABM tokens in Fleet. + CountABMTokens(ctx context.Context) (int, error) + // UpdateABMTokenTeams updates the default macOS, iOS, and iPadOS team IDs for a given ABM token. UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID *uint) (*ABMToken, error) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 76564ffc1201..56dc433539d8 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -4480,6 +4480,35 @@ func (svc *Service) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error return nil, fleet.ErrMissingLicense } +// ////////////////////////////////////////////////////////////////////////////// +// Count ABM tokens endpoint +// ////////////////////////////////////////////////////////////////////////////// + +type countABMTokensResponse struct { + Err error `json:"error,omitempty"` + Count int `json:"count"` +} + +func (r countABMTokensResponse) error() error { return r.Err } + +func countABMTokensEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (errorer, error) { + tokenCount, err := svc.CountABMTokens(ctx) + if err != nil { + return &countABMTokensResponse{Err: err}, nil + } + + return &countABMTokensResponse{Count: tokenCount}, nil +} + +func (svc *Service) CountABMTokens(ctx context.Context) (int, error) { + // Automatic enrollment (ABM/ADE/DEP) is a feature that requires a license. + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return 0, fleet.ErrMissingLicense +} + //////////////////////////////////////////////////////////////////////////////// // Update ABM token teams endpoint //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index c41915b91a01..e849eb4b0fd1 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -40,11 +40,11 @@ func (c *Client) GetAppleBM() (*fleet.AppleBM, error) { return responseBody.AppleBM, err } -func (c *Client) ListABMTokens() ([]*fleet.ABMToken, error) { - verb, path := "GET", "/api/latest/fleet/abm_tokens" - var responseBody listABMTokensResponse +func (c *Client) CountABMTokens() (int, error) { + verb, path := "GET", "/api/latest/fleet/abm_tokens/count" + var responseBody countABMTokensResponse err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "") - return responseBody.Tokens, err + return responseBody.Count, err } // RequestAppleCSR requests a signed CSR from the Fleet server and returns the diff --git a/server/service/handler.go b/server/service/handler.go index 3cb1816e12b5..74fb012fb8f6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -754,6 +754,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/abm_tokens", uploadABMTokenEndpoint, uploadABMTokenRequest{}) 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.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/teams", updateABMTokenTeamsEndpoint, updateABMTokenTeamsRequest{}) ue.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/renew", renewABMTokenEndpoint, renewABMTokenRequest{}) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 8790308fa9b2..f480b3de05a0 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -829,6 +829,10 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { require.Equal(t, "Fleet", mdmResp.CommonName) require.NotZero(t, mdmResp.RenewDate) + var countTokensResp countABMTokensResponse + s.DoJSON("GET", "/api/latest/fleet/abm_tokens/count", nil, http.StatusOK, &countTokensResp) + assert.EqualValues(t, 0, countTokensResp.Count) + // set up multiple ABM tokens with different org names defaultOrgName := "fleet_test" s.enableABM(defaultOrgName) @@ -860,6 +864,9 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name) require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name) + s.DoJSON("GET", "/api/latest/fleet/abm_tokens/count", nil, http.StatusOK, &countTokensResp) + assert.EqualValues(t, 2, countTokensResp.Count) + // create a new team tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: t.Name(),