Android WebApps: endpoint to create one, prevent android app configuration on webApps (#40329)
This commit is contained in:
@@ -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.
|
||||
+102
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
+1
@@ -0,0 +1 @@
|
||||
Not a .png
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+67
-34
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user