Android app self service: backend support (#34711)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #34389 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## 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 ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).
This commit is contained in:
+7
-1
@@ -663,6 +663,7 @@ func newWorkerIntegrationsSchedule(
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
bootstrapPackageStore fleet.MDMBootstrapPackageStore,
|
||||
vppInstaller fleet.AppleMDMVPPInstaller,
|
||||
androidModule android.Service,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronWorkerIntegrations)
|
||||
@@ -726,7 +727,12 @@ func newWorkerIntegrationsSchedule(
|
||||
Datastore: ds,
|
||||
Log: logger,
|
||||
}
|
||||
w.Register(jira, zendesk, macosSetupAsst, appleMDM, dbMigrate, vppVerify)
|
||||
softwareWorker := &worker.SoftwareWorker{
|
||||
Datastore: ds,
|
||||
Log: logger,
|
||||
AndroidModule: androidModule,
|
||||
}
|
||||
w.Register(jira, zendesk, macosSetupAsst, appleMDM, dbMigrate, vppVerify, softwareWorker)
|
||||
|
||||
// Read app config a first time before starting, to clear up any failer client
|
||||
// configuration if we're not on a fleet-owned server. Technically, the ServerURL
|
||||
|
||||
+5
-2
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
|
||||
otelmw "github.com/fleetdm/fleet/v4/server/service/middleware/otel"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
|
||||
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
|
||||
"github.com/fleetdm/fleet/v4/server/service/redis_policy_set"
|
||||
@@ -785,14 +786,15 @@ the way that the Fleet server works.
|
||||
if err != nil {
|
||||
initFatal(err, "initializing service")
|
||||
}
|
||||
activitiesModule := activities.NewActivityModule(ds, logger)
|
||||
androidSvc, err := android_service.NewService(
|
||||
ctx,
|
||||
logger,
|
||||
ds,
|
||||
svc,
|
||||
config.License.Key,
|
||||
config.Server.PrivateKey,
|
||||
ds,
|
||||
activitiesModule,
|
||||
)
|
||||
if err != nil {
|
||||
initFatal(err, "initializing android service")
|
||||
@@ -897,6 +899,7 @@ the way that the Fleet server works.
|
||||
redis_key_value.New(redisPool),
|
||||
scepConfigMgr,
|
||||
digiCertService,
|
||||
androidSvc,
|
||||
hydrantService,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1024,7 +1027,7 @@ the way that the Fleet server works.
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
vppInstaller := svc.(fleet.AppleMDMVPPInstaller)
|
||||
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, bootstrapPackageStore, vppInstaller)
|
||||
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, bootstrapPackageStore, vppInstaller, androidSvc)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register worker integrations schedule")
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ func setupMockDatastorePremiumService(t testing.TB) (*mock.Store, *eeservice.Ser
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
||||
"github.com/fleetdm/fleet/v4/server/sso"
|
||||
@@ -34,6 +35,7 @@ type Service struct {
|
||||
keyValueStore fleet.KeyValueStore
|
||||
scepConfigService fleet.SCEPConfigService
|
||||
digiCertService fleet.DigiCertService
|
||||
androidModule android.Service
|
||||
estService fleet.ESTService
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ func NewService(
|
||||
keyValueStore fleet.KeyValueStore,
|
||||
scepConfigService fleet.SCEPConfigService,
|
||||
digiCertService fleet.DigiCertService,
|
||||
androidService android.Service,
|
||||
estService fleet.ESTService,
|
||||
) (*Service, error) {
|
||||
authorizer, err := authz.NewAuthorizer()
|
||||
@@ -81,6 +84,7 @@ func NewService(
|
||||
keyValueStore: keyValueStore,
|
||||
scepConfigService: scepConfigService,
|
||||
digiCertService: digiCertService,
|
||||
androidModule: androidService,
|
||||
estService: estService,
|
||||
}
|
||||
|
||||
|
||||
@@ -1116,7 +1116,7 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
|
||||
}
|
||||
|
||||
platform := host.FleetPlatform()
|
||||
mobileAppleDevice := fleet.AppleDevicePlatform(platform) == fleet.IOSPlatform || fleet.AppleDevicePlatform(platform) == fleet.IPadOSPlatform
|
||||
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
|
||||
|
||||
if !mobileAppleDevice && (host.OrbitNodeKey == nil || *host.OrbitNodeKey == "") {
|
||||
// fleetd is required to install software so if the host is
|
||||
@@ -1239,7 +1239,7 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
|
||||
}
|
||||
}
|
||||
|
||||
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
|
||||
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
|
||||
SelfService: false,
|
||||
})
|
||||
return err
|
||||
@@ -2564,9 +2564,9 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f
|
||||
}
|
||||
|
||||
platform := host.FleetPlatform()
|
||||
mobileAppleDevice := fleet.AppleDevicePlatform(platform) == fleet.IOSPlatform || fleet.AppleDevicePlatform(platform) == fleet.IPadOSPlatform
|
||||
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
|
||||
|
||||
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.AppleDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
|
||||
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
|
||||
SelfService: true,
|
||||
})
|
||||
return err
|
||||
|
||||
+110
-64
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/itunes"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||||
"github.com/fleetdm/fleet/v4/server/worker"
|
||||
"github.com/go-kit/log/level"
|
||||
)
|
||||
|
||||
@@ -314,8 +316,8 @@ func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func getPlatformsFromSupportedDevices(supportedDevices []string) map[fleet.AppleDevicePlatform]struct{} {
|
||||
platforms := make(map[fleet.AppleDevicePlatform]struct{}, 1)
|
||||
func getPlatformsFromSupportedDevices(supportedDevices []string) map[fleet.InstallableDevicePlatform]struct{} {
|
||||
platforms := make(map[fleet.InstallableDevicePlatform]struct{}, 1)
|
||||
if len(supportedDevices) == 0 {
|
||||
platforms[fleet.MacOSPlatform] = struct{}{}
|
||||
return platforms
|
||||
@@ -334,6 +336,8 @@ func getPlatformsFromSupportedDevices(supportedDevices []string) map[fleet.Apple
|
||||
return platforms
|
||||
}
|
||||
|
||||
var androidApplicationID = regexp.MustCompile(`^([A-Za-z]{1}[A-Za-z\d_]*\.)+[A-Za-z][A-Za-z\d_]*$`)
|
||||
|
||||
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
|
||||
@@ -350,9 +354,10 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee
|
||||
if appID.Platform == "" {
|
||||
appID.Platform = fleet.MacOSPlatform
|
||||
}
|
||||
if appID.Platform != fleet.IOSPlatform && appID.Platform != fleet.IPadOSPlatform && appID.Platform != fleet.MacOSPlatform {
|
||||
|
||||
if !appID.Platform.IsValidInstallableDevicePlatform() {
|
||||
return 0, fleet.NewInvalidArgumentError("platform",
|
||||
fmt.Sprintf("platform must be one of '%s', '%s', or '%s", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform))
|
||||
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, appID.LabelsIncludeAny, appID.LabelsExcludeAny)
|
||||
@@ -377,73 +382,112 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee
|
||||
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)
|
||||
}
|
||||
|
||||
vppToken, err := svc.getVPPToken(ctx, teamID)
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "retrieving VPP token")
|
||||
}
|
||||
var app *fleet.VPPApp
|
||||
|
||||
assets, err := vpp.GetAssets(ctx, vppToken, &vpp.AssetFilter{AdamID: appID.AdamID})
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "retrieving VPP asset")
|
||||
}
|
||||
// Different flows based on platform
|
||||
switch appID.Platform {
|
||||
case fleet.AndroidPlatform:
|
||||
if !androidApplicationID.MatchString(appID.AdamID) {
|
||||
return 0, fleet.NewInvalidArgumentError("app_store_id", "app_store_id must be a valid Android application ID")
|
||||
}
|
||||
appID.SelfService = true
|
||||
appID.AddAutoInstallPolicy = false
|
||||
|
||||
if len(assets) == 0 {
|
||||
return 0, ctxerr.New(ctx,
|
||||
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 := itunes.GetAssetMetadata([]string{asset.AdamID}, &itunes.AssetMetadataFilter{Entity: "software"})
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata")
|
||||
}
|
||||
|
||||
assetMD := assetMetadata[asset.AdamID]
|
||||
|
||||
platforms := getPlatformsFromSupportedDevices(assetMD.SupportedDevices)
|
||||
if _, ok := platforms[appID.Platform]; !ok {
|
||||
return 0, fleet.NewInvalidArgumentError("app_store_id", fmt.Sprintf("%s isn't available for %s", assetMD.TrackName, appID.Platform))
|
||||
}
|
||||
|
||||
if appID.Platform == fleet.MacOSPlatform {
|
||||
// Check if we've already added an installer for this app
|
||||
exists, err := svc.ds.UploadedSoftwareExists(ctx, assetMD.BundleID, teamID)
|
||||
enterprise, err := svc.ds.GetEnterprise(ctx)
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "checking existence of VPP app installer")
|
||||
return 0, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
|
||||
}
|
||||
|
||||
if exists {
|
||||
return 0, ctxerr.Wrap(ctx, fleet.ConflictError{
|
||||
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage,
|
||||
assetMD.TrackName, teamName),
|
||||
}, "vpp app conflicts with existing software installer")
|
||||
androidApp, err := svc.androidModule.EnterprisesApplications(ctx, enterprise.Name(), 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")
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
app = &fleet.VPPApp{
|
||||
VPPAppTeam: appID,
|
||||
BundleIdentifier: appID.AdamID,
|
||||
IconURL: androidApp.IconUrl,
|
||||
Name: androidApp.Title,
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
err = worker.QueueMakeAndroidAppAvailableJob(context.Background(), svc.ds, svc.logger, appID.AdamID, app.AppTeamID, enterprise.Name())
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available")
|
||||
}
|
||||
|
||||
default:
|
||||
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 := itunes.GetAssetMetadata([]string{asset.AdamID}, &itunes.AssetMetadataFilter{Entity: "software"})
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "fetching VPP asset metadata")
|
||||
}
|
||||
|
||||
assetMD := assetMetadata[asset.AdamID]
|
||||
|
||||
platforms := getPlatformsFromSupportedDevices(assetMD.SupportedDevices)
|
||||
if _, ok := platforms[appID.Platform]; !ok {
|
||||
return 0, fleet.NewInvalidArgumentError("app_store_id", fmt.Sprintf("%s isn't available for %s", assetMD.TrackName, appID.Platform))
|
||||
}
|
||||
|
||||
if appID.Platform == fleet.MacOSPlatform {
|
||||
// Check if we've already added an installer for this app
|
||||
exists, err := svc.ds.UploadedSoftwareExists(ctx, assetMD.BundleID, teamID)
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "checking existence of VPP app installer")
|
||||
}
|
||||
|
||||
if exists {
|
||||
return 0, ctxerr.Wrap(ctx, fleet.ConflictError{
|
||||
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage,
|
||||
assetMD.TrackName, teamName),
|
||||
}, "vpp app conflicts with existing software installer")
|
||||
}
|
||||
}
|
||||
|
||||
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 = &fleet.VPPApp{
|
||||
VPPAppTeam: appID,
|
||||
BundleIdentifier: assetMD.BundleID,
|
||||
IconURL: assetMD.ArtworkURL,
|
||||
Name: assetMD.TrackName,
|
||||
LatestVersion: assetMD.Version,
|
||||
}
|
||||
}
|
||||
appID.CategoryIDs = catIDs
|
||||
|
||||
app := &fleet.VPPApp{
|
||||
VPPAppTeam: appID,
|
||||
BundleIdentifier: assetMD.BundleID,
|
||||
IconURL: assetMD.ArtworkURL,
|
||||
Name: assetMD.TrackName,
|
||||
LatestVersion: assetMD.Version,
|
||||
}
|
||||
|
||||
addedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID)
|
||||
@@ -464,6 +508,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee
|
||||
LabelsIncludeAny: actLabelsIncl,
|
||||
LabelsExcludeAny: actLabelsExcl,
|
||||
}
|
||||
|
||||
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "create activity for add app store app")
|
||||
}
|
||||
@@ -481,6 +526,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee
|
||||
}
|
||||
|
||||
return addedApp.TitleID, nil
|
||||
|
||||
}
|
||||
|
||||
func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.VPPApp, error) {
|
||||
@@ -488,10 +534,10 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V
|
||||
|
||||
// Map of adamID to platform, then to whether it's available as self-service
|
||||
// and installed during setup.
|
||||
adamIDMap := make(map[string]map[fleet.AppleDevicePlatform]fleet.VPPAppTeam)
|
||||
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.AppleDevicePlatform]fleet.VPPAppTeam, 1)
|
||||
adamIDMap[id.AdamID] = make(map[fleet.InstallableDevicePlatform]fleet.VPPAppTeam, 1)
|
||||
adamIDMap[id.AdamID][id.Platform] = fleet.VPPAppTeam{
|
||||
SelfService: id.SelfService,
|
||||
InstallDuringSetup: id.InstallDuringSetup,
|
||||
|
||||
@@ -1517,3 +1517,17 @@ func (ds *Datastore) ListAndroidEnrolledDevicesForReconcile(ctx context.Context)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func isAndroidHostConnectedToFleetMDM(ctx context.Context, q sqlx.QueryerContext, h *fleet.Host) (bool, error) {
|
||||
var isEnrolled bool
|
||||
|
||||
err := sqlx.GetContext(ctx, q, &isEnrolled, `
|
||||
SELECT 1 FROM host_mdm
|
||||
WHERE host_id = ? AND enrolled = 1
|
||||
`, h.ID)
|
||||
if err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "check android host mdm enrolled")
|
||||
}
|
||||
|
||||
return isEnrolled, nil
|
||||
}
|
||||
|
||||
@@ -92,10 +92,20 @@ func (ds *AndroidDatastore) DeleteOtherEnterprises(ctx context.Context, id uint)
|
||||
}
|
||||
|
||||
func (ds *AndroidDatastore) DeleteAllEnterprises(ctx context.Context) error {
|
||||
stmt := `DELETE FROM android_enterprises`
|
||||
_, err := ds.Writer(ctx).ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "deleting all enterprises")
|
||||
}
|
||||
return nil
|
||||
return ds.WithTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
stmt := `DELETE FROM android_enterprises`
|
||||
_, err := tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "deleting all enterprises")
|
||||
}
|
||||
|
||||
// Aligns Fleet's state with the AMAPI state
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM vpp_apps_teams WHERE platform = 'android'`)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "deleting all android app store apps")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -47,3 +47,7 @@ func (ds *AndroidDatastore) Writer(_ context.Context) *sqlx.DB {
|
||||
func (ds *AndroidDatastore) WithRetryTxx(ctx context.Context, fn common_mysql.TxFn) (err error) {
|
||||
return common_mysql.WithRetryTxx(ctx, ds.Writer(ctx), fn, ds.logger)
|
||||
}
|
||||
|
||||
func (ds *AndroidDatastore) WithTxx(ctx context.Context, fn common_mysql.TxFn) (err error) {
|
||||
return common_mysql.WithTxx(ctx, ds.Writer(ctx), fn, ds.logger)
|
||||
}
|
||||
|
||||
@@ -1847,13 +1847,17 @@ func (ds *Datastore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*f
|
||||
}
|
||||
|
||||
func (ds *Datastore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.Host) (bool, error) {
|
||||
if host.Platform == "windows" {
|
||||
switch host.Platform {
|
||||
case "windows":
|
||||
return isWindowsHostConnectedToFleetMDM(ctx, ds.reader(ctx), host)
|
||||
} else if host.Platform == "darwin" || host.Platform == "ipados" || host.Platform == "ios" {
|
||||
case "darwin", "ipados", "ios":
|
||||
return isAppleHostConnectedToFleetMDM(ctx, ds.reader(ctx), host)
|
||||
case "android":
|
||||
// Android hosts can only enroll via MDM
|
||||
return isAndroidHostConnectedToFleetMDM(ctx, ds.reader(ctx), host)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func batchSetProfileVariableAssociationsDB(
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ func TestUp_20240730374423(t *testing.T) {
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
var platform fleet.AppleDevicePlatform
|
||||
var platform fleet.InstallableDevicePlatform
|
||||
require.NoError(t, db.Get(&platform, `SELECT platform FROM vpp_apps WHERE adam_id = ?`, adamID))
|
||||
assert.Equal(t, fleet.MacOSPlatform, platform)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20251112191443, Down_20251112191443)
|
||||
}
|
||||
|
||||
func Up_20251112191443(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE vpp_apps_teams DROP FOREIGN KEY fk_vpp_apps_teams_vpp_token_id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop fk from vpp_apps_table: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_apps_teams MODIFY vpp_token_id INT UNSIGNED`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make vpp_apps_table.vpp_token_id nullable: %w", err)
|
||||
}
|
||||
|
||||
// Drop all FK constraints so we can modify the column size
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_app_upcoming_activities DROP CONSTRAINT fk_vpp_app_upcoming_activities_adam_id_platform`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop vpp_app_upcoming_activities.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE host_vpp_software_installs DROP CONSTRAINT host_vpp_software_installs_ibfk_3`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop host_vpp_software_installs.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_apps_teams DROP CONSTRAINT vpp_apps_teams_ibfk_3`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop vpp_apps_teams.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
// Do the actual column size modification
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_apps MODIFY COLUMN adam_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increase size of vpp_apps.adam_id: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_app_upcoming_activities MODIFY COLUMN adam_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increase size of vpp_app_upcoming_activities.adam_id: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE host_vpp_software_installs MODIFY COLUMN adam_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increase size of host_vpp_software_installs .adam_id: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_apps_teams MODIFY COLUMN adam_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increase size of vpp_apps_teams.adam_id: %w", err)
|
||||
}
|
||||
|
||||
// Add back all the FKs we deleted above
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_app_upcoming_activities ADD CONSTRAINT fk_vpp_app_upcoming_activities_adam_id_platform FOREIGN KEY (adam_id, platform) REFERENCES vpp_apps (adam_id, platform)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add vpp_app_upcoming_activities.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE host_vpp_software_installs ADD CONSTRAINT host_vpp_software_installs_ibfk_3 FOREIGN KEY (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add host_vpp_software_installs.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`ALTER TABLE vpp_apps_teams ADD CONSTRAINT vpp_apps_teams_ibfk_3 FOREIGN KEY (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add vpp_apps_teams.adam_id fk: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20251112191443(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20251112191443(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
// These tables should still have the FKs they had before the migration
|
||||
for tableName, fkName := range map[string]string{
|
||||
"vpp_app_upcoming_activities": "fk_vpp_app_upcoming_activities_adam_id_platform",
|
||||
"host_vpp_software_installs": "host_vpp_software_installs_ibfk_3",
|
||||
"vpp_apps_teams": "vpp_apps_teams_ibfk_3",
|
||||
} {
|
||||
var columnNames []string
|
||||
err := db.Select(&columnNames, `
|
||||
SELECT
|
||||
COLUMN_NAME
|
||||
FROM
|
||||
INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE
|
||||
REFERENCED_TABLE_SCHEMA = (SELECT DATABASE()) AND
|
||||
TABLE_NAME = ? AND CONSTRAINT_NAME = ?`, tableName, fkName)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, columnNames, []string{"adam_id", "platform"})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
nano_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
"github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
@@ -38,6 +39,9 @@ import (
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
// Compile-time interface check
|
||||
var _ activities.ActivityStore = (*Datastore)(nil)
|
||||
|
||||
const (
|
||||
defaultSelectLimit = 1000000
|
||||
mySQLTimestampFormat = "2006-01-02 15:04:05" // %Y/%m/%d %H:%M:%S
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -840,7 +840,7 @@ WHERE
|
||||
if row.VPPAdamID.Valid {
|
||||
vppAppID = &fleet.VPPAppID{
|
||||
AdamID: row.VPPAdamID.V,
|
||||
Platform: fleet.AppleDevicePlatform(row.VPPPlatform.V),
|
||||
Platform: fleet.InstallableDevicePlatform(row.VPPPlatform.V),
|
||||
}
|
||||
}
|
||||
return row.InstallerID.V, vppAppID, row.InHouseID.V, nil
|
||||
@@ -2963,6 +2963,39 @@ WHERE
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetIncludedHostUUIDMapForAppStoreApp(ctx context.Context, vppAppTeamID uint) (map[string]string, error) {
|
||||
return ds.getIncludedHostUUIDMapForSoftware(ctx, ds.writer(ctx), vppAppTeamID, softwareTypeVPP)
|
||||
}
|
||||
|
||||
func (ds *Datastore) getIncludedHostUUIDMapForSoftware(ctx context.Context, tx sqlx.ExtContext, softwareID uint, swType softwareType) (map[string]string, error) {
|
||||
filter := fmt.Sprintf(labelScopedFilter, swType)
|
||||
stmt := fmt.Sprintf(`SELECT
|
||||
h.uuid AS uuid,
|
||||
ad.applied_policy_id AS applied_policy_id
|
||||
FROM
|
||||
hosts h
|
||||
JOIN android_devices ad ON ad.enterprise_specific_id = h.uuid
|
||||
WHERE
|
||||
EXISTS (%s)
|
||||
AND platform = 'android'
|
||||
`, filter)
|
||||
|
||||
var queryResults []struct {
|
||||
UUID string `db:"uuid"`
|
||||
AppliedPolicyID string `db:"applied_policy_id"`
|
||||
}
|
||||
if err := sqlx.SelectContext(ctx, tx, &queryResults, stmt, softwareID, softwareID, softwareID); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software scope")
|
||||
}
|
||||
|
||||
res := make(map[string]string, len(queryResults))
|
||||
for _, result := range queryResults {
|
||||
res[result.UUID] = result.AppliedPolicyID
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
|
||||
return ds.getExcludedHostIDMapForSoftware(ctx, installerID, softwareTypeInstaller)
|
||||
}
|
||||
|
||||
+118
-13
@@ -465,7 +465,7 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets
|
||||
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
for _, toAdd := range toAddApps {
|
||||
vppAppTeamID, err := insertVPPAppTeams(ctx, tx, toAdd, teamID, vppToken.ID)
|
||||
vppAppTeamID, err := insertVPPAppTeams(ctx, tx, toAdd, teamID, &vppToken.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "SetTeamVPPApps inserting vpp app into team")
|
||||
}
|
||||
@@ -563,9 +563,16 @@ WHERE
|
||||
}
|
||||
|
||||
func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) {
|
||||
var vppTokenID *uint
|
||||
vppToken, err := ds.GetVPPTokenByTeamID(ctx, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam unable to get VPP Token ID")
|
||||
if !fleet.IsNotFound(err) || app.Platform != fleet.AndroidPlatform {
|
||||
return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam unable to get VPP Token ID")
|
||||
}
|
||||
}
|
||||
|
||||
if vppToken != nil {
|
||||
vppTokenID = &vppToken.ID
|
||||
}
|
||||
|
||||
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
@@ -580,7 +587,7 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp
|
||||
return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPApps transaction")
|
||||
}
|
||||
|
||||
vppAppTeamID, err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppToken.ID)
|
||||
vppAppTeamID, err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppTokenID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPAppTeams transaction")
|
||||
}
|
||||
@@ -711,7 +718,7 @@ ON DUPLICATE KEY UPDATE
|
||||
return ctxerr.Wrap(ctx, err, "insert VPP apps")
|
||||
}
|
||||
|
||||
func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) (uint, error) {
|
||||
func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID *uint) (uint, error) {
|
||||
stmt := `
|
||||
INSERT INTO vpp_apps_teams
|
||||
(adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id, install_during_setup)
|
||||
@@ -732,12 +739,15 @@ ON DUPLICATE KEY UPDATE
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID, appID.InstallDuringSetup, appID.InstallDuringSetup)
|
||||
if IsDuplicate(err) {
|
||||
err = &existsError{
|
||||
Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService),
|
||||
TeamID: teamID,
|
||||
ResourceType: "VPPAppID",
|
||||
if err != nil {
|
||||
if IsDuplicate(err) {
|
||||
err = &existsError{
|
||||
Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService),
|
||||
TeamID: teamID,
|
||||
ResourceType: "VPPAppID",
|
||||
}
|
||||
}
|
||||
return 0, ctxerr.Wrap(ctx, err, "inserting app store app")
|
||||
}
|
||||
|
||||
var id int64
|
||||
@@ -780,6 +790,8 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
|
||||
source = "ios_apps"
|
||||
case fleet.IPadOSPlatform:
|
||||
source = "ipados_apps"
|
||||
case fleet.AndroidPlatform:
|
||||
source = "android_apps"
|
||||
default:
|
||||
source = "apps"
|
||||
}
|
||||
@@ -792,6 +804,8 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
|
||||
if app.BundleIdentifier != "" {
|
||||
// match by bundle identifier first, or standard matching if we
|
||||
// don't have a bundle identifier match
|
||||
insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')`
|
||||
insertArgs = append(insertArgs, app.BundleIdentifier)
|
||||
switch source {
|
||||
case "ios_apps", "ipados_apps":
|
||||
selectStmt = `
|
||||
@@ -801,6 +815,13 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
|
||||
ORDER BY bundle_identifier = ? DESC
|
||||
LIMIT 1`
|
||||
selectArgs = []any{app.BundleIdentifier, source, app.Name, source, app.BundleIdentifier}
|
||||
case "android_apps":
|
||||
selectStmt = `
|
||||
SELECT id
|
||||
FROM software_titles
|
||||
WHERE application_id = ? AND additional_identifier IS NULL AND source = 'android_apps'`
|
||||
selectArgs = []any{app.BundleIdentifier}
|
||||
insertStmt = `INSERT INTO software_titles (name, source, application_id, extension_for) VALUES (?, ?, ?, '')`
|
||||
default:
|
||||
selectStmt = `
|
||||
SELECT id
|
||||
@@ -808,8 +829,6 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
|
||||
WHERE bundle_identifier = ? AND additional_identifier = 0`
|
||||
selectArgs = []any{app.BundleIdentifier}
|
||||
}
|
||||
insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')`
|
||||
insertArgs = append(insertArgs, app.BundleIdentifier)
|
||||
}
|
||||
|
||||
titleID, err := ds.optimisticGetOrInsertWithWriter(ctx,
|
||||
@@ -824,7 +843,7 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, ctxerr.Wrap(ctx, err, "optimistic get or insert VPP app")
|
||||
return 0, ctxerr.Wrap(ctx, err, "optimistic get or insert app store app")
|
||||
}
|
||||
|
||||
return titleID, nil
|
||||
@@ -888,7 +907,7 @@ func (ds *Datastore) GetTitleInfoFromVPPAppsTeamsID(ctx context.Context, vppApps
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error) {
|
||||
func (ds *Datastore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.InstallableDevicePlatform, teamID *uint) (*fleet.VPPApp, error) {
|
||||
stmt := `
|
||||
SELECT va.adam_id,
|
||||
va.bundle_identifier,
|
||||
@@ -1489,6 +1508,11 @@ func (ds *Datastore) DeleteVPPToken(ctx context.Context, tokenID uint) error {
|
||||
return ctxerr.Wrap(ctx, err, "removing policy automations associated with vpp token")
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM vpp_apps_teams WHERE vpp_token_id = ?`, tokenID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "removing vpp apps associated with vpp token")
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM vpp_tokens WHERE id = ?`, tokenID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "deleting vpp token")
|
||||
@@ -2055,3 +2079,84 @@ WHERE
|
||||
|
||||
return users, activities, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetAndroidAppsInScopeForHost(ctx context.Context, hostID uint) (applicationIDs []string, err error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
installable_id
|
||||
FROM (
|
||||
-- no labels
|
||||
SELECT
|
||||
0 AS count_installer_labels,
|
||||
0 AS count_host_labels,
|
||||
0 AS count_host_updated_after_labels,
|
||||
vpp_apps_teams.adam_id AS installable_id
|
||||
FROM vpp_apps_teams
|
||||
LEFT JOIN vpp_app_team_labels ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id
|
||||
WHERE vpp_app_team_labels.id IS NULL AND vpp_apps_teams.platform = 'android'
|
||||
|
||||
UNION
|
||||
|
||||
-- include any
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
0 AS count_host_updated_after_labels,
|
||||
vpp_apps_teams.adam_id AS installable_id
|
||||
FROM
|
||||
vpp_app_team_labels vatl
|
||||
LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id
|
||||
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
|
||||
AND lm.host_id = ?
|
||||
WHERE vatl.exclude = 0 AND vpp_apps_teams.platform = 'android'
|
||||
GROUP BY installable_id
|
||||
HAVING
|
||||
count_installer_labels > 0
|
||||
AND count_host_labels > 0
|
||||
|
||||
UNION
|
||||
|
||||
-- exclude any, ignore software that depends on labels created
|
||||
-- _after_ the label_updated_at timestamp of the host (because
|
||||
-- we don't have results for that label yet, the host may or may
|
||||
-- not be a member).
|
||||
SELECT
|
||||
COUNT(*) AS count_installer_labels,
|
||||
COUNT(lm.label_id) AS count_host_labels,
|
||||
SUM(
|
||||
CASE WHEN lbl.created_at IS NOT NULL
|
||||
AND lbl.label_membership_type = 0
|
||||
AND(
|
||||
SELECT
|
||||
label_updated_at FROM hosts
|
||||
WHERE
|
||||
id = ?) >= lbl.created_at THEN
|
||||
1
|
||||
WHEN lbl.created_at IS NOT NULL
|
||||
AND lbl.label_membership_type = 1 THEN
|
||||
1
|
||||
ELSE
|
||||
0
|
||||
END) AS count_host_updated_after_labels,
|
||||
vpp_apps_teams.adam_id AS installable_id
|
||||
FROM
|
||||
vpp_app_team_labels vatl
|
||||
LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id
|
||||
LEFT OUTER JOIN labels lbl ON lbl.id = vatl.label_id
|
||||
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
|
||||
AND lm.host_id = ?
|
||||
WHERE vatl.exclude = 1 AND vpp_apps_teams.platform = 'android'
|
||||
GROUP BY installable_id
|
||||
HAVING
|
||||
count_installer_labels > 0
|
||||
AND count_installer_labels = count_host_updated_after_labels
|
||||
AND count_host_labels = 0) t;
|
||||
`
|
||||
|
||||
err = sqlx.SelectContext(ctx, ds.reader(ctx), &applicationIDs, stmt, hostID, hostID, hostID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get in android apps in scope for host")
|
||||
}
|
||||
|
||||
return applicationIDs, err
|
||||
}
|
||||
|
||||
@@ -481,7 +481,7 @@ func createVPPAppInstallRequest(t *testing.T, ds *Datastore, host *fleet.Host, a
|
||||
|
||||
err := ds.InsertHostVPPSoftwareInstall(ctx, host.ID, fleet.VPPAppID{
|
||||
AdamID: adamID,
|
||||
Platform: fleet.AppleDevicePlatform(host.Platform),
|
||||
Platform: fleet.InstallableDevicePlatform(host.Platform),
|
||||
}, cmdUUID, eventID, fleet.HostSoftwareInstallOptions{})
|
||||
require.NoError(t, err)
|
||||
return cmdUUID
|
||||
|
||||
+36
-27
@@ -10,6 +10,15 @@ import (
|
||||
|
||||
type ContextKey string
|
||||
|
||||
type ActivityWebhookPayload struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ActorFullName *string `json:"actor_full_name"`
|
||||
ActorID *uint `json:"actor_id"`
|
||||
ActorEmail *string `json:"actor_email"`
|
||||
Type string `json:"type"`
|
||||
Details *json.RawMessage `json:"details"`
|
||||
}
|
||||
|
||||
// ActivityWebhookContextKey is the context key to indicate that the activity webhook has been processed before saving the activity.
|
||||
const ActivityWebhookContextKey = ContextKey("ActivityWebhook")
|
||||
|
||||
@@ -2244,15 +2253,15 @@ func (a ActivityDisabledVPP) Documentation() (activity string, details string, d
|
||||
}
|
||||
|
||||
type ActivityAddedAppStoreApp struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwareTitleId uint `json:"software_title_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform AppleDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwareTitleId uint `json:"software_title_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform InstallableDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityAddedAppStoreApp) ActivityName() string {
|
||||
@@ -2291,14 +2300,14 @@ func (a ActivityAddedAppStoreApp) Documentation() (activity string, details stri
|
||||
}
|
||||
|
||||
type ActivityDeletedAppStoreApp struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform AppleDevicePlatform `json:"platform"`
|
||||
SoftwareIconURL *string `json:"software_icon_url"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform InstallableDevicePlatform `json:"platform"`
|
||||
SoftwareIconURL *string `json:"software_icon_url"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityDeletedAppStoreApp) ActivityName() string {
|
||||
@@ -2393,16 +2402,16 @@ func (a ActivityInstalledAppStoreApp) Documentation() (string, string, string) {
|
||||
}
|
||||
|
||||
type ActivityEditedAppStoreApp struct {
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwareTitleID uint `json:"software_title_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform AppleDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareIconURL *string `json:"software_icon_url"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
SoftwareTitle string `json:"software_title"`
|
||||
SoftwareTitleID uint `json:"software_title_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
TeamName *string `json:"team_name"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
Platform InstallableDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
SoftwareIconURL *string `json:"software_icon_url"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||
}
|
||||
|
||||
func (a ActivityEditedAppStoreApp) ActivityName() string {
|
||||
|
||||
@@ -2058,7 +2058,7 @@ type Datastore interface {
|
||||
|
||||
// GetVPPAppMetadataByAdamIDPlatformTeamID returns the VPP app correspoding to the specified
|
||||
// ADAM ID, platform within the context of the specified team. It includes the vpp_app_team_id value.
|
||||
GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform AppleDevicePlatform, teamID *uint) (*VPPApp, error)
|
||||
GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform InstallableDevicePlatform, teamID *uint) (*VPPApp, error)
|
||||
|
||||
// DeleteSoftwareInstaller deletes the software installer corresponding to the id.
|
||||
DeleteSoftwareInstaller(ctx context.Context, id uint) error
|
||||
@@ -2067,6 +2067,8 @@ type Datastore interface {
|
||||
// the provided team.
|
||||
DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, appID VPPAppID) error
|
||||
|
||||
GetAndroidAppsInScopeForHost(ctx context.Context, hostID uint) (applicationIDs []string, err error)
|
||||
|
||||
// GetSummaryHostSoftwareInstalls returns the software install summary for
|
||||
// the given software installer id.
|
||||
GetSummaryHostSoftwareInstalls(ctx context.Context, installerID uint) (*SoftwareInstallerStatusSummary, error)
|
||||
@@ -2138,6 +2140,8 @@ type Datastore interface {
|
||||
// given VPP app, based on label membership.
|
||||
GetIncludedHostIDMapForVPPApp(ctx context.Context, vppAppTeamID uint) (map[uint]struct{}, error)
|
||||
|
||||
GetIncludedHostUUIDMapForAppStoreApp(ctx context.Context, vppAppTeamID uint) (map[string]string, error)
|
||||
|
||||
// GetExcludedHostIDMapForVPPApp gets the set of hosts that are NOT targeted/in scope for the
|
||||
// given VPP app, based on label membership.
|
||||
GetExcludedHostIDMapForVPPApp(ctx context.Context, vppAppTeamID uint) (map[uint]struct{}, error)
|
||||
|
||||
+11
-5
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
|
||||
@@ -1042,15 +1043,20 @@ const (
|
||||
IPadOS
|
||||
)
|
||||
|
||||
type AppleDevicePlatform string
|
||||
type InstallableDevicePlatform string
|
||||
|
||||
const (
|
||||
MacOSPlatform AppleDevicePlatform = "darwin"
|
||||
IOSPlatform AppleDevicePlatform = "ios"
|
||||
IPadOSPlatform AppleDevicePlatform = "ipados"
|
||||
MacOSPlatform InstallableDevicePlatform = "darwin"
|
||||
IOSPlatform InstallableDevicePlatform = "ios"
|
||||
IPadOSPlatform InstallableDevicePlatform = "ipados"
|
||||
AndroidPlatform InstallableDevicePlatform = "android"
|
||||
)
|
||||
|
||||
var VPPAppsPlatforms = []AppleDevicePlatform{IOSPlatform, IPadOSPlatform, MacOSPlatform}
|
||||
var VPPAppsPlatforms = []InstallableDevicePlatform{IOSPlatform, IPadOSPlatform, MacOSPlatform, AndroidPlatform}
|
||||
|
||||
func (p InstallableDevicePlatform) IsValidInstallableDevicePlatform() bool {
|
||||
return slices.Contains(VPPAppsPlatforms, p)
|
||||
}
|
||||
|
||||
type AppleDevicesToRefetch struct {
|
||||
HostID uint `db:"host_id"`
|
||||
|
||||
@@ -310,9 +310,9 @@ type PolicySoftwareInstallerData struct {
|
||||
}
|
||||
|
||||
type PolicyVPPData struct {
|
||||
ID uint `db:"id"`
|
||||
AdamID string `db:"adam_id"`
|
||||
Platform AppleDevicePlatform `db:"platform"`
|
||||
ID uint `db:"id"`
|
||||
AdamID string `db:"adam_id"`
|
||||
Platform InstallableDevicePlatform `db:"platform"`
|
||||
}
|
||||
|
||||
type PolicyScriptData struct {
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *SetupExperienceStatusResult) VPPAppID() (*VPPAppID, error) {
|
||||
|
||||
return &VPPAppID{
|
||||
AdamID: *s.VPPAppAdamID,
|
||||
Platform: AppleDevicePlatform(*s.VPPAppPlatform),
|
||||
Platform: InstallableDevicePlatform(*s.VPPAppPlatform),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -642,12 +642,12 @@ type VPPBatchPayload struct {
|
||||
}
|
||||
|
||||
type VPPBatchPayloadWithPlatform struct {
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
Platform AppleDevicePlatform `json:"platform"`
|
||||
InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
SelfService bool `json:"self_service"`
|
||||
Platform InstallableDevicePlatform `json:"platform"`
|
||||
InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
// Categories is the list of names of software categories associated with this VPP app.
|
||||
Categories []string `json:"categories"`
|
||||
// CategoryIDs is the list of IDs of software categories associated with this VPP app.
|
||||
|
||||
@@ -180,7 +180,7 @@ type VPPAppResponse struct {
|
||||
// AppStoreID is the ADAM ID for this app (set when uploading via batch/gitops).
|
||||
AppStoreID string `json:"app_store_id" db:"app_store_id"`
|
||||
// Platform is the platform this title ID corresponds to
|
||||
Platform AppleDevicePlatform `json:"platform" db:"platform"`
|
||||
Platform InstallableDevicePlatform `json:"platform" db:"platform"`
|
||||
|
||||
//// Custom icon fields (blank if not set)
|
||||
|
||||
|
||||
@@ -46,18 +46,18 @@ type SoftwareTitleIconStore interface {
|
||||
}
|
||||
|
||||
type DetailsForSoftwareIconActivity struct {
|
||||
SoftwareInstallerID *uint `db:"software_installer_id"`
|
||||
InHouseAppID *uint `db:"in_house_app_id"`
|
||||
AdamID *string `db:"adam_id"`
|
||||
VPPAppTeamID *uint `db:"vpp_app_team_id"`
|
||||
VPPIconUrl *string `db:"vpp_icon_url"`
|
||||
SoftwareTitle string `db:"software_title"`
|
||||
Filename *string `db:"filename"`
|
||||
TeamName *string `db:"team_name"`
|
||||
TeamID uint `db:"team_id"`
|
||||
SelfService bool `db:"self_service"`
|
||||
SoftwareTitleID uint `db:"software_title_id"`
|
||||
Platform *AppleDevicePlatform `json:"platform"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `db:"-"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `db:"-"`
|
||||
SoftwareInstallerID *uint `db:"software_installer_id"`
|
||||
InHouseAppID *uint `db:"in_house_app_id"`
|
||||
AdamID *string `db:"adam_id"`
|
||||
VPPAppTeamID *uint `db:"vpp_app_team_id"`
|
||||
VPPIconUrl *string `db:"vpp_icon_url"`
|
||||
SoftwareTitle string `db:"software_title"`
|
||||
Filename *string `db:"filename"`
|
||||
TeamName *string `db:"team_name"`
|
||||
TeamID uint `db:"team_id"`
|
||||
SelfService bool `db:"self_service"`
|
||||
SoftwareTitleID uint `db:"software_title_id"`
|
||||
Platform *InstallableDevicePlatform `json:"platform"`
|
||||
LabelsIncludeAny []ActivitySoftwareLabel `db:"-"`
|
||||
LabelsExcludeAny []ActivitySoftwareLabel `db:"-"`
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
type VPPAppID struct {
|
||||
// AdamID is a unique identifier assigned to each app in
|
||||
// the App Store, this value is managed by Apple.
|
||||
AdamID string `db:"adam_id" json:"app_store_id"`
|
||||
Platform AppleDevicePlatform `db:"platform" json:"platform"`
|
||||
AdamID string `db:"adam_id" json:"app_store_id"`
|
||||
Platform InstallableDevicePlatform `db:"platform" json:"platform"`
|
||||
}
|
||||
|
||||
// VPPAppTeam contains extra metadata injected by fleet
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestAllAndroidPackageDependencies(t *testing.T) {
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/log",
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit",
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android/tests...", // Android functionality moved to main datastore
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android/service", // Activities module
|
||||
).
|
||||
ShouldNotDependOn(
|
||||
"github.com/fleetdm/fleet/v4/server/service...",
|
||||
|
||||
@@ -33,6 +33,10 @@ type EnterprisesListFunc func(ctx context.Context, serverURL string) ([]*android
|
||||
|
||||
type SetAuthenticationSecretFunc func(secret string) error
|
||||
|
||||
type EnterprisesApplicationsFunc func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error)
|
||||
|
||||
type EnterprisesPoliciesModifyPolicyApplicationsFunc func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error)
|
||||
|
||||
type Client struct {
|
||||
SignupURLsCreateFunc SignupURLsCreateFunc
|
||||
SignupURLsCreateFuncInvoked bool
|
||||
@@ -64,6 +68,12 @@ type Client struct {
|
||||
SetAuthenticationSecretFunc SetAuthenticationSecretFunc
|
||||
SetAuthenticationSecretFuncInvoked bool
|
||||
|
||||
EnterprisesApplicationsFunc EnterprisesApplicationsFunc
|
||||
EnterprisesApplicationsFuncInvoked bool
|
||||
|
||||
EnterprisesPoliciesModifyPolicyApplicationsFunc EnterprisesPoliciesModifyPolicyApplicationsFunc
|
||||
EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -136,3 +146,17 @@ func (p *Client) SetAuthenticationSecret(secret string) error {
|
||||
p.mu.Unlock()
|
||||
return p.SetAuthenticationSecretFunc(secret)
|
||||
}
|
||||
|
||||
func (p *Client) EnterprisesApplications(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||||
p.mu.Lock()
|
||||
p.EnterprisesApplicationsFuncInvoked = true
|
||||
p.mu.Unlock()
|
||||
return p.EnterprisesApplicationsFunc(ctx, enterpriseName, packageName)
|
||||
}
|
||||
|
||||
func (p *Client) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||||
p.mu.Lock()
|
||||
p.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = true
|
||||
p.mu.Unlock()
|
||||
return p.EnterprisesPoliciesModifyPolicyApplicationsFunc(ctx, policyName, appPolicies)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package android
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
@@ -18,7 +20,12 @@ type Service interface {
|
||||
|
||||
// UnenrollAndroidHost triggers unenrollment (work profile removal) for the given Android host ID.
|
||||
UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
|
||||
EnterprisesApplications(ctx context.Context, enterpriseName, applicationID string) (*androidmanagement.Application, error)
|
||||
AddAppToAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string) error
|
||||
EnableAppReportsOnDefaultPolicy(ctx context.Context) error
|
||||
PatchDevice(ctx context.Context, policyID, deviceName string, device *androidmanagement.Device) (skip bool, apiErr error)
|
||||
PatchPolicy(ctx context.Context, policyID, policyName string, policy *androidmanagement.Policy, metadata map[string]string) (skip bool, err error)
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////
|
||||
|
||||
@@ -53,6 +53,10 @@ type Client interface {
|
||||
|
||||
// SetAuthenticationSecret sets the secret used for authentication.
|
||||
SetAuthenticationSecret(secret string) error
|
||||
|
||||
EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error)
|
||||
|
||||
EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error)
|
||||
}
|
||||
|
||||
type EnterprisesCreateRequest struct {
|
||||
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/pubsub"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
"github.com/go-json-experiment/json"
|
||||
kitlog "github.com/go-kit/log"
|
||||
@@ -68,9 +71,6 @@ func NewGoogleClient(ctx context.Context, logger kitlog.Logger, getenv func(stri
|
||||
}
|
||||
|
||||
func (g *GoogleClient) SignupURLsCreate(ctx context.Context, _, callbackURL string) (*android.SignupDetails, error) {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
signupURL, err := g.mgmt.SignupUrls.Create().ProjectId(g.androidProjectID).CallbackUrl(callbackURL).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating signup url: %w", err)
|
||||
@@ -83,9 +83,6 @@ func (g *GoogleClient) SignupURLsCreate(ctx context.Context, _, callbackURL stri
|
||||
|
||||
func (g *GoogleClient) EnterprisesCreate(ctx context.Context, req EnterprisesCreateRequest) (EnterprisesCreateResponse, error) {
|
||||
res := EnterprisesCreateResponse{}
|
||||
if g == nil || g.mgmt == nil {
|
||||
return res, errors.New("android management service not initialized")
|
||||
}
|
||||
|
||||
topicName, err := g.createPubSub(ctx, req.PubSubPushURL)
|
||||
if err != nil {
|
||||
@@ -162,8 +159,37 @@ func (g *GoogleClient) createPubSub(ctx context.Context, pushURL string) (string
|
||||
return topic.String(), nil
|
||||
}
|
||||
|
||||
// generatePolicyFieldMask creates an "update mask": a list of an androidmanagement.Policy's fields that will be updated in
|
||||
// a given call to EnterprisesPoliciesPatch. We omit `applications` from this list of fields to ensure that apps are only
|
||||
// updated through calls to EnterprisesPoliciesModifyPolicyApplications.
|
||||
// See https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/patch#query-parameters
|
||||
// for more details.
|
||||
func generatePolicyFieldMask() string {
|
||||
getJSONFieldName := func(t string) string {
|
||||
fieldName, _, _ := strings.Cut(t, ",")
|
||||
return fieldName
|
||||
}
|
||||
var p androidmanagement.Policy
|
||||
t := reflect.TypeOf(p)
|
||||
var mask []string
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
jsonTag, ok := f.Tag.Lookup("json")
|
||||
// ignore applications because we manage that directly
|
||||
if n := getJSONFieldName(jsonTag); ok &&
|
||||
n != "applications" &&
|
||||
n != "-" && n != "string" && n != "omitempty" {
|
||||
mask = append(mask, n)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(mask, ",")
|
||||
}
|
||||
|
||||
var policyFieldMask = generatePolicyFieldMask()
|
||||
|
||||
func (g *GoogleClient) EnterprisesPoliciesPatch(ctx context.Context, policyName string, policy *androidmanagement.Policy) (*androidmanagement.Policy, error) {
|
||||
ret, err := g.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx).Do()
|
||||
ret, err := g.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx).UpdateMask(policyFieldMask).Do()
|
||||
switch {
|
||||
case googleapi.IsNotModified(err):
|
||||
g.logger.Log("msg", "Android policy not modified", "policy_name", policyName)
|
||||
@@ -187,9 +213,6 @@ func (g *GoogleClient) EnterprisesDevicesPatch(ctx context.Context, deviceName s
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterprisesDevicesGet(ctx context.Context, deviceName string) (*androidmanagement.Device, error) {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
ret, err := g.mgmt.Enterprises.Devices.Get(deviceName).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting device %s: %w", deviceName, err)
|
||||
@@ -198,9 +221,6 @@ func (g *GoogleClient) EnterprisesDevicesGet(ctx context.Context, deviceName str
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterprisesDevicesDelete(ctx context.Context, deviceName string) error {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return errors.New("android management service not initialized")
|
||||
}
|
||||
_, err := g.mgmt.Enterprises.Devices.Delete(deviceName).Context(ctx).Do()
|
||||
switch {
|
||||
case googleapi.IsNotModified(err):
|
||||
@@ -214,9 +234,6 @@ func (g *GoogleClient) EnterprisesDevicesDelete(ctx context.Context, deviceName
|
||||
|
||||
func (g *GoogleClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, enterpriseName string, token *androidmanagement.EnrollmentToken,
|
||||
) (*androidmanagement.EnrollmentToken, error) {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
token, err := g.mgmt.Enterprises.EnrollmentTokens.Create(enterpriseName, token).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating enrollment token: %w", err)
|
||||
@@ -225,10 +242,6 @@ func (g *GoogleClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, en
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterpriseDelete(ctx context.Context, enterpriseName string) error {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return errors.New("android management service not initialized")
|
||||
}
|
||||
|
||||
// To find out the enterprise's PubSub topic, we need to get the enterprise first.
|
||||
// We can also pull the topic from the DB, but this way is more reliable.
|
||||
enterprise, err := g.mgmt.Enterprises.Get(enterpriseName).Context(ctx).Do()
|
||||
@@ -276,9 +289,6 @@ func (g *GoogleClient) EnterpriseDelete(ctx context.Context, enterpriseName stri
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterprisesList(ctx context.Context, serverURL string) ([]*androidmanagement.Enterprise, error) {
|
||||
if g == nil || g.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
resp, err := g.mgmt.Enterprises.List().ProjectId(g.androidProjectID).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing enterprises: %w", err)
|
||||
@@ -298,3 +308,51 @@ func getLastPart(ctx context.Context, name string) (string, error) {
|
||||
}
|
||||
return nameParts[len(nameParts)-1], nil
|
||||
}
|
||||
|
||||
type appNotFoundError struct{}
|
||||
|
||||
var _ fleet.NotFoundError = (*appNotFoundError)(nil)
|
||||
|
||||
func (p appNotFoundError) Error() string {
|
||||
return "Couldn’t add software. The application ID isn’t available in Play Store. Please find ID on the Play Store and try again."
|
||||
}
|
||||
|
||||
func (p appNotFoundError) IsNotFound() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) {
|
||||
path := fmt.Sprintf("%s/applications/%s", enterpriseName, packageName)
|
||||
app, err := g.mgmt.Enterprises.Applications.Get(path).Context(ctx).Do()
|
||||
if err != nil {
|
||||
var gapiErr *googleapi.Error
|
||||
if errors.As(err, &gapiErr) {
|
||||
if gapiErr.Code == http.StatusNotFound {
|
||||
return nil, ctxerr.Wrap(ctx, appNotFoundError{})
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("getting application %s: %w", packageName, err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (g *GoogleClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||||
var changes []*androidmanagement.ApplicationPolicyChange
|
||||
for _, p := range appPolicies {
|
||||
changes = append(changes, &androidmanagement.ApplicationPolicyChange{
|
||||
Application: p,
|
||||
})
|
||||
}
|
||||
req := androidmanagement.ModifyPolicyApplicationsRequest{
|
||||
Changes: changes,
|
||||
}
|
||||
ret, err := g.mgmt.Enterprises.Policies.ModifyPolicyApplications(policyName, &req).Context(ctx).Do()
|
||||
switch {
|
||||
case googleapi.IsNotModified(err):
|
||||
g.logger.Log("msg", "Android application policy not modified", "policy_name", policyName)
|
||||
return nil, err
|
||||
case err != nil:
|
||||
return nil, ctxerr.Wrapf(ctx, err, "modifying application policy %s", policyName)
|
||||
}
|
||||
return ret.Policy, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package androidmgmt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tj/assert"
|
||||
)
|
||||
|
||||
func TestGeneratePolicyFieldMask(t *testing.T) {
|
||||
const expectedMask = `accountTypesWithManagementDisabled,addUserDisabled,adjustVolumeDisabled,advancedSecurityOverrides,alwaysOnVpnPackage,androidDevicePolicyTracks,appAutoUpdatePolicy,appFunctions,assistContentPolicy,autoDateAndTimeZone,autoTimeRequired,blockApplicationsEnabled,bluetoothConfigDisabled,bluetoothContactSharingDisabled,bluetoothDisabled,cameraAccess,cameraDisabled,cellBroadcastsConfigDisabled,choosePrivateKeyRules,complianceRules,createWindowsDisabled,credentialProviderPolicyDefault,credentialsConfigDisabled,crossProfilePolicies,dataRoamingDisabled,debuggingFeaturesAllowed,defaultPermissionPolicy,deviceConnectivityManagement,deviceOwnerLockScreenInfo,deviceRadioState,displaySettings,encryptionPolicy,ensureVerifyAppsEnabled,enterpriseDisplayNameVisibility,factoryResetDisabled,frpAdminEmails,funDisabled,installAppsDisabled,installUnknownSourcesAllowed,keyguardDisabled,keyguardDisabledFeatures,kioskCustomLauncherEnabled,kioskCustomization,locationMode,longSupportMessage,maximumTimeToLock,microphoneAccess,minimumApiLevel,mobileNetworksConfigDisabled,modifyAccountsDisabled,mountPhysicalMediaDisabled,name,networkEscapeHatchEnabled,networkResetDisabled,oncCertificateProviders,openNetworkConfiguration,outgoingBeamDisabled,outgoingCallsDisabled,passwordPolicies,passwordRequirements,permissionGrants,permittedAccessibilityServices,permittedInputMethods,persistentPreferredActivities,personalUsagePolicies,playStoreMode,policyEnforcementRules,preferentialNetworkService,printingPolicy,privateKeySelectionEnabled,recommendedGlobalProxy,removeUserDisabled,safeBootDisabled,screenCaptureDisabled,setUserIconDisabled,setWallpaperDisabled,setupActions,shareLocationDisabled,shortSupportMessage,skipFirstUseHintsEnabled,smsDisabled,statusBarDisabled,statusReportingSettings,stayOnPluggedModes,systemUpdate,tetheringConfigDisabled,uninstallAppsDisabled,unmuteMicrophoneDisabled,usageLog,usbFileTransferDisabled,usbMassStorageEnabled,version,vpnConfigDisabled,wifiConfigDisabled,wifiConfigsLockdownEnabled,wipeDataFlags,workAccountSetupConfig`
|
||||
mask := generatePolicyFieldMask()
|
||||
assert.NotContains(t, mask, "omitempty")
|
||||
assert.NotContains(t, mask, "applications")
|
||||
assert.Equal(t, mask, expectedMask)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
"github.com/go-json-experiment/json"
|
||||
kitlog "github.com/go-kit/log"
|
||||
@@ -69,9 +70,6 @@ func (p *ProxyClient) SetAuthenticationSecret(secret string) error {
|
||||
// SignupURLsCreate hits the unauthenticated endpoint of the proxy. If a record already exists for this serverURL,
|
||||
// then the proxy will return a conflict error.
|
||||
func (p *ProxyClient) SignupURLsCreate(ctx context.Context, serverURL, callbackURL string) (*android.SignupDetails, error) {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
call := p.mgmt.SignupUrls.Create().CallbackUrl(callbackURL).Context(ctx)
|
||||
call.Header().Set("Origin", serverURL)
|
||||
signupURL, err := call.Do()
|
||||
@@ -92,10 +90,6 @@ func (p *ProxyClient) SignupURLsCreate(ctx context.Context, serverURL, callbackU
|
||||
// The reason is that we are passing additional information such as license key, pubSubURL, etc. Because of that,
|
||||
// we use a separate HTTP client in this method.
|
||||
func (p *ProxyClient) EnterprisesCreate(ctx context.Context, req EnterprisesCreateRequest) (EnterprisesCreateResponse, error) {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return EnterprisesCreateResponse{}, errors.New("android management service not initialized")
|
||||
}
|
||||
|
||||
type proxyEnterprise struct {
|
||||
FleetLicenseKey string `json:"fleetLicenseKey"`
|
||||
PubSubPushURL string `json:"pubsubPushUrl"`
|
||||
@@ -155,7 +149,7 @@ func (p *ProxyClient) EnterprisesCreate(ctx context.Context, req EnterprisesCrea
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesPoliciesPatch(ctx context.Context, policyName string, policy *androidmanagement.Policy) (*androidmanagement.Policy, error) {
|
||||
call := p.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx)
|
||||
call := p.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx).UpdateMask(policyFieldMask)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
ret, err := call.Do()
|
||||
switch {
|
||||
@@ -183,9 +177,6 @@ func (p *ProxyClient) EnterprisesDevicesPatch(ctx context.Context, deviceName st
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesDevicesGet(ctx context.Context, deviceName string) (*androidmanagement.Device, error) {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
call := p.mgmt.Enterprises.Devices.Get(deviceName).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
ret, err := call.Do()
|
||||
@@ -196,9 +187,6 @@ func (p *ProxyClient) EnterprisesDevicesGet(ctx context.Context, deviceName stri
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesDevicesDelete(ctx context.Context, deviceName string) error {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return errors.New("android management service not initialized")
|
||||
}
|
||||
call := p.mgmt.Enterprises.Devices.Delete(deviceName).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
_, err := call.Do()
|
||||
@@ -214,9 +202,7 @@ func (p *ProxyClient) EnterprisesDevicesDelete(ctx context.Context, deviceName s
|
||||
|
||||
func (p *ProxyClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, enterpriseName string,
|
||||
token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
|
||||
call := p.mgmt.Enterprises.EnrollmentTokens.Create(enterpriseName, token).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
token, err := call.Do()
|
||||
@@ -227,10 +213,6 @@ func (p *ProxyClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, ent
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterpriseDelete(ctx context.Context, enterpriseName string) error {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return errors.New("android management service not initialized")
|
||||
}
|
||||
|
||||
call := p.mgmt.Enterprises.Delete(enterpriseName).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
_, err := call.Do()
|
||||
@@ -246,9 +228,6 @@ func (p *ProxyClient) EnterpriseDelete(ctx context.Context, enterpriseName strin
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesList(ctx context.Context, serverURL string) ([]*androidmanagement.Enterprise, error) {
|
||||
if p == nil || p.mgmt == nil {
|
||||
return nil, errors.New("android management service not initialized")
|
||||
}
|
||||
call := p.mgmt.Enterprises.List().Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
call.Header().Set("Origin", serverURL)
|
||||
@@ -281,3 +260,47 @@ func isErrorCode(err error, code int) bool {
|
||||
ok := errors.As(err, &ae)
|
||||
return ok && ae.Code == code
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) {
|
||||
path := fmt.Sprintf("%s/applications/%s", enterpriseName, packageName)
|
||||
call := p.mgmt.Enterprises.Applications.Get(path).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
|
||||
app, err := call.Do()
|
||||
if err != nil {
|
||||
var gapiErr *googleapi.Error
|
||||
if errors.As(err, &gapiErr) {
|
||||
if gapiErr.Code == http.StatusNotFound {
|
||||
return nil, ctxerr.Wrap(ctx, appNotFoundError{})
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("getting application %s: %w", packageName, err)
|
||||
}
|
||||
return app, nil
|
||||
|
||||
}
|
||||
|
||||
func (p *ProxyClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||||
var changes []*androidmanagement.ApplicationPolicyChange
|
||||
for _, p := range appPolicies {
|
||||
changes = append(changes, &androidmanagement.ApplicationPolicyChange{
|
||||
Application: p,
|
||||
})
|
||||
}
|
||||
|
||||
req := androidmanagement.ModifyPolicyApplicationsRequest{
|
||||
Changes: changes,
|
||||
}
|
||||
|
||||
call := p.mgmt.Enterprises.Policies.ModifyPolicyApplications(policyName, &req).Context(ctx)
|
||||
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
|
||||
ret, err := call.Do()
|
||||
switch {
|
||||
case googleapi.IsNotModified(err):
|
||||
p.logger.Log("msg", "Android application policy not modified", "policy_name", policyName)
|
||||
return nil, err
|
||||
case err != nil:
|
||||
return nil, ctxerr.Wrapf(ctx, err, "modifying application policy %s", policyName)
|
||||
}
|
||||
return ret.Policy, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
@@ -14,10 +15,10 @@ import (
|
||||
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
|
||||
ds_mock "github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
"google.golang.org/api/googleapi"
|
||||
@@ -28,8 +29,8 @@ func TestEnterprisesAuth(t *testing.T) {
|
||||
androidAPIClient.InitCommonMocks()
|
||||
logger := kitlog.NewLogfmtLogger(os.Stdout)
|
||||
fleetDS := InitCommonDSMocks()
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -126,9 +127,9 @@ func TestEnterpriseSignupMissingPrivateKey(t *testing.T) {
|
||||
androidAPIClient.InitCommonMocks()
|
||||
logger := kitlog.NewLogfmtLogger(os.Stdout)
|
||||
fleetDS := InitCommonDSMocks()
|
||||
fleetSvc := mockService{}
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleAdmin)}
|
||||
@@ -210,6 +211,12 @@ func InitCommonDSMocks() *AndroidMockDS {
|
||||
ds.Store.BulkSetAndroidHostsUnenrolledFunc = func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
ds.Store.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
ds.Store.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
|
||||
return &fleet.Job{}, nil
|
||||
}
|
||||
return &ds
|
||||
}
|
||||
|
||||
@@ -222,16 +229,6 @@ type notFoundError struct{}
|
||||
func (e *notFoundError) Error() string { return "not found" }
|
||||
func (e *notFoundError) IsNotFound() bool { return true }
|
||||
|
||||
type mockService struct {
|
||||
mock.Mock
|
||||
fleet.Service
|
||||
}
|
||||
|
||||
// NewActivity mocks the fleet.Service method.
|
||||
func (m *mockService) NewActivity(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetEnterprise(t *testing.T) {
|
||||
logger := kitlog.NewLogfmtLogger(os.Stdout)
|
||||
user := &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleAdmin)}
|
||||
@@ -242,8 +239,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
androidAPIClient.InitCommonMocks()
|
||||
|
||||
fleetDS := InitCommonDSMocks()
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -279,8 +276,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -329,8 +326,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -371,8 +368,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -413,8 +410,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
return []*androidmanagement.Enterprise{}, nil
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -481,8 +478,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
@@ -513,8 +510,8 @@ func TestGetEnterprise(t *testing.T) {
|
||||
return nil, ¬FoundError{}
|
||||
}
|
||||
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(fleetDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, "test-private-key", &fleetDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterprise, err := svc.GetEnterprise(ctx)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/worker"
|
||||
"github.com/go-json-experiment/json"
|
||||
"github.com/go-kit/log/level"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -152,7 +153,7 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string,
|
||||
// Emit system activity: mdm_unenrolled. For Android BYOD, InstalledFromDEP is always false.
|
||||
// Use the computed display name from the device payload as lite host may not include it.
|
||||
displayName := svc.getComputerName(&device)
|
||||
_ = svc.fleetSvc.NewActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
||||
_ = svc.activityModule.NewActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
||||
HostSerial: "",
|
||||
HostDisplayName: displayName,
|
||||
InstalledFromDEP: false,
|
||||
@@ -268,7 +269,7 @@ func (svc *Service) handlePubSubEnrollment(ctx context.Context, token string, ra
|
||||
return ctxerr.Wrap(ctx, err, "set android host unenrolled on DELETED state (ENROLLMENT)")
|
||||
}
|
||||
displayName := svc.getComputerName(&device)
|
||||
_ = svc.fleetSvc.NewActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
||||
_ = svc.activityModule.NewActivity(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
||||
HostSerial: "",
|
||||
HostDisplayName: displayName,
|
||||
InstalledFromDEP: false,
|
||||
@@ -292,6 +293,9 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De
|
||||
return err
|
||||
}
|
||||
|
||||
// Enqueue a job to send any necessary self-service software.
|
||||
// Like Martin said below, this should properly be part of a device lifecycle action.
|
||||
|
||||
// Device may already be present in Fleet if device user removed the MDM profile and then re-enrolled
|
||||
host, err := svc.getExistingHost(ctx, device)
|
||||
if err != nil {
|
||||
@@ -306,7 +310,7 @@ func (svc *Service) enrollHost(ctx context.Context, device *androidmanagement.De
|
||||
var enrollmentTokenRequest enrollmentTokenRequest
|
||||
err = json.Unmarshal([]byte(device.EnrollmentTokenData), &enrollmentTokenRequest)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "unmarshilling enrollment token data")
|
||||
return ctxerr.Wrap(ctx, err, "unmarshalling enrollment token data")
|
||||
}
|
||||
|
||||
if host != nil {
|
||||
@@ -453,8 +457,9 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
}
|
||||
policy := ptr.String(fmt.Sprint(defaultAndroidPolicyID))
|
||||
if device.AppliedPolicyName != "" {
|
||||
policy, err := svc.getPolicyID(ctx, device)
|
||||
policy, err = svc.getPolicyID(ctx, device)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting Android policy ID")
|
||||
}
|
||||
@@ -469,7 +474,7 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De
|
||||
host.Device.LastPolicySyncTime = ptr.Time(policySyncTime)
|
||||
}
|
||||
host.SetNodeKey(device.HardwareInfo.EnterpriseSpecificId)
|
||||
_, err = svc.ds.NewAndroidHost(ctx, host)
|
||||
fleetHost, err := svc.ds.NewAndroidHost(ctx, host)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enrolling Android host")
|
||||
}
|
||||
@@ -482,6 +487,16 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De
|
||||
}
|
||||
}
|
||||
|
||||
enterprise, err := svc.ds.GetEnterprise(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get android enterprise")
|
||||
}
|
||||
|
||||
err = worker.QueueMakeAndroidAppsAvailableForHostJob(ctx, svc.fleetDS, svc.logger, device.HardwareInfo.EnterpriseSpecificId, fleetHost.Host.ID, enterprise.Name(), *policy)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueuing make android apps available for host job")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -26,8 +27,8 @@ func createAndroidService(t *testing.T) (android.Service, *AndroidMockDS) {
|
||||
androidAPIClient.InitCommonMocks()
|
||||
logger := kitlog.NewLogfmtLogger(os.Stdout)
|
||||
mockDS := InitCommonDSMocks()
|
||||
fleetSvc := mockService{}
|
||||
svc, err := NewServiceWithClient(logger, mockDS, &androidAPIClient, &fleetSvc, "test-private-key", &mockDS.DataStore)
|
||||
activityModule := activities.NewActivityModule(mockDS, logger)
|
||||
svc, err := NewServiceWithClient(logger, mockDS, &androidAPIClient, "test-private-key", &mockDS.DataStore, activityModule)
|
||||
require.NoError(t, err)
|
||||
|
||||
return svc, mockDS
|
||||
@@ -142,7 +143,7 @@ func TestPubSubEnrollment(t *testing.T) {
|
||||
}
|
||||
|
||||
mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost) (*fleet.AndroidHost, error) {
|
||||
return nil, nil // We do not care about return value here
|
||||
return &fleet.AndroidHost{Host: &fleet.Host{}}, nil
|
||||
}
|
||||
|
||||
enrollmentToken := enrollmentTokenRequest{
|
||||
@@ -171,7 +172,7 @@ func TestPubSubEnrollment(t *testing.T) {
|
||||
}
|
||||
|
||||
mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost) (*fleet.AndroidHost, error) {
|
||||
return nil, nil // We do not care about return value here
|
||||
return &fleet.AndroidHost{Host: &fleet.Host{}}, nil
|
||||
}
|
||||
mockDS.AssociateHostMDMIdPAccountFunc = func(ctx context.Context, hostUUID, accountUUID string) error {
|
||||
return nil
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
@@ -43,7 +44,7 @@ type Service struct {
|
||||
ds fleet.AndroidDatastore
|
||||
fleetDS fleet.Datastore
|
||||
androidAPIClient androidmgmt.Client
|
||||
fleetSvc fleet.Service
|
||||
activityModule activities.ActivityModule
|
||||
serverPrivateKey string
|
||||
|
||||
// SignupSSEInterval can be overwritten in tests.
|
||||
@@ -56,38 +57,50 @@ func NewService(
|
||||
ctx context.Context,
|
||||
logger kitlog.Logger,
|
||||
ds fleet.AndroidDatastore,
|
||||
fleetSvc fleet.Service,
|
||||
licenseKey string,
|
||||
serverPrivateKey string,
|
||||
fleetDS fleet.Datastore,
|
||||
activityModule activities.ActivityModule,
|
||||
) (android.Service, error) {
|
||||
client := newAMAPIClient(ctx, logger, licenseKey)
|
||||
return NewServiceWithClient(logger, ds, client, fleetSvc, serverPrivateKey, fleetDS)
|
||||
return NewServiceWithClient(logger, ds, client, serverPrivateKey, fleetDS, activityModule)
|
||||
}
|
||||
|
||||
func NewServiceWithClient(
|
||||
logger kitlog.Logger,
|
||||
ds fleet.AndroidDatastore,
|
||||
client androidmgmt.Client,
|
||||
fleetSvc fleet.Service,
|
||||
serverPrivateKey string,
|
||||
fleetDS fleet.Datastore,
|
||||
activityModule activities.ActivityModule,
|
||||
) (android.Service, error) {
|
||||
authorizer, err := authz.NewAuthorizer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new authorizer: %w", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
svc := &Service{
|
||||
logger: logger,
|
||||
authz: authorizer,
|
||||
ds: ds,
|
||||
androidAPIClient: client,
|
||||
fleetSvc: fleetSvc,
|
||||
serverPrivateKey: serverPrivateKey,
|
||||
SignupSSEInterval: DefaultSignupSSEInterval,
|
||||
fleetDS: fleetDS,
|
||||
}, nil
|
||||
activityModule: activityModule,
|
||||
}
|
||||
|
||||
// OK to use background context here because this function is only called during server bootstrap
|
||||
// Setting the secret here ensures that we don't have to configure it in lots of different places
|
||||
// when using the proxy client.
|
||||
ctx := context.Background()
|
||||
secret, err := svc.getClientAuthenticationSecret(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting client authentication secret")
|
||||
}
|
||||
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func newAMAPIClient(ctx context.Context, logger kitlog.Logger, licenseKey string) androidmgmt.Client {
|
||||
@@ -358,7 +371,7 @@ func (svc *Service) EnterpriseSignupCallback(ctx context.Context, signupToken st
|
||||
return ctxerr.Wrap(ctx, err, "getting user")
|
||||
}
|
||||
|
||||
if err = svc.fleetSvc.NewActivity(ctx, user, fleet.ActivityTypeEnabledAndroidMDM{}); err != nil {
|
||||
if err = svc.activityModule.NewActivity(ctx, user, fleet.ActivityTypeEnabledAndroidMDM{}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "create activity for enabled Android MDM")
|
||||
}
|
||||
|
||||
@@ -445,7 +458,7 @@ func (svc *Service) DeleteEnterprise(ctx context.Context) error {
|
||||
return ctxerr.Wrap(ctx, err, "bulk set android hosts as unenrolled")
|
||||
}
|
||||
|
||||
if err = svc.fleetSvc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDisabledAndroidMDM{}); err != nil {
|
||||
if err = svc.activityModule.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDisabledAndroidMDM{}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "create activity for disabled Android MDM")
|
||||
}
|
||||
|
||||
@@ -804,7 +817,7 @@ func unenrollAndroidHostEndpoint(ctx context.Context, request interface{}, svc a
|
||||
// The actual MDM status flip to Off is performed when Pub/Sub sends DELETED for the device.
|
||||
func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error {
|
||||
// Load host and authorize based on team
|
||||
h, err := svc.fleetSvc.GetHostLite(ctx, hostID)
|
||||
h, err := svc.fleetDS.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -841,7 +854,7 @@ func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
|
||||
// Emit activity: admin told Fleet to unenroll
|
||||
displayName := fleet.HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
|
||||
if err := svc.fleetSvc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeMDMUnenrolled{
|
||||
if err := svc.activityModule.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeMDMUnenrolled{
|
||||
HostSerial: h.HardwareSerial,
|
||||
HostDisplayName: displayName,
|
||||
InstalledFromDEP: false,
|
||||
@@ -852,6 +865,33 @@ func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) EnterprisesApplications(ctx context.Context, enterpriseName, applicationID string) (*androidmanagement.Application, error) {
|
||||
return svc.androidAPIClient.EnterprisesApplications(ctx, enterpriseName, applicationID)
|
||||
}
|
||||
|
||||
func (svc *Service) AddAppToAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string) error {
|
||||
|
||||
var appPolicies []*androidmanagement.ApplicationPolicy
|
||||
for _, a := range applicationIDs {
|
||||
appPolicies = append(appPolicies, &androidmanagement.ApplicationPolicy{
|
||||
PackageName: a,
|
||||
InstallType: "AVAILABLE",
|
||||
})
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for uuid, policyID := range hostUUIDs {
|
||||
policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, policyID)
|
||||
|
||||
_, err := svc.androidAPIClient.EnterprisesPoliciesModifyPolicyApplications(ctx, policyName, appPolicies)
|
||||
if err != nil {
|
||||
errs = append(errs, ctxerr.Wrapf(ctx, err, "google api: modify policy applications for host %s", uuid))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (svc *Service) EnableAppReportsOnDefaultPolicy(ctx context.Context) error {
|
||||
enterprise, err := svc.ds.GetEnterprise(ctx)
|
||||
if err != nil {
|
||||
@@ -890,3 +930,72 @@ func (svc *Service) EnableAppReportsOnDefaultPolicy(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) PatchDevice(ctx context.Context, policyID, deviceName string, device *androidmanagement.Device) (skip bool, apiErr error) {
|
||||
deviceRequest, err := newAndroidDeviceRequest(policyID, deviceName, device)
|
||||
if err != nil {
|
||||
return false, ctxerr.Wrapf(ctx, err, "prepare device request %s", deviceName)
|
||||
}
|
||||
|
||||
applied, apiErr := svc.androidAPIClient.EnterprisesDevicesPatch(ctx, deviceName, device)
|
||||
if apiErr != nil {
|
||||
var gerr *googleapi.Error
|
||||
if errors.As(apiErr, &gerr) {
|
||||
deviceRequest.StatusCode = gerr.Code
|
||||
}
|
||||
deviceRequest.ErrorDetails.V = apiErr.Error()
|
||||
deviceRequest.ErrorDetails.Valid = true
|
||||
|
||||
if skip = androidmgmt.IsNotModifiedError(apiErr); skip {
|
||||
apiErr = nil
|
||||
}
|
||||
} else {
|
||||
deviceRequest.StatusCode = http.StatusOK
|
||||
deviceRequest.AppliedPolicyVersion.V = applied.AppliedPolicyVersion
|
||||
deviceRequest.AppliedPolicyVersion.Valid = true
|
||||
}
|
||||
|
||||
if err := svc.fleetDS.NewAndroidPolicyRequest(ctx, deviceRequest); err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "save android device request")
|
||||
}
|
||||
return skip, nil
|
||||
}
|
||||
|
||||
func (svc *Service) PatchPolicy(ctx context.Context, policyID, policyName string,
|
||||
policy *androidmanagement.Policy, metadata map[string]string,
|
||||
) (skip bool, err error) {
|
||||
policyRequest, err := newAndroidPolicyRequest(policyID, policyName, policy, metadata)
|
||||
if err != nil {
|
||||
return false, ctxerr.Wrapf(ctx, err, "prepare policy request %s", policyName)
|
||||
}
|
||||
|
||||
applied, apiErr := svc.androidAPIClient.EnterprisesPoliciesPatch(ctx, policyName, policy)
|
||||
if apiErr != nil {
|
||||
var gerr *googleapi.Error
|
||||
if errors.As(apiErr, &gerr) {
|
||||
policyRequest.StatusCode = gerr.Code
|
||||
}
|
||||
policyRequest.ErrorDetails.V = apiErr.Error()
|
||||
policyRequest.ErrorDetails.Valid = true
|
||||
|
||||
// Note that from my tests, the "not modified" error is not reliable, the
|
||||
// AMAPI happily returned 200 even if the policy was the same (as
|
||||
// confirmed by the same version number being returned), so we do check
|
||||
// for this error, but do not build critical logic on top of it.
|
||||
//
|
||||
// Tests do show that the version number is properly incremented when the
|
||||
// policy changes, though.
|
||||
if skip = androidmgmt.IsNotModifiedError(apiErr); skip {
|
||||
apiErr = nil
|
||||
}
|
||||
} else {
|
||||
policyRequest.StatusCode = http.StatusOK
|
||||
policyRequest.PolicyVersion.V = applied.Version
|
||||
policyRequest.PolicyVersion.Valid = true
|
||||
}
|
||||
|
||||
if err := svc.fleetDS.NewAndroidPolicyRequest(ctx, policyRequest); err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "save android policy request")
|
||||
}
|
||||
return skip, nil
|
||||
}
|
||||
|
||||
@@ -59,10 +59,8 @@ func (s *enterpriseTestSuite) TestEnterprise() {
|
||||
assert.Equal(s.T(), tests.EnterpriseSignupURL, signupResp.Url)
|
||||
s.T().Logf("callbackURL: %s", s.ProxyCallbackURL)
|
||||
|
||||
s.FleetSvc.On("NewActivity", mock.Anything, mock.Anything, mock.AnythingOfType("fleet.ActivityTypeEnabledAndroidMDM")).Return(nil)
|
||||
const enterpriseToken = "enterpriseToken"
|
||||
res := s.Do("GET", s.ProxyCallbackURL, nil, http.StatusOK, "enterpriseToken", enterpriseToken)
|
||||
s.FleetSvc.AssertNumberOfCalls(s.T(), "NewActivity", 1)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "text/html; charset=UTF-8", res.Header.Get("Content-Type"))
|
||||
@@ -82,9 +80,7 @@ func (s *enterpriseTestSuite) TestEnterprise() {
|
||||
assert.Equal(s.T(), tests.EnterpriseID, resp.EnterpriseID)
|
||||
|
||||
// Delete enterprise and make sure we can't find it.
|
||||
s.FleetSvc.On("NewActivity", mock.Anything, mock.Anything, mock.AnythingOfType("fleet.ActivityTypeDisabledAndroidMDM")).Return(nil)
|
||||
s.Do("DELETE", "/api/v1/fleet/android_enterprise", nil, http.StatusOK)
|
||||
s.FleetSvc.AssertNumberOfCalls(s.T(), "NewActivity", 2)
|
||||
|
||||
// Reset LIST mock to empty after deletion
|
||||
s.AndroidAPIClient.EnterprisesListFunc = func(_ context.Context, _ string) ([]*androidmanagement.Enterprise, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -110,7 +112,8 @@ func (ts *WithServer) SetupSuite(t *testing.T, dbName string) {
|
||||
ts.createCommonProxyMocks(t)
|
||||
|
||||
logger := kitlog.NewLogfmtLogger(os.Stdout)
|
||||
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, &ts.FleetSvc, "test-private-key", ts.DS.Datastore)
|
||||
activityModule := activities.NewActivityModule(&ts.DS.DataStore, logger)
|
||||
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, "test-private-key", ts.DS.Datastore, activityModule)
|
||||
require.NoError(t, err)
|
||||
ts.Svc = svc
|
||||
|
||||
@@ -152,6 +155,9 @@ func (ts *WithServer) CreateCommonDSMocks() {
|
||||
ts.DS.BulkSetAndroidHostsUnenrolledFunc = func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
ts.DS.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *WithServer) createCommonProxyMocks(t *testing.T) {
|
||||
|
||||
@@ -1303,12 +1303,14 @@ type MapAdamIDsPendingInstallFunc func(ctx context.Context, hostID uint) (map[st
|
||||
|
||||
type GetTitleInfoFromVPPAppsTeamsIDFunc func(ctx context.Context, vppAppsTeamsID uint) (*fleet.PolicySoftwareTitle, error)
|
||||
|
||||
type GetVPPAppMetadataByAdamIDPlatformTeamIDFunc func(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error)
|
||||
type GetVPPAppMetadataByAdamIDPlatformTeamIDFunc func(ctx context.Context, adamID string, platform fleet.InstallableDevicePlatform, teamID *uint) (*fleet.VPPApp, error)
|
||||
|
||||
type DeleteSoftwareInstallerFunc func(ctx context.Context, id uint) error
|
||||
|
||||
type DeleteVPPAppFromTeamFunc func(ctx context.Context, teamID *uint, appID fleet.VPPAppID) error
|
||||
|
||||
type GetAndroidAppsInScopeForHostFunc func(ctx context.Context, hostID uint) (applicationIDs []string, err error)
|
||||
|
||||
type GetSummaryHostSoftwareInstallsFunc func(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error)
|
||||
|
||||
type GetSummaryHostVPPAppInstallsFunc func(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, error)
|
||||
@@ -1373,6 +1375,8 @@ type GetVPPTokenByLocationFunc func(ctx context.Context, loc string) (*fleet.VPP
|
||||
|
||||
type GetIncludedHostIDMapForVPPAppFunc func(ctx context.Context, vppAppTeamID uint) (map[uint]struct{}, error)
|
||||
|
||||
type GetIncludedHostUUIDMapForAppStoreAppFunc func(ctx context.Context, vppAppTeamID uint) (map[string]string, error)
|
||||
|
||||
type GetExcludedHostIDMapForVPPAppFunc func(ctx context.Context, vppAppTeamID uint) (map[uint]struct{}, error)
|
||||
|
||||
type ClearVPPAppAutoInstallPolicyStatusForHostsFunc func(ctx context.Context, vppAppTeamID uint, hostIDs []uint) error
|
||||
@@ -3535,6 +3539,9 @@ type DataStore struct {
|
||||
DeleteVPPAppFromTeamFunc DeleteVPPAppFromTeamFunc
|
||||
DeleteVPPAppFromTeamFuncInvoked bool
|
||||
|
||||
GetAndroidAppsInScopeForHostFunc GetAndroidAppsInScopeForHostFunc
|
||||
GetAndroidAppsInScopeForHostFuncInvoked bool
|
||||
|
||||
GetSummaryHostSoftwareInstallsFunc GetSummaryHostSoftwareInstallsFunc
|
||||
GetSummaryHostSoftwareInstallsFuncInvoked bool
|
||||
|
||||
@@ -3631,6 +3638,9 @@ type DataStore struct {
|
||||
GetIncludedHostIDMapForVPPAppFunc GetIncludedHostIDMapForVPPAppFunc
|
||||
GetIncludedHostIDMapForVPPAppFuncInvoked bool
|
||||
|
||||
GetIncludedHostUUIDMapForAppStoreAppFunc GetIncludedHostUUIDMapForAppStoreAppFunc
|
||||
GetIncludedHostUUIDMapForAppStoreAppFuncInvoked bool
|
||||
|
||||
GetExcludedHostIDMapForVPPAppFunc GetExcludedHostIDMapForVPPAppFunc
|
||||
GetExcludedHostIDMapForVPPAppFuncInvoked bool
|
||||
|
||||
@@ -8462,7 +8472,7 @@ func (s *DataStore) GetTitleInfoFromVPPAppsTeamsID(ctx context.Context, vppAppsT
|
||||
return s.GetTitleInfoFromVPPAppsTeamsIDFunc(ctx, vppAppsTeamsID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.AppleDevicePlatform, teamID *uint) (*fleet.VPPApp, error) {
|
||||
func (s *DataStore) GetVPPAppMetadataByAdamIDPlatformTeamID(ctx context.Context, adamID string, platform fleet.InstallableDevicePlatform, teamID *uint) (*fleet.VPPApp, error) {
|
||||
s.mu.Lock()
|
||||
s.GetVPPAppMetadataByAdamIDPlatformTeamIDFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
@@ -8483,6 +8493,13 @@ func (s *DataStore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, appI
|
||||
return s.DeleteVPPAppFromTeamFunc(ctx, teamID, appID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetAndroidAppsInScopeForHost(ctx context.Context, hostID uint) (applicationIDs []string, err error) {
|
||||
s.mu.Lock()
|
||||
s.GetAndroidAppsInScopeForHostFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetAndroidAppsInScopeForHostFunc(ctx, hostID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetSummaryHostSoftwareInstalls(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) {
|
||||
s.mu.Lock()
|
||||
s.GetSummaryHostSoftwareInstallsFuncInvoked = true
|
||||
@@ -8707,6 +8724,13 @@ func (s *DataStore) GetIncludedHostIDMapForVPPApp(ctx context.Context, vppAppTea
|
||||
return s.GetIncludedHostIDMapForVPPAppFunc(ctx, vppAppTeamID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetIncludedHostUUIDMapForAppStoreApp(ctx context.Context, vppAppTeamID uint) (map[string]string, error) {
|
||||
s.mu.Lock()
|
||||
s.GetIncludedHostUUIDMapForAppStoreAppFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetIncludedHostUUIDMapForAppStoreAppFunc(ctx, vppAppTeamID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetExcludedHostIDMapForVPPApp(ctx context.Context, vppAppTeamID uint) (map[uint]struct{}, error) {
|
||||
s.mu.Lock()
|
||||
s.GetExcludedHostIDMapForVPPAppFuncInvoked = true
|
||||
|
||||
@@ -55,15 +55,6 @@ func (svc *Service) ListActivities(ctx context.Context, opt fleet.ListActivities
|
||||
return svc.ds.ListActivities(ctx, opt)
|
||||
}
|
||||
|
||||
type ActivityWebhookPayload struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ActorFullName *string `json:"actor_full_name"`
|
||||
ActorID *uint `json:"actor_id"`
|
||||
ActorEmail *string `json:"actor_email"`
|
||||
Type string `json:"type"`
|
||||
Details *json.RawMessage `json:"details"`
|
||||
}
|
||||
|
||||
func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return newActivity(ctx, user, activity, svc.ds, svc.logger)
|
||||
}
|
||||
@@ -108,7 +99,7 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
|
||||
err := backoff.Retry(
|
||||
func() error {
|
||||
if err := server.PostJSONWithTimeout(
|
||||
context.Background(), webhookURL, &ActivityWebhookPayload{
|
||||
context.Background(), webhookURL, &fleet.ActivityWebhookPayload{
|
||||
Timestamp: timestamp,
|
||||
ActorFullName: userName,
|
||||
ActorID: userID,
|
||||
|
||||
@@ -164,7 +164,7 @@ func Test_logRoleChangeActivities(t *testing.T) {
|
||||
func TestActivityWebhooks(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
svc, ctx := newTestService(t, ds, nil, nil)
|
||||
var webhookBody = ActivityWebhookPayload{}
|
||||
var webhookBody = fleet.ActivityWebhookPayload{}
|
||||
webhookChannel := make(chan struct{}, 1)
|
||||
fail429 := false
|
||||
startMockServer := func(t *testing.T) string {
|
||||
@@ -172,7 +172,7 @@ func TestActivityWebhooks(t *testing.T) {
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
webhookBody = ActivityWebhookPayload{}
|
||||
webhookBody = fleet.ActivityWebhookPayload{}
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return // don't send the channel signal
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
)
|
||||
|
||||
func (s *integrationMDMTestSuite) TestAndroidAppSelfService() {
|
||||
ctx := context.Background()
|
||||
t := s.T()
|
||||
|
||||
appConf, err := s.ds.AppConfig(context.Background())
|
||||
require.NoError(s.T(), err)
|
||||
appConf.MDM.AndroidEnabledAndConfigured = false
|
||||
err = s.ds.SaveAppConfig(context.Background(), appConf)
|
||||
require.NoError(s.T(), err)
|
||||
s.setVPPTokenForTeam(0)
|
||||
|
||||
t.Cleanup(func() {
|
||||
appConf, err := s.ds.AppConfig(context.Background())
|
||||
require.NoError(s.T(), err)
|
||||
appConf.MDM.AndroidEnabledAndConfigured = true
|
||||
err = s.ds.SaveAppConfig(context.Background(), appConf)
|
||||
require.NoError(s.T(), err)
|
||||
})
|
||||
|
||||
// Adding android app before android MDM is turned on should fail
|
||||
var addAppResp addAppStoreAppResponse
|
||||
s.DoJSON(
|
||||
"POST",
|
||||
"/api/latest/fleet/software/app_store_apps",
|
||||
&addAppStoreAppRequest{AppStoreID: "com.should.fail", Platform: fleet.AndroidPlatform},
|
||||
http.StatusBadRequest,
|
||||
&addAppResp,
|
||||
)
|
||||
|
||||
EnterpriseID := "LC02k5wxw7"
|
||||
EnterpriseSignupURL := "https://enterprise.google.com/signup/android/email?origin=android&thirdPartyToken=B4D779F1C4DD9A440"
|
||||
s.androidAPIClient.InitCommonMocks()
|
||||
|
||||
s.androidAPIClient.EnterprisesCreateFunc = func(_ context.Context, _ androidmgmt.EnterprisesCreateRequest) (androidmgmt.EnterprisesCreateResponse, error) {
|
||||
return androidmgmt.EnterprisesCreateResponse{
|
||||
EnterpriseName: "enterprises/" + EnterpriseID,
|
||||
TopicName: "projects/android/topics/ae98ed130-5ce2-4ddb-a90a-191ec76976d5",
|
||||
}, nil
|
||||
}
|
||||
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(_ context.Context, policyName string, _ *androidmanagement.Policy) (*androidmanagement.Policy, error) {
|
||||
assert.Contains(t, policyName, EnterpriseID)
|
||||
return &androidmanagement.Policy{}, nil
|
||||
}
|
||||
s.androidAPIClient.EnterpriseDeleteFunc = func(_ context.Context, enterpriseName string) error {
|
||||
assert.Equal(t, "enterprises/"+EnterpriseID, enterpriseName)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.androidAPIClient.SignupURLsCreateFunc = func(_ context.Context, _, callbackURL string) (*android.SignupDetails, error) {
|
||||
s.proxyCallbackURL = callbackURL
|
||||
return &android.SignupDetails{
|
||||
Url: EnterpriseSignupURL,
|
||||
Name: "signupUrls/Cb08124d0999c464f",
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
|
||||
return &androidmanagement.Policy{}, nil
|
||||
}
|
||||
|
||||
s.androidAPIClient.EnterprisesDevicesPatchFunc = func(ctx context.Context, deviceName string, device *androidmanagement.Device) (*androidmanagement.Device, error) {
|
||||
return &androidmanagement.Device{}, nil
|
||||
}
|
||||
|
||||
// Create enterprise
|
||||
var signupResp android.EnterpriseSignupResponse
|
||||
s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp)
|
||||
|
||||
const enterpriseToken = "enterpriseToken"
|
||||
|
||||
// callback URL includes the host, need to extract the path so we can call it with our
|
||||
// HTTP request helpers
|
||||
u, err := url.Parse(s.proxyCallbackURL)
|
||||
require.NoError(t, err)
|
||||
s.Do("GET", u.Path, nil, http.StatusOK, "enterpriseToken", enterpriseToken)
|
||||
|
||||
// Update the LIST mock to return the enterprise after "creation"
|
||||
s.androidAPIClient.EnterprisesListFunc = func(_ context.Context, _ string) ([]*androidmanagement.Enterprise, error) {
|
||||
return []*androidmanagement.Enterprise{
|
||||
{Name: "enterprises/" + EnterpriseID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp := android.GetEnterpriseResponse{}
|
||||
s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp)
|
||||
assert.Equal(t, EnterpriseID, resp.EnterpriseID)
|
||||
|
||||
// Android MDM setup
|
||||
androidApp := &fleet.VPPApp{
|
||||
VPPAppTeam: fleet.VPPAppTeam{
|
||||
VPPAppID: fleet.VPPAppID{
|
||||
AdamID: "com.whatsapp",
|
||||
Platform: fleet.AndroidPlatform,
|
||||
},
|
||||
},
|
||||
Name: "WhatsApp",
|
||||
BundleIdentifier: "com.whatsapp",
|
||||
IconURL: "https://example.com/images/2",
|
||||
}
|
||||
|
||||
// Invalid application ID format: should fail
|
||||
r := s.Do(
|
||||
"POST",
|
||||
"/api/latest/fleet/software/app_store_apps",
|
||||
&addAppStoreAppRequest{AppStoreID: "thisisnotanappid", Platform: fleet.AndroidPlatform},
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
require.Contains(t, extractServerErrorText(r.Body), "app_store_id must be a valid Android application ID")
|
||||
|
||||
// Missing platform: should fail
|
||||
r = s.Do(
|
||||
"POST",
|
||||
"/api/latest/fleet/software/app_store_apps",
|
||||
&addAppStoreAppRequest{AppStoreID: "com.valid.app.id"},
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
require.Contains(t, extractServerErrorText(r.Body), "Error: Couldn't add software. com.valid.app.id isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.")
|
||||
|
||||
// Valid application ID format, but app isn't found: should fail
|
||||
// Update mock to return a 404
|
||||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||||
return nil, ¬FoundError{}
|
||||
}
|
||||
|
||||
r = s.Do(
|
||||
"POST",
|
||||
"/api/latest/fleet/software/app_store_apps",
|
||||
&addAppStoreAppRequest{AppStoreID: "com.app.id.not.found", Platform: fleet.AndroidPlatform},
|
||||
http.StatusUnprocessableEntity,
|
||||
)
|
||||
require.Contains(t, extractServerErrorText(r.Body), "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.")
|
||||
|
||||
s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
|
||||
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: "Test App"}, nil
|
||||
}
|
||||
|
||||
// Add Android app
|
||||
s.DoJSON(
|
||||
"POST",
|
||||
"/api/latest/fleet/software/app_store_apps",
|
||||
&addAppStoreAppRequest{AppStoreID: androidApp.AdamID, Platform: fleet.AndroidPlatform},
|
||||
http.StatusOK,
|
||||
&addAppResp,
|
||||
)
|
||||
|
||||
secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, secrets, 1)
|
||||
|
||||
assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
|
||||
require.NoError(t, err)
|
||||
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
|
||||
require.NotEmpty(t, pubsubToken.Value)
|
||||
|
||||
deviceID1 := createAndroidDeviceID("test-android")
|
||||
deviceID2 := createAndroidDeviceID("test-android-2")
|
||||
|
||||
enterpriseSpecificID1 := strings.ToUpper(uuid.New().String())
|
||||
enterpriseSpecificID2 := strings.ToUpper(uuid.New().String())
|
||||
var req android_service.PubSubPushRequest
|
||||
for _, d := range []struct {
|
||||
id string
|
||||
esi string
|
||||
}{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}} {
|
||||
enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(
|
||||
t,
|
||||
androidmanagement.Device{
|
||||
Name: d.id,
|
||||
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
|
||||
},
|
||||
d.esi,
|
||||
)
|
||||
|
||||
req = android_service.PubSubPushRequest{
|
||||
PubSubMessage: *enrollmentMessage,
|
||||
}
|
||||
|
||||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
||||
}
|
||||
|
||||
var hosts listHostsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts)
|
||||
|
||||
assert.Len(t, hosts.Hosts, 2)
|
||||
|
||||
host1 := hosts.Hosts[0]
|
||||
assert.Equal(t, host1.Platform, string(fleet.AndroidPlatform))
|
||||
|
||||
// Should see it in host software library
|
||||
getHostSw := getHostSoftwareResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
|
||||
assert.Len(t, getHostSw.Software, 1)
|
||||
s.Assert().NotNil(getHostSw.Software[0].AppStoreApp)
|
||||
s.Assert().Equal(androidApp.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
|
||||
|
||||
// Google AMAPI hasn't been hit yet
|
||||
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||||
|
||||
// Run worker, should run the job that assigns the app to the host's MDM policy
|
||||
s.runWorkerUntilDone()
|
||||
|
||||
// Should have hit the android API endpoint
|
||||
s.Assert().True(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
|
||||
|
||||
}
|
||||
@@ -35,6 +35,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
|
||||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||||
|
||||
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
||||
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
|
||||
@@ -67,6 +70,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/service/contract"
|
||||
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
|
||||
"github.com/fleetdm/fleet/v4/server/service/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
"github.com/fleetdm/fleet/v4/server/service/osquery_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/service/schedule"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
@@ -115,6 +119,8 @@ type integrationMDMTestSuite struct {
|
||||
appleGDMFSrv *httptest.Server
|
||||
mockedDownloadFleetdmMeta fleetdbase.Metadata
|
||||
scepConfig *eeservice.SCEPConfigService
|
||||
androidAPIClient *android_mock.Client
|
||||
proxyCallbackURL string
|
||||
}
|
||||
|
||||
// appleVPPConfigSrvConf is used to configure the mock server that mocks Apple's VPP endpoints.
|
||||
@@ -191,6 +197,18 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
|
||||
wlog = kitlog.NewNopLogger()
|
||||
}
|
||||
|
||||
activityModule := activities.NewActivityModule(s.ds, wlog)
|
||||
androidMockClient := &android_mock.Client{}
|
||||
androidMockClient.SetAuthenticationSecretFunc = func(secret string) error {
|
||||
return nil
|
||||
}
|
||||
androidSvc, err := android_service.NewServiceWithClient(wlog, s.ds, androidMockClient, "test-private-key", s.ds, activityModule)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
androidSvc.(*android_service.Service).AllowLocalhostServerURL = true
|
||||
|
||||
macosJob := &worker.MacosSetupAssistant{
|
||||
Datastore: s.ds,
|
||||
Log: wlog,
|
||||
@@ -207,9 +225,14 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
Log: wlog,
|
||||
Commander: mdmCommander,
|
||||
}
|
||||
softwareWorker := &worker.SoftwareWorker{
|
||||
Datastore: s.ds,
|
||||
Log: wlog,
|
||||
AndroidModule: androidSvc,
|
||||
}
|
||||
workr := worker.NewWorker(s.ds, wlog)
|
||||
workr.TestIgnoreUnknownJobs = true
|
||||
workr.Register(macosJob, appleMDMJob, vppVerifyJob)
|
||||
workr.Register(macosJob, appleMDMJob, vppVerifyJob, softwareWorker)
|
||||
|
||||
s.worker = workr
|
||||
|
||||
@@ -257,6 +280,8 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
Lq: s.lq,
|
||||
SoftwareInstallStore: softwareInstallerStore,
|
||||
BootstrapPackageStore: bootstrapPackageStore,
|
||||
androidMockClient: androidMockClient,
|
||||
androidModule: androidSvc,
|
||||
StartCronSchedules: []TestNewScheduleFunc{
|
||||
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
|
||||
return func() (fleet.CronSchedule, error) {
|
||||
@@ -385,6 +410,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
s.mdmStorage = mdmStorage
|
||||
s.mdmCommander = mdmCommander
|
||||
s.logger = serverLogger
|
||||
s.androidAPIClient = androidMockClient
|
||||
|
||||
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.fleetDMNextCSRStatus.Swap(http.StatusOK)
|
||||
@@ -518,9 +544,13 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
// Handle /assets
|
||||
if strings.Contains(r.URL.Path, "assets") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
assets := s.appleVPPConfigSrvConfig.Assets
|
||||
if adamID := r.URL.Query().Get("adamId"); adamID != "" {
|
||||
for _, a := range assets {
|
||||
adamID := r.URL.Query().Get("adamId")
|
||||
var assets []vpp.Asset
|
||||
switch adamID {
|
||||
case "":
|
||||
assets = s.appleVPPConfigSrvConfig.Assets
|
||||
default:
|
||||
for _, a := range s.appleVPPConfigSrvConfig.Assets {
|
||||
if a.AdamID == adamID {
|
||||
assets = []vpp.Asset{a}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/fleetdm/fleet/v4/server/service/integrationtest"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/service/modules/activities"
|
||||
"github.com/go-kit/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -24,13 +25,14 @@ func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
|
||||
logger := log.NewLogfmtLogger(os.Stdout)
|
||||
proxy := android_mock.Client{}
|
||||
proxy.InitCommonMocks()
|
||||
activityModule := activities.NewActivityModule(ds, logger)
|
||||
androidSvc, err := android_service.NewServiceWithClient(
|
||||
logger,
|
||||
ds,
|
||||
&proxy,
|
||||
fleetSvc,
|
||||
"test-private-key",
|
||||
ds,
|
||||
activityModule,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
androidSvc.(*android_service.Service).AllowLocalhostServerURL = true
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
)
|
||||
|
||||
type activityModule struct {
|
||||
repo ActivityStore
|
||||
logger kitlog.Logger
|
||||
}
|
||||
|
||||
type ActivityModule interface {
|
||||
NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
|
||||
}
|
||||
|
||||
// ActivityStore is the datastore interface needed to handle Fleet activities.
|
||||
// It is implemented by fleet.Datastore.
|
||||
type ActivityStore interface {
|
||||
AppConfig(ctx context.Context) (*fleet.AppConfig, error)
|
||||
NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time) error
|
||||
}
|
||||
|
||||
func NewActivityModule(repo ActivityStore, logger kitlog.Logger) ActivityModule {
|
||||
return &activityModule{
|
||||
repo: repo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
var automationActivityAuthor = "Fleet"
|
||||
|
||||
func (a *activityModule) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
appConfig, err := a.repo.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get app config")
|
||||
}
|
||||
|
||||
detailsBytes, err := json.Marshal(activity)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "marshaling activity details")
|
||||
}
|
||||
timestamp := time.Now()
|
||||
|
||||
if appConfig.WebhookSettings.ActivitiesWebhook.Enable {
|
||||
webhookURL := appConfig.WebhookSettings.ActivitiesWebhook.DestinationURL
|
||||
var userID *uint
|
||||
var userName *string
|
||||
var userEmail *string
|
||||
activityType := activity.ActivityName()
|
||||
|
||||
if user != nil {
|
||||
// To support creating activities with users that were deleted. This can happen
|
||||
// for automatically installed software which uses the author of the upload as the author of
|
||||
// the installation.
|
||||
if user.ID != 0 && !user.Deleted {
|
||||
userID = &user.ID
|
||||
}
|
||||
userName = &user.Name
|
||||
userEmail = &user.Email
|
||||
} else if automatableActivity, ok := activity.(fleet.AutomatableActivity); ok && automatableActivity.WasFromAutomation() {
|
||||
userName = &automationActivityAuthor
|
||||
}
|
||||
|
||||
// TODO: webhook module? probably webhook job too tbh since this isn't very resilient
|
||||
go func() {
|
||||
retryStrategy := backoff.NewExponentialBackOff()
|
||||
retryStrategy.MaxElapsedTime = 30 * time.Minute
|
||||
err := backoff.Retry(
|
||||
func() error {
|
||||
if err := server.PostJSONWithTimeout(
|
||||
context.Background(), webhookURL, &fleet.ActivityWebhookPayload{
|
||||
Timestamp: timestamp,
|
||||
ActorFullName: userName,
|
||||
ActorID: userID,
|
||||
ActorEmail: userEmail,
|
||||
Type: activityType,
|
||||
Details: (*json.RawMessage)(&detailsBytes),
|
||||
},
|
||||
); err != nil {
|
||||
var statusCoder kithttp.StatusCoder
|
||||
if errors.As(err, &statusCoder) && statusCoder.StatusCode() == http.StatusTooManyRequests {
|
||||
level.Debug(a.logger).Log("msg", "fire activity webhook", "err", err)
|
||||
return err
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
return nil
|
||||
}, retryStrategy,
|
||||
)
|
||||
if err != nil {
|
||||
level.Error(a.logger).Log(
|
||||
"msg", fmt.Sprintf("fire activity webhook to %s", server.MaskSecretURLParams(webhookURL)), "err",
|
||||
server.MaskURLError(err).Error(),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// We update the context to indicate that we processed the webhook.
|
||||
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
|
||||
return a.repo.NewActivity(ctx, user, activity, detailsBytes, timestamp)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -33,6 +34,9 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/logging"
|
||||
"github.com/fleetdm/fleet/v4/server/mail"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
|
||||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
|
||||
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
||||
@@ -55,6 +59,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/throttled/throttled/v2"
|
||||
"github.com/throttled/throttled/v2/store/memstore"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T, ds fleet.Datastore, rs fleet.QueryResultStore, lq fleet.LiveQueryStore, opts ...*TestServerOpts) (fleet.Service, context.Context) {
|
||||
@@ -232,6 +237,12 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
|
||||
}
|
||||
softwareInstallStore = store
|
||||
}
|
||||
|
||||
var androidModule android.Service
|
||||
if len(opts) > 0 {
|
||||
androidModule = opts[0].androidModule
|
||||
}
|
||||
|
||||
svc, err = eeservice.NewService(
|
||||
svc,
|
||||
ds,
|
||||
@@ -250,11 +261,13 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
|
||||
keyValueStore,
|
||||
scepConfigService,
|
||||
digiCertService,
|
||||
androidModule,
|
||||
estCAService,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
return svc, ctx
|
||||
}
|
||||
@@ -392,6 +405,8 @@ type TestServerOpts struct {
|
||||
EnableSCIM bool
|
||||
ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy
|
||||
HostIdentity *HostIdentity
|
||||
androidMockClient *android_mock.Client
|
||||
androidModule android.Service
|
||||
ConditionalAccess *ConditionalAccess
|
||||
}
|
||||
|
||||
@@ -424,6 +439,11 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
|
||||
if len(opts) > 0 && opts[0].Logger != nil {
|
||||
logger = opts[0].Logger
|
||||
}
|
||||
|
||||
if len(opts) > 0 {
|
||||
opts[0].FeatureRoutes = append(opts[0].FeatureRoutes, android_service.GetRoutes(svc, opts[0].androidModule))
|
||||
}
|
||||
|
||||
var mdmPusher nanomdm_push.Pusher
|
||||
if len(opts) > 0 && opts[0].MDMPusher != nil {
|
||||
mdmPusher = opts[0].MDMPusher
|
||||
@@ -1255,3 +1275,55 @@ func getURISchemas() []string {
|
||||
"z39.50s",
|
||||
}
|
||||
}
|
||||
|
||||
func createAndroidDeviceID(name string) string {
|
||||
return "enterprises/mock-enterprise-id/devices/" + name
|
||||
}
|
||||
|
||||
func enrollmentMessageWithEnterpriseSpecificID(t *testing.T, deviceInfo androidmanagement.Device, enterpriseSpecificID string) *android.PubSubMessage {
|
||||
deviceInfo.HardwareInfo = &androidmanagement.HardwareInfo{
|
||||
EnterpriseSpecificId: enterpriseSpecificID,
|
||||
Brand: "TestBrand",
|
||||
Model: "TestModel",
|
||||
SerialNumber: "test-serial",
|
||||
Hardware: "test-hardware",
|
||||
}
|
||||
deviceInfo.SoftwareInfo = &androidmanagement.SoftwareInfo{
|
||||
AndroidBuildNumber: "test-build",
|
||||
AndroidVersion: "1",
|
||||
}
|
||||
deviceInfo.MemoryInfo = &androidmanagement.MemoryInfo{
|
||||
TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
|
||||
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
|
||||
}
|
||||
|
||||
deviceInfo.MemoryEvents = []*androidmanagement.MemoryEvent{
|
||||
{
|
||||
EventType: "EXTERNAL_STORAGE_DETECTED",
|
||||
ByteCount: int64(64 * 1024 * 1024 * 1024), // 64GB external/built-in storage total capacity
|
||||
CreateTime: "2024-01-15T09:00:00Z",
|
||||
},
|
||||
{
|
||||
EventType: "INTERNAL_STORAGE_MEASURED",
|
||||
ByteCount: int64(10 * 1024 * 1024 * 1024), // 10GB free in system partition
|
||||
CreateTime: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
{
|
||||
EventType: "EXTERNAL_STORAGE_MEASURED",
|
||||
ByteCount: int64(25 * 1024 * 1024 * 1024), // 25GB free in external/built-in storage
|
||||
CreateTime: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(deviceInfo)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedData := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
return &android.PubSubMessage{
|
||||
Attributes: map[string]string{
|
||||
"notificationType": string(android.PubSubEnrollment),
|
||||
},
|
||||
Data: encodedData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type addAppStoreAppRequest struct {
|
||||
TeamID *uint `json:"team_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
Platform fleet.AppleDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
AutomaticInstall bool `json:"automatic_install"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
Categories []string `json:"categories"`
|
||||
TeamID *uint `json:"team_id"`
|
||||
AppStoreID string `json:"app_store_id"`
|
||||
Platform fleet.InstallableDevicePlatform `json:"platform"`
|
||||
SelfService bool `json:"self_service"`
|
||||
AutomaticInstall bool `json:"automatic_install"`
|
||||
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
type addAppStoreAppResponse struct {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
)
|
||||
|
||||
const softwareWorkerJobName = "software_worker"
|
||||
|
||||
type SoftwareWorkerTask string
|
||||
|
||||
type SoftwareWorker struct {
|
||||
Datastore fleet.Datastore
|
||||
AndroidModule android.Service
|
||||
Log kitlog.Logger
|
||||
}
|
||||
|
||||
func (v *SoftwareWorker) Name() string {
|
||||
return softwareWorkerJobName
|
||||
}
|
||||
|
||||
const makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host"
|
||||
const makeAndroidAppAvailableTask SoftwareWorkerTask = "make_android_app_available"
|
||||
|
||||
type softwareWorkerArgs struct {
|
||||
Task SoftwareWorkerTask `json:"task"`
|
||||
HostUUID string `json:"host_uuid"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
EnterpriseName string `json:"enterprise_name"`
|
||||
AppTeamID uint `json:"app_team_id"`
|
||||
HostID uint `json:"host_id"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) error {
|
||||
var args softwareWorkerArgs
|
||||
if err := json.Unmarshal(argsJSON, &args); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "unmarshal args")
|
||||
}
|
||||
|
||||
switch args.Task {
|
||||
|
||||
case makeAndroidAppsAvailableForHostTask:
|
||||
return ctxerr.Wrapf(
|
||||
ctx,
|
||||
v.makeAndroidAppsAvailableForHost(ctx, args.HostUUID, args.HostID, args.EnterpriseName, args.PolicyID),
|
||||
"running %s task",
|
||||
makeAndroidAppsAvailableForHostTask,
|
||||
)
|
||||
|
||||
case makeAndroidAppAvailableTask:
|
||||
return ctxerr.Wrapf(
|
||||
ctx,
|
||||
v.makeAndroidAppAvailable(ctx, args.ApplicationID, args.AppTeamID, args.EnterpriseName),
|
||||
"running %s task",
|
||||
makeAndroidAppAvailableTask,
|
||||
)
|
||||
|
||||
default:
|
||||
return ctxerr.Errorf(ctx, "unknown task: %v", args.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicationID string, appTeamID uint, enterpriseName string) error {
|
||||
hosts, err := v.Datastore.GetIncludedHostUUIDMapForAppStoreApp(ctx, appTeamID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "add app store app: getting android hosts in scope")
|
||||
}
|
||||
|
||||
// Update Android MDM policy to include the app in self service
|
||||
err = v.AndroidModule.AddAppToAndroidPolicy(ctx, enterpriseName, []string{applicationID}, hosts)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func QueueMakeAndroidAppAvailableJob(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, applicationID string, appTeamID uint, enterpriseName string) error {
|
||||
args := &softwareWorkerArgs{
|
||||
Task: makeAndroidAppAvailableTask,
|
||||
ApplicationID: applicationID,
|
||||
AppTeamID: appTeamID,
|
||||
EnterpriseName: enterpriseName,
|
||||
}
|
||||
|
||||
job, err := QueueJob(ctx, ds, softwareWorkerJobName, args)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "queueing job")
|
||||
}
|
||||
|
||||
level.Debug(logger).Log("job_id", job.ID, "job_name", softwareWorkerJobName, "task", makeAndroidAppAvailableTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, hostUUID string, hostID uint, enterpriseName, policyID string) error {
|
||||
|
||||
if policyID == "1" {
|
||||
var policy androidmanagement.Policy
|
||||
|
||||
policy.StatusReportingSettings = &androidmanagement.StatusReportingSettings{
|
||||
DeviceSettingsEnabled: true,
|
||||
MemoryInfoEnabled: true,
|
||||
NetworkInfoEnabled: true,
|
||||
DisplayInfoEnabled: true,
|
||||
PowerManagementEventsEnabled: true,
|
||||
HardwareStatusEnabled: true,
|
||||
SystemPropertiesEnabled: true,
|
||||
SoftwareInfoEnabled: true,
|
||||
CommonCriteriaModeEnabled: true,
|
||||
ApplicationReportsEnabled: true,
|
||||
ApplicationReportingSettings: nil, // only option is "includeRemovedApps", which I opted not to enable (we can diff apps to see removals)
|
||||
}
|
||||
|
||||
policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, hostUUID)
|
||||
_, err := v.AndroidModule.PatchPolicy(ctx, hostUUID, policyName, &policy, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
device := &androidmanagement.Device{
|
||||
PolicyName: policyName,
|
||||
// State must be specified when updating a device, otherwise it fails with
|
||||
// "Illegal state transition from ACTIVE to DEVICE_STATE_UNSPECIFIED"
|
||||
//
|
||||
// > Note that when calling enterprises.devices.patch, ACTIVE and
|
||||
// > DISABLED are the only allowable values.
|
||||
|
||||
// TODO(ap): should we send whatever the previous state was? If it was DISABLED,
|
||||
// we probably don't want to re-enable it by accident. Those are the only
|
||||
// 2 valid states when patching a device.
|
||||
State: "ACTIVE",
|
||||
}
|
||||
androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", hostUUID)
|
||||
}
|
||||
deviceName := fmt.Sprintf("%s/devices/%s", enterpriseName, androidHost.DeviceID)
|
||||
_, err = v.AndroidModule.PatchDevice(ctx, hostUUID, deviceName, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get android apps in scope for host")
|
||||
}
|
||||
|
||||
if len(appIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = v.AndroidModule.AddAppToAndroidPolicy(ctx, enterpriseName, appIDs, map[string]string{hostUUID: hostUUID})
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func QueueMakeAndroidAppsAvailableForHostJob(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, hostUUID string, hostID uint, enterpriseName, policyID string) error {
|
||||
args := &softwareWorkerArgs{
|
||||
Task: makeAndroidAppsAvailableForHostTask,
|
||||
HostUUID: hostUUID,
|
||||
HostID: hostID,
|
||||
EnterpriseName: enterpriseName,
|
||||
PolicyID: policyID,
|
||||
}
|
||||
|
||||
job, err := QueueJob(ctx, ds, softwareWorkerJobName, args)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "queueing job")
|
||||
}
|
||||
|
||||
level.Debug(logger).Log("job_id", job.ID, "job_name", softwareWorkerJobName, "task", makeAndroidAppsAvailableForHostTask)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
)
|
||||
|
||||
func TestSoftwareWorker(t *testing.T) {
|
||||
ds := mysql.CreateMySQLDS(t)
|
||||
// call TruncateTables immediately as some DB migrations may create jobs
|
||||
mysql.TruncateTables(t, ds)
|
||||
|
||||
mysql.SetTestABMAssets(t, ds, "fleet")
|
||||
|
||||
}
|
||||
@@ -38,15 +38,16 @@ var (
|
||||
flagExportName string
|
||||
|
||||
validNames = map[fleet.MDMAssetName]struct{}{
|
||||
fleet.MDMAssetABMCert: {},
|
||||
fleet.MDMAssetABMTokenDeprecated: {},
|
||||
fleet.MDMAssetABMKey: {},
|
||||
fleet.MDMAssetAPNSCert: {},
|
||||
fleet.MDMAssetAPNSKey: {},
|
||||
fleet.MDMAssetCACert: {},
|
||||
fleet.MDMAssetCAKey: {},
|
||||
fleet.MDMAssetSCEPChallenge: {},
|
||||
fleet.MDMAssetVPPTokenDeprecated: {},
|
||||
fleet.MDMAssetABMCert: {},
|
||||
fleet.MDMAssetABMTokenDeprecated: {},
|
||||
fleet.MDMAssetABMKey: {},
|
||||
fleet.MDMAssetAPNSCert: {},
|
||||
fleet.MDMAssetAPNSKey: {},
|
||||
fleet.MDMAssetCACert: {},
|
||||
fleet.MDMAssetCAKey: {},
|
||||
fleet.MDMAssetSCEPChallenge: {},
|
||||
fleet.MDMAssetVPPTokenDeprecated: {},
|
||||
fleet.MDMAssetAndroidFleetServerSecret: {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Get android enterprise applications',
|
||||
|
||||
|
||||
description: 'Gets an android enterprise application',
|
||||
|
||||
|
||||
inputs: {
|
||||
androidEnterpriseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
applicationId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
success: { description: 'The device of an Android enterprise was successfully retrieved.adfasd' },
|
||||
missingAuthHeader: { description: 'This request was missing an authorization header.', responseType: 'unauthorized'},
|
||||
unauthorized: { description: 'Invalid authentication token.', responseType: 'unauthorized'},
|
||||
notFound: { description: 'App not found', responseType: 'notFound' },
|
||||
deviceNoLongerManaged: { description: 'The device is no longer managed by the Android enterprise.', responseType: 'notFound' },
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({ androidEnterpriseId, applicationId}) {
|
||||
|
||||
// Extract fleetServerSecret from the Authorization header
|
||||
let authHeader = this.req.get('authorization');
|
||||
let fleetServerSecret;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer')) {
|
||||
fleetServerSecret = authHeader.replace('Bearer', '').trim();
|
||||
} else {
|
||||
throw 'missingAuthHeader';
|
||||
}
|
||||
|
||||
// Authenticate this request
|
||||
let thisAndroidEnterprise = await AndroidEnterprise.findOne({
|
||||
androidEnterpriseId: androidEnterpriseId
|
||||
});
|
||||
|
||||
// Return a 404 response if no records are found.
|
||||
if (!thisAndroidEnterprise) {
|
||||
throw 'notFound';
|
||||
}
|
||||
// Return an unauthorized response if the provided secret does not match.
|
||||
if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) {
|
||||
throw 'unauthorized';
|
||||
}
|
||||
|
||||
// Check the list of Android Enterprises managed by Fleet to see if this Android Enterprise is still managed.
|
||||
let isEnterpriseManagedByFleet = await sails.helpers.androidProxy.getIsEnterpriseManagedByFleet(androidEnterpriseId);
|
||||
// Return a 404 response if this Android enterprise is no longer managed by Fleet.
|
||||
if(!isEnterpriseManagedByFleet) {
|
||||
throw 'notFound';
|
||||
}
|
||||
|
||||
// Get the device for this Android enterprise.
|
||||
// Note: We're using sails.helpers.flow.build here to handle any errors that occur using google's node library.
|
||||
let getApplicationsResponse = await sails.helpers.flow.build(async () => {
|
||||
let { google } = require('googleapis');
|
||||
let androidmanagement = google.androidmanagement('v1');
|
||||
let googleAuth = new google.auth.GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/androidmanagement'],
|
||||
credentials: {
|
||||
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
|
||||
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
|
||||
},
|
||||
});
|
||||
// Acquire the google auth client, and bind it to all future calls
|
||||
let authClient = await googleAuth.getClient();
|
||||
google.options({ auth: authClient });
|
||||
// [?]: https://googleapis.dev/nodejs/googleapis/latest/androidmanagement/classes/Resource$Enterprises$Applications.html#get
|
||||
let getApplicationsResult = await androidmanagement.enterprises.devices.get({
|
||||
name: `enterprises/${androidEnterpriseId}/applications/${applicationId}`,
|
||||
});
|
||||
return getApplicationsResult.data;
|
||||
}).intercept((err) => {
|
||||
let errorString = err.toString();
|
||||
if (errorString.includes('Device is no longer being managed')) {
|
||||
return {'deviceNoLongerManaged': 'The device is no longer managed by the Android enterprise.'};
|
||||
}
|
||||
return new Error(`When attempting to get an application for an Android enterprise (${androidEnterpriseId}), an error occurred. Error: ${err}`);
|
||||
});
|
||||
|
||||
|
||||
// Return the device data back to the Fleet server.
|
||||
return getApplicationsResponse;
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
@@ -79,6 +79,7 @@ module.exports = {
|
||||
let patchPoliciesResponse = await androidmanagement.enterprises.policies.patch({
|
||||
name: `enterprises/${androidEnterpriseId}/policies/${policyId}`,
|
||||
requestBody: this.req.body,
|
||||
updateMask: this.req.param('updateMask') // Pass the update mask to avoid overwriting applications
|
||||
});
|
||||
return patchPoliciesResponse.data;
|
||||
}).intercept({status: 429}, (err)=>{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Modify android enterprise policy applications',
|
||||
|
||||
|
||||
description: 'Modifies applications in an Android enterprise policy',
|
||||
|
||||
|
||||
inputs: {
|
||||
androidEnterpriseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
policyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
success: { description: 'The policy applications of an Android enterprise was successfully updated.' },
|
||||
missingAuthHeader: { description: 'This request was missing an authorization header.', responseType: 'unauthorized'},
|
||||
unauthorized: { description: 'Invalid authentication token.', responseType: 'unauthorized'},
|
||||
notFound: { description: 'No Android enterprise found for this Fleet server.', responseType: 'notFound'},
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({ androidEnterpriseId, policyId}) {
|
||||
|
||||
// Extract fleetServerSecret from the Authorization header
|
||||
let authHeader = this.req.get('authorization');
|
||||
let fleetServerSecret;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer')) {
|
||||
fleetServerSecret = authHeader.replace('Bearer', '').trim();
|
||||
} else {
|
||||
throw 'missingAuthHeader';
|
||||
}
|
||||
|
||||
// Authenticate this request
|
||||
let thisAndroidEnterprise = await AndroidEnterprise.findOne({
|
||||
androidEnterpriseId: androidEnterpriseId
|
||||
});
|
||||
|
||||
// Return a 404 response if no records are found.
|
||||
if (!thisAndroidEnterprise) {
|
||||
throw 'notFound';
|
||||
}
|
||||
// Return an unauthorized response if the provided secret does not match.
|
||||
if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) {
|
||||
throw 'unauthorized';
|
||||
}
|
||||
|
||||
// Check the list of Android Enterprises managed by Fleet to see if this Android Enterprise is still managed.
|
||||
let isEnterpriseManagedByFleet = await sails.helpers.androidProxy.getIsEnterpriseManagedByFleet(androidEnterpriseId);
|
||||
// Return a 404 response if this Android enterprise is no longer managed by Fleet.
|
||||
if(!isEnterpriseManagedByFleet) {
|
||||
throw 'notFound';
|
||||
}
|
||||
|
||||
// Update the policy applications for this Android enterprise.
|
||||
// Note: We're using sails.helpers.flow.build here to handle any errors that occurr using google's node library.
|
||||
let modifyApplicationPolicyResponse = await sails.helpers.flow.build(async () => {
|
||||
let { google } = require('googleapis');
|
||||
let androidmanagement = google.androidmanagement('v1');
|
||||
let googleAuth = new google.auth.GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/androidmanagement'],
|
||||
credentials: {
|
||||
client_email: sails.config.custom.androidEnterpriseServiceAccountEmailAddress,// eslint-disable-line camelcase
|
||||
private_key: sails.config.custom.androidEnterpriseServiceAccountPrivateKey,// eslint-disable-line camelcase
|
||||
},
|
||||
});
|
||||
// Acquire the google auth client, and bind it to all future calls
|
||||
let authClient = await googleAuth.getClient();
|
||||
google.options({ auth: authClient });
|
||||
|
||||
let patchPoliciesResponse = await androidmanagement.enterprises.policies.modifyPolicyApplications({
|
||||
name: `enterprises/${androidEnterpriseId}/policies/${policyId}`,
|
||||
requestBody: this.req.body,
|
||||
});
|
||||
return patchPoliciesResponse.data;
|
||||
}).intercept((err) => {
|
||||
return new Error(`When attempting to update applications for a policy of Android enterprise (${androidEnterpriseId}), an error occurred. Error: ${err}`);
|
||||
});
|
||||
|
||||
|
||||
// Return the modified policy back to the Fleet server.
|
||||
return modifyApplicationPolicyResponse;
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
Vendored
+11
-9
@@ -1060,15 +1060,17 @@ module.exports.routes = {
|
||||
// ╔═╗╔╗╔╔╦╗╦═╗╔═╗╦╔╦╗ ╔═╗╦═╗╔═╗═╗ ╦╦ ╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
|
||||
// ╠═╣║║║ ║║╠╦╝║ ║║ ║║ ╠═╝╠╦╝║ ║╔╩╦╝╚╦╝ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗
|
||||
// ╩ ╩╝╚╝═╩╝╩╚═╚═╝╩═╩╝ ╩ ╩╚═╚═╝╩ ╚═ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝
|
||||
'POST /api/android/v1/signupUrls': { action: 'android-proxy/create-android-signup-url', csrf: false },
|
||||
'POST /api/android/v1/enterprises': { action: 'android-proxy/create-android-enterprise', csrf: false },
|
||||
'GET /api/android/v1/enterprises': { action: 'android-proxy/get-android-enterprises' },
|
||||
'POST /api/android/v1/enterprises/:androidEnterpriseId/enrollmentTokens': { action: 'android-proxy/create-android-enrollment-token', csrf: false },
|
||||
'PATCH /api/android/v1/enterprises/:androidEnterpriseId/policies/:policyId': { action: 'android-proxy/modify-android-policies', csrf: false },
|
||||
'DELETE /api/android/v1/enterprises/:androidEnterpriseId': { action: 'android-proxy/delete-one-android-enterprise', csrf: false },
|
||||
'GET /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/get-android-device', csrf: false },
|
||||
'DELETE /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/delete-android-device', csrf: false },
|
||||
'PATCH /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/modify-android-device', csrf: false },
|
||||
'POST /api/android/v1/signupUrls': { action: 'android-proxy/create-android-signup-url', csrf: false},
|
||||
'POST /api/android/v1/enterprises': { action: 'android-proxy/create-android-enterprise', csrf: false},
|
||||
'GET /api/android/v1/enterprises': { action: 'android-proxy/get-android-enterprises'},
|
||||
'POST /api/android/v1/enterprises/:androidEnterpriseId/enrollmentTokens': { action: 'android-proxy/create-android-enrollment-token', csrf: false},
|
||||
'PATCH /api/android/v1/enterprises/:androidEnterpriseId/policies/:policyId': { action: 'android-proxy/modify-android-policies', csrf: false},
|
||||
'DELETE /api/android/v1/enterprises/:androidEnterpriseId': { action: 'android-proxy/delete-one-android-enterprise', csrf: false},
|
||||
'GET /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/get-android-device', csrf: false},
|
||||
'DELETE /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/delete-android-device', csrf: false},
|
||||
'PATCH /api/android/v1/enterprises/:androidEnterpriseId/devices/:deviceId': { action: 'android-proxy/modify-android-device', csrf: false},
|
||||
'GET /api/android/v1/enterprises/:androidEnterpriseId/applications/:applicationId': { action: 'android-proxy/get-enterprise-applications', csrf: false, skipAssets: false},
|
||||
'POST /api/android/v1/enterprises/:androidEnterpriseId/policies/:policyId': { action: 'android-proxy/modify-enterprise-app-policy', csrf: false, skipAssets: false},
|
||||
|
||||
|
||||
// ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
"@sailshq/connect-redis": "^6.1.3",
|
||||
"@sailshq/lodash": "^3.10.7",
|
||||
"@sailshq/socket.io-redis": "^6.1.2",
|
||||
"googleapis": "148.0.0",
|
||||
"googleapis": "^165.0.0",
|
||||
"jsforce": "1.11.1",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"jsrsasign": "11.1.0",
|
||||
|
||||
Reference in New Issue
Block a user