Add ability to upload EULA via gitops (#30332)

relates to [#28691](https://github.com/fleetdm/fleet/issues/28691)

This adds the ability to upload the EULA users see during the setup
experience via gitops. It follows patterns used for uploading the
bootstrap package via gitops.

I've also added a sha256 column to the `eulas` table in order to easily
compare the existing eula with a new one to see if we need to perform an
upload.

Finally I added the support to generate this new gitops setting with the
`generate-gitops` command


- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For new Fleet configuration settings
- [x] Verified that the setting can be managed via GitOps, or confirmed
that the setting is explicitly being excluded from GitOps. If managing
via Gitops:
- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Added the setting to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled
- For database migrations:
- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez
2025-07-01 17:28:13 +01:00
committed by GitHub
parent f008d72107
commit e470a1ea22
23 changed files with 653 additions and 33 deletions
+1
View File
@@ -0,0 +1 @@
- add ability to add EULA end user sees during setup experience via gitops
+45 -1
View File
@@ -63,6 +63,8 @@ type generateGitopsClient interface {
ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error)
GetScriptContents(scriptID uint) ([]byte, error)
GetProfileContents(profileID string) ([]byte, error)
GetEULAMetadata() (*fleet.MDMEULA, error)
GetEULAContent(token string) ([]byte, error)
GetTeam(teamID uint) (*fleet.Team, error)
ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error)
GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error)
@@ -750,8 +752,43 @@ func (cmd *GenerateGitopsCommand) generateIntegrations(filePath string, integrat
return result, nil
}
func (cmd *GenerateGitopsCommand) generateEULA() (string, error) {
// Download the eula metadata for the token.
eulaMetadata, err := cmd.Client.GetEULAMetadata()
if err != nil {
// not found is OK, it means the user has not uploaded a EULA yet.
if strings.Contains(err.Error(), "Resource Not Found") {
return "", nil
}
fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting eula metadata: %s\n", err)
return "", err
}
// now we want the eula contents, which is a PDF.
eulaContent, err := cmd.Client.GetEULAContent(eulaMetadata.Token)
if err != nil {
fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting eula contents: %s\n", err)
return "", err
}
fileName := fmt.Sprintf("lib/eula/%s", eulaMetadata.Name)
cmd.FilesToWrite[fileName] = string(eulaContent)
path := fmt.Sprintf("./%s", fileName)
return path, nil
}
// This struct is used to represent the MDM configuration that is used with GitOps.
// It includes an additonal end user license agreement (EULA) field, which is
// not present in the fleet.MDM struct.
type gitopsMDM struct {
fleet.MDM
EndUserLicenseAgreement string `json:"end_user_license_agreement,omitempty"`
}
func (cmd *GenerateGitopsCommand) generateMDM(mdm *fleet.MDM) (map[string]interface{}, error) {
t := reflect.TypeOf(fleet.MDM{})
t := reflect.TypeOf(gitopsMDM{})
result := map[string]interface{}{
jsonFieldName(t, "AppleServerURL"): mdm.AppleServerURL,
jsonFieldName(t, "EndUserAuthentication"): mdm.EndUserAuthentication,
@@ -759,6 +796,13 @@ func (cmd *GenerateGitopsCommand) generateMDM(mdm *fleet.MDM) (map[string]interf
if cmd.AppConfig.License.IsPremium() {
result[jsonFieldName(t, "AppleBusinessManager")] = mdm.AppleBusinessManager
result[jsonFieldName(t, "VolumePurchasingProgram")] = mdm.VolumePurchasingProgram
eulaPath, err := cmd.generateEULA()
if err != nil {
fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating EULA: %s\n", err)
return nil, err
}
result[jsonFieldName(t, "EndUserLicenseAgreement")] = eulaPath
}
if !cmd.CLI.Bool("insecure") {
@@ -378,6 +378,17 @@ func (MockClient) Me() (*fleet.User, error) {
}, nil
}
func (MockClient) GetEULAMetadata() (*fleet.MDMEULA, error) {
return &fleet.MDMEULA{
Name: "test.pdf",
Token: "test-eula-token",
}, nil
}
func (MockClient) GetEULAContent(token string) ([]byte, error) {
return []byte("This is the EULA content."), nil
}
func compareDirs(t *testing.T, sourceDir, targetDir string) {
err := filepath.WalkDir(sourceDir, func(srcPath string, d os.DirEntry, walkErr error) error {
if d.IsDir() {
+208
View File
@@ -2,6 +2,7 @@ package fleetctl
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
@@ -23,6 +24,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/mock"
digicert_mock "github.com/fleetdm/fleet/v4/server/mock/digicert"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
scep_mock "github.com/fleetdm/fleet/v4/server/mock/scep"
@@ -3084,3 +3086,209 @@ func TestGitOpsNoTeamConditionalAccess(t *testing.T) {
require.True(t, appConfig.Integrations.ConditionalAccessEnabled.Set)
require.False(t, appConfig.Integrations.ConditionalAccessEnabled.Value)
}
func TestGitOpsEULASetting(t *testing.T) {
createGlobalGitOpsConfig := func(mdm string) string {
return fmt.Sprintf(`
controls:
queries:
policies:
agent_options:
software:
org_settings:
server_settings:
server_url: "https://foo.example.com"
org_info:
org_name: GitOps Test
secrets:
- secret: "global"
mdm:
%s
`, mdm)
}
// Create a temporary PDF file
pdfContent := []byte("%PDF-1\npdf-test")
tmpPDF, err := os.CreateTemp(t.TempDir(), "*.pdf")
require.NoError(t, err)
// Write a minimal valid PDF header so the file is recognized as a PDF.
_, err = tmpPDF.Write(pdfContent)
require.NoError(t, err)
pdfPath, err := filepath.Abs(tmpPDF.Name())
require.NoError(t, err)
// Create an invalid temp PDF file
tmpInvalidPDF, err := os.CreateTemp(t.TempDir(), "*.txt")
require.NoError(t, err)
_, err = tmpPDF.Write([]byte("not-a-pdf"))
require.NoError(t, err)
invalidPDFPath, err := filepath.Abs(tmpInvalidPDF.Name())
require.NoError(t, err)
cases := []struct {
name string
cfg string
mockSetup func(t *testing.T, ds *mock.Store)
dryRunAssertion func(t *testing.T, ds *mock.Store, out string, err error)
realRunAssertion func(t *testing.T, ds *mock.Store, out string, err error)
}{
{
name: "valid pdf file (no existing EULA uploaded)",
cfg: createGlobalGitOpsConfig(fmt.Sprintf(`end_user_license_agreement: "%s"`, pdfPath)),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return nil, &notFoundError{} // No existing EULA
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] would've applied EULA")
assert.False(t, ds.MDMInsertEULAFuncInvoked)
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] applied EULA")
assert.True(t, ds.MDMInsertEULAFuncInvoked)
},
},
{
name: "valid new pdf file (different EULA already uploaded)",
cfg: createGlobalGitOpsConfig(fmt.Sprintf(`end_user_license_agreement: "%s"`, pdfPath)),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return &fleet.MDMEULA{
Name: pdfPath,
Token: "test-token",
}, nil
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] would've applied EULA")
assert.False(t, ds.MDMDeleteEULAFuncInvoked)
assert.False(t, ds.MDMInsertEULAFuncInvoked)
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] applied EULA")
assert.True(t, ds.MDMDeleteEULAFuncInvoked) // deleted old EULA
assert.True(t, ds.MDMInsertEULAFuncInvoked) // new EULA was updated
},
},
{
name: "no EULA specified (no existing EULA uploaded)",
cfg: createGlobalGitOpsConfig(""),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return nil, &notFoundError{} // No existing EULA
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] would've applied EULA")
assert.False(t, ds.MDMDeleteEULAFuncInvoked)
assert.False(t, ds.MDMInsertEULAFuncInvoked)
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] applied EULA")
assert.False(t, ds.MDMDeleteEULAFuncInvoked) // no EULA to delete
assert.False(t, ds.MDMInsertEULAFuncInvoked) // no EULA to upload
},
},
{
name: "deleting existing EULA",
cfg: createGlobalGitOpsConfig(""),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return &fleet.MDMEULA{
Name: pdfPath,
Token: "test-token",
}, nil
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] would've applied EULA")
assert.False(t, ds.MDMDeleteEULAFuncInvoked)
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] applied EULA")
assert.True(t, ds.MDMDeleteEULAFuncInvoked) // deleted EULA
},
},
{
name: "not a PDF file",
cfg: createGlobalGitOpsConfig(fmt.Sprintf(`end_user_license_agreement: "%s"`, invalidPDFPath)),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return nil, &notFoundError{} // No existing EULA
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.ErrorContains(t, err, "invalid file type")
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.ErrorContains(t, err, "invalid file type")
},
},
{
name: "uploading the same EULA again",
cfg: createGlobalGitOpsConfig(""),
mockSetup: func(t *testing.T, ds *mock.Store) {
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
hash := sha256.Sum256(pdfContent) // Simulate same EULA
return &fleet.MDMEULA{
Name: pdfPath,
Token: "test-token",
Sha256: hash[:], // Simulate same EULA
}, nil
}
},
dryRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] would've applied EULA")
assert.False(t, ds.MDMInsertEULAFuncInvoked)
},
realRunAssertion: func(t *testing.T, ds *mock.Store, out string, err error) {
assert.NoError(t, err)
assert.Contains(t, out, "[+] applied EULA")
assert.False(t, ds.MDMInsertEULAFuncInvoked) // No new EULA uploaded
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t)
// these mocks are used for all tests
ds.MDMInsertEULAFunc = func(ctx context.Context, eula *fleet.MDMEULA) error {
return nil
}
ds.MDMDeleteEULAFunc = func(ctx context.Context, token string) error {
return nil
}
// these mocks are defined in the individual test cases
tt.mockSetup(t, ds)
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString(tt.cfg)
require.NoError(t, err)
// Dry run
out, err := RunAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
tt.dryRunAssertion(t, ds, out.String(), err)
if t.Failed() {
t.FailNow()
}
// Real run
out, err = RunAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
tt.realRunAssertion(t, ds, out.String(), err)
})
}
}
@@ -1,12 +1,12 @@
features:
enable_host_users: true
enable_software_inventory: true
additional_queries:
additional_queries:
time: "SELECT * FROM time"
macs: "SELECT mac FROM interface_details"
detail_query_overrides:
users:
mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;"
mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;"
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
@@ -64,6 +64,7 @@ mdm:
issuer_uri: https://some-mdm-issuer-uri.com
metadata: some-mdm-metadata
metadata_url: http://some-mdm-metadata-url.com
end_user_license_agreement: ./lib/eula/test.pdf
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
@@ -1,7 +1,7 @@
features:
enable_host_users: true
enable_software_inventory: true
additional_queries:
additional_queries:
time: "SELECT * FROM time"
macs: "SELECT mac FROM interface_details"
detail_query_overrides:
@@ -65,6 +65,7 @@ mdm:
issuer_uri: https://some-mdm-issuer-uri.com
metadata: ___GITOPS_COMMENT_6___
metadata_url: ___GITOPS_COMMENT_7___
end_user_license_agreement: ./lib/eula/test.pdf
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
@@ -99,6 +99,7 @@ org_settings:
issuer_uri: https://some-mdm-issuer-uri.com
metadata: # TODO: Add your MDM end user auth metadata here
metadata_url: # TODO: Add your MDM end user auth metadata URL here
end_user_license_agreement: ./lib/eula/test.pdf
volume_purchasing_program:
- location: Fleet Device Management Inc.
teams:
@@ -411,6 +411,9 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) {
return document, nil, nil
}
ds.MDMGetEULAMetadataFunc = func(ctx context.Context) (*fleet.MDMEULA, error) {
return nil, &notFoundError{} // No existing EULA
}
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
+17 -5
View File
@@ -462,7 +462,7 @@ func (svc *Service) GetMDMAppleBootstrapPackageSummary(ctx context.Context, team
return summary, nil
}
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, f io.ReadSeeker) error {
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, f io.ReadSeeker, dryRun bool) error {
if err := svc.authz.Authorize(ctx, &fleet.MDMEULA{}, fleet.ActionWrite); err != nil {
return err
}
@@ -489,10 +489,18 @@ func (svc *Service) MDMCreateEULA(ctx context.Context, name string, f io.ReadSee
return ctxerr.Wrap(ctx, err, "reading EULA bytes")
}
if dryRun {
return nil
}
hash := sha256.New()
_, _ = hash.Write(bytes)
eula := &fleet.MDMEULA{
Name: name,
Token: uuid.New().String(),
Bytes: bytes,
Name: name,
Token: uuid.New().String(),
Sha256: hash.Sum(nil),
Bytes: bytes,
}
if err := svc.ds.MDMInsertEULA(ctx, eula); err != nil {
@@ -510,11 +518,15 @@ func (svc *Service) MDMGetEULABytes(ctx context.Context, token string) (*fleet.M
return svc.ds.MDMGetEULABytes(ctx, token)
}
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string) error {
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string, dryRun bool) error {
if err := svc.authz.Authorize(ctx, &fleet.MDMEULA{}, fleet.ActionWrite); err != nil {
return err
}
if dryRun {
return nil
}
if err := svc.ds.MDMDeleteEULA(ctx, token); err != nil {
return ctxerr.Wrap(ctx, err, "deleting EULA")
}
+14
View File
@@ -906,6 +906,20 @@ allow {
action == write
}
# Global admins can read, write, and list MDM apple eula information.
allow {
object.type == "mdm_apple_eula"
subject.global_role == admin
action == [read, write, list][_]
}
# Global gitops can read and write the EULA.
allow {
object.type == "mdm_apple_eula"
subject.global_role == gitops
action == [read, write][_]
}
##
# MDM Apple Setup Assistant
##
+40
View File
@@ -2398,3 +2398,43 @@ func TestHostHealth(t *testing.T) {
{user: test.UserTeamMaintainerTeam2, object: hostHealth, action: read, allow: false},
})
}
func TestMDMAppleEULA(t *testing.T) {
t.Parallel()
eula := &fleet.MDMEULA{}
runTestCases(t, []authTestCase{
{user: nil, object: eula, action: read, allow: false},
{user: test.UserGitOps, object: eula, action: read, allow: true},
{user: test.UserGitOps, object: eula, action: write, allow: true},
{user: test.UserTeamGitOpsTeam1, object: eula, action: read, allow: false},
{user: test.UserTeamGitOpsTeam1, object: eula, action: write, allow: false},
{user: test.UserTeamGitOpsTeam2, object: eula, action: read, allow: false},
{user: test.UserTeamGitOpsTeam2, object: eula, action: write, allow: false},
{user: test.UserAdmin, object: eula, action: read, allow: true},
{user: test.UserAdmin, object: eula, action: write, allow: true},
{user: test.UserTeamAdminTeam1, object: eula, action: read, allow: false},
{user: test.UserTeamAdminTeam1, object: eula, action: write, allow: false},
{user: test.UserTeamAdminTeam2, object: eula, action: read, allow: false},
{user: test.UserTeamAdminTeam2, object: eula, action: write, allow: false},
{user: test.UserObserver, object: eula, action: read, allow: false},
{user: test.UserObserver, object: eula, action: write, allow: false},
{user: test.UserTeamObserverTeam1, object: eula, action: read, allow: false},
{user: test.UserTeamObserverTeam1, object: eula, action: write, allow: false},
{user: test.UserTeamObserverTeam2, object: eula, action: read, allow: false},
{user: test.UserTeamObserverTeam2, object: eula, action: write, allow: false},
{user: test.UserMaintainer, object: eula, action: read, allow: false},
{user: test.UserMaintainer, object: eula, action: write, allow: false},
{user: test.UserTeamMaintainerTeam1, object: eula, action: read, allow: false},
{user: test.UserTeamMaintainerTeam1, object: eula, action: write, allow: false},
{user: test.UserTeamMaintainerTeam2, object: eula, action: read, allow: false},
{user: test.UserTeamMaintainerTeam2, object: eula, action: write, allow: false},
})
}
+4 -4
View File
@@ -1274,7 +1274,7 @@ func batchSetProfileLabelAssociationsDB(
func (ds *Datastore) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) {
// Currently, there can only be one EULA in the database, and we're
// hardcoding it's id to be 1 in order to enforce this restriction.
stmt := "SELECT name, created_at, token FROM eulas WHERE id = 1"
stmt := "SELECT name, created_at, token, sha256 FROM eulas WHERE id = 1"
var eula fleet.MDMEULA
if err := sqlx.GetContext(ctx, ds.reader(ctx), &eula, stmt); err != nil {
if err == sql.ErrNoRows {
@@ -1301,11 +1301,11 @@ func (ds *Datastore) MDMInsertEULA(ctx context.Context, eula *fleet.MDMEULA) err
// We're intentionally hardcoding the id to be 1 because we only want to
// allow one EULA.
stmt := `
INSERT INTO eulas (id, name, bytes, token)
VALUES (1, ?, ?, ?)
INSERT INTO eulas (id, name, bytes, token, sha256)
VALUES (1, ?, ?, ?, ?)
`
_, err := ds.writer(ctx).ExecContext(ctx, stmt, eula.Name, eula.Bytes, eula.Token)
_, err := ds.writer(ctx).ExecContext(ctx, stmt, eula.Name, eula.Bytes, eula.Token, eula.Sha256)
if err != nil {
if IsDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMEULA", eula.Token))
+4 -3
View File
@@ -6756,9 +6756,10 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) {
func testMDMEULA(t *testing.T, ds *Datastore) {
ctx := context.Background()
eula := &fleet.MDMEULA{
Token: uuid.New().String(),
Name: "eula.pdf",
Bytes: []byte("contents"),
Token: uuid.New().String(),
Name: "eula.pdf",
Bytes: []byte("contents"),
Sha256: []byte("test-sha256"),
}
err := ds.MDMInsertEULA(ctx, eula)
@@ -0,0 +1,50 @@
package tables
import (
"crypto/sha256"
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
)
func init() {
MigrationClient.AddMigration(Up_20250701155654, Down_20250701155654)
}
func Up_20250701155654(tx *sql.Tx) error {
_, err := tx.Exec("ALTER TABLE eulas ADD COLUMN sha256 binary(32)")
if err != nil {
return fmt.Errorf("adding sha256 to eulas table: %w", err)
}
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
type eula struct {
ID int `db:"id"`
Bytes []byte `db:"bytes"`
}
var eulas []eula
err = txx.Select(&eulas, "SELECT id, bytes FROM eulas")
if err != nil {
return fmt.Errorf("selecting existing eulas: %w", err)
}
for _, e := range eulas {
hash := sha256.New()
_, _ = hash.Write(e.Bytes)
sha256Hash := hash.Sum(nil)
_, err = txx.Exec("UPDATE eulas SET sha256 = ? WHERE id = ?", sha256Hash, e.ID)
if err != nil {
return fmt.Errorf("updating eula %d with sha256: %w", e.ID, err)
}
}
return nil
}
func Down_20250701155654(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,33 @@
package tables
import (
"bytes"
"crypto/sha256"
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20250701155654(t *testing.T) {
db := applyUpToPrev(t)
eulaBytes := []byte("test eula content")
hash := sha256.New()
_, _ = hash.Write(eulaBytes)
sha256 := hash.Sum(nil)
execNoErr(t, db,
`INSERT INTO eulas (id, bytes, token, name) VALUES (?, ?, ?, ?)`,
1, eulaBytes, "test-token", "test-name",
)
// Apply current migration.
applyNext(t, db)
var got []byte
err := db.Get(&got, `SELECT sha256 FROM eulas WHERE id = ?`, 1)
require.NoError(t, err)
require.True(t, bytes.Equal(got, sha256))
}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -169,12 +169,13 @@ func (bp *MDMAppleBootstrapPackage) URL(host string) (string, error) {
type MDMEULA struct {
Name string `json:"name"`
Bytes []byte `json:"bytes"`
Sha256 []byte `json:"sha256" db:"sha256"`
Token string `json:"token"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
func (e MDMEULA) AuthzType() string {
return "mdm_apple"
return "mdm_apple_eula"
}
// ExpectedMDMProfile represents an MDM profile that is expected to be installed on a host.
+2 -2
View File
@@ -985,9 +985,9 @@ type Service interface {
// be used by clients to display information.
MDMGetEULAMetadata(ctx context.Context) (*MDMEULA, error)
// MDMCreateEULA adds a new EULA file.
MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker) error
MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker, dryRun bool) error
// MDMAppleDelete EULA removes an EULA entry.
MDMDeleteEULA(ctx context.Context, token string) error
MDMDeleteEULA(ctx context.Context, token string, dryRun bool) error
// Create or update the MDM Apple Setup Assistant for a team or no team.
SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst *MDMAppleSetupAssistant) (*MDMAppleSetupAssistant, error)
+13 -4
View File
@@ -292,19 +292,27 @@ func TestAppleMDMAuthorization(t *testing.T) {
checkAuthErr(t, err, shouldFailWithAuth)
_, err = svc.ListMDMAppleDevices(ctx)
checkAuthErr(t, err, shouldFailWithAuth)
}
// check EULA routes
_, err = svc.MDMGetEULAMetadata(ctx)
// some eula methods read and write access for gitops users. We test them separately
// from the other MDM methods.
testEULAMethods := func(t *testing.T, user *fleet.User, shouldFailWithAuth bool) {
ctx := test.UserContext(ctx, user)
_, err := svc.MDMGetEULAMetadata(ctx)
checkAuthErr(t, err, shouldFailWithAuth)
err = svc.MDMCreateEULA(ctx, "eula.pdf", bytes.NewReader([]byte("%PDF-")))
err = svc.MDMCreateEULA(ctx, "eula.pdf", bytes.NewReader([]byte("%PDF-")), false)
checkAuthErr(t, err, shouldFailWithAuth)
err = svc.MDMDeleteEULA(ctx, "foo")
err = svc.MDMDeleteEULA(ctx, "foo", false)
checkAuthErr(t, err, shouldFailWithAuth)
}
// Only global admins can access the endpoints.
testAuthdMethods(t, test.UserAdmin, false)
// Global admin and gitops users can access the eula endpoints.
testEULAMethods(t, test.UserAdmin, false)
testEULAMethods(t, test.UserGitOps, false)
// All other users should not have access to the endpoints.
for _, user := range []*fleet.User{
test.UserNoRoles,
@@ -314,6 +322,7 @@ func TestAppleMDMAuthorization(t *testing.T) {
test.UserTeamAdminTeam1,
} {
testAuthdMethods(t, user, true)
testEULAMethods(t, user, true)
}
// Token authenticated endpoints can be accessed by anyone.
ctx = test.UserContext(ctx, test.UserNoRoles)
+40
View File
@@ -1623,6 +1623,7 @@ func (c *Client) DoGitOps(
group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)}
group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions
delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup
var eulaPath string
// Labels
if config.Labels == nil || len(config.Labels) > 0 {
@@ -1796,7 +1797,24 @@ func (c *Client) DoGitOps(
WindowsEnabledAndConfigured: optjson.SetBool(windowsEnabledAndConfiguredAssumption),
}
}
// check for the eula in the mdmAppConfig. If it exists we want to delete it
// from the app config so it will not be applied to the group/team though the
// ApplyGroup method. It will be applied separately.
if endUserLicenseAgreement, ok := mdmAppConfig["end_user_license_agreement"].(string); ok && len(endUserLicenseAgreement) > 0 {
eulaPath = endUserLicenseAgreement
delete(mdmAppConfig, "end_user_license_agreement")
}
group.AppConfig.(map[string]interface{})["scripts"] = scripts
// we want to apply the EULA only for the global settings
if appConfig.License.IsPremium() && appConfig.MDM.EnabledAndConfigured {
err = c.doGitOpsEULA(eulaPath, logFn, dryRun)
if err != nil {
return nil, nil, err
}
}
} else if !config.IsNoTeam() {
team = make(map[string]interface{})
team["name"] = *config.TeamName
@@ -2437,6 +2455,28 @@ func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string,
return nil
}
func (c *Client) doGitOpsEULA(eulaPath string, logFn func(format string, args ...interface{}), dryRun bool) error {
if eulaPath == "" {
err := c.DeleteEULAIfNeeded(dryRun)
if err != nil {
return fmt.Errorf("error deleting EULA: %w", err)
}
} else {
err := c.UploadEULAIfNeeded(eulaPath, dryRun)
if err != nil {
return fmt.Errorf("error uploading EULA: %w", err)
}
}
if dryRun {
logFn("[+] would've applied EULA\n")
} else {
logFn("[+] applied EULA\n")
}
return nil
}
func (c *Client) GetGitOpsSecrets(
config *spec.GitOps,
) []string {
+139
View File
@@ -410,3 +410,142 @@ func (c *Client) MDMWipeHost(hostID uint) error {
}
return nil
}
type eulaContent struct {
Bytes []byte
}
// eulaContent implements the bodyHandler interface so that we can read the
// response body directly into a byte slice which represents the EULA file content.
// This handler will be called in the Client.parseResponse method.
func (ec *eulaContent) Handle(res *http.Response) error {
b, err := io.ReadAll(res.Body)
ec.Bytes = b
return err
}
func (c *Client) GetEULAContent(token string) ([]byte, error) {
verb, path := "GET", fmt.Sprintf("/api/latest/fleet/setup_experience/eula/%s", token)
request := getMDMEULARequest{}
var responseBody eulaContent
err := c.authenticatedRequest(request, verb, path, &responseBody)
return responseBody.Bytes, err
}
func (c *Client) GetEULAMetadata() (*fleet.MDMEULA, error) {
verb, path := "GET", "/api/latest/fleet/setup_experience/eula/metadata"
request := getMDMEULAMetadataRequest{}
var responseBody getMDMEULAMetadataResponse
err := c.authenticatedRequest(request, verb, path, &responseBody)
return responseBody.MDMEULA, err
}
func (c *Client) DeleteEULAIfNeeded(dryRun bool) error {
eula, err := c.GetEULAMetadata()
switch {
case errors.As(err, &notFoundErr{}):
// not found is OK, it means there is nothing to delete
return nil
case err != nil:
return fmt.Errorf("getting eula metadata: %w", err)
}
err = c.DeleteEULA(eula.Token, dryRun)
if err != nil {
return fmt.Errorf("deleting eula: %w", err)
}
return nil
}
func (c *Client) DeleteEULA(token string, dryRun bool) error {
verb, path := "DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/eula/%s", token)
request := deleteMDMEULARequest{}
var responseBody deleteMDMEULAResponse
err := c.authenticatedRequestWithQuery(request, verb, path, &responseBody, fmt.Sprintf("dry_run=%t", dryRun))
return err
}
func (c *Client) UploadEULAIfNeeded(eulaPath string, dryRun bool) error {
isFirstTime := false
oldMeta, err := c.GetEULAMetadata()
if err != nil {
// not found is OK, it means this is our first time uploading a eula
if !errors.As(err, &notFoundErr{}) {
return fmt.Errorf("getting eula metadata: %w", err)
}
isFirstTime = true
}
// read file to get the new file bytes
eulaBytes, err := os.ReadFile(eulaPath)
if err != nil {
return fmt.Errorf("reading eula file: %w", err)
}
if !isFirstTime {
newChecksum := sha256.Sum256(eulaBytes)
// compare checksums, if they're equal then we can skip the eula upload
if bytes.Equal(oldMeta.Sha256, newChecksum[:]) && oldMeta.Name == filepath.Base(eulaPath) {
return nil
}
// similar to the expected UI experience, delete the old eula first
err = c.DeleteEULA(oldMeta.Token, dryRun)
if err != nil {
return fmt.Errorf("deleting old eula: %w", err)
}
}
if err := c.UploadEULA(eulaPath, dryRun); err != nil {
return err
}
return nil
}
func (c *Client) UploadEULA(eulaPath string, dryRun bool) error {
verb, path := "POST", "/api/latest/fleet/setup_experience/eula"
var b bytes.Buffer
w := multipart.NewWriter(&b)
// add the eula field
fw, err := w.CreateFormFile("eula", filepath.Base(eulaPath))
if err != nil {
return err
}
file, err := os.Open(eulaPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(fw, file); err != nil {
return err
}
err = w.Close()
if err != nil {
return fmt.Errorf("closing writer: %w", err)
}
resp, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path, fmt.Sprintf("dry_run=%t", dryRun),
b.Bytes(),
map[string]string{
"Content-Type": w.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
})
if err != nil {
return fmt.Errorf("do multipart request: %w", err)
}
defer resp.Body.Close()
var eulaResponse createMDMEULAResponse
if err := c.parseResponse(verb, path, resp, &eulaResponse); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return nil
}
+3
View File
@@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
@@ -3657,6 +3658,7 @@ func (s *integrationMDMTestSuite) TestEULA() {
t := s.T()
pdfBytes := []byte("%PDF-1.pdf-contents")
pdfName := "eula.pdf"
pdfHash := sha256.Sum256(pdfBytes)
// trying to get metadata about an EULA that hasn't been uploaded yet is an error
metadataResp := getMDMEULAMetadataResponse{}
@@ -3676,6 +3678,7 @@ func (s *integrationMDMTestSuite) TestEULA() {
require.NotEmpty(t, metadataResp.MDMEULA.Token)
require.NotEmpty(t, metadataResp.MDMEULA.CreatedAt)
require.Equal(t, pdfName, metadataResp.MDMEULA.Name)
require.Equal(t, pdfHash[:], metadataResp.MDMEULA.Sha256)
eulaToken := metadataResp.Token
// download EULA
+14 -7
View File
@@ -241,7 +241,8 @@ func (svc *Service) VerifyMDMAppleConfigured(ctx context.Context) error {
////////////////////////////////////////////////////////////////////////////////
type createMDMEULARequest struct {
EULA *multipart.FileHeader
EULA *multipart.FileHeader
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not save changes
}
// TODO: We parse the whole body before running svc.authz.Authorize.
@@ -262,8 +263,13 @@ func (createMDMEULARequest) DecodeRequest(ctx context.Context, r *http.Request)
}
}
dryRun := false
if v := r.URL.Query().Get("dry_run"); v != "" {
dryRun, _ = strconv.ParseBool(v)
}
return &createMDMEULARequest{
EULA: r.MultipartForm.File["eula"][0],
EULA: r.MultipartForm.File["eula"][0],
DryRun: dryRun,
}, nil
}
@@ -281,14 +287,14 @@ func createMDMEULAEndpoint(ctx context.Context, request interface{}, svc fleet.S
}
defer ff.Close()
if err := svc.MDMCreateEULA(ctx, req.EULA.Filename, ff); err != nil {
if err := svc.MDMCreateEULA(ctx, req.EULA.Filename, ff, req.DryRun); err != nil {
return createMDMEULAResponse{Err: err}, nil
}
return createMDMEULAResponse{}, nil
}
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker) error {
func (svc *Service) MDMCreateEULA(ctx context.Context, name string, file io.ReadSeeker, dryRun bool) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
@@ -381,7 +387,8 @@ func (svc *Service) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, err
////////////////////////////////////////////////////////////////////////////////
type deleteMDMEULARequest struct {
Token string `url:"token"`
Token string `url:"token"`
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not delete
}
type deleteMDMEULAResponse struct {
@@ -392,13 +399,13 @@ func (r deleteMDMEULAResponse) Error() error { return r.Err }
func deleteMDMEULAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteMDMEULARequest)
if err := svc.MDMDeleteEULA(ctx, req.Token); err != nil {
if err := svc.MDMDeleteEULA(ctx, req.Token, req.DryRun); err != nil {
return deleteMDMEULAResponse{Err: err}, nil
}
return deleteMDMEULAResponse{}, nil
}
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string) error {
func (svc *Service) MDMDeleteEULA(ctx context.Context, token string, dryRun bool) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)