SAAD: Asset CRUD API (#49011)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen
2026-07-09 18:34:17 +02:00
committed by GitHub
parent 56763d13c1
commit a15d58e927
14 changed files with 1508 additions and 0 deletions
+148
View File
@@ -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
}
+503
View File
@@ -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)
})
}
+32
View File
@@ -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"
+105
View File
@@ -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()
+117
View File
@@ -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
}
@@ -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)
})
}
+48
View File
@@ -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"
}
+6
View File
@@ -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 {
+1
View File
@@ -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
+14
View File
@@ -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 {
+60
View File
@@ -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)
}
+60
View File
@@ -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)
}
+201
View File
@@ -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
////////////////////////////////////////////////////////////////////////////////
+6
View File
@@ -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{})