From e470a1ea22bd8bcc9e624d331f70868fdd141696 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 1 Jul 2025 17:28:13 +0100 Subject: [PATCH] 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 --- changes/issue-28691-add-eula-via-gitops | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 46 +++- cmd/fleetctl/fleetctl/generate_gitops_test.go | 11 + cmd/fleetctl/fleetctl/gitops_test.go | 208 ++++++++++++++++++ .../expectedOrgSettings-insecure.yaml | 5 +- .../generateGitops/expectedOrgSettings.yaml | 3 +- .../test_dir_premium/default.yml | 1 + .../fleetctl/testing_utils/testing_utils.go | 3 + ee/server/service/mdm.go | 22 +- server/authz/policy.rego | 14 ++ server/authz/policy_test.go | 40 ++++ server/datastore/mysql/mdm.go | 8 +- server/datastore/mysql/mdm_test.go | 7 +- .../20250701155654_AddEULAHashColumn.go | 50 +++++ .../20250701155654_AddEULAHashColumn_test.go | 33 +++ server/datastore/mysql/schema.sql | 7 +- server/fleet/mdm.go | 3 +- server/fleet/service.go | 4 +- server/service/apple_mdm_test.go | 17 +- server/service/client.go | 40 ++++ server/service/client_mdm.go | 139 ++++++++++++ server/service/integration_mdm_test.go | 3 + server/service/mdm.go | 21 +- 23 files changed, 653 insertions(+), 33 deletions(-) create mode 100644 changes/issue-28691-add-eula-via-gitops create mode 100644 server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn.go create mode 100644 server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn_test.go diff --git a/changes/issue-28691-add-eula-via-gitops b/changes/issue-28691-add-eula-via-gitops new file mode 100644 index 0000000000..d0aba29bf6 --- /dev/null +++ b/changes/issue-28691-add-eula-via-gitops @@ -0,0 +1 @@ +- add ability to add EULA end user sees during setup experience via gitops diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 0527268c83..fb5ad17b03 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -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") { diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 9c2da89d07..da3ecc428f 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -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() { diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 2bca6f94cf..19ff46772d 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -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, ¬FoundError{} // 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, ¬FoundError{} // 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, ¬FoundError{} // 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) + }) + } +} diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml index cdae94e7c4..77a1bb961c 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml @@ -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: diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml index b01c611951..70f5694cb0 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml @@ -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: diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml index 47d4aa43b6..8f34f609e2 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -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: diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index 784d804e91..94ecf93463 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -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, ¬FoundError{} // No existing EULA + } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 9a2a87dcd1..cc30a07947 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -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") } diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 48cf70b1c9..365d2b858f 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -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 ## diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go index 26782382fb..3aa7f43e98 100644 --- a/server/authz/policy_test.go +++ b/server/authz/policy_test.go @@ -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}, + }) +} diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 1d8134d326..38333d5f33 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -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)) diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 44f35d0d4a..9dce2eb705 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -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) diff --git a/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn.go b/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn.go new file mode 100644 index 0000000000..0507c08626 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn.go @@ -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 +} diff --git a/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn_test.go b/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn_test.go new file mode 100644 index 0000000000..e0cfbcb26c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250701155654_AddEULAHashColumn_test.go @@ -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)) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 7ef3bf3691..9b2a27045b 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -281,8 +281,9 @@ CREATE TABLE `eulas` ( `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `bytes` longblob, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `sha256` binary(32) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -1380,9 +1381,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=399 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=400 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 0c8a10448c..bba0e5e02a 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -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. diff --git a/server/fleet/service.go b/server/fleet/service.go index 956ebfe747..a3b7a601c1 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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) diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index d24504f4e6..3f9b6ba00a 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -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) diff --git a/server/service/client.go b/server/service/client.go index 218ac804fd..93dd8233df 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -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 { diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index 46da69a38d..6822d76692 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -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, ¬FoundErr{}): + // 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, ¬FoundErr{}) { + 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 +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 462b817026..0f49fc54e6 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -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 diff --git a/server/service/mdm.go b/server/service/mdm.go index 1824957389..e46fbd6616 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -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)