From a15d58e9278f381ca4fb90c02dda963e76fd2397 Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Thu, 9 Jul 2026 18:34:17 +0200 Subject: [PATCH] SAAD: Asset CRUD API (#49011) **Related issue:** Resolves #48568 partly # 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. (Will add in followup) - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added Apple DDM asset management endpoints: list, get, download (raw JSON), create, and delete. * Implemented datastore-backed Apple DDM asset CRUD with team-scoped and global access, plus configurable upload size limits. * Added strict asset JSON validation (including required fields, URI checks, and secret expansion rules). * **Bug Fixes** * Improved authorization handling by returning not-found responses for out-of-scope read/download/delete to avoid asset discovery. * Added clearer conflict and linked-profile error mapping for create/delete failures. * **Tests** * Added comprehensive authorization and validation test coverage for Apple DDM assets and policy behavior. --- ee/server/service/apple_mdm.go | 148 ++++++ ee/server/service/apple_mdm_test.go | 503 +++++++++++++++++++ server/authz/policy.rego | 32 ++ server/authz/policy_test.go | 105 ++++ server/datastore/mysql/apple_mdm.go | 117 +++++ server/datastore/mysql/apple_mdm_ddm_test.go | 207 ++++++++ server/fleet/apple_mdm.go | 48 ++ server/fleet/datastore.go | 6 + server/fleet/request.go | 1 + server/fleet/service.go | 14 + server/mock/datastore_mock.go | 60 +++ server/mock/service/service_mock.go | 60 +++ server/service/apple_mdm.go | 201 ++++++++ server/service/handler.go | 6 + 14 files changed, 1508 insertions(+) create mode 100644 ee/server/service/apple_mdm_test.go diff --git a/ee/server/service/apple_mdm.go b/ee/server/service/apple_mdm.go index 5772e3e117..d9c543fbc1 100644 --- a/ee/server/service/apple_mdm.go +++ b/ee/server/service/apple_mdm.go @@ -2,12 +2,16 @@ package service import ( "context" + "encoding/json" "fmt" + "net/url" + "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" ) func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) { @@ -81,3 +85,147 @@ func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enr return signed, nil } + +func (svc *Service) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + if err := svc.authz.Authorize(ctx, &fleet.DDMAssetAuthz{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, err + } + + assets, err := svc.ds.ListAppleDDMAssets(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing Apple DDM assets") + } + + return assets, nil +} + +func (svc *Service) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + if authzErr := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); authzErr != nil { + return nil, authzErr + } + + asset, err := svc.ds.GetAppleDDMAsset(ctx, assetUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting Apple DDM asset") + } + + if authzErr := svc.authz.Authorize(ctx, &fleet.DDMAssetAuthz{TeamID: asset.TeamID}, fleet.ActionRead); authzErr != nil { + // We return a not found error here to avoid leaking the existence of the asset to unauthorized users. + return nil, common_mysql.NotFound("Asset").WithName(assetUUID) + } + + return asset, nil +} + +func (svc *Service) DownloadAppleDDMAsset(ctx context.Context, assetUUID string) (name string, data []byte, err error) { + if authzErr := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); authzErr != nil { + return "", nil, authzErr + } + + asset, err := svc.ds.GetAppleDDMAssetForDownload(ctx, assetUUID) + if err != nil { + return "", nil, ctxerr.Wrap(ctx, err, "getting Apple DDM asset") + } + + if authzErr := svc.authz.Authorize(ctx, &fleet.DDMAssetAuthz{TeamID: asset.TeamID}, fleet.ActionRead); authzErr != nil { + // We return a not found error here to avoid leaking the existence of the asset to unauthorized users. + return "", nil, common_mysql.NotFound("Asset").WithName(assetUUID) + } + + return asset.Name + ".json", asset.Data, nil +} + +func (svc *Service) CreateAppleDDMAsset(ctx context.Context, teamID *uint, name string, data []byte) (string, error) { + if authzErr := svc.authz.Authorize(ctx, &fleet.DDMAssetAuthz{TeamID: teamID}, fleet.ActionWrite); authzErr != nil { + return "", authzErr + } + + identifier, err := svc.validateAppleDDMAsset(ctx, data) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "validating Apple DDM asset") + } + + assetUUID, err := svc.ds.CreateAppleDDMAsset(ctx, name, identifier, data, teamID) + if err != nil { + if alreadyExistsErr, ok := err.(fleet.AlreadyExistsError); ok && alreadyExistsErr.IsExists() { + switch { + case strings.Contains(alreadyExistsErr.Error(), "asset_name"): + return "", &fleet.ConflictError{Message: fmt.Sprintf("An asset with the name %q already exists for this team", name)} + case strings.Contains(alreadyExistsErr.Error(), "asset_identifier"): + return "", &fleet.ConflictError{Message: fmt.Sprintf("An asset with the identifier %q already exists for this team", identifier)} + } + } + return "", ctxerr.Wrap(ctx, err, "creating Apple DDM asset") + } + + return assetUUID, nil +} + +func (svc *Service) validateAppleDDMAsset(ctx context.Context, data []byte) (identifier string, err error) { + var rawAsset fleet.RawDDMAsset + if err := json.Unmarshal(data, &rawAsset); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling asset data") + } + + if rawAsset.Identifier == "" { + return "", &fleet.BadRequestError{Message: "Asset must contain a non-empty identifier"} + } + + if !strings.HasPrefix(rawAsset.Type, "com.apple.asset.") { + return "", &fleet.BadRequestError{Message: "Asset type must be a valid Apple asset type beginning with 'com.apple.asset.'"} + } + + // Check if Identifier uses a FLEET_SECRET, fail if so. + if strings.Contains(rawAsset.Identifier, "FLEET_SECRET") { + return "", &fleet.BadRequestError{Message: "Asset identifier must not contain a $FLEET_SECRET"} + } + + expanded, _, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data)) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "expanding embedded secrets and updated_at") + } + + if err := json.Unmarshal([]byte(expanded), &rawAsset); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshaling asset data") + } + + // We disallow authentication, as we force MDM auth when serving the assets. + if rawAsset.Payload.Authentication != nil { + return "", &fleet.BadRequestError{Message: "Asset payload must not contain an authentication key"} + } + + if rawAsset.Payload.Reference.DataURL == "" { + return "", &fleet.BadRequestError{Message: "Asset payload must contain a non-empty reference data URL"} + } + + if _, err := url.ParseRequestURI(rawAsset.Payload.Reference.DataURL); err != nil { + return "", &fleet.BadRequestError{Message: fmt.Sprintf("Invalid payload data URL: %v", err)} + } + + return rawAsset.Identifier, nil +} + +func (svc *Service) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error { + if authzErr := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); authzErr != nil { + return authzErr + } + + asset, err := svc.ds.GetAppleDDMAsset(ctx, assetUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting Apple DDM asset") + } + + if authzErr := svc.authz.Authorize(ctx, &fleet.DDMAssetAuthz{TeamID: asset.TeamID}, fleet.ActionWrite); authzErr != nil { + // We return a not found error here to avoid leaking the existence of the asset to unauthorized users. + return common_mysql.NotFound("Asset").WithName(assetUUID) + } + + if err := svc.ds.DeleteAppleDDMAsset(ctx, assetUUID); err != nil { + if fleet.IsForeignKey(err) { + return &fleet.BadRequestError{Message: "Couldn't delete. A configuration profile is linked to this asset. Please delete the profile and try again."} + } + return ctxerr.Wrap(ctx, err, "deleting Apple DDM asset") + } + + return nil +} diff --git a/ee/server/service/apple_mdm_test.go b/ee/server/service/apple_mdm_test.go new file mode 100644 index 0000000000..d82e5003bd --- /dev/null +++ b/ee/server/service/apple_mdm_test.go @@ -0,0 +1,503 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" + "github.com/stretchr/testify/require" +) + +func TestListAppleDDMAssets(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("Observer cannot list DDM assets", func(t *testing.T) { + ds.ListAppleDDMAssetsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + return []*fleet.DDMAsset{}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleObserver)}}) + + _, err := svc.ListAppleDDMAssets(ctx, nil) + require.Error(t, err) + var forbiddenErr *authz.Forbidden + require.ErrorAs(t, err, &forbiddenErr) + require.False(t, ds.ListAppleDDMAssetsFuncInvoked) + + ds.ListAppleDDMAssetsFuncInvoked = false + }) + + t.Run("Global admin can list DDM assets", func(t *testing.T) { + ds.ListAppleDDMAssetsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + return []*fleet.DDMAsset{}, nil + } + + _, err := svc.ListAppleDDMAssets(ctx, nil) + require.NoError(t, err) + require.True(t, ds.ListAppleDDMAssetsFuncInvoked) + + ds.ListAppleDDMAssetsFuncInvoked = false + }) + + t.Run("Team admin can list DDM assets for their team", func(t *testing.T) { + ds.ListAppleDDMAssetsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + return []*fleet.DDMAsset{}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin), Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + + _, err := svc.ListAppleDDMAssets(ctx, new(uint(1))) + require.NoError(t, err) + require.True(t, ds.ListAppleDDMAssetsFuncInvoked) + + ds.ListAppleDDMAssetsFuncInvoked = false + }) + + t.Run("Team admin cannot list DDM assets for other teams", func(t *testing.T) { + ds.ListAppleDDMAssetsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + return []*fleet.DDMAsset{}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + + _, err := svc.ListAppleDDMAssets(ctx, new(uint(2))) + require.Error(t, err) + var forbiddenErr *authz.Forbidden + require.ErrorAs(t, err, &forbiddenErr) + require.False(t, ds.ListAppleDDMAssetsFuncInvoked) + + ds.ListAppleDDMAssetsFuncInvoked = false + }) +} + +func TestGetAppleDDMAsset(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("Observer cannot get DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleObserver)}}) + _, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team Observer cannot get DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}}) + _, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Global admin can get DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + asset, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.Equal(t, "some-asset-uuid", asset.AssetUUID) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin can get DDM asset for their team", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID, TeamID: new(uint(1))}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + asset, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.Equal(t, "some-asset-uuid", asset.AssetUUID) + require.Equal(t, uint(1), *asset.TeamID) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin cannot get DDM asset for other teams", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID, TeamID: new(uint(2))}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + _, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Not found asset returns not found error", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return nil, common_mysql.NotFound("asset") + } + + _, err := svc.GetAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) +} + +func TestDownloadAppleDDMAsset(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("Observer cannot download DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return &fleet.DownloadableDDMAsset{DDMAsset: fleet.DDMAsset{AssetUUID: assetUUID}, Data: []byte("some data")}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleObserver)}}) + _, _, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) + + t.Run("Team Observer cannot download DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return &fleet.DownloadableDDMAsset{DDMAsset: fleet.DDMAsset{AssetUUID: assetUUID}, Data: []byte("some data")}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}}) + _, _, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) + + t.Run("Global admin can download DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return &fleet.DownloadableDDMAsset{DDMAsset: fleet.DDMAsset{AssetUUID: assetUUID, Name: assetUUID}, Data: []byte("some data")}, nil + } + name, data, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.Equal(t, "some-asset-uuid.json", name) + require.Equal(t, []byte("some data"), data) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) + + t.Run("Team admin can download DDM asset for their team", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return &fleet.DownloadableDDMAsset{DDMAsset: fleet.DDMAsset{AssetUUID: assetUUID, Name: assetUUID, TeamID: new(uint(1))}, Data: []byte("some data")}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + name, data, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.Equal(t, "some-asset-uuid.json", name) + require.Equal(t, []byte("some data"), data) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) + + t.Run("Team admin cannot download DDM asset for other teams", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return &fleet.DownloadableDDMAsset{DDMAsset: fleet.DDMAsset{AssetUUID: assetUUID, TeamID: new(uint(2))}, Data: []byte("some data")}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + _, _, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) + + t.Run("Not found asset returns not found error", func(t *testing.T) { + ds.GetAppleDDMAssetForDownloadFunc = func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + return nil, common_mysql.NotFound("asset") + } + + _, _, err := svc.DownloadAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetForDownloadFuncInvoked) + + ds.GetAppleDDMAssetForDownloadFuncInvoked = false + }) +} + +func TestDeleteAppleDDMAsset(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + t.Run("Observer cannot delete DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleObserver)}}) + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team Observer cannot delete DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}}) + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + }) + + t.Run("Global admin can delete DDM asset", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID}, nil + } + ds.DeleteAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) error { + return nil + } + + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + require.True(t, ds.DeleteAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + ds.DeleteAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin can delete DDM asset for their team", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID, TeamID: new(uint(1))}, nil + } + ds.DeleteAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) error { + return nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.NoError(t, err) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + require.True(t, ds.DeleteAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + ds.DeleteAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin cannot delete DDM asset for other teams", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return &fleet.DDMAsset{AssetUUID: assetUUID, TeamID: new(uint(2))}, nil + } + ds.DeleteAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) error { + return nil + } + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + require.False(t, ds.DeleteAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + ds.DeleteAppleDDMAssetFuncInvoked = false + }) + + t.Run("Not found asset returns not found error", func(t *testing.T) { + ds.GetAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + return nil, common_mysql.NotFound("asset") + } + ds.DeleteAppleDDMAssetFunc = func(ctx context.Context, assetUUID string) error { + return nil + } + + err := svc.DeleteAppleDDMAsset(ctx, "some-asset-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.True(t, ds.GetAppleDDMAssetFuncInvoked) + require.False(t, ds.DeleteAppleDDMAssetFuncInvoked) + + ds.GetAppleDDMAssetFuncInvoked = false + ds.DeleteAppleDDMAssetFuncInvoked = false + }) +} + +func TestCreateAppleDDMAsset(t *testing.T) { + ds := new(mock.Store) + svc := newTestService(t, ds) + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + validData := []byte(`{"Type":"com.apple.asset.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"https://example.com/data"}}}`) + + ds.CreateAppleDDMAssetFunc = func(ctx context.Context, name, identifier string, data []byte, teamID *uint) (string, error) { + return "some-asset-uuid", nil + } + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return document, nil, nil + } + + reset := func() { + ds.CreateAppleDDMAssetFuncInvoked = false + ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked = false + } + + t.Run("Observer cannot create DDM asset", func(t *testing.T) { + defer reset() + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleObserver)}}) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", validData) + require.Error(t, err) + var forbiddenErr *authz.Forbidden + require.ErrorAs(t, err, &forbiddenErr) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + + ds.CreateAppleDDMAssetFuncInvoked = false + }) + + t.Run("Global admin can create DDM asset", func(t *testing.T) { + defer reset() + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", validData) + require.NoError(t, err) + require.True(t, ds.CreateAppleDDMAssetFuncInvoked) + + ds.CreateAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin can create DDM asset for their team", func(t *testing.T) { + defer reset() + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + _, err := svc.CreateAppleDDMAsset(ctx, new(uint(1)), "asset", validData) + require.NoError(t, err) + require.True(t, ds.CreateAppleDDMAssetFuncInvoked) + + ds.CreateAppleDDMAssetFuncInvoked = false + }) + + t.Run("Team admin cannot create DDM asset for other teams", func(t *testing.T) { + defer reset() + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: nil, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}}) + _, err := svc.CreateAppleDDMAsset(ctx, new(uint(2)), "asset", validData) + require.Error(t, err) + var forbiddenErr *authz.Forbidden + require.ErrorAs(t, err, &forbiddenErr) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + + ds.CreateAppleDDMAssetFuncInvoked = false + }) + + t.Run("Malformed JSON is rejected", func(t *testing.T) { + defer reset() + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", []byte(`{not json`)) + require.Error(t, err) + require.False(t, ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Empty identifier is rejected", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"com.apple.asset.data","Identifier":"","Payload":{"Reference":{"DataURL":"https://example.com/data"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Invalid asset type is rejected", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"com.example.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"https://example.com/data"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Empty payload reference data URL is rejected", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"com.apple.asset.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":""}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Invalid payload reference data URL is rejected", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"com.apple.asset.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"notaurl"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Secret in type is rejected before expansion", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"$FLEET_SECRET_TYPE","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"https://example.com/data"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Secret in identifier is rejected before expansion", func(t *testing.T) { + defer reset() + data := []byte(`{"Type":"com.apple.asset.data","Identifier":"$FLEET_SECRET_ID","Payload":{"Reference":{"DataURL":"https://example.com/data"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.Error(t, err) + require.False(t, ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Secret in payload data URL is expanded and allowed", func(t *testing.T) { + defer reset() + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return string(validData), nil, nil + } + defer func() { + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return document, nil, nil + } + }() + data := []byte(`{"Type":"com.apple.asset.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"$FLEET_SECRET_URL"}}}`) + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", data) + require.NoError(t, err) + require.True(t, ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked) + require.True(t, ds.CreateAppleDDMAssetFuncInvoked) + }) + + t.Run("Expanded payload with authentication key is rejected", func(t *testing.T) { + defer reset() + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return `{"Type":"com.apple.asset.data","Identifier":"com.example.asset","Payload":{"Reference":{"DataURL":"https://example.com/data"},"Authentication":{"Username":"u"}}}`, nil, nil + } + defer func() { + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return document, nil, nil + } + }() + _, err := svc.CreateAppleDDMAsset(ctx, nil, "asset", validData) + require.Error(t, err) + require.True(t, ds.ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked) + require.False(t, ds.CreateAppleDDMAssetFuncInvoked) + }) +} diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 3eaaf0f211..a49dade2a4 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -967,6 +967,38 @@ allow { # Apple MDM ## +# Global admins, maintainers, and gitops can write DDM assets. +allow { + object.type == "ddm_asset" + subject.global_role == [admin, maintainer, gitops][_] + action == write +} + +# Global admins, maintainers, technicians, and gitops can read DDM assets. +allow { + object.type == "ddm_asset" + subject.global_role == [admin, maintainer, technician, gitops][_] + action == read +} + +# Team admins, maintainers and gitops can write DDM assets on their team. +allow { + not is_null(object.team_id) + object.team_id != 0 + object.type == "ddm_asset" + team_role(subject, object.team_id) == [admin, maintainer, gitops][_] + action == write +} + +# Team admins, maintainers, technicians and gitops can read DDM assets on their teams. +allow { + not is_null(object.team_id) + object.team_id != 0 + object.type == "ddm_asset" + team_role(subject, object.team_id) == [admin, maintainer, technician, gitops][_] + action == read +} + # Global admins can read, write, and list MDM apple information. allow { object.type == "mdm_apple" diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go index d9f8db8b67..9b4ef960ea 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -2270,6 +2270,111 @@ func TestAuthorizeMDMConfigProfile(t *testing.T) { }) } +func TestAuthorizeDDMAssets(t *testing.T) { + t.Parallel() + + globalAsset := &fleet.DDMAssetAuthz{} + team1Asset := &fleet.DDMAssetAuthz{ + TeamID: new(uint(1)), + } + runTestCases(t, []authTestCase{ + {user: test.UserNoRoles, object: globalAsset, action: write, allow: false}, + {user: test.UserNoRoles, object: globalAsset, action: read, allow: false}, + {user: test.UserNoRoles, object: team1Asset, action: write, allow: false}, + {user: test.UserNoRoles, object: team1Asset, action: read, allow: false}, + + {user: test.UserAdmin, object: globalAsset, action: write, allow: true}, + {user: test.UserAdmin, object: globalAsset, action: read, allow: true}, + {user: test.UserAdmin, object: team1Asset, action: write, allow: true}, + {user: test.UserAdmin, object: team1Asset, action: read, allow: true}, + + {user: test.UserMaintainer, object: globalAsset, action: write, allow: true}, + {user: test.UserMaintainer, object: globalAsset, action: read, allow: true}, + {user: test.UserMaintainer, object: team1Asset, action: write, allow: true}, + {user: test.UserMaintainer, object: team1Asset, action: read, allow: true}, + + {user: test.UserObserver, object: globalAsset, action: write, allow: false}, + {user: test.UserObserver, object: globalAsset, action: read, allow: false}, + {user: test.UserObserver, object: team1Asset, action: write, allow: false}, + {user: test.UserObserver, object: team1Asset, action: read, allow: false}, + + {user: test.UserObserverPlus, object: globalAsset, action: write, allow: false}, + {user: test.UserObserverPlus, object: globalAsset, action: read, allow: false}, + {user: test.UserObserverPlus, object: team1Asset, action: write, allow: false}, + {user: test.UserObserverPlus, object: team1Asset, action: read, allow: false}, + + {user: test.UserTechnician, object: globalAsset, action: write, allow: false}, + {user: test.UserTechnician, object: globalAsset, action: read, allow: true}, + {user: test.UserTechnician, object: team1Asset, action: write, allow: false}, + {user: test.UserTechnician, object: team1Asset, action: read, allow: true}, + + {user: test.UserGitOps, object: globalAsset, action: write, allow: true}, + {user: test.UserGitOps, object: globalAsset, action: read, allow: true}, + {user: test.UserGitOps, object: team1Asset, action: write, allow: true}, + {user: test.UserGitOps, object: team1Asset, action: read, allow: true}, + + {user: test.UserTeamAdminTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamAdminTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamAdminTeam1, object: team1Asset, action: write, allow: true}, + {user: test.UserTeamAdminTeam1, object: team1Asset, action: read, allow: true}, + + {user: test.UserTeamAdminTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamAdminTeam2, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamMaintainerTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam1, object: team1Asset, action: write, allow: true}, + {user: test.UserTeamMaintainerTeam1, object: team1Asset, action: read, allow: true}, + + {user: test.UserTeamMaintainerTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamMaintainerTeam2, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamObserverTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamObserverTeam1, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamObserverTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamObserverTeam2, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamObserverPlusTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam1, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamObserverPlusTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamObserverPlusTeam2, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamGitOpsTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam1, object: team1Asset, action: write, allow: true}, + {user: test.UserTeamGitOpsTeam1, object: team1Asset, action: read, allow: true}, + + {user: test.UserTeamGitOpsTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamGitOpsTeam2, object: team1Asset, action: read, allow: false}, + + {user: test.UserTeamTechnicianTeam1, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamTechnicianTeam1, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamTechnicianTeam1, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamTechnicianTeam1, object: team1Asset, action: read, allow: true}, + + {user: test.UserTeamTechnicianTeam2, object: globalAsset, action: write, allow: false}, + {user: test.UserTeamTechnicianTeam2, object: globalAsset, action: read, allow: false}, + {user: test.UserTeamTechnicianTeam2, object: team1Asset, action: write, allow: false}, + {user: test.UserTeamTechnicianTeam2, object: team1Asset, action: read, allow: false}, + }) +} + func TestAuthorizeMDMAppleSettings(t *testing.T) { t.Parallel() diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index a69017c1a3..925b430742 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -7800,3 +7800,120 @@ func (ds *Datastore) GetABMTokenOrgNamesAssociatedByDefaultTeams(ctx context.Con return orgNames, nil } + +func (ds *Datastore) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + if teamID == nil { + teamID = new(uint(0)) + } + + assets := []*fleet.DDMAsset{} + err := sqlx.SelectContext(ctx, ds.reader(ctx), &assets, `SELECT asset_uuid, team_id, identifier, name, token, created_at, uploaded_at + FROM mdm_apple_declaration_assets WHERE team_id = ?`, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing apple ddm assets") + } + return assets, nil +} + +func (ds *Datastore) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + if assetUUID == "" { + return nil, ctxerr.New(ctx, "asset UUID is required") + } + + var asset fleet.DDMAsset + err := sqlx.GetContext(ctx, ds.reader(ctx), &asset, `SELECT asset_uuid, team_id, identifier, name, token, created_at, uploaded_at + FROM mdm_apple_declaration_assets WHERE asset_uuid = ?`, assetUUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("Asset").WithName(assetUUID) + } + return nil, ctxerr.Wrap(ctx, err, "getting apple ddm asset") + } + return &asset, nil +} + +func (ds *Datastore) GetAppleDDMAssetForDownload(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + if assetUUID == "" { + return nil, ctxerr.New(ctx, "asset UUID is required") + } + + var asset fleet.DownloadableDDMAsset + err := sqlx.GetContext(ctx, ds.reader(ctx), &asset, `SELECT asset_uuid, team_id, identifier, name, token, created_at, uploaded_at, raw_json + FROM mdm_apple_declaration_assets WHERE asset_uuid = ?`, assetUUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("Asset").WithName(assetUUID) + } + return nil, ctxerr.Wrap(ctx, err, "getting apple ddm asset") + } + return &asset, nil +} + +func (ds *Datastore) CreateAppleDDMAsset(ctx context.Context, name, identifier string, data []byte, teamID *uint) (string, error) { + if name == "" { + return "", ctxerr.New(ctx, "asset name is required") + } + if identifier == "" { + return "", ctxerr.New(ctx, "asset identifier is required") + } + if len(data) == 0 { + return "", ctxerr.New(ctx, "asset data is required") + } + + assetUUID := uuid.NewString() + if teamID == nil { + teamID = new(uint(0)) + } + + now := time.Now() + + _, secretsUpdatedAt, err := ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data)) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "expanding embedded secrets") + } + + _, err = ds.writer(ctx).ExecContext(ctx, ` + INSERT INTO mdm_apple_declaration_assets + (asset_uuid, team_id, identifier, name, raw_json, uploaded_at, secrets_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + assetUUID, teamID, identifier, name, data, now, secretsUpdatedAt) + if err != nil { + if IsDuplicate(err) { + switch { + case strings.Contains(err.Error(), "asset_team_name"): + return "", alreadyExists("asset_name", name).WithTeamID(*teamID) + case strings.Contains(err.Error(), "asset_team_identifier"): + return "", alreadyExists("asset_identifier", identifier).WithTeamID(*teamID) + } + } + return "", ctxerr.Wrap(ctx, err, "inserting apple ddm asset") + } + + return assetUUID, nil +} + +func (ds *Datastore) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error { + if assetUUID == "" { + return ctxerr.New(ctx, "asset UUID is required") + } + + res, err := ds.writer(ctx).ExecContext(ctx, ` + DELETE FROM mdm_apple_declaration_assets + WHERE asset_uuid = ?`, assetUUID) + if err != nil { + if isMySQLForeignKey(err) { + return foreignKey("asset", assetUUID) + } + return ctxerr.Wrap(ctx, err, "deleting apple ddm asset") + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "checking rows affected for apple ddm asset deletion") + } + if rowsAffected == 0 { + return ctxerr.Wrap(ctx, notFound("Asset").WithName(assetUUID)) + } + + return nil +} diff --git a/server/datastore/mysql/apple_mdm_ddm_test.go b/server/datastore/mysql/apple_mdm_ddm_test.go index 145ebd4099..f23170643b 100644 --- a/server/datastore/mysql/apple_mdm_ddm_test.go +++ b/server/datastore/mysql/apple_mdm_ddm_test.go @@ -8,6 +8,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,6 +21,11 @@ func TestMDMDDMApple(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ + {"ListAppleDDMAssets", testListAppleDDMAssets}, + {"GetAppleDDMAsset", testGetAppleDDMAsset}, + {"GetAppleDDMAssetForDownload", testGetAppleDDMAssetForDownload}, + {"CreateAppleDDMAsset", testCreateAppleDDMAsset}, + {"DeleteAppleDDMAsset", testDeleteAppleDDMAsset}, {"StoreDDMStatusReportSkipsRemoveRows", testStoreDDMStatusReportSkipsRemoveRows}, {"CleanUpDuplicateRemoveInstallAcrossBatches", testCleanUpDuplicateRemoveInstallAcrossBatches}, {"ChannelScopeIsolation", testDDMChannelScopeIsolation}, @@ -409,3 +415,204 @@ func testCleanUpDuplicateRemoveInstallAcrossBatches(t *testing.T, ds *Datastore) } } } + +func testListAppleDDMAssets(t *testing.T, ds *Datastore) { + t.Run("no assets returns empty list", func(t *testing.T) { + ctx := t.Context() + assets, err := ds.ListAppleDDMAssets(ctx, nil) + require.NoError(t, err) + require.Empty(t, assets) + }) + + t.Run("returns assets for requested team", func(t *testing.T) { + ctx := t.Context() + + // insert helper + _, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), new(uint(1))) + require.NoError(t, err) + + assets, err := ds.ListAppleDDMAssets(ctx, nil) + require.NoError(t, err) + require.Empty(t, assets) + + assets, err = ds.ListAppleDDMAssets(ctx, new(uint(1))) + require.NoError(t, err) + require.Len(t, assets, 1) + }) +} + +func testGetAppleDDMAsset(t *testing.T, ds *Datastore) { + t.Run("returns not found for missing asset", func(t *testing.T) { + ctx := t.Context() + asset, err := ds.GetAppleDDMAsset(ctx, "fake-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.Nil(t, asset) + }) + + t.Run("returns asset for existing asset", func(t *testing.T) { + ctx := t.Context() + assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + asset, err := ds.GetAppleDDMAsset(ctx, assetUUID) + require.NoError(t, err) + require.NotNil(t, asset) + }) + + t.Run("return error for empty asset uuid", func(t *testing.T) { + ctx := t.Context() + asset, err := ds.GetAppleDDMAsset(ctx, "") + require.Error(t, err) + require.Contains(t, err.Error(), "asset UUID is required") + require.Nil(t, asset) + }) +} + +func testGetAppleDDMAssetForDownload(t *testing.T, ds *Datastore) { + t.Run("returns not found for missing asset", func(t *testing.T) { + ctx := t.Context() + asset, err := ds.GetAppleDDMAssetForDownload(ctx, "fake-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.Nil(t, asset) + }) + + t.Run("returns asset values for existing asset", func(t *testing.T) { + ctx := t.Context() + assetName := "asset-1" + assetUUID, err := ds.CreateAppleDDMAsset(ctx, assetName, "asset.identifier", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + asset, err := ds.GetAppleDDMAssetForDownload(ctx, assetUUID) + require.NoError(t, err) + require.NotNil(t, asset) + require.Equal(t, assetName, asset.Name) + require.NotNil(t, asset.Data) + }) + + t.Run("return error for empty asset uuid", func(t *testing.T) { + ctx := t.Context() + asset, err := ds.GetAppleDDMAssetForDownload(ctx, "") + require.Error(t, err) + require.Contains(t, err.Error(), "asset UUID is required") + require.Nil(t, asset) + }) +} + +func testDeleteAppleDDMAsset(t *testing.T, ds *Datastore) { + t.Run("returns not found for missing asset", func(t *testing.T) { + ctx := t.Context() + err := ds.DeleteAppleDDMAsset(ctx, "fake-uuid") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + }) + + t.Run("returns error for empty asset UUID", func(t *testing.T) { + ctx := t.Context() + err := ds.DeleteAppleDDMAsset(ctx, "") + require.Error(t, err) + require.Contains(t, err.Error(), "asset UUID is required") + }) + + t.Run("deletes existing asset", func(t *testing.T) { + ctx := t.Context() + assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + err = ds.DeleteAppleDDMAsset(ctx, assetUUID) + require.NoError(t, err) + }) + + t.Run("returns foreign key error for asset with declaration association", func(t *testing.T) { + ctx := t.Context() + assetUUID, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + // Insert a declaration, and decl<->asset association. + declUUID := uuid.NewString() + decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{ + DeclarationUUID: declUUID, + Identifier: "declaration.identifier", + Name: "decl-name", + RawJSON: []byte(`{"foo":"bar"}`), + }, nil) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, `INSERT INTO mdm_apple_declaration_asset_references (declaration_uuid, asset_uuid) VALUES (?, ?)`, decl.DeclarationUUID, assetUUID) + require.NoError(t, err) + return nil + }) + + err = ds.DeleteAppleDDMAsset(ctx, assetUUID) + require.Error(t, err) + var foreignKeyErr *foreignKeyError + require.ErrorAs(t, err, &foreignKeyErr) + }) +} + +func testCreateAppleDDMAsset(t *testing.T, ds *Datastore) { + t.Run("creates asset with valid data", func(t *testing.T) { + ctx := t.Context() + assetUUID, err := ds.CreateAppleDDMAsset(ctx, "valid-asset", "valid-asset-identifier", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + require.NotEmpty(t, assetUUID) + }) + + t.Run("fails to create asset with empty name", func(t *testing.T) { + ctx := t.Context() + _, err := ds.CreateAppleDDMAsset(ctx, "", "asset.identifier", []byte(`{"foo":"bar"}`), nil) + require.Error(t, err) + require.Contains(t, err.Error(), "asset name is required") + }) + + t.Run("fails to create asset with empty identifier", func(t *testing.T) { + ctx := t.Context() + _, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "", []byte(`{"foo":"bar"}`), nil) + require.Error(t, err) + require.Contains(t, err.Error(), "asset identifier is required") + }) + + t.Run("fails to create asset with empty data", func(t *testing.T) { + ctx := t.Context() + _, err := ds.CreateAppleDDMAsset(ctx, "asset-1", "asset.identifier", nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "asset data is required") + }) + + t.Run("returns already exists error when creating asset with duplicate identifier", func(t *testing.T) { + ctx := t.Context() + assetIdentifier := "conflict.asset.identifier" + _, err := ds.CreateAppleDDMAsset(ctx, "conflict-asset-1", assetIdentifier, []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + _, err = ds.CreateAppleDDMAsset(ctx, "asset-2", assetIdentifier, []byte(`{"foo":"baz"}`), nil) + require.Error(t, err) + var alreadyExistsErr *existsError + require.ErrorAs(t, err, &alreadyExistsErr) + }) + + t.Run("returns already exists error when creating asset with duplicate name", func(t *testing.T) { + ctx := t.Context() + + assetName := "conflict-asset-name" + _, err := ds.CreateAppleDDMAsset(ctx, assetName, "conflict.asset.identifier-one", []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + _, err = ds.CreateAppleDDMAsset(ctx, assetName, "conflict.asset.identifier-2", []byte(`{"foo":"baz"}`), nil) + require.Error(t, err) + var alreadyExistsErr *existsError + require.ErrorAs(t, err, &alreadyExistsErr) + }) + + t.Run("does not conflict across teams", func(t *testing.T) { + ctx := t.Context() + assetIdentifier := "no-conflict.asset.identifier" + assetName := "no-conflict.asset-1" + _, err := ds.CreateAppleDDMAsset(ctx, assetName, assetIdentifier, []byte(`{"foo":"bar"}`), nil) + require.NoError(t, err) + + _, err = ds.CreateAppleDDMAsset(ctx, assetName, assetIdentifier, []byte(`{"foo":"baz"}`), new(uint(1))) + require.NoError(t, err) + }) +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 2ce0deda5d..7345793cbd 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -1525,3 +1525,51 @@ type ADUEEnrollmentChallenge struct { ExpiresAt time.Time `db:"expires_at"` UsedAt *time.Time `db:"used_at"` } + +// DDMAsset is the JSON representation of an asset, only excluding the raw json. +type DDMAsset struct { + AssetUUID string `db:"asset_uuid" json:"asset_uuid"` + Name string `db:"name" json:"name"` + Identifier string `db:"identifier" json:"identifier"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UploadedAt *time.Time `db:"uploaded_at" json:"uploaded_at"` + Checksum []byte `db:"token" json:"checksum"` + TeamID *uint `db:"team_id" json:"-"` // Retrieve team ID for logic, but do not return it in JSON. +} + +// DownloadableDDMAsset is a service struct that contains the DDMAsset for logic checks, and the raw JSON for serving back to the caller. +type DownloadableDDMAsset struct { + DDMAsset + Data []byte `db:"raw_json" json:"-"` +} + +type RawDDMAsset struct { + Type string `json:"Type"` + Identifier string `json:"Identifier"` + Payload RawDDMAssetPayload `json:"Payload"` +} + +type RawDDMAssetPayload struct { + Reference RawDDMAssetPayloadReference `json:"Reference"` + Authentication json.RawMessage `json:"Authentication"` // We don't care about the inner, we use it for checking existence +} + +// Struct describing the AssetData reference payload structure +// https://developer.apple.com/documentation/devicemanagement/assetdatareferenceobject (asset data is used as an example here) +type RawDDMAssetPayloadReference struct { + ContentType string `json:"ContentType,omitempty"` + DataURL string `json:"DataURL"` + HashSHA256 string `json:"Hash-SHA-256,omitempty"` + Size int64 `json:"Size,omitempty"` +} + +// DDMAssetAuthz is used to check user authorization to read/write an +// DDM asset. +type DDMAssetAuthz struct { + TeamID *uint `json:"team_id" renameto:"fleet_id"` // required for authorization by team +} + +// AuthzType implements authz.AuthzTyper. +func (d DDMAssetAuthz) AuthzType() string { + return "ddm_asset" +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index d5df1481c6..97e7bb4c12 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -3555,6 +3555,12 @@ type Datastore interface { ConsumeADUEEnrollmentChallenge(ctx context.Context, challenge string) (*ADUEEnrollmentChallenge, error) // CleanupExpiredADUEEnrollmentChallenges deletes enrollment challenges expired more than 1 day ago. CleanupExpiredADUEEnrollmentChallenges(ctx context.Context) error + + ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*DDMAsset, error) + GetAppleDDMAsset(ctx context.Context, assetUUID string) (*DDMAsset, error) + GetAppleDDMAssetForDownload(ctx context.Context, assetUUID string) (*DownloadableDDMAsset, error) + CreateAppleDDMAsset(ctx context.Context, name, identifier string, data []byte, teamID *uint) (string, error) + DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error } type AndroidDatastore interface { diff --git a/server/fleet/request.go b/server/fleet/request.go index 6a629475b8..41ed8103ef 100644 --- a/server/fleet/request.go +++ b/server/fleet/request.go @@ -12,6 +12,7 @@ const ( MaxBatchScriptSize int64 = 25 * units.MiB MaxProfileSize int64 = 1.5 * units.MiB // 1.5 to allow for roughly 1MB content, and B64 encoding MaxBatchProfileSize int64 = 25 * units.MiB + MaxMDMAssetSize int64 = 1.5 * units.MiB // 1.5 to allow for roughly 1MB content, and B64 encoding MaxEULASize int64 = 25 * units.MiB MaxSoftwareBatchSize int64 = 25 * units.MiB // Takes multiple installers, with scripts and queries MaxMDMCommandSize int64 = 2 * units.MiB diff --git a/server/fleet/service.go b/server/fleet/service.go index c92b6bc903..b38831b538 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1544,6 +1544,20 @@ type Service interface { // UnenrollMDM unenrolls the host from MDM UnenrollMDM(ctx context.Context, hostID uint) error + + ////////////////////////////////////////////////////////////////////////////// + // Apple MDM Assets + + // ListAppleDDMAssets returns a list of assets used for Apple DDM belonging to the specified team, in their API representation. + ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*DDMAsset, error) + // GetAppleDDMAsset returns the asset with the given UUID, in its API representation. + GetAppleDDMAsset(ctx context.Context, assetUUID string) (*DDMAsset, error) + // DownloadAppleDDMAsset returns the filename and contents of the asset with the given UUID. + DownloadAppleDDMAsset(ctx context.Context, assetUUID string) (filename string, data []byte, err error) + // CreateAppleDDMAsset creates a new asset used for Apple DDM. It returns the UUID of the created asset. + CreateAppleDDMAsset(ctx context.Context, teamID *uint, name string, data []byte) (string, error) + // DeleteAppleDDMAsset deletes the asset with the given UUID. + DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error } type KeyValueStore interface { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 7f10e2a4c0..6ecb4d300d 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -2148,6 +2148,16 @@ type ConsumeADUEEnrollmentChallengeFunc func(ctx context.Context, challenge stri type CleanupExpiredADUEEnrollmentChallengesFunc func(ctx context.Context) error +type ListAppleDDMAssetsFunc func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) + +type GetAppleDDMAssetFunc func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) + +type GetAppleDDMAssetForDownloadFunc func(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) + +type CreateAppleDDMAssetFunc func(ctx context.Context, name string, identifier string, data []byte, teamID *uint) (string, error) + +type DeleteAppleDDMAssetFunc func(ctx context.Context, assetUUID string) error + type DataStore struct { AppConfigFunc AppConfigFunc AppConfigFuncInvoked bool @@ -5335,6 +5345,21 @@ type DataStore struct { CleanupExpiredADUEEnrollmentChallengesFunc CleanupExpiredADUEEnrollmentChallengesFunc CleanupExpiredADUEEnrollmentChallengesFuncInvoked bool + ListAppleDDMAssetsFunc ListAppleDDMAssetsFunc + ListAppleDDMAssetsFuncInvoked bool + + GetAppleDDMAssetFunc GetAppleDDMAssetFunc + GetAppleDDMAssetFuncInvoked bool + + GetAppleDDMAssetForDownloadFunc GetAppleDDMAssetForDownloadFunc + GetAppleDDMAssetForDownloadFuncInvoked bool + + CreateAppleDDMAssetFunc CreateAppleDDMAssetFunc + CreateAppleDDMAssetFuncInvoked bool + + DeleteAppleDDMAssetFunc DeleteAppleDDMAssetFunc + DeleteAppleDDMAssetFuncInvoked bool + mu sync.Mutex } @@ -12771,3 +12796,38 @@ func (s *DataStore) CleanupExpiredADUEEnrollmentChallenges(ctx context.Context) s.mu.Unlock() return s.CleanupExpiredADUEEnrollmentChallengesFunc(ctx) } + +func (s *DataStore) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + s.mu.Lock() + s.ListAppleDDMAssetsFuncInvoked = true + s.mu.Unlock() + return s.ListAppleDDMAssetsFunc(ctx, teamID) +} + +func (s *DataStore) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + s.mu.Lock() + s.GetAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.GetAppleDDMAssetFunc(ctx, assetUUID) +} + +func (s *DataStore) GetAppleDDMAssetForDownload(ctx context.Context, assetUUID string) (*fleet.DownloadableDDMAsset, error) { + s.mu.Lock() + s.GetAppleDDMAssetForDownloadFuncInvoked = true + s.mu.Unlock() + return s.GetAppleDDMAssetForDownloadFunc(ctx, assetUUID) +} + +func (s *DataStore) CreateAppleDDMAsset(ctx context.Context, name string, identifier string, data []byte, teamID *uint) (string, error) { + s.mu.Lock() + s.CreateAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.CreateAppleDDMAssetFunc(ctx, name, identifier, data, teamID) +} + +func (s *DataStore) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error { + s.mu.Lock() + s.DeleteAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.DeleteAppleDDMAssetFunc(ctx, assetUUID) +} diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 6189f98acd..c831730a43 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -942,6 +942,16 @@ type GetGroupedCertificateAuthoritiesFunc func(ctx context.Context, includeSecre type UnenrollMDMFunc func(ctx context.Context, hostID uint) error +type ListAppleDDMAssetsFunc func(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) + +type GetAppleDDMAssetFunc func(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) + +type DownloadAppleDDMAssetFunc func(ctx context.Context, assetUUID string) (filename string, data []byte, err error) + +type CreateAppleDDMAssetFunc func(ctx context.Context, teamID *uint, name string, data []byte) (string, error) + +type DeleteAppleDDMAssetFunc func(ctx context.Context, assetUUID string) error + type Service struct { EnrollOsqueryFunc EnrollOsqueryFunc EnrollOsqueryFuncInvoked bool @@ -2326,6 +2336,21 @@ type Service struct { UnenrollMDMFunc UnenrollMDMFunc UnenrollMDMFuncInvoked bool + ListAppleDDMAssetsFunc ListAppleDDMAssetsFunc + ListAppleDDMAssetsFuncInvoked bool + + GetAppleDDMAssetFunc GetAppleDDMAssetFunc + GetAppleDDMAssetFuncInvoked bool + + DownloadAppleDDMAssetFunc DownloadAppleDDMAssetFunc + DownloadAppleDDMAssetFuncInvoked bool + + CreateAppleDDMAssetFunc CreateAppleDDMAssetFunc + CreateAppleDDMAssetFuncInvoked bool + + DeleteAppleDDMAssetFunc DeleteAppleDDMAssetFunc + DeleteAppleDDMAssetFuncInvoked bool + mu sync.Mutex } @@ -5555,3 +5580,38 @@ func (s *Service) UnenrollMDM(ctx context.Context, hostID uint) error { s.mu.Unlock() return s.UnenrollMDMFunc(ctx, hostID) } + +func (s *Service) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + s.mu.Lock() + s.ListAppleDDMAssetsFuncInvoked = true + s.mu.Unlock() + return s.ListAppleDDMAssetsFunc(ctx, teamID) +} + +func (s *Service) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + s.mu.Lock() + s.GetAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.GetAppleDDMAssetFunc(ctx, assetUUID) +} + +func (s *Service) DownloadAppleDDMAsset(ctx context.Context, assetUUID string) (filename string, data []byte, err error) { + s.mu.Lock() + s.DownloadAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.DownloadAppleDDMAssetFunc(ctx, assetUUID) +} + +func (s *Service) CreateAppleDDMAsset(ctx context.Context, teamID *uint, name string, data []byte) (string, error) { + s.mu.Lock() + s.CreateAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.CreateAppleDDMAssetFunc(ctx, teamID, name, data) +} + +func (s *Service) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error { + s.mu.Lock() + s.DeleteAppleDDMAssetFuncInvoked = true + s.mu.Unlock() + return s.DeleteAppleDDMAssetFunc(ctx, assetUUID) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 36a882de62..dfb55cc951 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3669,6 +3669,207 @@ func (svc *Service) MDMAppleDisableFileVaultAndEscrow(ctx context.Context, teamI return fleet.ErrMissingLicense } +type listAppleDDMAssetsRequest struct { + TeamID *uint `query:"fleet_id,optional"` +} + +type listAppleDDMAssetsResponse struct { + Assets []*fleet.DDMAsset `json:"assets"` + Err error `json:"error,omitempty"` +} + +func (r listAppleDDMAssetsResponse) Error() error { return r.Err } + +func listAppleDDMAssetsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*listAppleDDMAssetsRequest) + assets, err := svc.ListAppleDDMAssets(ctx, req.TeamID) + if err != nil { + return listAppleDDMAssetsResponse{Err: err}, nil + } + return listAppleDDMAssetsResponse{Assets: assets}, nil +} + +func (svc *Service) ListAppleDDMAssets(ctx context.Context, teamID *uint) ([]*fleet.DDMAsset, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +type getAppleDDMAssetRequest struct { + AssetUUID string `url:"asset_uuid"` + Alt string `query:"alt,optional"` +} + +func (r getAppleDDMAssetRequest) ValidateRequest() error { + if r.Alt != "" && strings.ToLower(r.Alt) != "media" { + return &fleet.BadRequestError{Message: "Alt query param value is invalid. Supported values are empty and \"media\""} + } + return nil +} + +type getAppleDDMAssetResponse struct { + Asset *fleet.DDMAsset `json:",omitempty"` + Err error `json:"error,omitempty"` +} + +func (r getAppleDDMAssetResponse) Error() error { return r.Err } + +type downloadAppleDDMAssetResponse struct { + Name string + Data []byte + Err error `json:"error,omitempty"` +} + +func (r downloadAppleDDMAssetResponse) Error() error { return r.Err } + +func (r downloadAppleDDMAssetResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", strconv.Itoa(len(r.Data))) + w.Header().Set("Content-Type", "application/json") // We know we return JSON, if we ever serve other files we need a generic octet-stream + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename=%q`, r.Name)) // make the caller download the file + if n, err := w.Write(r.Data); err != nil { + logging.WithExtras(ctx, "err", err, "written", n) + } +} + +func getAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*getAppleDDMAssetRequest) + + if strings.ToLower(req.Alt) == "media" { + name, data, err := svc.DownloadAppleDDMAsset(ctx, req.AssetUUID) + if err != nil { + return downloadAppleDDMAssetResponse{Err: err}, nil + } + return downloadAppleDDMAssetResponse{Name: name, Data: data}, nil + } + + asset, err := svc.GetAppleDDMAsset(ctx, req.AssetUUID) + if err != nil { + return getAppleDDMAssetResponse{Err: err}, nil + } + return getAppleDDMAssetResponse{Asset: asset}, nil +} + +func (svc *Service) GetAppleDDMAsset(ctx context.Context, assetUUID string) (*fleet.DDMAsset, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +func (svc *Service) DownloadAppleDDMAsset(ctx context.Context, assetUUID string) (name string, data []byte, err error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return "", nil, fleet.ErrMissingLicense +} + +type createAppleDDMAssetRequest struct { + TeamID *uint + Asset *multipart.FileHeader +} + +func (createAppleDDMAssetRequest) DecodeRequest(ctx context.Context, r *http.Request) (any, error) { + decoded := new(createAppleDDMAssetRequest) + + err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + val, ok := r.MultipartForm.Value["fleet_id"] + if !ok || len(val) < 1 { + // default is no team + decoded.TeamID = new(uint(0)) + } else { + fleetID, err := strconv.ParseUint(val[0], 10, 32) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid fleet_id: %s", val[0])} + } + decoded.TeamID = new(uint(fleetID)) + } + + fhs, ok := r.MultipartForm.File["asset"] + if !ok || len(fhs) < 1 { + return nil, &fleet.BadRequestError{Message: "no file headers for asset"} + } + decoded.Asset = fhs[0] + + if !strings.HasSuffix(decoded.Asset.Filename, ".json") { + return nil, &fleet.BadRequestError{Message: "Invalid file type for asset. Only \".json\" files are allowed"} + } + + return decoded, nil +} + +type createAppleDDMAssetResponse struct { + AssetUUID string `json:"asset_uuid,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r createAppleDDMAssetResponse) Error() error { return r.Err } + +func createAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*createAppleDDMAssetRequest) + f, err := req.Asset.Open() + if err != nil { + return createAppleDDMAssetResponse{Err: err}, nil + } + defer f.Close() + assetData, err := io.ReadAll(f) + if err != nil { + return createAppleDDMAssetResponse{Err: err}, nil + } + assetName := strings.TrimSuffix(req.Asset.Filename, ".json") + assetUUID, err := svc.CreateAppleDDMAsset(ctx, req.TeamID, assetName, assetData) + if err != nil { + return createAppleDDMAssetResponse{Err: err}, nil + } + return createAppleDDMAssetResponse{AssetUUID: assetUUID}, nil +} + +func (svc *Service) CreateAppleDDMAsset(ctx context.Context, teamID *uint, name string, data []byte) (string, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return "", fleet.ErrMissingLicense +} + +type deleteAppleDDMAssetRequest struct { + AssetUUID string `url:"asset_uuid"` +} + +type deleteAppleDDMAssetResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteAppleDDMAssetResponse) Error() error { return r.Err } + +func (r deleteAppleDDMAssetResponse) Status() int { return http.StatusNoContent } + +func deleteAppleDDMAssetEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*deleteAppleDDMAssetRequest) + if err := svc.DeleteAppleDDMAsset(ctx, req.AssetUUID); err != nil { + return deleteAppleDDMAssetResponse{Err: err}, nil + } + return deleteAppleDDMAssetResponse{}, nil +} + +func (svc *Service) DeleteAppleDDMAsset(ctx context.Context, assetUUID string) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + //////////////////////////////////////////////////////////////////////////////// // Implementation of nanomdm's CheckinAndCommandService interface //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/handler.go b/server/service/handler.go index e3c9c331ce..1a10dc5c69 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -772,6 +772,12 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAppleMW.WithRequestBodySizeLimit(fleet.MaxProfileSize).POST("/api/_version_/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileEndpoint, preassignMDMAppleProfileRequest{}) mdmAppleMW.POST("/api/_version_/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentEndpoint, matchMDMApplePreassignmentRequest{}) + // This section handles MDM "assets", specifically for Apple DDM. + mdmAppleMW.GET("/api/_version_/fleet/assets", listAppleDDMAssetsEndpoint, listAppleDDMAssetsRequest{}) + mdmAppleMW.GET("/api/_version_/fleet/assets/{asset_uuid}", getAppleDDMAssetEndpoint, getAppleDDMAssetRequest{}) + mdmAppleMW.WithRequestBodySizeLimit(fleet.MaxMDMAssetSize).POST("/api/_version_/fleet/assets", createAppleDDMAssetEndpoint, createAppleDDMAssetRequest{}) + mdmAppleMW.DELETE("/api/_version_/fleet/assets/{asset_uuid}", deleteAppleDDMAssetEndpoint, deleteAppleDDMAssetRequest{}) + mdmAnyMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAnyMDM()) mdmAnyMW.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/configuration_profiles", getHostProfilesEndpoint, getHostProfilesRequest{})