fleet/ee/server/service/vpp.go
Martin Angers ba04887100
Backend: Support labels_include_all for installers/apps (#41324)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40721 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] 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.

- [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

## 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)

- [ ] QA'd all new/changed functionality manually

I (Martin) did test `labels_include_all` for FMA, custom installer, IPA
and VPP apps, and it seemed to all work great for gitops apply and
gitops generate, **except for VPP apps** which seem to have 2 important
pre-existing bugs, see
https://github.com/fleetdm/fleet/issues/40723#issuecomment-4041780707

## New Fleet configuration settings

- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled

---------

Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
2026-03-18 13:27:53 -04:00

1333 lines
48 KiB
Go

package service
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"image/png"
"io"
"maps"
"net/http"
"net/url"
"regexp"
"slices"
"sort"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
"google.golang.org/api/androidmanagement/v1"
)
// Used for overriding the env var value in testing
var testSetEmptyPrivateKey bool
// getVPPToken returns the base64 encoded VPP token, ready for use in requests to Apple's VPP API.
// It returns an error if the token is expired.
func (svc *Service) getVPPToken(ctx context.Context, teamID *uint) (string, error) {
token, err := svc.ds.GetVPPTokenByTeamID(ctx, teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fleet.NewUserMessageError(errors.New("No available VPP Token"), http.StatusUnprocessableEntity)
}
return "", ctxerr.Wrap(ctx, err, "fetching vpp token")
}
if time.Now().After(token.RenewDate) {
return "", fleet.NewUserMessageError(errors.New("Couldn't install. VPP token expired."), http.StatusUnprocessableEntity)
}
return token.Token, nil
}
var isAdamID = regexp.MustCompile(`^[0-9]+$`)
func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
var teamID *uint
var manualAgentInstall bool
if teamName != "" {
tm, err := svc.ds.TeamByName(ctx, teamName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return nil, nil
}
return nil, err
}
teamID = &tm.ID
manualAgentInstall = tm.Config.MDM.MacOSSetup.ManualAgentInstall.Value
} else {
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
manualAgentInstall = ac.MDM.MacOSSetup.ManualAgentInstall.Value
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating authorization")
}
// Adding VPP apps will add them to all available platforms per decision:
// https://github.com/fleetdm/fleet/issues/19447#issuecomment-2256598681
// The code is already here to support individual platforms, so we can easily enable it later.
payloadsWithPlatform := make([]fleet.VPPBatchPayloadWithPlatform, 0, len(payloads))
for _, payload := range payloads {
if payload.Platform == "" && isAdamID.MatchString(payload.AppStoreID) {
// add all possible Apple platforms, we'll remove the ones that this app doesn't support later
payloadsWithPlatform = append(payloadsWithPlatform,
fleet.VPPBatchPayloadWithPlatform{
AppStoreID: payload.AppStoreID,
SelfService: payload.SelfService,
InstallDuringSetup: payload.InstallDuringSetup,
Platform: fleet.MacOSPlatform,
LabelsExcludeAny: payload.LabelsExcludeAny,
LabelsIncludeAny: payload.LabelsIncludeAny,
LabelsIncludeAll: payload.LabelsIncludeAll,
Categories: payload.Categories,
DisplayName: payload.DisplayName,
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
},
fleet.VPPBatchPayloadWithPlatform{
AppStoreID: payload.AppStoreID,
SelfService: payload.SelfService,
InstallDuringSetup: payload.InstallDuringSetup,
Platform: fleet.IOSPlatform,
LabelsExcludeAny: payload.LabelsExcludeAny,
LabelsIncludeAny: payload.LabelsIncludeAny,
LabelsIncludeAll: payload.LabelsIncludeAll,
Categories: payload.Categories,
DisplayName: payload.DisplayName,
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
},
fleet.VPPBatchPayloadWithPlatform{
AppStoreID: payload.AppStoreID,
SelfService: payload.SelfService,
InstallDuringSetup: payload.InstallDuringSetup,
Platform: fleet.IPadOSPlatform,
LabelsExcludeAny: payload.LabelsExcludeAny,
LabelsIncludeAny: payload.LabelsIncludeAny,
LabelsIncludeAll: payload.LabelsIncludeAll,
Categories: payload.Categories,
DisplayName: payload.DisplayName,
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
},
)
}
payloadsWithPlatform = append(payloadsWithPlatform, fleet.VPPBatchPayloadWithPlatform{
AppStoreID: payload.AppStoreID,
SelfService: payload.SelfService,
InstallDuringSetup: payload.InstallDuringSetup,
Platform: payload.Platform,
LabelsExcludeAny: payload.LabelsExcludeAny,
LabelsIncludeAny: payload.LabelsIncludeAny,
LabelsIncludeAll: payload.LabelsIncludeAll,
Categories: payload.Categories,
DisplayName: payload.DisplayName,
Configuration: payload.Configuration,
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
})
}
var incomingAppleApps, incomingAndroidApps []fleet.VPPAppTeam
var vppToken string
// Don't check for token if we're only disassociating assets
if len(payloads) > 0 {
for _, payload := range payloadsWithPlatform {
if payload.Platform == "" {
payload.Platform = fleet.MacOSPlatform
}
if !payload.Platform.SupportsAppStoreApps() {
return nil, fleet.NewInvalidArgumentError("app_store_apps.platform",
fmt.Sprintf("platform must be one of '%s', '%s', '%s', or '%s'", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform, fleet.AndroidPlatform))
}
// Block Fleet Agent apps from being added via GitOps
if payload.Platform == fleet.AndroidPlatform && strings.HasPrefix(payload.AppStoreID, fleetAgentPackagePrefix) {
return nil, fleet.NewInvalidArgumentError("app_store_id", "The Fleet agent cannot be added manually. "+
"It is automatically managed by Fleet when Android MDM is enabled.")
}
if payload.Platform == fleet.MacOSPlatform && ptr.ValOrZero(payload.InstallDuringSetup) && manualAgentInstall {
return nil, fleet.NewUserMessageError(
errors.New(`Couldn't edit software. "setup_experience" cannot be used for macOS software if "manual_agent_install" is enabled.`),
http.StatusUnprocessableEntity)
}
var err error
if payload.Platform.IsApplePlatform() && vppToken == "" {
vppToken, err = svc.getVPPToken(ctx, teamID)
if err != nil {
return nil, fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "could not retrieve vpp token"), http.StatusUnprocessableEntity)
}
}
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels for batch adding vpp app")
}
payload.Categories = server.RemoveDuplicatesFromSlice(payload.Categories)
catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, payload.Categories)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software category ids")
}
if len(catIDs) != len(payload.Categories) {
return nil, &fleet.BadRequestError{
Message: "some or all of the categories provided don't exist",
InternalErr: fmt.Errorf("categories provided: %v", payload.Categories),
}
}
appStoreApp := fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: payload.AppStoreID,
Platform: payload.Platform,
},
SelfService: payload.SelfService,
InstallDuringSetup: payload.InstallDuringSetup,
ValidatedLabels: validatedLabels,
CategoryIDs: catIDs,
DisplayName: ptr.String(payload.DisplayName),
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
}
switch payload.Platform {
case fleet.AndroidPlatform:
if strings.HasPrefix(payload.AppStoreID, fleet.AndroidWebAppPrefix) && payload.Configuration != nil {
return nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.")
}
appStoreApp.SelfService = true
appStoreApp.Configuration = payload.Configuration
incomingAndroidApps = append(incomingAndroidApps, appStoreApp)
case fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform:
incomingAppleApps = append(incomingAppleApps, appStoreApp)
}
}
if len(incomingAppleApps) > 0 {
if dryRun {
// If we're doing a dry run, we stop here and return no error to avoid making any changes.
// That way we validate if a VPP token is available even on dry runs keeping it consistent.
return nil, nil
}
var missingAssets []string
assets, err := vpp.GetAssets(ctx, vppToken, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "unable to retrieve assets")
}
assetMap := map[string]struct{}{}
for _, asset := range assets {
assetMap[asset.AdamID] = struct{}{}
}
for _, vppAppID := range incomingAppleApps {
if _, ok := assetMap[vppAppID.AdamID]; !ok {
missingAssets = append(missingAssets, vppAppID.AdamID)
}
}
if len(missingAssets) != 0 {
reqErr := ctxerr.Errorf(ctx, "requested app not available on vpp account: %s", strings.Join(missingAssets, ","))
return nil, fleet.NewUserMessageError(reqErr, http.StatusUnprocessableEntity)
}
}
}
if dryRun {
// If we're doing a dry run, we stop here and return no error to avoid making any changes.
// Another dry run check is inside the payload size > 0 statement.
return nil, nil
}
allPlatformApps := slices.Concat(incomingAppleApps, incomingAndroidApps)
var appStoreApps []*fleet.VPPApp
if len(incomingAppleApps) > 0 {
apps, err := getVPPAppsMetadata(ctx, incomingAppleApps, vppToken, svc.getVPPConfig(ctx))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "refreshing VPP app metadata")
}
if len(apps) == 0 {
return nil, fleet.NewInvalidArgumentError("app_store_apps",
"no valid apps found matching the provided app store IDs and platforms")
}
appStoreApps = append(appStoreApps, apps...)
}
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get android enterprise")
}
androidHostPoliciesToUpdate := map[string]string{}
if len(incomingAndroidApps) == 0 {
// get the currently available VPP apps, and the hosts that have them in scope,
// to update their set of apps to the empty set (and remove/uninstall the apps).
removedApps, err := svc.ds.GetVPPApps(ctx, teamID)
if err != nil {
return nil, err
}
for _, app := range removedApps {
if app.Platform == fleet.AndroidPlatform {
hostsInScope, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, app.AppTeamID)
if err != nil {
return nil, err
}
maps.Copy(androidHostPoliciesToUpdate, hostsInScope)
}
}
} else {
if enterprise == nil {
return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
}
for _, a := range incomingAndroidApps {
androidApp, err := svc.androidModule.EnterprisesApplications(ctx, enterprise.Name(), a.AdamID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, fleet.NewInvalidArgumentError("app_store_id", "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.")
}
return nil, ctxerr.Wrap(ctx, err, "bulk add app store apps: check if android app exists")
}
appStoreApps = append(appStoreApps, &fleet.VPPApp{
VPPAppTeam: a,
BundleIdentifier: a.AdamID,
IconURL: androidApp.IconUrl,
Name: androidApp.Title,
TeamID: teamID,
})
}
}
if len(appStoreApps) > 0 {
if err := svc.ds.BatchInsertVPPApps(ctx, appStoreApps); err != nil {
return nil, ctxerr.Wrap(ctx, err, "inserting vpp app metadata")
}
}
appStoreIDToTitleID := make(map[string]uint, len(appStoreApps))
for _, a := range appStoreApps {
// The string representation includes the adam ID AND the platform, so it's unique per software title.
appStoreIDToTitleID[a.VPPAppID.String()] = a.TitleID
}
// Filter out the apps with invalid platforms
if len(appStoreApps) != len(allPlatformApps) {
allPlatformApps = make([]fleet.VPPAppTeam, 0, len(appStoreApps))
for _, app := range appStoreApps {
allPlatformApps = append(allPlatformApps, app.VPPAppTeam)
}
}
setupExperienceChanged, err := svc.ds.SetTeamVPPApps(ctx, teamID, allPlatformApps, appStoreIDToTitleID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "no vpp token to set team vpp assets"), http.StatusUnprocessableEntity)
}
return nil, ctxerr.Wrap(ctx, err, "set team vpp assets")
}
// Do cleanup here because this is API call 2 of 2 for setting software from GitOps
var tmID uint
if teamID != nil {
tmID = *teamID
}
// Apply auto-update config for iOS/iPadOS VPP apps
// First, get existing auto-update schedules to know which apps already have configs
existingIosAppSchedules, err := svc.ds.ListSoftwareAutoUpdateSchedules(ctx, tmID, "ios_apps")
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing existing auto-update schedules for ios apps")
}
existingIPadOsSchedules, err := svc.ds.ListSoftwareAutoUpdateSchedules(ctx, tmID, "ipados_apps")
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing existing auto-update schedules for ipados apps")
}
// Combine schedules from both sources
existingSchedules := slices.Concat(existingIosAppSchedules, existingIPadOsSchedules)
existingSchedulesByTitleID := make(map[uint]bool, len(existingSchedules))
for _, schedule := range existingSchedules {
existingSchedulesByTitleID[schedule.TitleID] = true
}
for _, app := range allPlatformApps {
if app.Platform != fleet.IOSPlatform && app.Platform != fleet.IPadOSPlatform {
continue
}
titleID, ok := appStoreIDToTitleID[app.VPPAppID.String()]
if !ok {
svc.logger.ErrorContext(ctx, "software title missing for vpp app", "vpp_app_id", app.VPPAppID.String())
continue
}
hasAutoUpdateSettings := app.AutoUpdateEnabled != nil || app.AutoUpdateStartTime != nil || app.AutoUpdateEndTime != nil
hasExistingSchedule := existingSchedulesByTitleID[titleID]
// Only update if: app has auto update settings OR app has an existing schedule to disable
if !hasAutoUpdateSettings && !hasExistingSchedule {
continue
}
cfg := fleet.SoftwareAutoUpdateConfig{
AutoUpdateEnabled: app.AutoUpdateEnabled,
AutoUpdateStartTime: app.AutoUpdateStartTime,
AutoUpdateEndTime: app.AutoUpdateEndTime,
}
if app.AutoUpdateEnabled == nil {
cfg.AutoUpdateEnabled = ptr.Bool(false)
}
// Validate auto-update window if enabled or if times are provided
hasTimesSet := app.AutoUpdateStartTime != nil || app.AutoUpdateEndTime != nil
if (app.AutoUpdateEnabled != nil && *app.AutoUpdateEnabled) || hasTimesSet {
schedule := fleet.SoftwareAutoUpdateSchedule{SoftwareAutoUpdateConfig: cfg}
if err := schedule.WindowIsValid(); err != nil {
return nil, ctxerr.Wrap(ctx, err, "invalid auto-update window for vpp app")
}
}
if err := svc.ds.UpdateSoftwareTitleAutoUpdateConfig(ctx, titleID, tmID, cfg); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating auto-update config for vpp app")
}
}
if err := svc.ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, tmID); err != nil {
return nil, err // returned error already includes context that we could include here
}
addedApps, err := svc.ds.GetVPPApps(ctx, teamID)
if err != nil {
return nil, err
}
var appIDs []string
for _, app := range addedApps {
if app.Platform == fleet.AndroidPlatform {
hostsInScope, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, app.AppTeamID)
if err != nil {
return nil, err
}
maps.Copy(androidHostPoliciesToUpdate, hostsInScope)
appIDs = append(appIDs, app.AppStoreID)
}
}
if len(androidHostPoliciesToUpdate) > 0 && enterprise != nil {
for hostUUID, policyID := range androidHostPoliciesToUpdate {
err := worker.QueueBulkSetAndroidAppsAvailableForHost(ctx, svc.ds, svc.logger, hostUUID, policyID, appIDs, enterprise.Name())
if err != nil {
return nil, ctxerr.WrapWithData(
ctx,
err,
"batch associate app store apps: add apps to android MDM policy",
map[string]any{
"policy_id": policyID,
"host_uuid": hostUUID,
"application_ids": appIDs,
},
)
}
}
}
if setupExperienceChanged {
err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEditedSetupExperienceSoftware{TeamID: ptr.ValOrZero(teamID), TeamName: teamName})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create edited setup experience activity")
}
}
if len(addedApps) == 0 {
return []fleet.VPPAppResponse{}, nil
}
return addedApps, nil
}
func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet.VPPApp, error) {
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
vppToken, err := svc.getVPPToken(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "retrieving VPP token")
}
assets, err := vpp.GetAssets(ctx, vppToken, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "fetching Apple VPP assets")
}
if len(assets) == 0 {
return []*fleet.VPPApp{}, nil
}
var adamIDs []string
for _, a := range assets {
adamIDs = append(adamIDs, a.AdamID)
}
metadata, err := apple_apps.GetMetadata(adamIDs, vppToken, svc.getVPPConfig(ctx))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata")
}
assignedApps, err := svc.ds.GetAssignedVPPApps(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "retrieving assigned VPP apps")
}
var apps []*fleet.VPPApp
var appsToUpdate []*fleet.VPPApp
for _, a := range assets {
m, ok := metadata[a.AdamID]
if !ok {
// Then this adam_id is not a VPP software entity, so skip it.
continue
}
for _, app := range apple_apps.ToVPPApps(m) {
if appFleet, ok := assignedApps[app.VPPAppID]; ok {
// Then this is already assigned, so filter it out.
app.SelfService = appFleet.SelfService
appsToUpdate = append(appsToUpdate, &app)
continue
}
apps = append(apps, &app)
}
}
if len(appsToUpdate) > 0 {
if err := svc.ds.BatchInsertVPPApps(ctx, appsToUpdate); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating existing VPP apps")
}
}
// Sort apps by name and by platform
sort.Slice(apps, func(i, j int) bool {
if apps[i].Name != apps[j].Name {
return apps[i].Name < apps[j].Name
}
return apps[i].Platform < apps[j].Platform
})
return apps, nil
}
var androidApplicationID = regexp.MustCompile(`^([A-Za-z]{1}[A-Za-z\d_]*\.)+[A-Za-z][A-Za-z\d_]*$`)
// fleetAgentPackagePrefix is the package prefix for Fleet Android agent.
// IT admins should not be able to add this app manually via the Software page as it is managed automatically by Fleet.
const fleetAgentPackagePrefix = "com.fleetdm.agent"
func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID fleet.VPPAppTeam) (uint, error) {
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil {
return 0, err
}
if appID.AddAutoInstallPolicy {
// Currently, same write permissions are applied on software and policies,
// but leaving this here in case it changes in the future.
if err := svc.authz.Authorize(ctx, &fleet.Policy{PolicyData: fleet.PolicyData{TeamID: teamID}}, fleet.ActionWrite); err != nil {
return 0, err
}
}
// Validate platform
if appID.Platform == "" {
appID.Platform = fleet.MacOSPlatform
}
if !appID.Platform.SupportsAppStoreApps() {
return 0, fleet.NewInvalidArgumentError("platform",
fmt.Sprintf("platform must be one of '%s', '%s', '%s', or '%s'", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform, fleet.AndroidPlatform))
}
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, appID.LabelsIncludeAny, appID.LabelsExcludeAny, appID.LabelsIncludeAll)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "validating software labels for adding vpp app")
}
teamName := fleet.TeamNameNoTeam
if teamID != nil && *teamID != 0 {
tm, err := svc.ds.TeamLite(ctx, *teamID)
if fleet.IsNotFound(err) {
return 0, fleet.NewInvalidArgumentError("team_id/fleet_id", fmt.Sprintf("fleet %d does not exist", *teamID)).
WithStatus(http.StatusNotFound)
} else if err != nil {
return 0, ctxerr.Wrap(ctx, err, "checking if team exists")
}
teamName = tm.Name
}
if appID.AddAutoInstallPolicy && appID.Platform != fleet.MacOSPlatform {
return 0, fleet.NewUserMessageError(errors.New("Currently, automatic install is only supported on macOS, Windows, and Linux. Please add the app without automatic_install and manually install it on the Host details page."), http.StatusBadRequest)
}
isAndroidAppID := androidApplicationID.MatchString(appID.AdamID)
var app *fleet.VPPApp
var androidEnterpriseName string
// Different flows based on platform
switch appID.Platform {
case fleet.AndroidPlatform:
if !isAndroidAppID {
return 0, fleet.NewInvalidArgumentError("app_store_id", "Application ID must be a valid Android application ID")
}
if strings.HasPrefix(appID.AdamID, fleetAgentPackagePrefix) {
return 0, fleet.NewInvalidArgumentError("app_store_id", "The Fleet agent cannot be added manually. "+
"It is automatically managed by Fleet when Android MDM is enabled.")
}
if strings.HasPrefix(appID.AdamID, fleet.AndroidWebAppPrefix) && appID.Configuration != nil {
return 0, fleet.NewInvalidArgumentError("configuration", "Couldn't add. Android web apps don't support configurations.")
}
appID.SelfService = true
appID.AddAutoInstallPolicy = false
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return 0, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
}
androidEnterpriseName = enterprise.Name()
androidApp, err := svc.androidModule.EnterprisesApplications(ctx, androidEnterpriseName, appID.AdamID)
if err != nil {
if fleet.IsNotFound(err) {
return 0, fleet.NewInvalidArgumentError("app_store_id", "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.")
}
return 0, ctxerr.Wrap(ctx, err, "add app store app: check if android app exists")
}
app = &fleet.VPPApp{
VPPAppTeam: appID,
BundleIdentifier: appID.AdamID,
IconURL: androidApp.IconUrl,
Name: androidApp.Title,
TeamID: teamID,
}
default:
if isAndroidAppID {
return 0, fleet.NewInvalidArgumentError(
"app_store_id",
fmt.Sprintf(
"Couldn't add software. %s isn't available in Apple Business Manager or Play Store. Please purchase a license in Apple Business Manager or find the app in Play Store and try again.",
appID.AdamID,
),
)
}
vppToken, err := svc.getVPPToken(ctx, teamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "retrieving VPP token")
}
assets, err := vpp.GetAssets(ctx, vppToken, &vpp.AssetFilter{AdamID: appID.AdamID})
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "retrieving VPP asset")
}
if len(assets) == 0 {
return 0, fleet.NewInvalidArgumentError("app_store_id",
fmt.Sprintf("Error: Couldn't add software. %s isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.", appID.AdamID))
}
asset := assets[0]
assetMetadata, err := apple_apps.GetMetadata([]string{asset.AdamID}, vppToken, svc.getVPPConfig(ctx))
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata")
}
assetMD := assetMetadata[asset.AdamID]
// Configuration is an Android only feature
appID.Configuration = nil
platforms := apple_apps.ToVPPApps(assetMD)
appFromApple, ok := platforms[appID.Platform]
if !ok {
return 0, fleet.NewInvalidArgumentError("app_store_id", fmt.Sprintf("%s isn't available for %s", assetMD.Attributes.Name, appID.Platform))
}
if appID.Platform == fleet.MacOSPlatform {
exists, err := svc.ds.CheckConflictingInstallerExists(ctx, teamID, appFromApple.BundleIdentifier, string(appID.Platform))
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "checking existence of conflicting installer")
}
if exists {
return 0, ctxerr.Wrap(ctx, fleet.ConflictError{
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage,
assetMD.Attributes.Name, teamName),
}, "vpp app conflicts with existing software installer")
}
} else if appID.Platform == fleet.IOSPlatform || appID.Platform == fleet.IPadOSPlatform {
// Check if an in-house app (IPA) with the same bundle identifier already exists
exists, err := svc.ds.CheckConflictingInHouseAppExists(ctx, teamID, appFromApple.BundleIdentifier, string(appID.Platform))
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "checking existence of conflicting installer")
}
if exists {
return 0, ctxerr.Wrap(ctx, fleet.ConflictError{
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage,
assetMD.Attributes.Name, teamName),
}, "vpp app conflicts with existing in-house app")
}
}
appID.ValidatedLabels = validatedLabels
appID.Categories = server.RemoveDuplicatesFromSlice(appID.Categories)
catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, appID.Categories)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting software category ids")
}
if len(catIDs) != len(appID.Categories) {
return 0, &fleet.BadRequestError{
Message: "some or all of the categories provided don't exist",
InternalErr: fmt.Errorf("categories provided: %v", appID.Categories),
}
}
appID.CategoryIDs = catIDs
app = &appFromApple
app.VPPAppTeam = appID
}
var androidConfigChanged bool
// note that if appID.Configuration is nil, InsertVPPAppWithTeam will ignore it (it will not
// update or remove it), so here we ignore it too if it is nil.
if appID.Configuration != nil && appID.Platform == fleet.AndroidPlatform {
changed, err := svc.ds.HasAndroidAppConfigurationChanged(ctx, appID.AdamID, ptr.ValOrZero(teamID), appID.Configuration)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "checking android app configuration change")
}
androidConfigChanged = changed
}
addedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "writing VPP app to db")
}
if appID.Platform == fleet.AndroidPlatform {
err := worker.QueueMakeAndroidAppAvailableJob(ctx, svc.ds, svc.logger, appID.AdamID, addedApp.AppTeamID, androidEnterpriseName, androidConfigChanged)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available")
}
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(addedApp.ValidatedLabels)
act := fleet.ActivityAddedAppStoreApp{
AppStoreID: app.AdamID,
Platform: app.Platform,
TeamName: &teamName,
SoftwareTitle: app.Name,
SoftwareTitleId: addedApp.TitleID,
TeamID: teamID,
SelfService: app.SelfService,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
Configuration: app.Configuration,
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
return 0, ctxerr.Wrap(ctx, err, "create activity for add app store app")
}
if appID.AddAutoInstallPolicy && app.AddedAutomaticInstallPolicy != nil {
policyAct := fleet.ActivityTypeCreatedPolicy{
ID: app.AddedAutomaticInstallPolicy.ID,
Name: app.AddedAutomaticInstallPolicy.Name,
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil {
svc.logger.WarnContext(ctx, "failed to create activity for create automatic install policy for app store app", "err", err)
}
}
return addedApp.TitleID, nil
}
func (svc *Service) getVPPConfig(ctx context.Context) apple_apps.Config {
return apple_apps.Configure(ctx, svc.ds, svc.config.License.Key, svc.config.MDM.AppleConnectJWT)
}
func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam, vppToken string, vppConfig apple_apps.Config) ([]*fleet.VPPApp, error) {
var apps []*fleet.VPPApp
// Map of adamID to platform, then to whether it's available as self-service
// and installed during setup.
adamIDMap := make(map[string]map[fleet.InstallableDevicePlatform]fleet.VPPAppTeam)
for _, id := range ids {
if _, ok := adamIDMap[id.AdamID]; !ok {
adamIDMap[id.AdamID] = make(map[fleet.InstallableDevicePlatform]fleet.VPPAppTeam, 1)
adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{
SelfService: id.SelfService,
InstallDuringSetup: id.InstallDuringSetup,
ValidatedLabels: id.ValidatedLabels,
AppTeamID: id.AppTeamID,
Categories: id.Categories,
CategoryIDs: id.CategoryIDs,
DisplayName: id.DisplayName,
AutoUpdateEnabled: id.AutoUpdateEnabled,
AutoUpdateStartTime: id.AutoUpdateStartTime,
AutoUpdateEndTime: id.AutoUpdateEndTime,
}
} else {
adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{
SelfService: id.SelfService,
InstallDuringSetup: id.InstallDuringSetup,
ValidatedLabels: id.ValidatedLabels,
AppTeamID: id.AppTeamID,
Categories: id.Categories,
CategoryIDs: id.CategoryIDs,
DisplayName: id.DisplayName,
AutoUpdateEnabled: id.AutoUpdateEnabled,
AutoUpdateStartTime: id.AutoUpdateStartTime,
AutoUpdateEndTime: id.AutoUpdateEndTime,
}
}
}
var adamIDs []string
for adamID := range adamIDMap {
adamIDs = append(adamIDs, adamID)
}
assetMetadata, err := apple_apps.GetMetadata(adamIDs, vppToken, vppConfig)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata")
}
for adamID, metadata := range assetMetadata {
platforms := apple_apps.ToVPPApps(metadata)
for platform, retrievedApp := range platforms {
if props, ok := adamIDMap[adamID][platform]; ok {
app := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: adamID,
Platform: platform,
},
SelfService: props.SelfService,
InstallDuringSetup: props.InstallDuringSetup,
ValidatedLabels: props.ValidatedLabels,
AppTeamID: props.AppTeamID,
Categories: props.Categories,
CategoryIDs: props.CategoryIDs,
DisplayName: props.DisplayName,
AutoUpdateEnabled: props.AutoUpdateEnabled,
AutoUpdateStartTime: props.AutoUpdateStartTime,
AutoUpdateEndTime: props.AutoUpdateEndTime,
},
BundleIdentifier: retrievedApp.BundleIdentifier,
IconURL: retrievedApp.IconURL,
Name: retrievedApp.Name,
LatestVersion: retrievedApp.LatestVersion,
}
apps = append(apps, app)
} else {
continue
}
}
}
return apps, nil
}
func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, payload fleet.AppStoreAppUpdatePayload) (*fleet.VPPAppStoreApp, *fleet.ActivityEditedAppStoreApp, error) {
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil {
return nil, nil, err
}
// If there's an auto-update config, validate it.
// Note that applying this config is done in a separate service method.
schedule := fleet.SoftwareAutoUpdateSchedule{
SoftwareAutoUpdateConfig: fleet.SoftwareAutoUpdateConfig{
AutoUpdateEnabled: payload.AutoUpdateEnabled,
AutoUpdateStartTime: payload.AutoUpdateStartTime,
AutoUpdateEndTime: payload.AutoUpdateEndTime,
},
}
if payload.AutoUpdateEnabled != nil && *payload.AutoUpdateEnabled {
if err := schedule.WindowIsValid(); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: validating auto-update schedule")
}
}
var teamName string
if teamID != nil && *teamID != 0 {
tm, err := svc.ds.TeamLite(ctx, *teamID)
if fleet.IsNotFound(err) {
return nil, nil, fleet.NewInvalidArgumentError("team_id/fleet_id", fmt.Sprintf("fleet %d does not exist", *teamID)).
WithStatus(http.StatusNotFound)
} else if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: checking if team exists")
}
teamName = tm.Name
}
var validatedLabels *fleet.LabelIdentsWithScope
if payload.LabelsExcludeAny != nil || payload.LabelsIncludeAny != nil || payload.LabelsIncludeAll != nil {
var err error
validatedLabels, err = ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: validating software labels")
}
}
meta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting vpp app metadata")
}
if payload.DisplayName != nil && *payload.DisplayName != meta.DisplayName {
trimmed := strings.TrimSpace(*payload.DisplayName)
if trimmed == "" && *payload.DisplayName != "" {
return nil, nil, fleet.NewInvalidArgumentError("display_name", "Cannot have a display name that is all whitespace.")
}
*payload.DisplayName = trimmed
}
selfServiceVal := meta.SelfService
if payload.SelfService != nil && meta.Platform != fleet.AndroidPlatform {
selfServiceVal = *payload.SelfService
}
if payload.Configuration != nil && meta.Platform != fleet.AndroidPlatform {
payload.Configuration = nil
}
appToWrite := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: meta.AdamID, Platform: meta.Platform,
},
SelfService: selfServiceVal,
ValidatedLabels: validatedLabels,
DisplayName: payload.DisplayName,
Configuration: payload.Configuration,
},
TeamID: teamID,
TitleID: titleID,
BundleIdentifier: meta.BundleIdentifier,
Name: meta.Name,
LatestVersion: meta.LatestVersion,
}
if meta.IconURL != nil {
appToWrite.IconURL = *meta.IconURL
}
if payload.Categories != nil {
payload.Categories = server.RemoveDuplicatesFromSlice(payload.Categories)
catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, payload.Categories)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "getting software category ids")
}
if len(catIDs) != len(payload.Categories) {
return nil, nil, &fleet.BadRequestError{
Message: "some or all of the categories provided don't exist",
InternalErr: fmt.Errorf("categories provided: %v", payload.Categories),
}
}
appToWrite.CategoryIDs = catIDs
}
// check if labels have changed
var existingLabels fleet.LabelIdentsWithScope
switch {
case len(meta.LabelsExcludeAny) > 0:
existingLabels.LabelScope = fleet.LabelScopeExcludeAny
existingLabels.ByName = make(map[string]fleet.LabelIdent, len(meta.LabelsExcludeAny))
for _, l := range meta.LabelsExcludeAny {
existingLabels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID}
}
case len(meta.LabelsIncludeAny) > 0:
existingLabels.LabelScope = fleet.LabelScopeIncludeAny
existingLabels.ByName = make(map[string]fleet.LabelIdent, len(meta.LabelsIncludeAny))
for _, l := range meta.LabelsIncludeAny {
existingLabels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID}
}
case len(meta.LabelsIncludeAll) > 0:
existingLabels.LabelScope = fleet.LabelScopeIncludeAll
existingLabels.ByName = make(map[string]fleet.LabelIdent, len(meta.LabelsIncludeAll))
for _, l := range meta.LabelsIncludeAll {
existingLabels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID}
}
}
var labelsChanged bool
if validatedLabels != nil {
labelsChanged = !validatedLabels.Equal(&existingLabels)
}
// Get the hosts that are NOT in label scope currently (before the update happens)
var hostsNotInScope map[uint]struct{}
if labelsChanged {
hostsNotInScope, err = svc.ds.GetExcludedHostIDMapForVPPApp(ctx, meta.VPPAppsTeamsID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting hosts not in scope for vpp app")
}
}
var androidConfigChanged bool
// note that if appID.Configuration is nil, InsertVPPAppWithTeam will ignore it (it will not
// update or remove it), so here we ignore it too if it is nil.
if payload.Configuration != nil && meta.Platform == fleet.AndroidPlatform {
if strings.HasPrefix(meta.AdamID, fleet.AndroidWebAppPrefix) {
return nil, nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.")
}
// check if configuration has changed
androidConfigChanged, err = svc.ds.HasAndroidAppConfigurationChanged(ctx, meta.AdamID, ptr.ValOrZero(teamID), payload.Configuration)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: checking if android app configuration changed")
}
}
// Update the app
insertedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, appToWrite, teamID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: write app to db")
}
// if labelsChanged, new hosts may require having the app made available, and if config
// changed, the app policy must be updated.
if meta.Platform == fleet.AndroidPlatform && (labelsChanged || androidConfigChanged) {
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return nil, nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
}
err = worker.QueueMakeAndroidAppAvailableJob(ctx, svc.ds, svc.logger, appToWrite.AdamID, insertedApp.AppTeamID, enterprise.Name(), androidConfigChanged)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available")
}
}
if labelsChanged {
// Get the hosts that are now IN label scope (after the update)
hostsInScope, err := svc.ds.GetIncludedHostIDMapForVPPApp(ctx, meta.VPPAppsTeamsID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting hosts in scope for vpp app")
}
var hostsToClear []uint
for id := range hostsInScope {
if _, ok := hostsNotInScope[id]; ok {
// it was not in scope but now it is, so we should clear policy status
hostsToClear = append(hostsToClear, id)
}
}
// We clear the policy status here because otherwise the policy automation machinery
// won't pick this up and the software won't install.
if err := svc.ds.ClearVPPAppAutoInstallPolicyStatusForHosts(ctx, meta.VPPAppsTeamsID, hostsToClear); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "failed to clear auto install policy status for host")
}
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(validatedLabels)
displayNameVal := ptr.ValOrZero(payload.DisplayName)
act := fleet.ActivityEditedAppStoreApp{
TeamName: &teamName,
TeamID: teamID,
SelfService: selfServiceVal,
SoftwareTitleID: titleID,
SoftwareTitle: meta.Name,
AppStoreID: meta.AdamID,
Platform: meta.Platform,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
SoftwareIconURL: meta.IconURL,
SoftwareDisplayName: displayNameVal,
Configuration: appToWrite.Configuration,
}
updatedAppMeta, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: getting updated app metadata")
}
return updatedAppMeta, &act, nil
}
func (svc *Service) UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*fleet.VPPTokenDB, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
privateKey := svc.config.Server.PrivateKey
if testSetEmptyPrivateKey {
privateKey = ""
}
if len(privateKey) == 0 {
return nil, ctxerr.New(ctx, "Couldn't add content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
}
if token == nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
}
tokenBytes, err := io.ReadAll(token)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading VPP token")
}
locName, err := vpp.GetConfig(string(tokenBytes))
if err != nil {
var vppErr *vpp.ErrorResponse
if errors.As(err, &vppErr) {
// Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
if vppErr.ErrorNumber == 9622 {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
}
}
return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple")
}
data := fleet.VPPTokenData{
Token: string(tokenBytes),
Location: locName,
}
tok, err := svc.ds.InsertVPPToken(ctx, &data)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "writing VPP token to db")
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEnabledVPP{
Location: locName,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for upload VPP token")
}
return tok, nil
}
func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
privateKey := svc.config.Server.PrivateKey
if testSetEmptyPrivateKey {
privateKey = ""
}
if len(privateKey) == 0 {
return nil, ctxerr.New(ctx, "Couldn't add content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
}
if token == nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
}
tokenBytes, err := io.ReadAll(token)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading VPP token")
}
locName, err := vpp.GetConfig(string(tokenBytes))
if err != nil {
var vppErr *vpp.ErrorResponse
if errors.As(err, &vppErr) {
// Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
if vppErr.ErrorNumber == 9622 {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
}
}
return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple")
}
data := fleet.VPPTokenData{
Token: string(tokenBytes),
Location: locName,
}
tok, err := svc.ds.UpdateVPPToken(ctx, tokenID, &data)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating vpp token")
}
return tok, nil
}
func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return nil, err
}
tok, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, teamIDs)
if err != nil {
var errTokConstraint fleet.ErrVPPTokenTeamConstraint
if errors.As(err, &errTokConstraint) {
return nil, ctxerr.Wrap(ctx, fleet.NewUserMessageError(errTokConstraint, http.StatusConflict))
}
return nil, ctxerr.Wrap(ctx, err, "updating vpp token team")
}
return tok, nil
}
func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil {
return nil, err
}
return svc.ds.ListVPPTokens(ctx)
}
func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error {
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
return err
}
tok, err := svc.ds.GetVPPToken(ctx, tokenID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting vpp token")
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDisabledVPP{
Location: tok.Location,
}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for delete VPP token")
}
return svc.ds.DeleteVPPToken(ctx, tokenID)
}
func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) {
// Authorization for this endpoint is a bit different - basically we want the same
// write permissions as for App Store apps (fleet.VPPApp struct), but there is no
// team id available when this endpoint is called, so we allow any team user to
// call it as long as they have the acceptable role. To achieve this, we grab the
// first team id from the user's list of teams, if they have a non-global role.
var teamID *uint
if user := authz.UserFromContext(ctx); user != nil {
if len(user.Teams) > 0 {
teamID = &user.Teams[0].ID
}
}
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil {
return "", err
}
// title and startURL are required but are already validated during the DecodeRequest implementation.
if parsedURL, err := url.Parse(startURL); err != nil || !parsedURL.IsAbs() {
return "", &fleet.BadRequestError{Message: "Couldn't create. The start URL must be a valid absolute URL.", InternalErr: err}
}
// icon, if provided, must be a .png file and must be square and at least 512x512 pixels.
var iconData []byte
if icon != nil {
const invalidIconErrMsg = `Couldn't create. The icon must be a PNG file and square, with dimensions of at least 512 x 512px.`
b, err := io.ReadAll(icon)
if err != nil {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err}
}
iconData = b
// decoding errors if it is not a valid png
cfg, err := png.DecodeConfig(bytes.NewReader(iconData))
if err != nil {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err}
}
// check that it is square
if cfg.Width != cfg.Height {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg}
}
// check minimal size requirement (only needs to test one, as at this point it is square)
if cfg.Width < 512 {
return "", &fleet.BadRequestError{Message: invalidIconErrMsg}
}
}
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get android enterprise")
}
webApp := &androidmanagement.WebApp{
DisplayMode: "STANDALONE", // always standalone for now per spec
Title: title,
StartUrl: startURL,
}
if len(iconData) > 0 {
// must be the "actual bytes of the image in a base64url encoded string"
b64Icon := base64.URLEncoding.EncodeToString(iconData)
webApp.Icons = append(webApp.Icons, &androidmanagement.WebAppIcon{ImageData: b64Icon})
}
createdApp, err := svc.androidModule.CreateAndroidWebApp(ctx, enterprise.Name(), webApp)
if err != nil {
if androidmgmt.IsBadRequestError(err) {
return "", &fleet.BadRequestError{Message: "Couldn't create. Please check the provided data and try again.", InternalErr: err}
}
return "", ctxerr.Wrap(ctx, err, "creating android web app")
}
packageName := strings.TrimPrefix(createdApp.Name, fmt.Sprintf("%s/webApps/", enterprise.Name()))
if packageName == createdApp.Name || !strings.HasPrefix(packageName, fleet.AndroidWebAppPrefix) {
// logging this as an error, because the frontend uses the package name to hide some actions
// not available to WebApps, we must know if somehow android changes how those get named.
svc.logger.ErrorContext(ctx, "created Android webApp does not have expected package name format", "package_name", createdApp.Name)
}
return packageName, nil
}