diff --git a/changes/40024-endpoint-to-create-android-webapp b/changes/40024-endpoint-to-create-android-webapp new file mode 100644 index 0000000000..181e618ae0 --- /dev/null +++ b/changes/40024-endpoint-to-create-android-webapp @@ -0,0 +1 @@ +- Added support to create an Android webapp and add it to Fleet to be installed during setup experience or via self-service. diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 2083fb23be..a003ab1f0d 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -1,13 +1,17 @@ package service import ( + "bytes" "context" "database/sql" + "encoding/base64" "errors" "fmt" + "image/png" "io" "maps" "net/http" + "net/url" "regexp" "slices" "sort" @@ -18,10 +22,12 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt" "github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/worker" + "google.golang.org/api/androidmanagement/v1" ) // Used for overriding the env var value in testing @@ -212,6 +218,10 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, } switch payload.Platform { case fleet.AndroidPlatform: + if strings.HasPrefix(payload.AppStoreID, fleet.AndroidWebAppPrefix) && payload.Configuration != nil { + return nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.") + } + appStoreApp.SelfService = true appStoreApp.Configuration = payload.Configuration incomingAndroidApps = append(incomingAndroidApps, appStoreApp) @@ -278,7 +288,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, enterprise, err := svc.ds.GetEnterprise(ctx) if err != nil && !fleet.IsNotFound(err) { - return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} + return nil, ctxerr.Wrap(ctx, err, "get android enterprise") } androidHostPoliciesToUpdate := map[string]string{} @@ -605,6 +615,11 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee return 0, fleet.NewInvalidArgumentError("app_store_id", "The Fleet agent cannot be added manually. "+ "It is automatically managed by Fleet when Android MDM is enabled.") } + + if strings.HasPrefix(appID.AdamID, fleet.AndroidWebAppPrefix) && appID.Configuration != nil { + return 0, fleet.NewInvalidArgumentError("configuration", "Couldn't add. Android web apps don't support configurations.") + } + appID.SelfService = true appID.AddAutoInstallPolicy = false @@ -999,6 +1014,10 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID // note that if appID.Configuration is nil, InsertVPPAppWithTeam will ignore it (it will not // update or remove it), so here we ignore it too if it is nil. if payload.Configuration != nil && meta.Platform == fleet.AndroidPlatform { + if strings.HasPrefix(meta.AdamID, fleet.AndroidWebAppPrefix) { + return nil, nil, fleet.NewInvalidArgumentError("configuration", "Couldn't edit. Android web apps don't support configurations.") + } + // check if configuration has changed androidConfigChanged, err = svc.ds.HasAndroidAppConfigurationChanged(ctx, meta.AdamID, ptr.ValOrZero(teamID), payload.Configuration) if err != nil { @@ -1217,3 +1236,85 @@ func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { return svc.ds.DeleteVPPToken(ctx, tokenID) } + +func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) { + // Authorization for this endpoint is a bit different - basically we want the same + // write permissions as for App Store apps (fleet.VPPApp struct), but there is no + // team id available when this endpoint is called, so we allow any team user to + // call it as long as they have the acceptable role. To achieve this, we grab the + // first team id from the user's list of teams, if they have a non-global role. + var teamID *uint + if user := authz.UserFromContext(ctx); user != nil { + if len(user.Teams) > 0 { + teamID = &user.Teams[0].ID + } + } + + if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil { + return "", err + } + + // title and startURL are required but are already validated during the DecodeRequest implementation. + if parsedURL, err := url.Parse(startURL); err != nil || !parsedURL.IsAbs() { + return "", &fleet.BadRequestError{Message: "Couldn't create. The start URL must be a valid absolute URL.", InternalErr: err} + } + + // icon, if provided, must be a .png file and must be square and at least 512x512 pixels. + var iconData []byte + if icon != nil { + const invalidIconErrMsg = `Couldn't create. The icon must be a PNG file and square, with dimensions of at least 512 x 512px.` + + b, err := io.ReadAll(icon) + if err != nil { + return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err} + } + iconData = b + + // decoding errors if it is not a valid png + cfg, err := png.DecodeConfig(bytes.NewReader(iconData)) + if err != nil { + return "", &fleet.BadRequestError{Message: invalidIconErrMsg, InternalErr: err} + } + + // check that it is square + if cfg.Width != cfg.Height { + return "", &fleet.BadRequestError{Message: invalidIconErrMsg} + } + + // check minimal size requirement (only needs to test one, as at this point it is square) + if cfg.Width < 512 { + return "", &fleet.BadRequestError{Message: invalidIconErrMsg} + } + } + + enterprise, err := svc.ds.GetEnterprise(ctx) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "get android enterprise") + } + + webApp := &androidmanagement.WebApp{ + DisplayMode: "STANDALONE", // always standalone for now per spec + Title: title, + StartUrl: startURL, + } + if len(iconData) > 0 { + // must be the "actual bytes of the image in a base64url encoded string" + b64Icon := base64.URLEncoding.EncodeToString(iconData) + webApp.Icons = append(webApp.Icons, &androidmanagement.WebAppIcon{ImageData: b64Icon}) + } + createdApp, err := svc.androidModule.CreateAndroidWebApp(ctx, enterprise.Name(), webApp) + if err != nil { + if androidmgmt.IsBadRequestError(err) { + return "", &fleet.BadRequestError{Message: "Couldn't create. Please check the provided data and try again.", InternalErr: err} + } + return "", ctxerr.Wrap(ctx, err, "creating android web app") + } + + packageName := strings.TrimPrefix(createdApp.Name, fmt.Sprintf("%s/webApps/", enterprise.Name())) + if packageName == createdApp.Name || !strings.HasPrefix(packageName, fleet.AndroidWebAppPrefix) { + // logging this as an error, because the frontend uses the package name to hide some actions + // not available to WebApps, we must know if somehow android changes how those get named. + svc.logger.ErrorContext(ctx, "created Android webApp does not have expected package name format", "package_name", createdApp.Name) + } + return packageName, nil +} diff --git a/server/fleet/android.go b/server/fleet/android.go index 8480f8b478..9a0568d656 100644 --- a/server/fleet/android.go +++ b/server/fleet/android.go @@ -14,6 +14,8 @@ import ( "google.golang.org/api/androidmanagement/v1" ) +const AndroidWebAppPrefix = "com.google.enterprise.webapp" + // MDMAndroidConfigProfile represents an Android MDM profile in Fleet. This does not map // directly to a specific policy in the Android API, rather the policy applied is the // result of combining all applicable profiles. diff --git a/server/fleet/service.go b/server/fleet/service.go index 6d74b49b56..d1f7a52724 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -865,6 +865,8 @@ type Service interface { GetVPPTokens(ctx context.Context) ([]*VPPTokenDB, error) DeleteVPPToken(ctx context.Context, tokenID uint) error + CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) + BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []VPPBatchPayload, dryRun bool) ([]VPPAppResponse, error) // GetHostDEPAssignment retrieves the host DEP assignment for the specified host. @@ -1038,6 +1040,11 @@ type Service interface { // error can be raised to the user. VerifyMDMWindowsConfigured(ctx context.Context) error + // VerifyMDMAndroidConfigured verifies that the server is configured for + // Android MDM. If an error is returned, authorization is skipped so the + // error can be raised to the user. + VerifyMDMAndroidConfigured(ctx context.Context) error + // VerifyAnyMDMConfigured verifies that the server is configured for any MDM // (Apple, Windows, or Android). If an error is returned, authorization is // skipped so the error can be raised to the user. diff --git a/server/mdm/android/mock/client.go b/server/mdm/android/mock/client.go index 0ac90d51cf..468428c1de 100644 --- a/server/mdm/android/mock/client.go +++ b/server/mdm/android/mock/client.go @@ -41,6 +41,8 @@ type EnterprisesPoliciesModifyPolicyApplicationsFunc func(ctx context.Context, p type EnterprisesPoliciesRemovePolicyApplicationsFunc func(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) +type EnterprisesWebAppsCreateFunc func(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) + type Client struct { SignupURLsCreateFunc SignupURLsCreateFunc SignupURLsCreateFuncInvoked bool @@ -84,6 +86,9 @@ type Client struct { EnterprisesPoliciesRemovePolicyApplicationsFunc EnterprisesPoliciesRemovePolicyApplicationsFunc EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked bool + EnterprisesWebAppsCreateFunc EnterprisesWebAppsCreateFunc + EnterprisesWebAppsCreateFuncInvoked bool + mu sync.Mutex } @@ -184,3 +189,10 @@ func (p *Client) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context p.mu.Unlock() return p.EnterprisesPoliciesRemovePolicyApplicationsFunc(ctx, policyName, packageNames) } + +func (p *Client) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + p.mu.Lock() + p.EnterprisesWebAppsCreateFuncInvoked = true + p.mu.Unlock() + return p.EnterprisesWebAppsCreateFunc(ctx, enterpriseName, webApp) +} diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index ec13335273..ef82c14384 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -41,10 +41,13 @@ type Service interface { 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) - // verifyExistingEnterpriseIfAny checks if there's an existing enterprise in the database + // VerifyExistingEnterpriseIfAny checks if there's an existing enterprise in the database // and if so, verifies it still exists in Google API. If it doesn't exist, performs cleanup. // Returns fleet.IsNotFound error if enterprise was deleted, nil if no enterprise exists or verification passed. VerifyExistingEnterpriseIfAny(ctx context.Context) error + + // CreateAndroidWebApp creates a new web app for the given enterprise. + CreateAndroidWebApp(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) } // ///////////////////////////////////////////// diff --git a/server/mdm/android/service/androidmgmt/client.go b/server/mdm/android/service/androidmgmt/client.go index 593a0de794..5c1145b23c 100644 --- a/server/mdm/android/service/androidmgmt/client.go +++ b/server/mdm/android/service/androidmgmt/client.go @@ -2,6 +2,8 @@ package androidmgmt import ( "context" + "errors" + "net/http" "github.com/fleetdm/fleet/v4/server/mdm/android" "google.golang.org/api/androidmanagement/v1" @@ -63,9 +65,17 @@ type Client interface { EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) + // EnterprisesPoliciesModifyPolicyApplications adds or updates the given apps in the policy. + // See: https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/modifyPolicyApplications EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) + // EnterprisesPoliciesRemovePolicyApplications removes the given apps from the policy. + // See: https://developers.google.com/android/management/reference/rest/v1/enterprises.policies/removePolicyApplications EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) + + // EnterprisesWebAppsCreate creates a web app in the enterprise. + // See: https://developers.google.com/android/management/reference/rest/v1/enterprises.webApps/create + EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) } type EnterprisesCreateRequest struct { @@ -96,3 +106,13 @@ type EnterprisesCreateResponse struct { func IsNotModifiedError(err error) bool { return googleapi.IsNotModified(err) } + +// IsBadRequestError reports whether the AMAPI error indicates that the +// request was invalid due to a client error. +func IsBadRequestError(err error) bool { + var ae *googleapi.Error + if errors.As(err, &ae) { + return ae.Code == http.StatusBadRequest + } + return false +} diff --git a/server/mdm/android/service/androidmgmt/google_client.go b/server/mdm/android/service/androidmgmt/google_client.go index 8f0ffa439b..88f12f0336 100644 --- a/server/mdm/android/service/androidmgmt/google_client.go +++ b/server/mdm/android/service/androidmgmt/google_client.go @@ -392,3 +392,11 @@ func (g *GoogleClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.C } return ret.Policy, nil } + +func (g *GoogleClient) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + ret, err := g.mgmt.Enterprises.WebApps.Create(enterpriseName, webApp).Context(ctx).Do() + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "create webapp") + } + return ret, nil +} diff --git a/server/mdm/android/service/androidmgmt/proxy_client.go b/server/mdm/android/service/androidmgmt/proxy_client.go index 15fae68785..9e8ecba111 100644 --- a/server/mdm/android/service/androidmgmt/proxy_client.go +++ b/server/mdm/android/service/androidmgmt/proxy_client.go @@ -357,3 +357,13 @@ func (p *ProxyClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.Co } return ret.Policy, nil } + +func (p *ProxyClient) EnterprisesWebAppsCreate(ctx context.Context, enterpriseName string, webApp *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + call := p.mgmt.Enterprises.WebApps.Create(enterpriseName, webApp).Context(ctx) + call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret) + ret, err := call.Do() + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "create webapp") + } + return ret, nil +} diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index a47f8877e9..fabe43cbe3 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -1388,3 +1388,11 @@ func (svc *Service) SetAppsForAndroidPolicy(ctx context.Context, enterpriseName } return errors.Join(errs...) } + +func (svc *Service) CreateAndroidWebApp(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + app, err := svc.androidAPIClient.EnterprisesWebAppsCreate(ctx, enterpriseName, app) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating Android web app") + } + return app, nil +} diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index c102b3caa8..4837631e20 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -546,6 +546,8 @@ type GetVPPTokensFunc func(ctx context.Context) ([]*fleet.VPPTokenDB, error) type DeleteVPPTokenFunc func(ctx context.Context, tokenID uint) error +type CreateAndroidWebAppFunc func(ctx context.Context, title string, startURL string, icon io.Reader) (string, error) + type BatchAssociateVPPAppsFunc func(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error) type GetHostDEPAssignmentFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostDEPAssignment, error) @@ -640,6 +642,8 @@ type VerifyMDMAppleConfiguredFunc func(ctx context.Context) error type VerifyMDMWindowsConfiguredFunc func(ctx context.Context) error +type VerifyMDMAndroidConfiguredFunc func(ctx context.Context) error + type VerifyAnyMDMConfiguredFunc func(ctx context.Context) error type MDMAppleUploadBootstrapPackageFunc func(ctx context.Context, name string, pkg io.Reader, teamID uint, dryRun bool) error @@ -1674,6 +1678,9 @@ type Service struct { DeleteVPPTokenFunc DeleteVPPTokenFunc DeleteVPPTokenFuncInvoked bool + CreateAndroidWebAppFunc CreateAndroidWebAppFunc + CreateAndroidWebAppFuncInvoked bool + BatchAssociateVPPAppsFunc BatchAssociateVPPAppsFunc BatchAssociateVPPAppsFuncInvoked bool @@ -1815,6 +1822,9 @@ type Service struct { VerifyMDMWindowsConfiguredFunc VerifyMDMWindowsConfiguredFunc VerifyMDMWindowsConfiguredFuncInvoked bool + VerifyMDMAndroidConfiguredFunc VerifyMDMAndroidConfiguredFunc + VerifyMDMAndroidConfiguredFuncInvoked bool + VerifyAnyMDMConfiguredFunc VerifyAnyMDMConfiguredFunc VerifyAnyMDMConfiguredFuncInvoked bool @@ -4025,6 +4035,13 @@ func (s *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { return s.DeleteVPPTokenFunc(ctx, tokenID) } +func (s *Service) CreateAndroidWebApp(ctx context.Context, title string, startURL string, icon io.Reader) (string, error) { + s.mu.Lock() + s.CreateAndroidWebAppFuncInvoked = true + s.mu.Unlock() + return s.CreateAndroidWebAppFunc(ctx, title, startURL, icon) +} + func (s *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error) { s.mu.Lock() s.BatchAssociateVPPAppsFuncInvoked = true @@ -4354,6 +4371,13 @@ func (s *Service) VerifyMDMWindowsConfigured(ctx context.Context) error { return s.VerifyMDMWindowsConfiguredFunc(ctx) } +func (s *Service) VerifyMDMAndroidConfigured(ctx context.Context) error { + s.mu.Lock() + s.VerifyMDMAndroidConfiguredFuncInvoked = true + s.mu.Unlock() + return s.VerifyMDMAndroidConfiguredFunc(ctx) +} + func (s *Service) VerifyAnyMDMConfigured(ctx context.Context) error { s.mu.Lock() s.VerifyAnyMDMConfiguredFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 731423e522..144b0537ee 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -867,6 +867,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/spec/certificate_authorities", batchApplyCertificateAuthoritiesEndpoint, batchApplyCertificateAuthoritiesRequest{}) ue.GET("/api/_version_/fleet/spec/certificate_authorities", getCertificateAuthoritiesSpecEndpoint, getCertificateAuthoritiesSpecRequest{}) + mdmAndroidMW := ue.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAndroidMDM()) + mdmAndroidMW.POST("/api/_version_/fleet/software/web_apps", createAndroidWebAppEndpoint, createAndroidWebAppRequest{}) + ipBanner := redis.NewIPBanner(redisPool, "ipbanner::", deviceIPAllowedConsecutiveFailingRequestsCount, deviceIPAllowedConsecutiveFailingRequestsTimeWindow, diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index 66f938d376..e50566e9fd 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/url" + "os" + "path/filepath" "slices" "strings" "testing" @@ -1105,3 +1107,225 @@ func (s *integrationMDMTestSuite) TestAndroidAppsUninstallOnDelete() { require.NotNil(t, getHostSw.Software[0].AppStoreApp) require.Equal(t, androidApps[4].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID) } + +func (s *integrationMDMTestSuite) TestAndroidWebApps() { + ctx := context.Background() + t := s.T() + + s.setSkipWorkerJobs(t) + appConf, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + appConf.MDM.AndroidEnabledAndConfigured = false + err = s.ds.SaveAppConfig(ctx, appConf) + require.NoError(t, err) + + // attempt to create a webapp before android MDM is enabled + body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Web App"}, + "url": {"https://example.com"}, + }) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusBadRequest, headers) + require.Contains(t, extractServerErrorText(res.Body), "Android MDM isn't turned on.") + + enterpriseID := s.enableAndroidMDM(t) + + s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + id := uuid.NewString() + return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/%s", enterpriseID, id)}, nil + } + + cases := []struct { + desc string + title string + url string + iconFile string // filename in testdata/icons/ + + wantStatus int + wantErrMsg string + }{ + { + desc: "missing title", + title: "", + url: "http://example.com", + wantStatus: http.StatusBadRequest, + wantErrMsg: "title multipart field is required", + }, + { + desc: "missing url", + title: "WebApp", + url: "", + wantStatus: http.StatusBadRequest, + wantErrMsg: "url multipart field is required", + }, + { + desc: "invalid url", + title: "WebApp", + url: "non-absolute-url", + wantStatus: http.StatusBadRequest, + wantErrMsg: "The start URL must be a valid absolute URL.", + }, + { + desc: "valid without icon", + title: "WebApp", + url: "http://example.com", + wantStatus: http.StatusOK, + wantErrMsg: "", + }, + { + desc: "invalid icon not a png", + title: "WebApp", + url: "http://example.com", + iconFile: "not-a-png.txt", + wantStatus: http.StatusBadRequest, + wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.", + }, + { + desc: "invalid icon not square", + title: "WebApp", + url: "http://example.com", + iconFile: "non-square.png", + wantStatus: http.StatusBadRequest, + wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.", + }, + { + desc: "invalid icon too small", + title: "WebApp", + url: "http://example.com", + iconFile: "200px-square.png", + wantStatus: http.StatusBadRequest, + wantErrMsg: "The icon must be a PNG file and square, with dimensions of at least 512 x 512px.", + }, + { + desc: "valid with icon", + title: "WebApp", + url: "http://example.com", + iconFile: "512px-square.png", + wantStatus: http.StatusOK, + wantErrMsg: "", + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + var filename string + var iconData []byte + if c.iconFile != "" { + filename = c.iconFile + b, err := os.ReadFile(filepath.Join("testdata", "icons", c.iconFile)) + require.NoError(t, err) + iconData = b + } + + body, headers := generateMultipartRequest(t, "icon", filename, iconData, s.token, map[string][]string{ + "title": {c.title}, + "url": {c.url}, + }) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), c.wantStatus, headers) + if c.wantErrMsg != "" { + require.Contains(t, extractServerErrorText(res.Body), c.wantErrMsg) + } else { + var resp createAndroidWebAppResponse + err := json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + require.NotEmpty(t, resp.AppStoreID) + } + }) + } +} + +func (s *integrationMDMTestSuite) TestAndroidWebAppsCannotSetConfiguration() { + ctx := context.Background() + t := s.T() + + s.setSkipWorkerJobs(t) + appConf, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + appConf.MDM.AndroidEnabledAndConfigured = false + err = s.ds.SaveAppConfig(ctx, appConf) + require.NoError(t, err) + + enterpriseID := s.enableAndroidMDM(t) + var count int + s.androidAPIClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + count++ + id := "abc" + fmt.Sprint(count) + return &androidmanagement.WebApp{Name: fmt.Sprintf("enterprises/%s/webApps/com.google.enterprise.webapp.%s", enterpriseID, id)}, nil + } + s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) { + ix := strings.LastIndex(packageName, ".") // title is the final segment + return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: packageName[ix+1:]}, nil + } + + // create a webapp + body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Web App"}, + "url": {"https://example.com"}, + }) + var resp createAndroidWebAppResponse + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/web_apps", body.Bytes(), http.StatusOK, headers) + err = json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + webAppID := resp.AppStoreID + + // add it to Fleet with configuration, will fail + res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: webAppID, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.") + + // add it without configuration, will work + var addResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: webAppID, Platform: fleet.AndroidPlatform, + }, http.StatusOK, &addResp) + webAppTitleID := addResp.TitleID + + // update it with configuration, will fail + res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID), + &updateAppStoreAppRequest{Configuration: json.RawMessage(`{"key":"value"}`)}, + http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.") + + // update it without configuration, will work + var updateResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", webAppTitleID), + &updateAppStoreAppRequest{DisplayName: ptr.String("MyWebApp")}, + http.StatusOK, &updateResp) + require.Equal(t, webAppID, updateResp.AppStoreApp.AdamID) + require.Equal(t, "MyWebApp", updateResp.AppStoreApp.DisplayName) + require.Equal(t, "abc1", updateResp.AppStoreApp.Name) + require.True(t, updateResp.AppStoreApp.SelfService) + require.Nil(t, updateResp.AppStoreApp.Configuration) + + // batch-set with configuration, will fail + res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + DryRun: false, + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)}, + }, + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.") + + // batch-set with multiple Android apps, with configuration on the webApp, will fail + res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + DryRun: false, + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage(`{"key":"value"}`)}, + {AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform}, + }, + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(res.Body), "Android web apps don't support configurations.") + + // batch-set without configuration, will work + var batchResp batchAssociateAppStoreAppsResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", + batchAssociateAppStoreAppsRequest{ + DryRun: false, + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: webAppID, SelfService: true, Platform: fleet.AndroidPlatform}, + {AppStoreID: "com.google.chrome", SelfService: true, Platform: fleet.AndroidPlatform}, + }, + }, http.StatusOK, &batchResp) + require.Len(t, batchResp.Apps, 2) +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index eb9bf8370b..72a0b5ac4b 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11046,27 +11046,48 @@ func (s *integrationTestSuite) TestMDMNotConfiguredEndpoints() { h.OrbitNodeKey = &orbitKey windowsOnly := windowsMDMConfigurationRequiredEndpoints() + androidOnly := androidMDMConfigurationRequiredEndpoints() for _, route := range mdmConfigurationRequiredEndpoints() { var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured path := route.path if slices.Contains(windowsOnly, path) { expectedErr = fleet.ErrWindowsMDMNotConfigured + } else if slices.Contains(androidOnly, path) { + expectedErr = fleet.ErrAndroidMDMNotConfigured } + if route.deviceAuthenticated { path = fmt.Sprintf(path, tkn) } - var params interface{} - if route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status" { + + // build the body of the request + var params any + var multipartBody *bytes.Buffer + var headers map[string]string + switch { + case route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status": params = getOrbitSetupExperienceStatusRequest{ OrbitNodeKey: *h.OrbitNodeKey, } - } - // These routes don't require MDM because they can be used to change end-user auth, but they do require a license. - if route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup") { + + case route.method == "POST" && route.path == "/api/latest/fleet/software/web_apps": + multipartBody, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Test App"}, + "url": {"https://example.com"}, + }) + + case route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup"): + // These routes don't require MDM because they can be used to change end-user auth, but they do require a license. expectedErr = fleet.ErrMissingLicense } - res := s.Do(route.method, path, params, expectedErr.StatusCode()) + + var res *http.Response + if multipartBody != nil { + res = s.DoRawWithHeaders(route.method, path, multipartBody.Bytes(), expectedErr.StatusCode(), headers) + } else { + res = s.Do(route.method, path, params, expectedErr.StatusCode()) + } errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, expectedErr.Error(), fmt.Sprintf("%s %s", route.method, path)) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4ac69f3ca2..69d7e27b64 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -5685,28 +5685,47 @@ func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() { h.OrbitNodeKey = &orbitKey windowsOnly := windowsMDMConfigurationRequiredEndpoints() + androidOnly := androidMDMConfigurationRequiredEndpoints() for _, route := range mdmConfigurationRequiredEndpoints() { var expectedErr fleet.ErrWithStatusCode = fleet.ErrMDMNotConfigured path := route.path if slices.Contains(windowsOnly, path) { expectedErr = fleet.ErrWindowsMDMNotConfigured + } else if slices.Contains(androidOnly, path) { + expectedErr = fleet.ErrAndroidMDMNotConfigured } if route.deviceAuthenticated { path = fmt.Sprintf(path, tkn) } + // build the body of the request var params any - if route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status" { + var multipartBody *bytes.Buffer + var headers map[string]string + switch { + case route.method == "POST" && route.path == "/api/fleet/orbit/setup_experience/status": params = getOrbitSetupExperienceStatusRequest{ OrbitNodeKey: *h.OrbitNodeKey, } - } - // These routes don't require MDM if you're only changing end-user auth, so we'll set something else to check. - if route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup") { + + case route.method == "POST" && route.path == "/api/latest/fleet/software/web_apps": + multipartBody, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ + "title": {"Test App"}, + "url": {"https://example.com"}, + }) + + case route.method == "PATCH" && (route.path == "/api/latest/fleet/setup_experience" || route.path == "/api/latest/fleet/mdm/apple/setup"): + // These routes don't require MDM if you're only changing end-user auth, so we'll set something else to check. params = fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)} } - res := s.Do(route.method, path, params, expectedErr.StatusCode()) + + var res *http.Response + if multipartBody != nil { + res = s.DoRawWithHeaders(route.method, path, multipartBody.Bytes(), expectedErr.StatusCode(), headers) + } else { + res = s.Do(route.method, path, params, expectedErr.StatusCode()) + } errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, expectedErr.Error(), fmt.Sprintf("%s %s", route.method, path)) } diff --git a/server/service/middleware/mdmconfigured/mdmconfigured.go b/server/service/middleware/mdmconfigured/mdmconfigured.go index c21456e357..f32324ff17 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured.go @@ -21,7 +21,7 @@ func NewMDMConfigMiddleware(svc fleet.Service) *Middleware { func (m *Middleware) VerifyAppleMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { if err := m.svc.VerifyMDMAppleConfigured(ctx); err != nil { return nil, err } @@ -35,7 +35,7 @@ func (m *Middleware) VerifyAppleMDM() endpoint.Middleware { // This is used on API endpoints that are reused on Linux hosts (which don't require Apple MDM to be configured). func (m *Middleware) VerifyAppleMDMOnMacOSHosts() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { host, ok := hostctx.FromContext(ctx) if !ok { return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) @@ -53,7 +53,7 @@ func (m *Middleware) VerifyAppleMDMOnMacOSHosts() endpoint.Middleware { func (m *Middleware) VerifyWindowsMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { if err := m.svc.VerifyMDMWindowsConfigured(ctx); err != nil { return nil, err } @@ -63,9 +63,21 @@ func (m *Middleware) VerifyWindowsMDM() endpoint.Middleware { } } +func (m *Middleware) VerifyAndroidMDM() endpoint.Middleware { + return func(next endpoint.Endpoint) endpoint.Endpoint { + return func(ctx context.Context, req any) (any, error) { + if err := m.svc.VerifyMDMAndroidConfigured(ctx); err != nil { + return nil, err + } + + return next(ctx, req) + } + } +} + func (m *Middleware) VerifyAnyMDM() endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { - return func(ctx context.Context, req interface{}) (interface{}, error) { + return func(ctx context.Context, req any) (any, error) { if err := m.svc.VerifyAnyMDMConfigured(ctx); err != nil { return nil, err } diff --git a/server/service/middleware/mdmconfigured/mdmconfigured_test.go b/server/service/middleware/mdmconfigured/mdmconfigured_test.go index fe1e0919aa..fb41aeb2f0 100644 --- a/server/service/middleware/mdmconfigured/mdmconfigured_test.go +++ b/server/service/middleware/mdmconfigured/mdmconfigured_test.go @@ -39,6 +39,14 @@ func (m *mockService) VerifyMDMWindowsConfigured(ctx context.Context) error { return nil } +// VerifyMDMAndroidConfigured marks whether Android MDM is enabled for the test. +func (m *mockService) VerifyMDMAndroidConfigured(ctx context.Context) error { + if !m.androidConfigured.Load() { + return fleet.ErrAndroidMDMNotConfigured + } + return nil +} + // VerifyAnyMDMConfigured is the mock implementation that mirrors the production // VerifyAnyMDMConfigured service method, adding Android to the Apple/Windows check. func (m *mockService) VerifyAnyMDMConfigured(ctx context.Context) error { @@ -116,6 +124,40 @@ func TestWindowsMDMNotConfigured(t *testing.T) { require.False(t, nextCalled) } +func TestAndroidMDMConfigured(t *testing.T) { + svc := mockService{} + svc.androidConfigured.Store(true) + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req any) (any, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAndroidMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.NoError(t, err) + require.True(t, nextCalled) +} + +func TestAndroidMDMNotConfigured(t *testing.T) { + svc := mockService{} + svc.androidConfigured.Store(false) + mw := NewMDMConfigMiddleware(&svc) + + nextCalled := false + next := func(ctx context.Context, req any) (any, error) { + nextCalled = true + return struct{}{}, nil + } + + f := mw.VerifyAndroidMDM()(next) + _, err := f(context.Background(), struct{}{}) + require.ErrorIs(t, err, fleet.ErrAndroidMDMNotConfigured) + require.False(t, nextCalled) +} + // TestAnyMDMConfigured exercises the new middleware that recognizes Apple, // Windows, or Android MDM individually or in combination. func TestAnyMDMConfigured(t *testing.T) { diff --git a/server/service/testdata/icons/200px-square.png b/server/service/testdata/icons/200px-square.png new file mode 100644 index 0000000000..a4384fc8ca Binary files /dev/null and b/server/service/testdata/icons/200px-square.png differ diff --git a/server/service/testdata/icons/512px-square.png b/server/service/testdata/icons/512px-square.png new file mode 100644 index 0000000000..4e52c276a2 Binary files /dev/null and b/server/service/testdata/icons/512px-square.png differ diff --git a/server/service/testdata/icons/non-square.png b/server/service/testdata/icons/non-square.png new file mode 100644 index 0000000000..ec66e3216f Binary files /dev/null and b/server/service/testdata/icons/non-square.png differ diff --git a/server/service/testdata/icons/not-a-png.txt b/server/service/testdata/icons/not-a-png.txt new file mode 100644 index 0000000000..019b281952 --- /dev/null +++ b/server/service/testdata/icons/not-a-png.txt @@ -0,0 +1 @@ +Not a .png diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index f3796e01fb..9f97487dca 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -949,6 +949,7 @@ func mdmConfigurationRequiredEndpoints() []struct { {"PATCH", "/api/latest/fleet/mdm/apple/setup", false, true}, {"PATCH", "/api/latest/fleet/setup_experience", false, true}, {"POST", "/api/fleet/orbit/setup_experience/status", false, true}, + {"POST", "/api/latest/fleet/software/web_apps", false, true}, } } @@ -958,6 +959,12 @@ func windowsMDMConfigurationRequiredEndpoints() []string { } } +func androidMDMConfigurationRequiredEndpoints() []string { + return []string{ + "/api/latest/fleet/software/web_apps", + } +} + // getURLSchemas returns a list of all valid URI schemas func getURISchemas() []string { return []string{ diff --git a/server/service/vpp.go b/server/service/vpp.go index 028396a49c..c79cd42120 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -428,3 +428,78 @@ func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { return fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// POST /api/_version_/software/web_apps +//////////////////////////////////////////////////////////////////////////////// + +type createAndroidWebAppRequest struct { + Title string + URL string + Icon *multipart.FileHeader +} + +func (createAndroidWebAppRequest) DecodeRequest(ctx context.Context, r *http.Request) (any, error) { + decoded := createAndroidWebAppRequest{} + + err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + title := r.FormValue("title") + if title == "" { + return nil, &fleet.BadRequestError{Message: "title multipart field is required"} + } + decoded.Title = title + + url := r.FormValue("url") + if url == "" { + return nil, &fleet.BadRequestError{Message: "url multipart field is required"} + } + decoded.URL = url + + if len(r.MultipartForm.File["icon"]) > 0 { + decoded.Icon = r.MultipartForm.File["icon"][0] + } + + return &decoded, nil +} + +type createAndroidWebAppResponse struct { + AppStoreID string `json:"app_store_id"` + Err error `json:"error,omitempty"` +} + +func (r createAndroidWebAppResponse) Error() error { return r.Err } + +func createAndroidWebAppEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*createAndroidWebAppRequest) + + var iconReader io.Reader + if req.Icon != nil { + f, err := req.Icon.Open() + if err != nil { + return createAndroidWebAppResponse{Err: err}, nil + } + defer f.Close() + iconReader = f + } + + appID, err := svc.CreateAndroidWebApp(ctx, req.Title, req.URL, iconReader) + if err != nil { + return createAndroidWebAppResponse{Err: err}, nil + } + return createAndroidWebAppResponse{AppStoreID: appID}, nil +} + +func (svc *Service) CreateAndroidWebApp(ctx context.Context, title, startURL string, icon io.Reader) (string, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return "", fleet.ErrMissingLicense +} diff --git a/server/service/vpp_test.go b/server/service/vpp_test.go index 8260c526f5..68969a1ca6 100644 --- a/server/service/vpp_test.go +++ b/server/service/vpp_test.go @@ -2,25 +2,51 @@ package service import ( "context" + "log/slog" + "os" "testing" "time" "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + "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" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service/modules/activities" "github.com/fleetdm/fleet/v4/server/test" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" + "google.golang.org/api/androidmanagement/v1" ) func TestVPPAuth(t *testing.T) { ds := new(mock.Store) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetAndroidFleetServerSecret: {Value: []byte("secret")}, + } + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return assets, nil + } - svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + androidMockClient := &android_mock.Client{} + androidMockClient.SetAuthenticationSecretFunc = func(secret string) error { return nil } + androidMockClient.EnterprisesWebAppsCreateFunc = func(ctx context.Context, enterpriseName string, app *androidmanagement.WebApp) (*androidmanagement.WebApp, error) { + return &androidmanagement.WebApp{Name: "webapp1"}, nil + } + activityModule := activities.NewActivityModule() + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + androidSvc, err := android_service.NewServiceWithClient(logger, ds, androidMockClient, "test-private-key", ds, activityModule, config.AndroidAgentConfig{}) + require.NoError(t, err) + + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, androidModule: androidSvc}) // use a custom implementation of checkAuthErr as the service call will fail // with a different error for in case of authorization success and the @@ -35,39 +61,40 @@ func TestVPPAuth(t *testing.T) { } testCases := []struct { - name string - user *fleet.User - teamID *uint - shouldFailRead bool - shouldFailWrite bool + name string + user *fleet.User + teamID *uint + shouldFailRead bool + shouldFailWrite bool + shouldFailCreateWebApp bool }{ - {"no role no team", test.UserNoRoles, nil, true, true}, - {"no role team", test.UserNoRoles, ptr.Uint(1), true, true}, - {"global admin no team", test.UserAdmin, nil, false, false}, - {"global admin team", test.UserAdmin, ptr.Uint(1), false, false}, - {"global maintainer no team", test.UserMaintainer, nil, false, false}, - {"global maintainer team", test.UserMaintainer, ptr.Uint(1), false, false}, - {"global observer no team", test.UserObserver, nil, true, true}, - {"global observer team", test.UserObserver, ptr.Uint(1), true, true}, - {"global observer+ no team", test.UserObserverPlus, nil, true, true}, - {"global observer+ team", test.UserObserverPlus, ptr.Uint(1), true, true}, - {"global gitops no team", test.UserGitOps, nil, true, false}, - {"global gitops team", test.UserGitOps, ptr.Uint(1), true, false}, - {"team admin no team", test.UserTeamAdminTeam1, nil, true, true}, - {"team admin team", test.UserTeamAdminTeam1, ptr.Uint(1), false, false}, - {"team admin other team", test.UserTeamAdminTeam2, ptr.Uint(1), true, true}, - {"team maintainer no team", test.UserTeamMaintainerTeam1, nil, true, true}, - {"team maintainer team", test.UserTeamMaintainerTeam1, ptr.Uint(1), false, false}, - {"team maintainer other team", test.UserTeamMaintainerTeam2, ptr.Uint(1), true, true}, - {"team observer no team", test.UserTeamObserverTeam1, nil, true, true}, - {"team observer team", test.UserTeamObserverTeam1, ptr.Uint(1), true, true}, - {"team observer other team", test.UserTeamObserverTeam2, ptr.Uint(1), true, true}, - {"team observer+ no team", test.UserTeamObserverPlusTeam1, nil, true, true}, - {"team observer+ team", test.UserTeamObserverPlusTeam1, ptr.Uint(1), true, true}, - {"team observer+ other team", test.UserTeamObserverPlusTeam2, ptr.Uint(1), true, true}, - {"team gitops no team", test.UserTeamGitOpsTeam1, nil, true, true}, - {"team gitops team", test.UserTeamGitOpsTeam1, ptr.Uint(1), true, false}, - {"team gitops other team", test.UserTeamGitOpsTeam2, ptr.Uint(1), true, true}, + {"no role no team", test.UserNoRoles, nil, true, true, true}, + {"no role team", test.UserNoRoles, ptr.Uint(1), true, true, true}, + {"global admin no team", test.UserAdmin, nil, false, false, false}, + {"global admin team", test.UserAdmin, ptr.Uint(1), false, false, false}, + {"global maintainer no team", test.UserMaintainer, nil, false, false, false}, + {"global maintainer team", test.UserMaintainer, ptr.Uint(1), false, false, false}, + {"global observer no team", test.UserObserver, nil, true, true, true}, + {"global observer team", test.UserObserver, ptr.Uint(1), true, true, true}, + {"global observer+ no team", test.UserObserverPlus, nil, true, true, true}, + {"global observer+ team", test.UserObserverPlus, ptr.Uint(1), true, true, true}, + {"global gitops no team", test.UserGitOps, nil, true, false, false}, + {"global gitops team", test.UserGitOps, ptr.Uint(1), true, false, false}, + {"team admin no team", test.UserTeamAdminTeam1, nil, true, true, false}, + {"team admin team", test.UserTeamAdminTeam1, ptr.Uint(1), false, false, false}, + {"team admin other team", test.UserTeamAdminTeam2, ptr.Uint(1), true, true, false}, + {"team maintainer no team", test.UserTeamMaintainerTeam1, nil, true, true, false}, + {"team maintainer team", test.UserTeamMaintainerTeam1, ptr.Uint(1), false, false, false}, + {"team maintainer other team", test.UserTeamMaintainerTeam2, ptr.Uint(1), true, true, false}, + {"team observer no team", test.UserTeamObserverTeam1, nil, true, true, true}, + {"team observer team", test.UserTeamObserverTeam1, ptr.Uint(1), true, true, true}, + {"team observer other team", test.UserTeamObserverTeam2, ptr.Uint(1), true, true, true}, + {"team observer+ no team", test.UserTeamObserverPlusTeam1, nil, true, true, true}, + {"team observer+ team", test.UserTeamObserverPlusTeam1, ptr.Uint(1), true, true, true}, + {"team observer+ other team", test.UserTeamObserverPlusTeam2, ptr.Uint(1), true, true, true}, + {"team gitops no team", test.UserTeamGitOpsTeam1, nil, true, true, false}, + {"team gitops team", test.UserTeamGitOpsTeam1, ptr.Uint(1), true, false, false}, + {"team gitops other team", test.UserTeamGitOpsTeam2, ptr.Uint(1), true, true, false}, } for _, tt := range testCases { @@ -88,6 +115,9 @@ func TestVPPAuth(t *testing.T) { ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ID: 1, OrgName: "org", Teams: []fleet.TeamTuple{{ID: 1}}}, nil } + ds.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) { + return &android.Enterprise{}, nil + } // Note: these calls always return an error because they're attempting to unmarshal a // non-existent VPP token. @@ -104,6 +134,9 @@ func TestVPPAuth(t *testing.T) { } else { checkAuthErr(t, tt.shouldFailWrite, err) } + + _, err = svc.CreateAndroidWebApp(ctx, "test", "http://example.com", nil) + checkAuthErr(t, tt.shouldFailCreateWebApp, err) }) } }