feat: VPP token CRUD (#20108)
# Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
- Adds the functionality for the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and
|
||||
`GET /vpp` endpoints.
|
||||
@@ -4224,41 +4224,20 @@ func decrypt(encrypted []byte, privateKey string) ([]byte, error) {
|
||||
|
||||
decrypted, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
return nil, fmt.Errorf("decrypting: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error {
|
||||
stmt := `
|
||||
INSERT INTO mdm_config_assets
|
||||
(name, value, md5_checksum)
|
||||
VALUES
|
||||
%s`
|
||||
|
||||
var args []any
|
||||
var insertVals strings.Builder
|
||||
|
||||
for _, a := range assets {
|
||||
encryptedVal, err := encrypt(a.Value, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, fmt.Sprintf("encrypting mdm config asset %s", a.Name))
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert mdm config assets")
|
||||
}
|
||||
|
||||
hexChecksum := md5ChecksumBytes(encryptedVal)
|
||||
insertVals.WriteString(`(?, ?, UNHEX(?)),`)
|
||||
args = append(args, a.Name, encryptedVal, hexChecksum)
|
||||
}
|
||||
|
||||
stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ","))
|
||||
|
||||
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
_, err := tx.ExecContext(ctx, stmt, args...)
|
||||
return err
|
||||
return nil
|
||||
})
|
||||
|
||||
return ctxerr.Wrap(ctx, err, "writing mdm config assets to db")
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
@@ -4344,6 +4323,16 @@ WHERE name IN (?) AND deletion_uuid = ''`
|
||||
}
|
||||
|
||||
func (ds *Datastore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName) error {
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
if err := softDeleteMDMConfigAssetsByName(ctx, tx, assetNames); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete mdm config assets by name")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func softDeleteMDMConfigAssetsByName(ctx context.Context, tx sqlx.ExtContext, assetNames []fleet.MDMAssetName) error {
|
||||
stmt := `
|
||||
UPDATE
|
||||
mdm_config_assets
|
||||
@@ -4358,13 +4347,60 @@ WHERE
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, deletionUUID, assetNames)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sqlx.In DeleteMDMConfigAssetsByName")
|
||||
return ctxerr.Wrap(ctx, err, "sqlx.In softDeleteMDMConfigAssetsByName")
|
||||
}
|
||||
|
||||
_, err = ds.writer(ctx).ExecContext(ctx, stmt, args...)
|
||||
_, err = tx.ExecContext(ctx, stmt, args...)
|
||||
return ctxerr.Wrap(ctx, err, "deleting mdm config assets")
|
||||
}
|
||||
|
||||
func insertMDMConfigAssets(ctx context.Context, tx sqlx.ExtContext, assets []fleet.MDMConfigAsset, privateKey string) error {
|
||||
stmt := `
|
||||
INSERT INTO mdm_config_assets
|
||||
(name, value, md5_checksum)
|
||||
VALUES
|
||||
%s`
|
||||
|
||||
var args []any
|
||||
var insertVals strings.Builder
|
||||
|
||||
for _, a := range assets {
|
||||
encryptedVal, err := encrypt(a.Value, privateKey)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, fmt.Sprintf("encrypting mdm config asset %s", a.Name))
|
||||
}
|
||||
|
||||
hexChecksum := md5ChecksumBytes(encryptedVal)
|
||||
insertVals.WriteString(`(?, ?, UNHEX(?)),`)
|
||||
args = append(args, a.Name, encryptedVal, hexChecksum)
|
||||
}
|
||||
|
||||
stmt = fmt.Sprintf(stmt, strings.TrimSuffix(insertVals.String(), ","))
|
||||
|
||||
_, err := tx.ExecContext(ctx, stmt, args...)
|
||||
|
||||
return ctxerr.Wrap(ctx, err, "writing mdm config assets to db")
|
||||
}
|
||||
|
||||
func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error {
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
var names []fleet.MDMAssetName
|
||||
for _, a := range assets {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
|
||||
if err := softDeleteMDMConfigAssetsByName(ctx, tx, names); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "upsert mdm config assets soft delete")
|
||||
}
|
||||
|
||||
if err := insertMDMConfigAssets(ctx, tx, assets, ds.serverPrivateKey); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "upsert mdm config assets insert")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched
|
||||
// (their details haven't been updated in the given `interval`).
|
||||
func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval time.Duration) (uuids []string, err error) {
|
||||
|
||||
@@ -5508,11 +5508,11 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) {
|
||||
assets := []fleet.MDMConfigAsset{
|
||||
{
|
||||
Name: fleet.MDMAssetCACert,
|
||||
Value: []byte("some bytes"),
|
||||
Value: []byte("a"),
|
||||
},
|
||||
{
|
||||
Name: fleet.MDMAssetCAKey,
|
||||
Value: []byte("some other bytes"),
|
||||
Value: []byte("b"),
|
||||
},
|
||||
}
|
||||
wantAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
|
||||
@@ -5552,6 +5552,37 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) {
|
||||
require.Len(t, h, 1)
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCACert])
|
||||
|
||||
// Replace the assets
|
||||
|
||||
newAssets := []fleet.MDMConfigAsset{
|
||||
{
|
||||
Name: fleet.MDMAssetCACert,
|
||||
Value: []byte("c"),
|
||||
},
|
||||
{
|
||||
Name: fleet.MDMAssetCAKey,
|
||||
Value: []byte("d"),
|
||||
},
|
||||
}
|
||||
|
||||
wantNewAssets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
|
||||
for _, a := range newAssets {
|
||||
wantNewAssets[a.Name] = a
|
||||
}
|
||||
|
||||
err = ds.ReplaceMDMConfigAssets(ctx, newAssets)
|
||||
require.NoError(t, err)
|
||||
|
||||
a, err = ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wantNewAssets, a)
|
||||
|
||||
h, err = ds.GetAllMDMConfigAssetsHashes(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, h, 2)
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCACert])
|
||||
require.NotEmpty(t, h[fleet.MDMAssetCAKey])
|
||||
|
||||
// Soft delete the assets
|
||||
|
||||
err = ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey})
|
||||
@@ -5576,19 +5607,25 @@ func testMDMConfigAsset(t *testing.T, ds *Datastore) {
|
||||
|
||||
var ar []assetRow
|
||||
|
||||
err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets WHERE name IN (?, ?) ORDER BY name", fleet.MDMAssetCACert, fleet.MDMAssetCAKey)
|
||||
err = sqlx.SelectContext(ctx, ds.reader(ctx), &ar, "SELECT name, value, deletion_uuid, deleted_at FROM mdm_config_assets")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, ar, 2)
|
||||
require.Len(t, ar, 4)
|
||||
|
||||
for i, a := range ar {
|
||||
require.Equal(t, assets[i].Name, fleet.MDMAssetName(a.Name))
|
||||
require.NotEmpty(t, a.Value)
|
||||
d, err := decrypt(a.Value, ds.serverPrivateKey)
|
||||
expected := make(map[string]fleet.MDMConfigAsset)
|
||||
|
||||
for _, a := range append(assets, newAssets...) {
|
||||
expected[string(a.Value)] = a
|
||||
}
|
||||
|
||||
for _, got := range ar {
|
||||
d, err := decrypt(got.Value, ds.serverPrivateKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, assets[i].Value, d)
|
||||
require.NotEmpty(t, a.DeletionUUID)
|
||||
require.NotEmpty(t, a.DeletedAt)
|
||||
require.Equal(t, expected[string(d)].Name, fleet.MDMAssetName(got.Name))
|
||||
require.NotEmpty(t, got.Value)
|
||||
require.Equal(t, expected[string(d)].Value, d)
|
||||
require.NotEmpty(t, got.DeletionUUID)
|
||||
require.NotEmpty(t, got.DeletedAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1294,6 +1294,11 @@ type Datastore interface {
|
||||
// DeleteMDMConfigAssetsByName soft deletes the given MDM config assets.
|
||||
DeleteMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) error
|
||||
|
||||
// ReplaceMDMConfigAssets replaces (soft delete if they exist + insert) `MDMConfigAsset`s in a
|
||||
// single transaction. Useful for "renew" flows where users are updating the assets with newly
|
||||
// generated ones.
|
||||
ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Microsoft MDM
|
||||
|
||||
|
||||
@@ -564,6 +564,8 @@ const (
|
||||
// MDMAssetSCEPChallenge defines the shared secret used to issue SCEP
|
||||
// certificatges to Apple devices.
|
||||
MDMAssetSCEPChallenge MDMAssetName = "scep_challenge"
|
||||
// MDMAssetVPPToken is the name of the token used by MDM to authenticate to Apple's VPP service.
|
||||
MDMAssetVPPToken MDMAssetName = "vpp_token"
|
||||
)
|
||||
|
||||
type MDMConfigAsset struct {
|
||||
@@ -628,3 +630,29 @@ func FilterMacOSOnlyProfilesFromIOSIPadOS(profiles []*MDMAppleProfilePayload) []
|
||||
|
||||
// RefetchCommandUUIDPrefix is the prefix used for MDM commands used to refetch information from iOS/iPadOS devices.
|
||||
const RefetchCommandUUIDPrefix = "REFETCH-"
|
||||
|
||||
// VPPTokenInfo is the representation of the VPP token that we send out via API.
|
||||
type VPPTokenInfo struct {
|
||||
OrgName string `json:"org_name"`
|
||||
RenewDate string `json:"renew_date"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
// VPPTokenRaw is the representation of the decoded JSON object that is downloaded from ABM.
|
||||
type VPPTokenRaw struct {
|
||||
OrgName string `json:"orgName"`
|
||||
Token string `json:"token"`
|
||||
ExpDate string `json:"expDate"`
|
||||
}
|
||||
|
||||
// VPPTokenData is the VPP data we store in the DB.
|
||||
type VPPTokenData struct {
|
||||
// Location comes from an Apple API:
|
||||
// https://developer.apple.com/documentation/devicemanagement/client_config. It is the name of
|
||||
// the "library" of apps in ABM that is associated with this VPP token.
|
||||
Location string `json:"location"`
|
||||
|
||||
// Token is the token that is downloaded from ABM. It is a base64 encoded JSON object with the
|
||||
// structure of `VPPTokenRaw`.
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -703,6 +703,10 @@ type Service interface {
|
||||
UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error
|
||||
DeleteMDMAppleAPNSCert(ctx context.Context) error
|
||||
|
||||
UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error
|
||||
GetMDMAppleVPPToken(ctx context.Context) (*VPPTokenInfo, error)
|
||||
DeleteMDMAppleVPPToken(ctx context.Context) error
|
||||
|
||||
// GetHostDEPAssignment retrieves the host DEP assignment for the specified host.
|
||||
GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error)
|
||||
|
||||
|
||||
@@ -847,6 +847,8 @@ type GetAllMDMConfigAssetsHashesFunc func(ctx context.Context, assetNames []flee
|
||||
|
||||
type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) error
|
||||
|
||||
type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error
|
||||
|
||||
type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error
|
||||
|
||||
type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error)
|
||||
@@ -2222,6 +2224,9 @@ type DataStore struct {
|
||||
DeleteMDMConfigAssetsByNameFunc DeleteMDMConfigAssetsByNameFunc
|
||||
DeleteMDMConfigAssetsByNameFuncInvoked bool
|
||||
|
||||
ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc
|
||||
ReplaceMDMConfigAssetsFuncInvoked bool
|
||||
|
||||
WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc
|
||||
WSTEPStoreCertificateFuncInvoked bool
|
||||
|
||||
@@ -5321,6 +5326,13 @@ func (s *DataStore) DeleteMDMConfigAssetsByName(ctx context.Context, assetNames
|
||||
return s.DeleteMDMConfigAssetsByNameFunc(ctx, assetNames)
|
||||
}
|
||||
|
||||
func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error {
|
||||
s.mu.Lock()
|
||||
s.ReplaceMDMConfigAssetsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ReplaceMDMConfigAssetsFunc(ctx, assets)
|
||||
}
|
||||
|
||||
func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error {
|
||||
s.mu.Lock()
|
||||
s.WSTEPStoreCertificateFuncInvoked = true
|
||||
|
||||
@@ -722,6 +722,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
||||
ue.POST("/api/_version_/fleet/mdm/apple/apns_certificate", uploadMDMAppleAPNSCertEndpoint, uploadMDMAppleAPNSCertRequest{})
|
||||
ue.DELETE("/api/_version_/fleet/mdm/apple/apns_certificate", deleteMDMAppleAPNSCertEndpoint, deleteMDMAppleAPNSCertRequest{})
|
||||
|
||||
ue.POST("/api/_version_/fleet/mdm/apple/vpp_token", uploadMDMAppleVPPTokenEndpoint, uploadMDMAppleVPPTokenRequest{})
|
||||
ue.GET("/api/_version_/fleet/vpp", getMDMAppleVPPTokenEndpoint, getMDMAppleVPPTokenRequest{})
|
||||
ue.DELETE("/api/_version_/fleet/mdm/apple/vpp_token", deleteMDMAppleVPPTokenEndpoint, deleteMDMAppleVPPTokenRequest{})
|
||||
|
||||
// Deprecated: GET /mdm/apple_bm is now deprecated, replaced by the
|
||||
// GET /abm endpoint.
|
||||
ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil)
|
||||
|
||||
@@ -94,6 +94,7 @@ type integrationMDMTestSuite struct {
|
||||
mdmCommander *apple_mdm.MDMAppleCommander
|
||||
logger kitlog.Logger
|
||||
scepChallenge string
|
||||
appleVPPConfigSrv *httptest.Server
|
||||
mockedDownloadFleetdmMeta fleetdbase.Metadata
|
||||
}
|
||||
|
||||
@@ -302,8 +303,27 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
}
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
|
||||
s.appleVPPConfigSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := []byte(`{"locationName": "Fleet Location One"}`)
|
||||
if strings.Contains(r.URL.RawQuery, "invalidToken") {
|
||||
// This replicates the response sent back from Apple's VPP endpoints when an invalid
|
||||
// token is passed. For more details see:
|
||||
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
|
||||
// https://developer.apple.com/documentation/devicemanagement/client_config
|
||||
// https://developer.apple.com/documentation/devicemanagement/errorresponse
|
||||
// Note that the Apple server returns 200 in this case.
|
||||
resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`)
|
||||
}
|
||||
|
||||
if strings.Contains(r.URL.RawQuery, "serverError") {
|
||||
resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
|
||||
s.T().Cleanup(fleetdmSrv.Close)
|
||||
|
||||
s.mockedDownloadFleetdmMeta = fleetdbase.Metadata{
|
||||
MSIURL: fmt.Sprintf("https://download-testing.fleetdm.com/archive/stable/%s/fleetd-base.msi", uuid.NewString()),
|
||||
@@ -319,7 +339,6 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
require.NoError(s.T(), json.NewEncoder(w).Encode(s.mockedDownloadFleetdmMeta))
|
||||
|
||||
}
|
||||
}))
|
||||
s.T().Setenv("FLEET_DEV_DOWNLOAD_FLEETDM_URL", downloadFleetdmSrv.URL)
|
||||
@@ -334,6 +353,9 @@ func (s *integrationMDMTestSuite) SetupSuite() {
|
||||
// enable MDM flows
|
||||
s.appleCoreCertsSetup()
|
||||
s.enableABM()
|
||||
|
||||
s.T().Cleanup(fleetdmSrv.Close)
|
||||
s.T().Cleanup(s.appleVPPConfigSrv.Close)
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TearDownSuite() {
|
||||
@@ -960,7 +982,7 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() {
|
||||
// Validate errors if no private key is set
|
||||
testSetEmptyPrivateKey = true
|
||||
t.Cleanup(func() { testSetEmptyPrivateKey = false })
|
||||
s.uploadAPNSCert([]byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusInternalServerError, "Couldn't upload APNs certificate. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusInternalServerError, "Couldn't upload APNs certificate. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||||
|
||||
r := s.Do("GET", "/api/latest/fleet/mdm/apple/request_csr", getMDMAppleCSRRequest{}, http.StatusInternalServerError)
|
||||
require.Contains(t, extractServerErrorText(r.Body), "Couldn't download signed CSR. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||||
@@ -978,7 +1000,7 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() {
|
||||
require.Nil(t, assets)
|
||||
|
||||
// trying to upload a certificate without generating a private key first is not allowed
|
||||
s.uploadAPNSCert([]byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusBadRequest, "Please generate a private key first.")
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("-----BEGIN CERTIFICATE-----\nZm9vCg==\n-----END CERTIFICATE-----"), http.StatusBadRequest, "Please generate a private key first.")
|
||||
|
||||
// Check that we return bad gateway if the website API errors
|
||||
s.FailNextCSRRequestWith(http.StatusInternalServerError)
|
||||
@@ -988,22 +1010,22 @@ func (s *integrationMDMTestSuite) TestGetMDMCSR() {
|
||||
require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed")
|
||||
|
||||
// Invalid APNS cert upload attempt
|
||||
s.uploadAPNSCert([]byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.")
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", []byte("invalid-cert"), http.StatusUnprocessableEntity, "Invalid certificate. Please provide a valid certificate from Apple Push Certificate Portal.")
|
||||
|
||||
// simulate a renew flow
|
||||
s.appleCoreCertsSetup()
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) uploadAPNSCert(pemBytes []byte, expectedStatus int, wantErr string) {
|
||||
func (s *integrationMDMTestSuite) uploadDataViaForm(endpoint, fieldName, fileName string, data []byte, expectedStatus int, wantErr string) {
|
||||
t := s.T()
|
||||
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
|
||||
// add the package field
|
||||
fw, err := w.CreateFormFile("certificate", "certificate.pem")
|
||||
fw, err := w.CreateFormFile(fieldName, fileName)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(fw, bytes.NewBuffer(pemBytes))
|
||||
_, err = io.Copy(fw, bytes.NewBuffer(data))
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
@@ -1014,13 +1036,59 @@ func (s *integrationMDMTestSuite) uploadAPNSCert(pemBytes []byte, expectedStatus
|
||||
"Authorization": fmt.Sprintf("Bearer %s", s.token),
|
||||
}
|
||||
|
||||
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/apns_certificate", b.Bytes(), expectedStatus, headers)
|
||||
res := s.DoRawWithHeaders("POST", endpoint, b.Bytes(), expectedStatus, headers)
|
||||
if wantErr != "" {
|
||||
errMsg := extractServerErrorText(res.Body)
|
||||
assert.Contains(t, errMsg, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestMDMVPPToken() {
|
||||
t := s.T()
|
||||
// Invalid token
|
||||
testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?invalidToken"
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.")
|
||||
|
||||
// Simulate a server error from the Apple API
|
||||
testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?serverError"
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "calling Apple VPP config endpoint failed with status 500")
|
||||
|
||||
// Valid token
|
||||
orgName := "Fleet Device Management Inc."
|
||||
location := "Fleet Location One"
|
||||
token := "mycooltoken"
|
||||
expDate := "2025-06-24T15:50:50+0000"
|
||||
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||||
testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "")
|
||||
|
||||
// Get the token
|
||||
var resp getMDMAppleVPPTokenResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
require.Equal(t, orgName, resp.OrgName)
|
||||
require.Equal(t, location, resp.Location)
|
||||
require.Equal(t, expDate, resp.RenewDate)
|
||||
|
||||
// Simulate renewal flow
|
||||
orgName = "Fleet Device Management Inc. New Org Name"
|
||||
token = "myothercooltoken"
|
||||
expDate = "2026-06-24T15:50:50+0000"
|
||||
tokenJSON = fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "")
|
||||
|
||||
resp = getMDMAppleVPPTokenResponse{}
|
||||
s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp)
|
||||
require.NoError(t, resp.Err)
|
||||
require.Equal(t, orgName, resp.OrgName)
|
||||
require.Equal(t, location, resp.Location)
|
||||
require.Equal(t, expDate, resp.RenewDate)
|
||||
|
||||
// Delete and check that it's not appearing anymore
|
||||
s.Do("DELETE", "/api/latest/fleet/mdm/apple/vpp_token", &deleteMDMAppleVPPTokenRequest{}, http.StatusNoContent)
|
||||
s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusNotFound, &resp)
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
|
||||
t := s.T()
|
||||
|
||||
@@ -6402,7 +6470,7 @@ func (s *integrationMDMTestSuite) TestRunMDMCommands() {
|
||||
|
||||
// create a Windows host enrolled in MDM
|
||||
enrolledWindows := createOrbitEnrolledHost(t, "windows", "h1", s.ds)
|
||||
//deviceID := "DB257C3A08778F4FB61E2749066C1F27"
|
||||
// deviceID := "DB257C3A08778F4FB61E2749066C1F27"
|
||||
mdmDevice := mdmtest.NewTestMDMClientWindowsProgramatic(s.server.URL, *enrolledWindows.OrbitNodeKey)
|
||||
err := mdmDevice.Enroll()
|
||||
require.NoError(t, err)
|
||||
@@ -8245,7 +8313,6 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() {
|
||||
|
||||
// lock the host without viewing the PIN
|
||||
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent)
|
||||
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
|
||||
@@ -8927,7 +8994,7 @@ func (s *integrationMDMTestSuite) appleCoreCertsSetup() {
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, testCert, csr.PublicKey, testKey)
|
||||
require.NoError(t, err)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
s.uploadAPNSCert(certPEM, http.StatusAccepted, "")
|
||||
s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", certPEM, http.StatusAccepted, "")
|
||||
|
||||
assets, err = s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey, fleet.MDMAssetAPNSCert})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
@@ -2444,3 +2445,254 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error {
|
||||
|
||||
return svc.ds.SaveAppConfig(ctx, appCfg)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// POST /mdm/apple/vpp_token
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type uploadMDMAppleVPPTokenRequest struct {
|
||||
File *multipart.FileHeader
|
||||
}
|
||||
|
||||
func (uploadMDMAppleVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
||||
decoded := uploadMDMAppleVPPTokenRequest{}
|
||||
|
||||
err := r.ParseMultipartForm(512 * units.MiB)
|
||||
if err != nil {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: "failed to parse multipart form",
|
||||
InternalErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 {
|
||||
return nil, &fleet.BadRequestError{
|
||||
Message: "token multipart field is required",
|
||||
InternalErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
decoded.File = r.MultipartForm.File["token"][0]
|
||||
|
||||
return &decoded, nil
|
||||
}
|
||||
|
||||
type uploadMDMAppleVPPTokenResponse struct {
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r uploadMDMAppleVPPTokenResponse) Status() int { return http.StatusAccepted }
|
||||
|
||||
func (r uploadMDMAppleVPPTokenResponse) error() error {
|
||||
return r.Err
|
||||
}
|
||||
|
||||
func uploadMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*uploadMDMAppleVPPTokenRequest)
|
||||
file, err := req.File.Open()
|
||||
if err != nil {
|
||||
return uploadMDMAppleAPNSCertResponse{Err: err}, nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := svc.UploadMDMAppleVPPToken(ctx, file); err != nil {
|
||||
return &uploadMDMAppleVPPTokenResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return &uploadMDMAppleVPPTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateKey := svc.config.Server.PrivateKey
|
||||
if testSetEmptyPrivateKey {
|
||||
privateKey = ""
|
||||
}
|
||||
|
||||
if len(privateKey) == 0 {
|
||||
return ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
|
||||
}
|
||||
|
||||
tokenBytes, err := io.ReadAll(token)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reading VPP token")
|
||||
}
|
||||
|
||||
locName, tokenValid, err := getVPPConfig(string(tokenBytes))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating VPP token with Apple")
|
||||
}
|
||||
|
||||
if !tokenValid {
|
||||
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager."))
|
||||
}
|
||||
|
||||
decodedTokenBytes, err := base64.StdEncoding.DecodeString(string(tokenBytes))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "decoding VPP token")
|
||||
}
|
||||
|
||||
data := fleet.VPPTokenData{
|
||||
Token: string(decodedTokenBytes),
|
||||
Location: locName,
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "creating VPP data object for storage")
|
||||
}
|
||||
|
||||
err = svc.ds.ReplaceMDMConfigAssets(ctx, []fleet.MDMConfigAsset{
|
||||
{Name: fleet.MDMAssetVPPToken, Value: dataBytes},
|
||||
})
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "writing VPP token to db")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var testOverrideAppleVPPConfigURL string
|
||||
|
||||
// getVPPConfig fetches the VPP config from Apple's VPP API. This doubles as a verification that the
|
||||
// user-provided VPP token is valid.
|
||||
func getVPPConfig(token string) (string, bool, error) {
|
||||
url := "https://vpp.itunes.apple.com/mdm/v2/client/config"
|
||||
if testOverrideAppleVPPConfigURL != "" {
|
||||
url = testOverrideAppleVPPConfigURL
|
||||
}
|
||||
|
||||
bearer := fmt.Sprintf("Bearer %s", token)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("creating request to Apple VPP endpoint: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", bearer)
|
||||
|
||||
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("making request to Apple VPP endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("reading response body from Apple VPP endpoint: %w", err)
|
||||
}
|
||||
|
||||
// For some reason, Apple returns 200 OK even if you pass an invalid token in the Auth header.
|
||||
// We will need to parse the response and check to see if it contains an error.
|
||||
|
||||
var respJSON struct {
|
||||
LocationName string `json:"locationName"`
|
||||
ErrorNumber int `json:"errorNumber"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &respJSON); err != nil {
|
||||
return "", false, fmt.Errorf("parsing response body from Apple VPP endpoint: %w", err)
|
||||
}
|
||||
|
||||
// Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes
|
||||
if resp.StatusCode == 401 || respJSON.ErrorNumber == 9622 {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", false, fmt.Errorf("calling Apple VPP config endpoint failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return respJSON.LocationName, true, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// GET /vpp
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type getMDMAppleVPPTokenRequest struct{}
|
||||
|
||||
type getMDMAppleVPPTokenResponse struct {
|
||||
*fleet.VPPTokenInfo
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r getMDMAppleVPPTokenResponse) error() error {
|
||||
return r.Err
|
||||
}
|
||||
|
||||
func getMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
vpp, err := svc.GetMDMAppleVPPToken(ctx)
|
||||
if err != nil {
|
||||
return &getMDMAppleVPPTokenResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return &getMDMAppleVPPTokenResponse{VPPTokenInfo: vpp}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) GetMDMAppleVPPToken(ctx context.Context) (*fleet.VPPTokenInfo, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetMap, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken})
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name VPP token")
|
||||
}
|
||||
|
||||
var tokenData fleet.VPPTokenData
|
||||
if err := json.Unmarshal(assetMap[fleet.MDMAssetVPPToken].Value, &tokenData); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token data")
|
||||
}
|
||||
|
||||
var rawToken fleet.VPPTokenRaw
|
||||
if err := json.Unmarshal([]byte(tokenData.Token), &rawToken); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token")
|
||||
}
|
||||
|
||||
info := fleet.VPPTokenInfo{
|
||||
Location: tokenData.Location,
|
||||
RenewDate: rawToken.ExpDate,
|
||||
OrgName: rawToken.OrgName,
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// DELETE /mdm/apple/vpp_token
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type deleteMDMAppleVPPTokenRequest struct{}
|
||||
|
||||
type deleteMDMAppleVPPTokenResponse struct {
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r deleteMDMAppleVPPTokenResponse) error() error { return r.Err }
|
||||
|
||||
func (r deleteMDMAppleVPPTokenResponse) Status() int { return http.StatusNoContent }
|
||||
|
||||
func deleteMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
if err := svc.DeleteMDMAppleVPPToken(ctx); err != nil {
|
||||
return &deleteMDMAppleVPPTokenResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
return &deleteMDMAppleVPPTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) DeleteMDMAppleVPPToken(ctx context.Context) error {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken})
|
||||
}
|
||||
|
||||
@@ -153,6 +153,15 @@ func TestMDMAppleAuthorization(t *testing.T) {
|
||||
|
||||
err = svc.DeleteMDMAppleAPNSCert(ctx) // Don't expect anything other than an authz error here, since this is pretty much just a DB wrapper.
|
||||
checkAuthErr(t, shouldFailWithAuth, err)
|
||||
|
||||
err = svc.UploadMDMAppleVPPToken(ctx, nil)
|
||||
checkAuthErr(t, shouldFailWithAuth, err)
|
||||
|
||||
_, err = svc.GetMDMAppleVPPToken(ctx)
|
||||
checkAuthErr(t, shouldFailWithAuth, err)
|
||||
|
||||
err = svc.DeleteMDMAppleVPPToken(ctx)
|
||||
checkAuthErr(t, shouldFailWithAuth, err)
|
||||
}
|
||||
|
||||
// Only global admins can access the endpoints.
|
||||
|
||||
Reference in New Issue
Block a user