diff --git a/changes/27042-microsoft-compliance-partner-backend b/changes/27042-microsoft-compliance-partner-backend new file mode 100644 index 0000000000..1f55309374 --- /dev/null +++ b/changes/27042-microsoft-compliance-partner-backend @@ -0,0 +1 @@ +* Added support for Microsoft "Conditional acccess" as a compliance partner. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 69cdd6e707..8f23fdaa7a 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -55,6 +55,7 @@ import ( "github.com/fleetdm/fleet/v4/server/pubsub" "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/service/async" + "github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy" "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" "github.com/fleetdm/fleet/v4/server/service/redis_key_value" "github.com/fleetdm/fleet/v4/server/service/redis_lock" @@ -712,6 +713,25 @@ the way that the Fleet server works. ctx, cancelFunc := context.WithCancel(baseCtx) defer cancelFunc() + var conditionalAccessMicrosoftProxy *conditional_access_microsoft_proxy.Proxy + if config.MicrosoftCompliancePartner.IsSet() { + var err error + conditionalAccessMicrosoftProxy, err = conditional_access_microsoft_proxy.New( + config.MicrosoftCompliancePartner.ProxyURI, + config.MicrosoftCompliancePartner.ProxyAPIKey, + func() (string, error) { + appCfg, err := ds.AppConfig(ctx) + if err != nil { + return "", fmt.Errorf("failed to load appconfig: %w", err) + } + return appCfg.ServerSettings.ServerURL, nil + }, + ) + if err != nil { + initFatal(err, "new microsoft compliance proxy") + } + } + eh := errorstore.NewHandler(ctx, redisPool, logger, config.Logging.ErrorRetentionPeriod) ctx = ctxerr.NewContext(ctx, eh) svc, err := service.NewService( @@ -740,6 +760,7 @@ the way that the Fleet server works. wstepCertManager, eeservice.NewSCEPConfigService(logger, nil), digicert.NewService(digicert.WithLogger(logger)), + conditionalAccessMicrosoftProxy, ) if err != nil { initFatal(err, "initializing service") diff --git a/cmd/fleetctl/fleetctl/apply_test.go b/cmd/fleetctl/fleetctl/apply_test.go index edcac4aad9..5b2170b754 100644 --- a/cmd/fleetctl/fleetctl/apply_test.go +++ b/cmd/fleetctl/fleetctl/apply_test.go @@ -212,6 +212,9 @@ func TestApplyTeamSpecs(t *testing.T) { ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { return document, nil, nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } filename := writeTmpYml(t, ` --- @@ -809,6 +812,9 @@ func TestApplyAppConfigDryRunIssue(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } // first, set the default app config's agent options as set after fleetctl setup name := writeTmpYml(t, `--- @@ -1366,6 +1372,9 @@ func TestApplyAsGitOps(t *testing.T) { ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { return document, nil, nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } // Apply global config. name := writeTmpYml(t, `--- @@ -2173,6 +2182,9 @@ func TestApplyMacosSetup(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } return ds } @@ -2815,6 +2827,10 @@ func TestApplySpecs(t *testing.T) { return nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return &fleet.ConditionalAccessMicrosoftIntegration{}, nil + } + // teams - team ID 1 already exists teamsByName := map[string]*fleet.Team{ "team1": { @@ -4095,3 +4111,15 @@ func TestApplyFileExtensionValidation(t *testing.T) { }) } } + +type notFoundError struct{} + +var _ fleet.NotFoundError = (*notFoundError)(nil) + +func (e *notFoundError) IsNotFound() bool { + return true +} + +func (e *notFoundError) Error() string { + return "" +} diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 2436467d65..303cace2e5 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -681,13 +681,11 @@ func (cmd *GenerateGitopsCommand) generateIntegrations(filePath string, integrat result = result["global_integrations"].(map[string]interface{}) } else { result = result["team_integrations"].(map[string]interface{}) - if result["google_calendar"] != nil { - result = map[string]interface{}{ - "google_calendar": result["google_calendar"], - } - } else { - result = nil - } + + // We currently don't support configuring Jira and Zendesk integrations on the team. + delete(result, "jira") + delete(result, "zendesk") + // Team integrations don't have secrets right now, so just return as-is. return result, nil } @@ -1018,13 +1016,14 @@ func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string result := make([]map[string]interface{}, len(policies)) for i, policy := range policies { policySpec := map[string]interface{}{ - jsonFieldName(t, "Name"): policy.Name, - jsonFieldName(t, "Description"): policy.Description, - jsonFieldName(t, "Resolution"): policy.Resolution, - jsonFieldName(t, "Query"): policy.Query, - jsonFieldName(t, "Platform"): policy.Platform, - jsonFieldName(t, "Critical"): policy.Critical, - jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled, + jsonFieldName(t, "Name"): policy.Name, + jsonFieldName(t, "Description"): policy.Description, + jsonFieldName(t, "Resolution"): policy.Resolution, + jsonFieldName(t, "Query"): policy.Query, + jsonFieldName(t, "Platform"): policy.Platform, + jsonFieldName(t, "Critical"): policy.Critical, + jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled, + jsonFieldName(t, "ConditionalAccessEnabled"): policy.ConditionalAccessEnabled, } // Handle software automation. if policy.InstallSoftware != nil { diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 7c75232a62..d5124c1a22 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -255,6 +255,7 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { }, { LabelName: "Label B", }}, + ConditionalAccessEnabled: true, }, InstallSoftware: &fleet.PolicySoftwareTitle{ SoftwareTitleID: 1, @@ -265,12 +266,13 @@ func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { return []*fleet.Policy{ { PolicyData: fleet.PolicyData{ - ID: 1, - Name: "Team Policy", - Query: "SELECT * FROM team_policy WHERE id = 1", - Resolution: ptr.String("Do a team thing"), - Description: "This is a team policy", - Platform: "linux,windows", + ID: 1, + Name: "Team Policy", + Query: "SELECT * FROM team_policy WHERE id = 1", + Resolution: ptr.String("Do a team thing"), + Description: "This is a team policy", + Platform: "linux,windows", + ConditionalAccessEnabled: true, }, RunScript: &fleet.PolicyScript{ ID: 1, diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index e2585d652c..5986c8aa7b 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -3018,3 +3019,63 @@ func TestGitOpsMDMAuthSettings(t *testing.T) { require.Empty(t, appConfig.MDM.EndUserAuthentication.SSOProviderSettings.MetadataURL) require.Empty(t, appConfig.MDM.EndUserAuthentication.SSOProviderSettings.IDPName) } + +func TestGitOpsTeamConditionalAccess(t *testing.T) { + teamName := "TestTeamConditionalAccess" + + ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) + + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return &fleet.ConditionalAccessMicrosoftIntegration{}, nil + } + + // Create integration with conditional access enabled. + _, err := ds.NewTeam(context.Background(), &fleet.Team{Name: teamName, Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + ConditionalAccessEnabled: optjson.SetBool(true), + }, + }}) + require.NoError(t, err) + require.NotNil(t, *savedTeams[teamName]) + + // Do a GitOps run with conditional access not set. + t.Setenv("TEST_TEAM_NAME", teamName) + _, err = RunAppNoChecks([]string{"gitops", "-f", "testdata/gitops/team_config_webhook.yml"}) + require.NoError(t, err) + + team, err := ds.TeamByName(context.Background(), teamName) + require.NoError(t, err) + require.NotNil(t, team) + require.True(t, team.Config.Integrations.ConditionalAccessEnabled.Set) + require.False(t, team.Config.Integrations.ConditionalAccessEnabled.Value) +} + +func TestGitOpsNoTeamConditionalAccess(t *testing.T) { + globalFileBasic := createGlobalFileBasic(t, fleetServerURL, orgName) + ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) + + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return &fleet.ConditionalAccessMicrosoftIntegration{}, nil + } + + appConfig := fleet.AppConfig{ + Integrations: fleet.Integrations{ + ConditionalAccessEnabled: optjson.SetBool(true), + }, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &appConfig, nil + } + + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + appConfig = *config + return nil + } + + // Do a GitOps run with conditional access not set. + _, err := RunAppNoChecks([]string{"gitops", "-f", globalFileBasic.Name()}) + require.NoError(t, err) + require.True(t, appConfig.Integrations.ConditionalAccessEnabled.Set) + require.False(t, appConfig.Integrations.ConditionalAccessEnabled.Value) +} diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 53ee2a2106..3c9caee7fc 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -93,7 +93,8 @@ "google_calendar": null, "ndes_scep_proxy": null, "custom_scep_proxy": null, - "digicert": null + "digicert": null, + "conditional_access_enabled": null }, "mdm": { "android_enabled_and_configured": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index 312791297d..5ac1c43739 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -66,7 +66,8 @@ "google_calendar": null, "ndes_scep_proxy": null, "custom_scep_proxy": null, - "digicert": null + "digicert": null, + "conditional_access_enabled": null }, "mdm": { "android_enabled_and_configured": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index d6cd1ab04a..0876d9aba2 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -14,6 +14,7 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + conditional_access_enabled: null custom_scep_proxy: null digicert: null google_calendar: null diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index c1b5123fc4..7e5e41c0d9 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -14,6 +14,7 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + conditional_access_enabled: null custom_scep_proxy: null digicert: null google_calendar: null diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 617763acd3..2a8f309cfa 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -149,7 +149,8 @@ "google_calendar": null, "ndes_scep_proxy": null, "custom_scep_proxy": null, - "digicert": null + "digicert": null, + "conditional_access_enabled": null }, "update_interval": { "osquery_detail": "1h0m0s", @@ -168,7 +169,8 @@ }, "license": { "tier": "free", - "expiration": "0001-01-01T00:00:00Z" + "expiration": "0001-01-01T00:00:00Z", + "managed_cloud": false }, "logging": { "debug": true, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 62d6216dfc..76b17918da 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -14,6 +14,7 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + conditional_access_enabled: null custom_scep_proxy: null digicert: null google_calendar: null @@ -69,6 +70,7 @@ spec: license: expiration: "0001-01-01T00:00:00Z" tier: free + managed_cloud: false logging: debug: true json: false diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json index 9993409eab..f94cc8b618 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json @@ -23,7 +23,8 @@ "integrations": { "jira": null, "zendesk": null, - "google_calendar": null + "google_calendar": null, + "conditional_access_enabled": null }, "features": { "enable_host_users": true, @@ -106,7 +107,8 @@ "integrations": { "jira": null, "zendesk": null, - "google_calendar": null + "google_calendar": null, + "conditional_access_enabled": null }, "features": { "enable_host_users": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml index 2793acaab8..573e8520eb 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -11,6 +11,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_updates: @@ -65,6 +66,7 @@ spec: host_expiry_window: 15 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_updates: diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json index 7afa367ad1..240dd5d1bd 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -83,7 +83,8 @@ "updated_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z", "critical": false, - "calendar_events_enabled": true + "calendar_events_enabled": true, + "conditional_access_enabled": false }, { "id": 2, @@ -99,7 +100,8 @@ "updated_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z", "critical": false, - "calendar_events_enabled": false + "calendar_events_enabled": false, + "conditional_access_enabled": false } ], "status": "offline", diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml index 71ddab9d40..51d9456d83 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -68,6 +68,7 @@ spec: updated_at: "0001-01-01T00:00:00Z" critical: false calendar_events_enabled: true + conditional_access_enabled: false - author_email: "alice@example.com" author_id: 1 author_name: Alice @@ -82,6 +83,7 @@ spec: updated_at: "0001-01-01T00:00:00Z" critical: false calendar_events_enabled: false + conditional_access_enabled: false policy_updated_at: "0001-01-01T00:00:00Z" public_ip: "" primary_ip: "" diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json b/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json index 5974075695..382c102dc9 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json @@ -193,6 +193,7 @@ "group_id": 123456789 } ], + "conditional_access_enabled": true, "google_calendar": [ { "domain": "fleetdm.com", diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml index 72c2a343c7..cc36db7126 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml @@ -1,4 +1,5 @@ - calendar_events_enabled: false + conditional_access_enabled: true critical: false description: This is a global policy install_software: @@ -9,4 +10,4 @@ name: Global Policy platform: darwin query: SELECT * FROM global_policy WHERE id = 1 - resolution: Do a global thing \ No newline at end of file + resolution: Do a global thing diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml index 517cc37800..cdae94e7c4 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml @@ -13,6 +13,7 @@ host_expiry_settings: host_expiry_enabled: false host_expiry_window: 59995 integrations: + conditional_access_enabled: true custom_scep_proxy: - challenge: some-custom-scep-proxy-challenge name: some-custom-scep-proxy-name diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml index 1c7dcf9042..b01c611951 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml @@ -13,6 +13,7 @@ host_expiry_settings: host_expiry_enabled: false host_expiry_window: 59995 integrations: + conditional_access_enabled: true custom_scep_proxy: - challenge: ___GITOPS_COMMENT_5___ name: some-custom-scep-proxy-name @@ -118,4 +119,4 @@ webhook_settings: destination_url: https://some-vulerabilities-webhook-url.com enable_vulnerabilities_webhook: true host_batch_size: 3 -yara_rules: {} \ No newline at end of file +yara_rules: {} diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml index a1616be62b..37e6a59d0a 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml @@ -5,6 +5,7 @@ host_expiry_settings: host_expiry_enabled: false host_expiry_window: 1 integrations: + conditional_access_enabled: true google_calendar: enable_calendar_events: true webhook_url: https://some-team-google-calendar-webhook.com @@ -23,4 +24,4 @@ webhook_settings: days_count: 3 destination_url: https://some-team-host-status-webhook.com enable_host_status_webhook: false - host_percentage: 2 \ No newline at end of file + host_percentage: 2 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml index 9410c9cf68..9006aa49d6 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml @@ -5,6 +5,7 @@ host_expiry_settings: host_expiry_enabled: false host_expiry_window: 1 integrations: + conditional_access_enabled: true google_calendar: enable_calendar_events: true webhook_url: https://some-team-google-calendar-webhook.com @@ -23,4 +24,4 @@ webhook_settings: days_count: 3 destination_url: https://some-team-host-status-webhook.com enable_host_status_webhook: false - host_percentage: 2 \ No newline at end of file + host_percentage: 2 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json b/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json index 3028c3c8d1..4fc34d9884 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/teamConfig.json @@ -66,6 +66,7 @@ "group_id": 123456789 } ], + "conditional_access_enabled": true, "google_calendar": { "enable_calendar_events": true, "webhook_url": "https://some-team-google-calendar-webhook.com" diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml index d6628556bf..d06b393b34 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_free/default.yml @@ -56,6 +56,7 @@ org_settings: host_expiry_enabled: false host_expiry_window: 59995 integrations: + conditional_access_enabled: true custom_scep_proxy: - challenge: # TODO: Add your custom SCEP proxy challenge here name: some-custom-scep-proxy-name @@ -151,6 +152,7 @@ org_settings: yara_rules: policies: - calendar_events_enabled: false + conditional_access_enabled: true critical: false description: This is a global policy install_software: 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 f885a337c5..61a3ae589d 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -41,6 +41,7 @@ org_settings: host_expiry_enabled: false host_expiry_window: 59995 integrations: + conditional_access_enabled: true custom_scep_proxy: - challenge: # TODO: Add your custom SCEP proxy challenge here name: some-custom-scep-proxy-name @@ -149,6 +150,7 @@ org_settings: yara_rules: policies: - calendar_events_enabled: false + conditional_access_enabled: true critical: false description: This is a global policy install_software: diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml index 4879c5fba1..e375ad6b51 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml @@ -24,6 +24,7 @@ controls: name: No team policies: - calendar_events_enabled: false + conditional_access_enabled: true critical: false description: This is a team policy name: Team Policy diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml index 242c83d3f8..53f8053ab2 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml @@ -39,6 +39,7 @@ controls: name: Team A policies: - calendar_events_enabled: false + conditional_access_enabled: true critical: false description: This is a team policy name: Team Policy @@ -84,6 +85,7 @@ team_settings: host_expiry_enabled: false host_expiry_window: 1 integrations: + conditional_access_enabled: true google_calendar: enable_calendar_events: true webhook_url: https://some-team-google-calendar-webhook.com diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index b4b9464a8f..84ab99de9b 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -14,6 +14,7 @@ spec: activity_expiry_enabled: false activity_expiry_window: 0 integrations: + conditional_access_enabled: null custom_scep_proxy: null digicert: null google_calendar: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index f2919fad67..435e8c7786 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -14,6 +14,7 @@ spec: activity_expiry_enabled: false activity_expiry_window: 0 integrations: + conditional_access_enabled: null custom_scep_proxy: null digicert: null google_calendar: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index adb6ac2107..cb3930cb4b 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -11,6 +11,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: @@ -56,6 +57,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 964c064235..ac4f55344a 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -11,6 +11,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: @@ -56,6 +57,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 01def9ac0e..28665e26ed 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -11,6 +11,7 @@ spec: enable_software_inventory: false integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 55650c4a60..1418c61803 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -10,6 +10,7 @@ spec: host_expiry_window: 0 integrations: google_calendar: null + conditional_access_enabled: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index 42cef53b3b..784d804e91 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -19,7 +19,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/cached_mysql" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mdm/apple" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" @@ -89,7 +89,8 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http }, nil } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, - _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, @@ -388,7 +389,8 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, nil } ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, - tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + tmFilter fleet.TeamFilter, + ) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { return nil, 0, nil, nil } ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { diff --git a/cmd/fleetctl/fleetctl/users_test.go b/cmd/fleetctl/fleetctl/users_test.go index f9dd954e5b..9237d95831 100644 --- a/cmd/fleetctl/fleetctl/users_test.go +++ b/cmd/fleetctl/fleetctl/users_test.go @@ -15,7 +15,6 @@ import ( "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/test" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,18 +47,6 @@ func TestUserDelete(t *testing.T) { assert.Equal(t, uint(42), deletedUser) } -type notFoundError struct{} - -var _ fleet.NotFoundError = (*notFoundError)(nil) - -func (e *notFoundError) IsNotFound() bool { - return true -} - -func (e *notFoundError) Error() string { - return "" -} - // TestUserCreateForcePasswordReset tests that the `fleetctl user create` command // creates a user with the proper "AdminForcePasswordReset" value depending on // the passed flags (e.g. SSO users shouldn't be required to do password reset on first login). diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md index d49e43b027..1a341e1252 100644 --- a/docs/Configuration/yaml-files.md +++ b/docs/Configuration/yaml-files.md @@ -703,7 +703,7 @@ org_settings: ### integrations -The `integrations` section lets you configure your Google Calendar, Jira, and Zendesk. After configuration, you can enable [automations](https://fleetdm.com/docs/using-fleet/automations) like calendar event and ticket creation for failing policies. Currently, enabling ticket creation is only available using Fleet's UI or [API](https://fleetdm.com/docs/rest-api/rest-api) (YAML files coming soon). +The `integrations` section lets you configure your Google Calendar, Conditional Access (for hosts in "No team"), Jira, and Zendesk. After configuration, you can enable [automations](https://fleetdm.com/docs/using-fleet/automations) like calendar event and ticket creation for failing policies. Currently, enabling ticket creation is only available using Fleet's UI or [API](https://fleetdm.com/docs/rest-api/rest-api) (YAML files coming soon). In addition, you can configure your certificate authorities (CA) to help your end users connect to Wi-Fi. Learn more about certificate authorities in Fleet [here](https://fleetdm.com/guides/certificate-authorities). @@ -714,6 +714,7 @@ In addition, you can configure your certificate authorities (CA) to help your en ```yaml org_settings: integrations: + conditional_access_enabled: true google_calendar: - api_key_json: $GOOGLE_CALENDAR_API_KEY_JSON domain: fleetdm.com diff --git a/docs/Contributing/reference/api-for-contributors.md b/docs/Contributing/reference/api-for-contributors.md index fe8521a3a6..c534e75fdc 100644 --- a/docs/Contributing/reference/api-for-contributors.md +++ b/docs/Contributing/reference/api-for-contributors.md @@ -4726,7 +4726,7 @@ None. ```json { - "admin_consented": false + "configuration_completed": false } ``` diff --git a/docs/Contributing/reference/audit-logs.md b/docs/Contributing/reference/audit-logs.md index b2c417dd51..ac030ac1fc 100644 --- a/docs/Contributing/reference/audit-logs.md +++ b/docs/Contributing/reference/audit-logs.md @@ -1803,6 +1803,52 @@ This activity contains the following fields: } ``` +## added_conditional_access_integration_microsoft + +Generated when Microsoft Entra is connected for conditonal access. + +This activity does not contain any detail fields. + +## deleted_conditional_access_integration_microsoft + +Generated when Microsoft Entra is integration is disconnected. + +This activity does not contain any detail fields. + +## enabled_conditional_access_automations + +Generated when conditional access automations are enabled for a team. + +This activity contains the following field: +- "team_id": The ID of the team ("null" for "No team"). +- "team_name": The name of the team (empty for "No team"). + +#### Example + +```json +{ + "team_id": 5, + "team_name": "Workstations" +} +``` + +## disabled_conditional_access_automations + +Generated when conditional access automations are disabled for a team. + +This activity contains the following field: +- "team_id": The ID of the team (`null` for "No team"). +- "team_name": The name of the team (empty for "No team"). + +#### Example + +```json +{ + "team_id": 5, + "team_name": "Workstations" +} +``` + diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 678ce2eb0d..c7d4f590d3 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -97,6 +97,7 @@ func setupMockDatastorePremiumService(t testing.TB) (*mock.Store, *eeservice.Ser nil, nil, nil, + nil, ) if err != nil { panic(err) @@ -266,6 +267,9 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { return 0, nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, &eeservice.NotFoundError{} + } } authzCtx := &authz_ctx.AuthorizationContext{} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index f2b1d2b9ff..533fa7dbce 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -166,6 +166,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T windowsUpdatesUpdated bool macOSDiskEncryptionUpdated bool macOSEnableEndUserAuthUpdated bool + conditionalAccessUpdated bool ) if payload.MDM != nil { if payload.MDM.MacOSUpdates != nil { @@ -254,7 +255,8 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T team.Config.Integrations.Jira = payload.Integrations.Jira team.Config.Integrations.Zendesk = payload.Integrations.Zendesk } - // Only update the calendar integration if it's not nil + + // Only update the calendar integration if it's not nil. if payload.Integrations.GoogleCalendar != nil { invalid := &fleet.InvalidArgumentError{} _ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, false, invalid) @@ -263,6 +265,19 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar } + + // Only update conditional_access_enabled if it's not nil. + if payload.Integrations.ConditionalAccessEnabled.Set { + if err := fleet.ValidateConditionalAccessIntegration(ctx, + svc, + team.Config.Integrations.ConditionalAccessEnabled.Value, + payload.Integrations.ConditionalAccessEnabled.Value, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + conditionalAccessUpdated = team.Config.Integrations.ConditionalAccessEnabled.Value != payload.Integrations.ConditionalAccessEnabled.Value + team.Config.Integrations.ConditionalAccessEnabled = payload.Integrations.ConditionalAccessEnabled + } } if payload.WebhookSettings != nil || payload.Integrations != nil { @@ -399,6 +414,32 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T return nil, ctxerr.Wrap(ctx, err, "update macos setup enable end user auth") } } + // Create activity if conditional access was enabled or disabled for the team. + if conditionalAccessUpdated { + if team.Config.Integrations.ConditionalAccessEnabled.Value { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEnabledConditionalAccessAutomations{ + TeamID: &team.ID, + TeamName: team.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for enabling conditional access") + } + } else { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDisabledConditionalAccessAutomations{ + TeamID: &team.ID, + TeamName: team.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for disabling conditional access") + } + } + } return team, err } @@ -1078,6 +1119,18 @@ func (svc *Service) createTeamFromSpec( } } + var conditionalAccessEnabled optjson.Bool + if spec.Integrations.ConditionalAccessEnabled != nil { + if err := fleet.ValidateConditionalAccessIntegration(ctx, + svc, + false, + *spec.Integrations.ConditionalAccessEnabled, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + conditionalAccessEnabled = optjson.SetBool(*spec.Integrations.ConditionalAccessEnabled) + } + if dryRun { for _, secret := range secrets { available, err := svc.ds.IsEnrollSecretAvailable(ctx, secret.Secret, true, nil) @@ -1118,7 +1171,8 @@ func (svc *Service) createTeamFromSpec( HostStatusWebhook: hostStatusWebhook, }, Integrations: fleet.TeamIntegrations{ - GoogleCalendar: spec.Integrations.GoogleCalendar, + GoogleCalendar: spec.Integrations.GoogleCalendar, + ConditionalAccessEnabled: conditionalAccessEnabled, }, Software: spec.Software, }, @@ -1128,6 +1182,19 @@ func (svc *Service) createTeamFromSpec( return nil, err } + if conditionalAccessEnabled.Set && conditionalAccessEnabled.Value { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEnabledConditionalAccessAutomations{ + TeamID: &tm.ID, + TeamName: tm.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for conditional access") + } + } + if enableDiskEncryption && appCfg.MDM.EnabledAndConfigured { // TODO: Are we missing an activity or anything else for BitLocker here? if err := svc.MDMAppleEnableFileVaultAndEscrow(ctx, &tm.ID); err != nil { @@ -1336,6 +1403,18 @@ func (svc *Service) editTeamFromSpec( team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar } + oldConditionalAccessEnabled := team.Config.Integrations.ConditionalAccessEnabled.Value + if spec.Integrations.ConditionalAccessEnabled != nil { + if err := fleet.ValidateConditionalAccessIntegration(ctx, + svc, + team.Config.Integrations.ConditionalAccessEnabled.Value, + *spec.Integrations.ConditionalAccessEnabled, + ); err != nil { + return ctxerr.Wrap(ctx, err) + } + team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(*spec.Integrations.ConditionalAccessEnabled) + } + if opts.DryRun { for _, secret := range secrets { available, err := svc.ds.IsEnrollSecretAvailable(ctx, secret.Secret, false, &team.ID) @@ -1437,6 +1516,37 @@ func (svc *Service) editTeamFromSpec( } } + // Create activity if conditional access was enabled or disabled for the team. + if spec.Integrations.ConditionalAccessEnabled != nil { + if *spec.Integrations.ConditionalAccessEnabled { + if !oldConditionalAccessEnabled { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEnabledConditionalAccessAutomations{ + TeamID: &team.ID, + TeamName: team.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for enabling conditional access") + } + } + } else { + if oldConditionalAccessEnabled { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDisabledConditionalAccessAutomations{ + TeamID: &team.ID, + TeamName: team.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for disabling conditional access") + } + } + } + } + return nil } diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index a4630ef203..5e1c4751a7 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -116,8 +116,8 @@ export enum ActivityType { CanceledUninstallSoftware = "canceled_uninstall_software", EnabledAndroidMdm = "enabled_android_mdm", DisabledAndroidMdm = "disabled_android_mdm", - ConfiguredMSEntraConditionalAccess = "added_conditional_access_microsoft", - DeletedMSEntraConditionalAccess = "deleted_conditional_access_microsoft", + ConfiguredMSEntraConditionalAccess = "added_conditional_access_integration_microsoft", + DeletedMSEntraConditionalAccess = "deleted_conditional_access_integration_microsoft", // enable/disable above feature for a team EnabledConditionalAccessAutomations = "enabled_conditional_access_automations", DisabledConditionalAccessAutomations = "disabled_conditional_access_automations", diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index e604c2f011..e3d0d1c3b1 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -51,7 +51,7 @@ const getIntegrationSettingsNavItems = ( }, ]; - if (isManagedCloud && featureFlags.allowConditionalAccess === "true") { + if (isManagedCloud) { items.push({ title: "Conditional access", urlSection: "conditional-access", diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx index fa9cf3255c..4a633c7228 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx @@ -27,10 +27,7 @@ const IntegrationsPage = ({ const { section } = params; - if ( - section?.includes("conditional-access") && - (!isManagedCloud || featureFlags.allowConditionalAccess !== "true") - ) { + if (section?.includes("conditional-access") && !isManagedCloud) { router.push(paths.ADMIN_SETTINGS); } const navItems = getIntegrationSettingsNavItems(isManagedCloud); diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 62252688b5..292f14bf14 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -1047,7 +1047,9 @@ const ManagePolicyPage = ({ false; const isConditionalAccessEnabled = - teamConfig?.integrations.conditional_access_enabled ?? false; + (teamIdForApi === API_NO_TEAM_ID + ? globalConfig?.integrations.conditional_access_enabled + : teamConfig?.integrations.conditional_access_enabled) ?? false; const getAutomationsDropdownOptions = (configPresent: boolean) => { let disabledInstallTooltipContent: TooltipContent; @@ -1125,10 +1127,7 @@ const ManagePolicyPage = ({ }, ]; - if ( - globalConfigFromContext?.license.managed_cloud && - featureFlags.allowConditionalAccess === "true" - ) { + if (globalConfigFromContext?.license.managed_cloud) { options.push({ label: "Conditional access", value: "conditional_access", diff --git a/frontend/test/jest.config.ts b/frontend/test/jest.config.ts index 2407e81b10..c587a302cd 100644 --- a/frontend/test/jest.config.ts +++ b/frontend/test/jest.config.ts @@ -45,7 +45,7 @@ const config: Config = { transformIgnorePatterns: [`/node_modules/(?!(${esModules})/)`], globals: { TransformStream, - featureFlags: { allowConditionalAccess: "true" }, + featureFlags: {}, }, }; diff --git a/it-and-security/lib/macos/configuration-profiles/company-portal-single-signon-extension.mobileconfig b/it-and-security/lib/macos/configuration-profiles/company-portal-single-signon-extension.mobileconfig new file mode 100644 index 0000000000..005468e65c --- /dev/null +++ b/it-and-security/lib/macos/configuration-profiles/company-portal-single-signon-extension.mobileconfig @@ -0,0 +1,58 @@ + + + + + PayloadContent + + + ExtensionData + + Enable_SSO_On_All_ManagedApps + 1 + + ExtensionIdentifier + com.microsoft.CompanyPortalMac.ssoextension + Hosts + + PayloadDisplayName + Company Portal Single Sign-On Extension + PayloadIdentifier + com.apple.extensiblesso.F82C8673-439F-4751-B562-42517A5FD990 + PayloadType + com.apple.extensiblesso + PayloadUUID + F82C8673-439F-4751-B562-42517A5FD990 + PayloadVersion + 1 + TeamIdentifier + UBF8T346G9 + Type + Redirect + URLs + + https://login.microsoftonline.com + https://login.microsoft.com + https://sts.windows.net + https://login.partner.microsoftonline.cn + https://login.chinacloudapi.cn + https://login.microsoftonline.us + https://login-us.microsoftonline.com + + + + PayloadDisplayName + Company Portal Single Sign-On Extension + PayloadIdentifier + com.fleetdm.D4DB9649-BA4A-4FA1-AA4F-D1CF606308B1 + PayloadOrganization + + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + com.fleetdm.D4DB9649-BA4A-4FA1-AA4F-D1CF606308B1 + PayloadVersion + 1 + + diff --git a/it-and-security/lib/macos/scripts/user-enroll-entra-company-portal.sh b/it-and-security/lib/macos/scripts/user-enroll-entra-company-portal.sh new file mode 100644 index 0000000000..e40ace11d3 --- /dev/null +++ b/it-and-security/lib/macos/scripts/user-enroll-entra-company-portal.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +open "/Applications/Company Portal.app" --args -r diff --git a/security/status.md b/security/status.md index d5579dca8d..be656784c6 100644 --- a/security/status.md +++ b/security/status.md @@ -89,7 +89,7 @@ Following is the vulnerability report of Fleet and its dependencies. - **Author:** @lucasmrod - **Status:** `not_affected` - **Status notes:** The fleetctl tool is used by IT admins to generate packages so the vulnerable code cannot be controlled by attackers. -- **Products:**: `fleetctl`,`pkg:golang/github.com/goreleaser/nfpm/v2` +- **Products:**: `fleetctl`,`pkg:maven/commons-beanutils/commons-beanutils` - **Justification:** `vulnerable_code_cannot_be_controlled_by_adversary` - **Timestamp:** 2025-06-02 07:33:44 diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 81cdf229c1..48cf70b1c9 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -1071,3 +1071,13 @@ allow { subject.global_role == [admin, maintainer][_] action == [read, write][_] } + +## +# Microsoft Compliance Partner +## +# Global admins can configure Microsoft conditional access. +allow { + object.type == "conditional_access_microsoft" + subject.global_role == admin + action == write +} diff --git a/server/config/config.go b/server/config/config.go index d0e5911e18..8eaaa4cc32 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -589,36 +589,37 @@ type PackagingConfig struct { // structs, Manager.addConfigs and Manager.LoadConfig should be // updated to set and retrieve the configurations as appropriate. type FleetConfig struct { - Mysql MysqlConfig - MysqlReadReplica MysqlConfig `yaml:"mysql_read_replica"` - Redis RedisConfig - Server ServerConfig - Auth AuthConfig - App AppConfig - Session SessionConfig - Osquery OsqueryConfig - Activity ActivityConfig - Logging LoggingConfig - Firehose FirehoseConfig - Kinesis KinesisConfig - Lambda LambdaConfig - S3 S3Config - Email EmailConfig - SES SESConfig - PubSub PubSubConfig - Filesystem FilesystemConfig - Webhook WebhookConfig - KafkaREST KafkaRESTConfig - License LicenseConfig - Vulnerabilities VulnerabilitiesConfig - Upgrades UpgradesConfig - Sentry SentryConfig - GeoIP GeoIPConfig - Prometheus PrometheusConfig - Packaging PackagingConfig - MDM MDMConfig - Calendar CalendarConfig - Partnerships PartnershipsConfig + Mysql MysqlConfig + MysqlReadReplica MysqlConfig `yaml:"mysql_read_replica"` + Redis RedisConfig + Server ServerConfig + Auth AuthConfig + App AppConfig + Session SessionConfig + Osquery OsqueryConfig + Activity ActivityConfig + Logging LoggingConfig + Firehose FirehoseConfig + Kinesis KinesisConfig + Lambda LambdaConfig + S3 S3Config + Email EmailConfig + SES SESConfig + PubSub PubSubConfig + Filesystem FilesystemConfig + Webhook WebhookConfig + KafkaREST KafkaRESTConfig + License LicenseConfig + Vulnerabilities VulnerabilitiesConfig + Upgrades UpgradesConfig + Sentry SentryConfig + GeoIP GeoIPConfig + Prometheus PrometheusConfig + Packaging PackagingConfig + MDM MDMConfig + Calendar CalendarConfig + Partnerships PartnershipsConfig + MicrosoftCompliancePartner MicrosoftCompliancePartnerConfig `yaml:"microsoft_compliance_partner"` } type PartnershipsConfig struct { @@ -626,6 +627,21 @@ type PartnershipsConfig struct { EnablePrimo bool `yaml:"enable_primo"` } +// MicrosoftCompliancePartnerConfig holds the server configuration for the "Conditional access" feature. +// Currently only set on Cloud environments. +type MicrosoftCompliancePartnerConfig struct { + // ProxyAPIKey is a shared key required to use the Microsoft Compliance Partner proxy API (fleetdm.com). + ProxyAPIKey string `yaml:"proxy_api_key"` + // ProxyURI is the URI of the Microsoft Compliance Partner proxy (for development/testing). + ProxyURI string `yaml:"proxy_uri"` +} + +// IsSet returns if the compliance partner configuration is set. +// Currently only set on Cloud environments. +func (m MicrosoftCompliancePartnerConfig) IsSet() bool { + return m.ProxyAPIKey != "" +} + type MDMConfig struct { AppleAPNsCert string `yaml:"apple_apns_cert"` AppleAPNsCertBytes string `yaml:"apple_apns_cert_bytes"` @@ -1420,6 +1436,11 @@ func (man Manager) addConfigs() { // Partnerships man.addConfigBool("partnerships.enable_secureframe", false, "Point transparency URL at Secureframe landing page") + + // Microsoft Compliance Partner + man.addConfigString("microsoft_compliance_partner.proxy_api_key", "", "Shared key required to use the Microsoft Compliance Partner proxy API") + man.addConfigString("microsoft_compliance_partner.proxy_uri", "https://fleetdm.com", "URI of the Microsoft Compliance Partner proxy (for development/testing)") + man.addConfigBool("partnerships.enable_primo", false, "Cosmetically disables team capabilities in the UI") } @@ -1705,6 +1726,10 @@ func (man Manager) LoadConfig() FleetConfig { EnableSecureframe: man.getConfigBool("partnerships.enable_secureframe"), EnablePrimo: man.getConfigBool("partnerships.enable_primo"), }, + MicrosoftCompliancePartner: MicrosoftCompliancePartnerConfig{ + ProxyAPIKey: man.getConfigString("microsoft_compliance_partner.proxy_api_key"), + ProxyURI: man.getConfigString("microsoft_compliance_partner.proxy_uri"), + }, } // ensure immediately that the async config is valid for all known tasks diff --git a/server/datastore/mysql/conditional_access_microsoft.go b/server/datastore/mysql/conditional_access_microsoft.go new file mode 100644 index 0000000000..642f076e7b --- /dev/null +++ b/server/datastore/mysql/conditional_access_microsoft.go @@ -0,0 +1,148 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) ConditionalAccessMicrosoftCreateIntegration( + ctx context.Context, tenantID string, proxyServerSecret string, +) error { + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Currently only one global integration is supported, thus we need to delete the existing + // one before creating a new one. + if _, err := tx.ExecContext(ctx, + `DELETE FROM microsoft_compliance_partner_integrations;`, + ); err != nil { + return ctxerr.Wrap(ctx, err, "deleting microsoft_compliance_partner_integrations") + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO microsoft_compliance_partner_integrations (tenant_id, proxy_server_secret) VALUES (?, ?);`, + tenantID, proxyServerSecret, + ); err != nil { + return ctxerr.Wrap(ctx, err, "inserting new microsoft_compliance_partner_integrations") + } + return nil + }) +} + +func (ds *Datastore) ConditionalAccessMicrosoftMarkSetupDone(ctx context.Context) error { + // Currently only one global integration is supported. + if _, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE microsoft_compliance_partner_integrations SET setup_done = true;`, + ); err != nil { + return ctxerr.Wrap(ctx, err, "deleting microsoft_compliance_partner_integrations") + } + return nil +} + +func (ds *Datastore) ConditionalAccessMicrosoftGet(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return getConditionalAccessMicrosoft(ctx, ds.reader(ctx)) +} + +func getConditionalAccessMicrosoft(ctx context.Context, q sqlx.QueryerContext) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + var integration fleet.ConditionalAccessMicrosoftIntegration + err := sqlx.GetContext( + ctx, q, &integration, + // Currently only one global integration is supported. + `SELECT tenant_id, proxy_server_secret, setup_done FROM microsoft_compliance_partner_integrations;`, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("MicrosoftCompliancePartnerIntegration")) + } + return nil, ctxerr.Wrap(ctx, err, "getting microsoft_compliance_partner_integrations") + } + return &integration, nil +} + +func (ds *Datastore) ConditionalAccessMicrosoftDelete(ctx context.Context) error { + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Currently only one global integration is supported. + if _, err := tx.ExecContext(ctx, `DELETE FROM microsoft_compliance_partner_integrations;`); err != nil { + return ctxerr.Wrap(ctx, err, "deleting microsoft_compliance_partner_integrations") + } + // Remove all last reported statuses. + if _, err := tx.ExecContext(ctx, `DELETE FROM microsoft_compliance_partner_host_statuses;`); err != nil { + return ctxerr.Wrap(ctx, err, "deleting microsoft_compliance_partner_host_statuses") + } + return nil + }) +} + +func (ds *Datastore) LoadHostConditionalAccessStatus(ctx context.Context, hostID uint) (*fleet.HostConditionalAccessStatus, error) { + var hostConditionalAccessStatus fleet.HostConditionalAccessStatus + if err := sqlx.GetContext(ctx, + ds.reader(ctx), + &hostConditionalAccessStatus, + `SELECT + mcphs.host_id, mcphs.device_id, mcphs.user_principal_name, mcphs.compliant, mcphs.created_at, mcphs.updated_at, mcphs.managed, + h.os_version, hdn.display_name + FROM microsoft_compliance_partner_host_statuses mcphs + JOIN host_display_names hdn ON hdn.host_id=mcphs.host_id + JOIN hosts h ON h.id=mcphs.host_id + WHERE mcphs.host_id = ?`, + hostID, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("HostConditionalAccessStatus").WithID(hostID)) + } + } + hostConditionalAccessStatus.OSVersion = strings.TrimPrefix(hostConditionalAccessStatus.OSVersion, "macOS ") + return &hostConditionalAccessStatus, nil +} + +func (ds *Datastore) CreateHostConditionalAccessStatus(ctx context.Context, hostID uint, deviceID string, userPrincipalName string) error { + // Most of the time this information won't change, so use the reader first. + var hostConditionalAccessStatus fleet.HostConditionalAccessStatus + err := sqlx.GetContext(ctx, + ds.reader(ctx), + &hostConditionalAccessStatus, + `SELECT device_id, user_principal_name FROM microsoft_compliance_partner_host_statuses WHERE host_id = ?`, + hostID, + ) + switch { + case err == nil: + if deviceID == hostConditionalAccessStatus.DeviceID && userPrincipalName == hostConditionalAccessStatus.UserPrincipalName { + // Nothing to do, the Entra account on the device is still the same. + return nil + } + // If we got here it means the host's Entra data has changed, so we will override the host's status row. + case errors.Is(err, sql.ErrNoRows): + // OK, let's create one. + default: + return ctxerr.Wrap(ctx, err, "failed to get microsoft_compliance_partner_host_statuses") + } + + // Create or override existing row for the host. + if _, err := ds.writer(ctx).ExecContext(ctx, + `INSERT INTO microsoft_compliance_partner_host_statuses + (host_id, device_id, user_principal_name) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + device_id = VALUES(device_id), + user_principal_name = VALUES(user_principal_name), + managed = NULL, + compliant = NULL`, + hostID, deviceID, userPrincipalName, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create host conditional access status") + } + return nil +} + +func (ds *Datastore) SetHostConditionalAccessStatus(ctx context.Context, hostID uint, managed, compliant bool) error { + if _, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE microsoft_compliance_partner_host_statuses SET managed = ?, compliant = ? WHERE host_id = ?;`, + managed, compliant, hostID, + ); err != nil { + return ctxerr.Wrap(ctx, err, "update host conditional access status") + } + return nil +} diff --git a/server/datastore/mysql/conditional_access_microsoft_test.go b/server/datastore/mysql/conditional_access_microsoft_test.go new file mode 100644 index 0000000000..23dd090ff9 --- /dev/null +++ b/server/datastore/mysql/conditional_access_microsoft_test.go @@ -0,0 +1,187 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestConditionalAccess(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"Setup", testConditionalAccessSetup}, + {"Hosts", testConditionalAccessHosts}, + } + + for _, c := range cases { + t.Helper() + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + + c.fn(t, ds) + }) + } +} + +func testConditionalAccessSetup(t *testing.T, ds *Datastore) { + ctx := context.Background() + + _, err := ds.ConditionalAccessMicrosoftGet(ctx) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + err = ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "foobar", "insecure") + require.NoError(t, err) + ca, err := ds.ConditionalAccessMicrosoftGet(ctx) + require.NoError(t, err) + require.NotNil(t, ca) + require.False(t, ca.SetupDone) + require.Equal(t, "insecure", ca.ProxyServerSecret) + require.Equal(t, "foobar", ca.TenantID) + + // ConditionalAccessMicrosoftCreateIntegration replaces the existing one. + err = ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "foobar2", "insecure2") + require.NoError(t, err) + ca, err = ds.ConditionalAccessMicrosoftGet(ctx) + require.NoError(t, err) + require.NotNil(t, ca) + require.False(t, ca.SetupDone) + require.Equal(t, "insecure2", ca.ProxyServerSecret) + require.Equal(t, "foobar2", ca.TenantID) + + err = ds.ConditionalAccessMicrosoftMarkSetupDone(ctx) + require.NoError(t, err) + + ca, err = ds.ConditionalAccessMicrosoftGet(ctx) + require.NoError(t, err) + require.NotNil(t, ca) + require.True(t, ca.SetupDone) + require.Equal(t, "insecure2", ca.ProxyServerSecret) + require.Equal(t, "foobar2", ca.TenantID) + + err = ds.ConditionalAccessMicrosoftDelete(ctx) + require.NoError(t, err) + + _, err = ds.ConditionalAccessMicrosoftGet(ctx) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + // Create a new one after deleting the existing. + err = ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "foobar3", "insecure3") + require.NoError(t, err) + ca, err = ds.ConditionalAccessMicrosoftGet(ctx) + require.NoError(t, err) + require.NotNil(t, ca) + require.False(t, ca.SetupDone) + require.Equal(t, "insecure3", ca.ProxyServerSecret) + require.Equal(t, "foobar3", ca.TenantID) +} + +func testConditionalAccessHosts(t *testing.T, ds *Datastore) { + ctx := context.Background() + + err := ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "foobar", "insecure") + require.NoError(t, err) + + // Test with non-existent host. + _, err = ds.LoadHostConditionalAccessStatus(ctx, 999_999) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + noTeamHost := newTestHostWithPlatform(t, ds, "host1", "darwin", nil) + + // Test with an existent host but no status yet. + _, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + // Nothing happens if the host doesn't have an entry yet. + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, false, false) + require.NoError(t, err) + _, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID", "foobar@example.onmicrosoft.com") + require.NoError(t, err) + + s, err := ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID", s.DeviceID) + require.Equal(t, "foobar@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + // When the status entry is created, these are not set yet. + // These values are updated during detail query ingestion. + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) + + // Execute with same values should do nothing. + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID", "foobar@example.onmicrosoft.com") + require.NoError(t, err) + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID", s.DeviceID) + require.Equal(t, "foobar@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + // These values are updated during detail query ingestion. + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) + + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, true, false) + require.NoError(t, err) + + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID", s.DeviceID) + require.Equal(t, "foobar@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + require.NotNil(t, s.Managed) + require.True(t, *s.Managed) + require.NotNil(t, s.Compliant) + require.False(t, *s.Compliant) + + err = ds.SetHostConditionalAccessStatus(ctx, noTeamHost.ID, false, true) + require.NoError(t, err) + + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.NotNil(t, s.Managed) + require.False(t, *s.Managed) + require.NotNil(t, s.Compliant) + require.True(t, *s.Compliant) + + // Simulate a device changing its device ID and user principal name + // (e.g. log out from Entra on device and log in again). + // We should update its data and clear its statuses. + err = ds.CreateHostConditionalAccessStatus(ctx, noTeamHost.ID, "entraDeviceID2", "foobar2@example.onmicrosoft.com") + require.NoError(t, err) + s, err = ds.LoadHostConditionalAccessStatus(ctx, noTeamHost.ID) + require.NoError(t, err) + require.Equal(t, noTeamHost.ID, s.HostID) + require.Equal(t, "entraDeviceID2", s.DeviceID) + require.Equal(t, "foobar2@example.onmicrosoft.com", s.UserPrincipalName) + require.Equal(t, "host1", s.DisplayName) + require.Equal(t, "15.4.1", s.OSVersion) + require.NotZero(t, s.CreatedAt) + require.NotZero(t, s.UpdatedAt) + require.Nil(t, s.Managed) + require.Nil(t, s.Compliant) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 7d7f0f618b..7a29ab51ff 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -547,6 +547,7 @@ var hostRefs = []string{ "host_scim_user", "batch_script_execution_host_results", "host_mdm_commands", + "microsoft_compliance_partner_host_statuses", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not @@ -3000,6 +3001,9 @@ func (ds *Datastore) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [ if err := cleanupQueryResultsOnTeamChange(ctx, tx, hostIDsBatch); err != nil { return ctxerr.Wrap(ctx, err, "AddHostsToTeam delete query results") } + if err := cleanupConditionalAccessOnTeamChange(ctx, tx, hostIDsBatch); err != nil { + return ctxerr.Wrap(ctx, err, "AddHostsToTeam delete conditional access") + } query, args, err := sqlx.In(`UPDATE hosts SET team_id = ? WHERE id IN (?)`, teamID, hostIDsBatch) if err != nil { diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 3e1f8d69ef..32f672ce74 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -7469,6 +7469,9 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { _, err = ds.BatchExecuteScript(ctx, nil, script.ID, []uint{host.ID}) require.NoError(t, err) + err = ds.CreateHostConditionalAccessStatus(ctx, host.ID, "entraDeviceID", "userPrincipalName") + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/migrations/tables/20250609120000_MicrosoftCompliancePartner.go b/server/datastore/mysql/migrations/tables/20250609120000_MicrosoftCompliancePartner.go new file mode 100644 index 0000000000..2da008a515 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250609120000_MicrosoftCompliancePartner.go @@ -0,0 +1,58 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20250609120000, Down_20250609120000) +} + +func Up_20250609120000(tx *sql.Tx) error { + // microsoft_compliance_partner_integrations stores the Microsoft Compliance Partner integrations. + // On the first version this table will only contain one row (one tenant supported for all devices in Fleet). + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS microsoft_compliance_partner_integrations ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + + tenant_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + proxy_server_secret VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + setup_done BOOLEAN NOT NULL DEFAULT FALSE, + + created_at DATETIME(6) NULL DEFAULT NOW(6), + updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6), + + UNIQUE KEY idx_microsoft_compliance_partner_tenant_id (tenant_id) + )`); err != nil { + return fmt.Errorf("failed to create microsoft_compliance_partner table: %w", err) + } + + // microsoft_compliance_partner_host_statuses is used to track the "Device ID" and "User Principal Name" + // of the host in Entra and the last "managed" and "compliant" statuses reported to Microsoft Intune servers. + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS microsoft_compliance_partner_host_statuses ( + host_id INT UNSIGNED NOT NULL PRIMARY KEY, + + device_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + user_principal_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + + managed BOOLEAN NULL, + compliant BOOLEAN NULL, + + created_at DATETIME(6) NULL DEFAULT NOW(6), + updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6) + )`); err != nil { + return fmt.Errorf("failed to create microsoft_compliance_partner_host_statuses table: %w", err) + } + + // Adding a new field to policies to enable/disable them for conditional access. + _, err := tx.Exec(`ALTER TABLE policies ADD COLUMN conditional_access_enabled TINYINT(1) UNSIGNED NOT NULL DEFAULT '0'`) + if err != nil { + return fmt.Errorf("failed to add conditional_access_enabled to policies: %w", err) + } + + return nil +} + +func Down_20250609120000(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 368caf89dc..6baa194031 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -25,7 +25,7 @@ const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled, p.software_installer_id, p.script_id, - p.vpp_apps_teams_id + p.vpp_apps_teams_id, p.conditional_access_enabled ` var ( @@ -325,11 +325,14 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger kitlog.Logger, p p.Name = norm.NFC.String(p.Name) updateStmt := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, script_id = ?, vpp_apps_teams_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, + platforms = ?, critical = ?, calendar_events_enabled = ?, + software_installer_id = ?, script_id = ?, vpp_apps_teams_id = ?, + conditional_access_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := db.ExecContext( - ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ID, + ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -965,11 +968,15 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI res, err := db.ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, script_id, vpp_apps_teams_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies ( + name, query, description, team_id, resolution, author_id, + platforms, critical, calendar_events_enabled, software_installer_id, + script_id, vpp_apps_teams_id, conditional_access_enabled, checksum + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, - args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, args.VPPAppsTeamsID, + args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, args.VPPAppsTeamsID, args.ConditionalAccessEnabled, ) switch { case err == nil: @@ -1216,8 +1223,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs software_installer_id, vpp_apps_teams_id, script_id, + conditional_access_enabled, checksum - ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), @@ -1228,7 +1236,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs calendar_events_enabled = VALUES(calendar_events_enabled), software_installer_id = VALUES(software_installer_id), vpp_apps_teams_id = VALUES(vpp_apps_teams_id), - script_id = VALUES(script_id) + script_id = VALUES(script_id), + conditional_access_enabled = VALUES(conditional_access_enabled) `, policiesChecksumComputedColumn(), ) for teamID, teamPolicySpecs := range teamIDToPolicies { @@ -1252,7 +1261,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs ctx, query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical, - spec.CalendarEventsEnabled, softwareInstallerID, vppAppsTeamsID, scriptID, + spec.CalendarEventsEnabled, softwareInstallerID, vppAppsTeamsID, scriptID, spec.ConditionalAccessEnabled, ) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") @@ -1462,6 +1471,18 @@ func cleanupQueryResultsOnTeamChange(ctx context.Context, tx sqlx.ExtContext, ho return nil } +func cleanupConditionalAccessOnTeamChange(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint) error { + const cleanupQuery = `DELETE FROM microsoft_compliance_partner_host_statuses WHERE host_id IN (?)` + query, args, err := sqlx.In(cleanupQuery, hostIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build cleanup conditional access") + } + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return ctxerr.Wrap(ctx, err, "exec cleanup query conditional access") + } + return nil +} + func cleanupPolicyMembershipOnPolicyUpdate( ctx context.Context, queryerContext sqlx.QueryerContext, db sqlx.ExecerContext, policyID uint, platforms string, ) error { @@ -1986,6 +2007,17 @@ func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fl return policies, nil } +func (ds *Datastore) GetPoliciesForConditionalAccess(ctx context.Context, teamID uint) ([]uint, error) { + // Currently, the "Conditional access" feature is for macOS hosts only. + query := `SELECT id FROM policies WHERE team_id = ? AND conditional_access_enabled AND (platforms LIKE '%darwin%' OR platforms = '');` + var policyIDs []uint + err := sqlx.SelectContext(ctx, ds.reader(ctx), &policyIDs, query, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies for conditional access") + } + return policyIDs, nil +} + func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { if len(policyIDs) == 0 { return nil, nil diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index fa908e422a..b41c309eb3 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -1120,6 +1120,8 @@ func newTestHostWithPlatform(t *testing.T, ds *Datastore, hostname, platform str UUID: uuid.NewString(), Hostname: hostname, Platform: platform, + OSVersion: "15.4.1", + ComputerName: hostname, }) require.NoError(t, err) if teamID != nil { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index da7d73d14b..3dd35de031 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -96,7 +96,7 @@ CREATE TABLE `app_config_json` ( PRIMARY KEY (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"gitops\": {\"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"digicert\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null, \"custom_scep_proxy\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"gitops\": {\"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"digicert\": null, \"google_calendar\": null, \"ndes_scep_proxy\": null, \"custom_scep_proxy\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `batch_script_execution_host_results` ( @@ -1329,15 +1329,41 @@ CREATE TABLE `mdm_windows_enrollments` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `microsoft_compliance_partner_host_statuses` ( + `host_id` int unsigned NOT NULL, + `device_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `user_principal_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `managed` tinyint(1) DEFAULT NULL, + `compliant` tinyint(1) DEFAULT NULL, + `created_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`host_id`) +) 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 */; +CREATE TABLE `microsoft_compliance_partner_integrations` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `proxy_server_secret` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `setup_done` tinyint(1) NOT NULL DEFAULT '0', + `created_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_microsoft_compliance_partner_tenant_id` (`tenant_id`) +) 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 */; CREATE TABLE `migration_status_tables` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `version_id` bigint NOT NULL, `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=392 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=393 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,20250520153848,1,'2020-01-01 01:01:01'),(388,20250528115932,1,'2020-01-01 01:01:01'),(389,20250529102706,1,'2020-01-01 01:01:01'),(390,20250603105558,1,'2020-01-01 01:01:01'),(391,20250609102714,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,20250520153848,1,'2020-01-01 01:01:01'),(388,20250528115932,1,'2020-01-01 01:01:01'),(389,20250529102706,1,'2020-01-01 01:01:01'),(390,20250603105558,1,'2020-01-01 01:01:01'),(391,20250609102714,1,'2020-01-01 01:01:01'),(392,20250609120000,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` ( @@ -1693,6 +1719,7 @@ CREATE TABLE `policies` ( `software_installer_id` int unsigned DEFAULT NULL, `script_id` int unsigned DEFAULT NULL, `vpp_apps_teams_id` int unsigned DEFAULT NULL, + `conditional_access_enabled` tinyint unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 7585a7e749..c6a723ae70 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -206,6 +206,11 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeCanceledInstallSoftware{}, ActivityTypeCanceledUninstallSoftware{}, ActivityTypeCanceledInstallAppStoreApp{}, + + ActivityTypeAddedConditionalAccessIntegrationMicrosoft{}, + ActivityTypeDeletedConditionalAccessIntegrationMicrosoft{}, + ActivityTypeEnabledConditionalAccessAutomations{}, + ActivityTypeDisabledConditionalAccessAutomations{}, } type ActivityDetails interface { @@ -2508,3 +2513,63 @@ func (a ActivityTypeRanScriptBatch) Documentation() (string, string, string) { "host_count": 12 }` } + +type ActivityTypeAddedConditionalAccessIntegrationMicrosoft struct{} + +func (a ActivityTypeAddedConditionalAccessIntegrationMicrosoft) ActivityName() string { + return "added_conditional_access_integration_microsoft" +} + +func (a ActivityTypeAddedConditionalAccessIntegrationMicrosoft) Documentation() (string, string, string) { + return "Generated when Microsoft Entra is connected for conditonal access.", + "This activity does not contain any detail fields.", "" +} + +type ActivityTypeDeletedConditionalAccessIntegrationMicrosoft struct{} + +func (a ActivityTypeDeletedConditionalAccessIntegrationMicrosoft) ActivityName() string { + return "deleted_conditional_access_integration_microsoft" +} + +func (a ActivityTypeDeletedConditionalAccessIntegrationMicrosoft) Documentation() (string, string, string) { + return "Generated when Microsoft Entra is integration is disconnected.", + "This activity does not contain any detail fields.", "" +} + +type ActivityTypeEnabledConditionalAccessAutomations struct { + TeamID *uint `json:"team_id"` + TeamName string `json:"team_name"` +} + +func (a ActivityTypeEnabledConditionalAccessAutomations) ActivityName() string { + return "enabled_conditional_access_automations" +} + +func (a ActivityTypeEnabledConditionalAccessAutomations) Documentation() (string, string, string) { + return "Generated when conditional access automations are enabled for a team.", + `This activity contains the following field: +- "team_id": The ID of the team ("null" for "No team"). +- "team_name": The name of the team (empty for "No team").`, `{ + "team_id": 5, + "team_name": "Workstations" +}` +} + +type ActivityTypeDisabledConditionalAccessAutomations struct { + TeamID *uint `json:"team_id"` + TeamName string `json:"team_name"` +} + +func (a ActivityTypeDisabledConditionalAccessAutomations) ActivityName() string { + return "disabled_conditional_access_automations" +} + +func (a ActivityTypeDisabledConditionalAccessAutomations) Documentation() (string, string, string) { + return "Generated when conditional access automations are disabled for a team.", + `This activity contains the following field: +- "team_id": The ID of the team (` + "`null`" + ` for "No team"). +- "team_name": The name of the team (empty for "No team").`, `{ + "team_id": 5, + "team_name": "Workstations" +}` +} diff --git a/server/fleet/app.go b/server/fleet/app.go index 1810f0cf78..8b65d770a7 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -77,6 +77,15 @@ type SSOSettings struct { EnableJITRoleSync bool `json:"enable_jit_role_sync"` } +// ConditionalAccessSettings holds the global settings for the "Conditional access" feature. +type ConditionalAccessSettings struct { + // MicrosoftEntraTenantID is the Entra's tenant ID. + MicrosoftEntraTenantID string `json:"microsoft_entra_tenant_id"` + // MicrosoftEntraConnectionConfigured is true when the tenant has been configured + // for "Conditional access" on Entra and Fleet. + MicrosoftEntraConnectionConfigured bool `json:"microsoft_entra_connection_configured"` +} + // SMTPSettings is part of the AppConfig which defines the wire representation // of the app config endpoints type SMTPSettings struct { @@ -553,6 +562,7 @@ type AppConfig struct { // // This field is a pointer to avoid returning this information to non-global-admins. SSOSettings *SSOSettings `json:"sso_settings,omitempty"` + // FleetDesktop holds settings for Fleet Desktop that can be changed via the API. FleetDesktop FleetDesktopSettings `json:"fleet_desktop"` @@ -1345,6 +1355,9 @@ type LicenseInfo struct { Note string `json:"note,omitempty"` // AllowDisableTelemetry allows specific customers to not send analytics AllowDisableTelemetry bool `json:"allow_disable_telemetry,omitempty"` + // ManagedCloud indicates whether this Fleet instance is a cloud instance. + // Currently only used to display UI features only present on cloud instances. + ManagedCloud bool `json:"managed_cloud"` } func (l *LicenseInfo) IsPremium() bool { diff --git a/server/fleet/conditional_access_microsoft.go b/server/fleet/conditional_access_microsoft.go new file mode 100644 index 0000000000..01d6ad304b --- /dev/null +++ b/server/fleet/conditional_access_microsoft.go @@ -0,0 +1,46 @@ +package fleet + +// ConditionalAccessMicrosoftIntegrations holds settings for a "Conditional access" integration. +type ConditionalAccessMicrosoftIntegration struct { + // TenantID is the Entra's tenant ID. + TenantID string `db:"tenant_id"` + // ProxyServerSecret is the secret used to authenticate a Cloud instance. + ProxyServerSecret string `db:"proxy_server_secret"` + // SetupDone is true when the Entra admin has consented and the tenant has been provisioned. + SetupDone bool `db:"setup_done"` +} + +// AuthzType implements authz.AuthzTyper. +func (c *ConditionalAccessMicrosoftIntegration) AuthzType() string { + return "conditional_access_microsoft" +} + +// HostConditionalAccessStatus holds "Conditional access" status for a host. +type HostConditionalAccessStatus struct { + // HostID is the host's ID. + HostID uint `db:"host_id"` + + // DeviceID is Entra's Device ID assigned when the device first logs in to Entra (obtained using a detail query). + DeviceID string `db:"device_id"` + // DeviceID is Entra's User Principal Name that logged in the device (obtained using a detail query). + UserPrincipalName string `db:"user_principal_name"` + + // Managed holds the last "DeviceManagementState" reported to Entra. + // It is true if the host is MDM enrolled, false otherwise. + // + // This field is used to know if Fleet needs to update the status on Entra. + Managed *bool `db:"managed"` + // Compliant holds the last "complianceStatus" reported to Entra. + // It is true if all configured policies are passing. + // + // This field is used to know if Fleet needs to update the status on Entra. + Compliant *bool `db:"compliant"` + + // DisplayName is the host's display name to reported to Entra. + DisplayName string `db:"display_name"` + // OSVersion is the host's OS version reported to Entra + OSVersion string `db:"os_version"` + + // UpdateCreateTimestamps holds the timestamps of the entry. + UpdateCreateTimestamps +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 909d3dcf53..673dd56def 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -14,11 +14,10 @@ import ( "github.com/fleetdm/fleet/v4/server/health" "github.com/fleetdm/fleet/v4/server/mdm/android" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" "github.com/jmoiron/sqlx" - - "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" ) type CarveStore interface { @@ -743,6 +742,8 @@ type Datastore interface { GetPoliciesWithAssociatedVPP(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyVPPData, error) GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyScriptData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) + // GetPoliciesForConditionalAccess returns the team policies that are configured for "Conditional access". + GetPoliciesForConditionalAccess(ctx context.Context, teamID uint) ([]uint, error) // Methods used for async processing of host policy query results. AsyncBatchInsertPolicyMembership(ctx context.Context, batch []PolicyMembershipResult) error @@ -2122,6 +2123,33 @@ type Datastore interface { ScimLastRequest(ctx context.Context) (*ScimLastRequest, error) // UpdateScimLastRequest updates the last SCIM request info UpdateScimLastRequest(ctx context.Context, lastRequest *ScimLastRequest) error + + // ///////////////////////////////////////////////////////////////////////////// + // Microsoft Compliance Partner + + // ConditionalAccessMicrosoftCreateIntegration creates the Conditional Access integration on the datastore. + // The integration is created as "not done". + // Currently only one integration can be configured, so this method replaces any existing integration. + ConditionalAccessMicrosoftCreateIntegration(ctx context.Context, tenantID, proxyServerSecret string) error + // ConditionalAccessMicrosoftGet returns the current Conditional Access integration. + // Returns a NotFoundError error if there's none. + ConditionalAccessMicrosoftGet(ctx context.Context) (*ConditionalAccessMicrosoftIntegration, error) + // ConditionalAccessMicrosoftMarkSetupDone marks the configuration as done on the datastore. + ConditionalAccessMicrosoftMarkSetupDone(ctx context.Context) error + // ConditionalAccessMicrosoftDelete deletes the integration from the datastore. + // It will also cleanup all recorded compliance status of all hosts from the datastore. + ConditionalAccessMicrosoftDelete(ctx context.Context) error + // LoadHostConditionalAccessStatus will load the current "Conditional Access" status of a host. + // The status holds Entra's "Device ID", "User Principal Name", and last reported "managed" and "compliant" status. + // Returns a NotFoundError error if there's no entry for the host. + LoadHostConditionalAccessStatus(ctx context.Context, hostID uint) (*HostConditionalAccessStatus, error) + // CreateHostConditionalAccessStatus creates the entry for the host on the datastore. + // This does not set the "managed" or "compliant" status yet, this just creates the entry needed with Entra information. + // If the host already has a different deviceID/userPrincipalName it will override them. + CreateHostConditionalAccessStatus(ctx context.Context, hostID uint, deviceID string, userPrincipalName string) error + // SetHostConditionalAccessStatus sets the "managed" and "compliant" statuses last set on Entra. + // It does nothing if the host doesn't have a status entry created with CreateHostConditionalAccessStatus yet. + SetHostConditionalAccessStatus(ctx context.Context, hostID uint, managed, compliant bool) error } type AndroidDatastore interface { diff --git a/server/fleet/integrations.go b/server/fleet/integrations.go index 8f107cbf0b..40ed449f51 100644 --- a/server/fleet/integrations.go +++ b/server/fleet/integrations.go @@ -19,6 +19,8 @@ type TeamIntegrations struct { Jira []*TeamJiraIntegration `json:"jira"` Zendesk []*TeamZendeskIntegration `json:"zendesk"` GoogleCalendar *TeamGoogleCalendarIntegration `json:"google_calendar"` + // ConditionalAccessEnabled indicates whether the conditional access feature is enabled on this team. + ConditionalAccessEnabled optjson.Bool `json:"conditional_access_enabled,omitempty"` } // MatchWithIntegrations matches the team integrations to their corresponding @@ -416,6 +418,46 @@ type Integrations struct { // NDESSCEPProxy settings. In JSON, not specifying this field means keep current setting, null means clear settings. NDESSCEPProxy optjson.Any[NDESSCEPProxyIntegration] `json:"ndes_scep_proxy"` CustomSCEPProxy optjson.Slice[CustomSCEPProxyIntegration] `json:"custom_scep_proxy"` + // ConditionalAccessEnabled indicates whether conditional access is enabled/disabled for "No team". + ConditionalAccessEnabled optjson.Bool `json:"conditional_access_enabled"` +} + +// ValidateConditionalAccessIntegration validates "Conditional access" can be enabled on a team/"No team". +// It checks the global setup of the feature has been made. +func ValidateConditionalAccessIntegration( + ctx context.Context, + g interface { + ConditionalAccessMicrosoftGet(context.Context) (*ConditionalAccessMicrosoftIntegration, error) + }, + currentConditionalAccessEnabled bool, + newConditionalAccessEnabled bool, +) error { + switch { + case currentConditionalAccessEnabled == newConditionalAccessEnabled: + // No change, mothing to do. + case currentConditionalAccessEnabled && !newConditionalAccessEnabled: + // Disabling feature on team/no-team, nothing to do. + case !currentConditionalAccessEnabled && newConditionalAccessEnabled: + // Enabling feature on team/no-team. + var settings *ConditionalAccessSettings + conditionalAccessIntegration, err := g.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + return fmt.Errorf("load conditional access microsoft: %w", err) + } + if conditionalAccessIntegration != nil { + settings = &ConditionalAccessSettings{ + MicrosoftEntraTenantID: conditionalAccessIntegration.TenantID, + MicrosoftEntraConnectionConfigured: conditionalAccessIntegration.SetupDone, + } + } + if settings == nil || !settings.MicrosoftEntraConnectionConfigured { + return NewInvalidArgumentError( + "integrations.conditional_access_enabled", + "Couldn't enable because the integration isn't configured", + ) + } + } + return nil } func ValidateEnabledActivitiesWebhook(webhook ActivitiesWebhookSettings, invalid *InvalidArgumentError) { diff --git a/server/fleet/policies.go b/server/fleet/policies.go index b662366863..8b71426f93 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -52,6 +52,10 @@ type PolicyPayload struct { LabelsIncludeAny []string // LabelsExcludeAny is a list of labels excluded from being targeted by this policy LabelsExcludeAny []string + // ConditionalAccessEnabled indicates whether this is a policy used for Microsoft conditional access. + // + // Only applies to team policies. + ConditionalAccessEnabled bool } // NewTeamPolicyPayload holds data for team policy creation. @@ -88,6 +92,8 @@ type NewTeamPolicyPayload struct { LabelsIncludeAny []string // LabelsExcludeAny is a list of labels excluded from being targeted by this policy LabelsExcludeAny []string + // ConditionalAccessEnabled indicates whether this is a policy used for Microsoft conditional access. + ConditionalAccessEnabled bool } var ( @@ -190,6 +196,10 @@ type ModifyPolicyPayload struct { LabelsIncludeAny []string `json:"labels_include_any"` // LabelsExcludeAny is a list of labels excluded from being targeted by this policy LabelsExcludeAny []string `json:"labels_exclude_any"` + // ConditionalAccessEnabled indicates whether this is a policy used for Microsoft conditional access. + // + // Only applies to team policies. + ConditionalAccessEnabled *bool `json:"conditional_access_enabled" premium:"true"` } // Verify verifies the policy payload is valid. @@ -247,11 +257,19 @@ type PolicyData struct { // LabelsExcludeAny is a list of labels excluded from being targeted by this policy LabelsExcludeAny []LabelIdent `json:"labels_exclude_any,omitempty"` + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` SoftwareInstallerID *uint `json:"-" db:"software_installer_id"` VPPAppsTeamsID *uint `json:"-" db:"vpp_apps_teams_id"` ScriptID *uint `json:"-" db:"script_id"` + // ConditionalAccessEnabled indicates whether this is a policy used for Microsoft conditional access. + // + // Only applies to team policies. + ConditionalAccessEnabled bool `json:"conditional_access_enabled" db:"conditional_access_enabled"` + UpdateCreateTimestamps } @@ -364,6 +382,10 @@ type PolicySpec struct { ScriptID *uint `json:"script_id"` LabelsIncludeAny []string `json:"labels_include_any,omitempty"` LabelsExcludeAny []string `json:"labels_exclude_any,omitempty"` + // ConditionalAccessEnabled indicates whether this is a policy used for Microsoft conditional access. + // + // Only applies to team policies. + ConditionalAccessEnabled bool `json:"conditional_access_enabled"` } // PolicySoftwareTitle contains software title data for policies. diff --git a/server/fleet/service.go b/server/fleet/service.go index 2862c59415..084192949a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1247,6 +1247,19 @@ type Service interface { // ScimDetails returns the details of last access to Fleet's SCIM endpoints ScimDetails(ctx context.Context) (ScimDetails, error) + + // ///////////////////////////////////////////////////////////////////////////// + // Microsoft Conditional Access + + // ConditionalAccessMicrosoftCreateIntegration kicks-off the integration with Entra + // and returns the consent URL to redirect the admin to. + ConditionalAccessMicrosoftCreateIntegration(ctx context.Context, tenantID string) (adminConsentURL string, err error) + // ConditionalAccessMicrosoftGet returns the current (currently unique) integration. + ConditionalAccessMicrosoftGet(ctx context.Context) (*ConditionalAccessMicrosoftIntegration, error) + // ConditionalAccessMicrosoftConfirm finalizes the integration (marks integration as done). + ConditionalAccessMicrosoftConfirm(ctx context.Context) (configurationCompleted bool, err error) + // ConditionalAccessMicrosoftDelete deletes the integration and deprovisions the tenant on Entra. + ConditionalAccessMicrosoftDelete(ctx context.Context) error } type KeyValueStore interface { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index c4be41d77e..57c40d7e04 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -476,6 +476,8 @@ type TeamSpecWebhookSettings struct { type TeamSpecIntegrations struct { // If value is nil, we don't want to change the existing value. GoogleCalendar *TeamGoogleCalendarIntegration `json:"google_calendar"` + // ConditionalAccessEnabled indicates whether "Conditional access" is enabled/disabled for the team. + ConditionalAccessEnabled *bool `json:"conditional_access_enabled"` } // TeamSpecsDryRunAssumptions holds the assumptions that are made when applying team specs in dry-run mode. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index fbbf642c54..7eced15251 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -556,6 +556,8 @@ type GetPoliciesWithAssociatedScriptFunc func(ctx context.Context, teamID uint, type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) +type GetPoliciesForConditionalAccessFunc func(ctx context.Context, teamID uint) ([]uint, error) + type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error type AsyncBatchUpdatePolicyTimestampFunc func(ctx context.Context, ids []uint, ts time.Time) error @@ -1364,6 +1366,20 @@ type ScimLastRequestFunc func(ctx context.Context) (*fleet.ScimLastRequest, erro type UpdateScimLastRequestFunc func(ctx context.Context, lastRequest *fleet.ScimLastRequest) error +type ConditionalAccessMicrosoftCreateIntegrationFunc func(ctx context.Context, tenantID string, proxyServerSecret string) error + +type ConditionalAccessMicrosoftGetFunc func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) + +type ConditionalAccessMicrosoftMarkSetupDoneFunc func(ctx context.Context) error + +type ConditionalAccessMicrosoftDeleteFunc func(ctx context.Context) error + +type LoadHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint) (*fleet.HostConditionalAccessStatus, error) + +type CreateHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint, deviceID string, userPrincipalName string) error + +type SetHostConditionalAccessStatusFunc func(ctx context.Context, hostID uint, managed bool, compliant bool) error + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -2166,6 +2182,9 @@ type DataStore struct { GetCalendarPoliciesFunc GetCalendarPoliciesFunc GetCalendarPoliciesFuncInvoked bool + GetPoliciesForConditionalAccessFunc GetPoliciesForConditionalAccessFunc + GetPoliciesForConditionalAccessFuncInvoked bool + AsyncBatchInsertPolicyMembershipFunc AsyncBatchInsertPolicyMembershipFunc AsyncBatchInsertPolicyMembershipFuncInvoked bool @@ -3378,6 +3397,27 @@ type DataStore struct { UpdateScimLastRequestFunc UpdateScimLastRequestFunc UpdateScimLastRequestFuncInvoked bool + ConditionalAccessMicrosoftCreateIntegrationFunc ConditionalAccessMicrosoftCreateIntegrationFunc + ConditionalAccessMicrosoftCreateIntegrationFuncInvoked bool + + ConditionalAccessMicrosoftGetFunc ConditionalAccessMicrosoftGetFunc + ConditionalAccessMicrosoftGetFuncInvoked bool + + ConditionalAccessMicrosoftMarkSetupDoneFunc ConditionalAccessMicrosoftMarkSetupDoneFunc + ConditionalAccessMicrosoftMarkSetupDoneFuncInvoked bool + + ConditionalAccessMicrosoftDeleteFunc ConditionalAccessMicrosoftDeleteFunc + ConditionalAccessMicrosoftDeleteFuncInvoked bool + + LoadHostConditionalAccessStatusFunc LoadHostConditionalAccessStatusFunc + LoadHostConditionalAccessStatusFuncInvoked bool + + CreateHostConditionalAccessStatusFunc CreateHostConditionalAccessStatusFunc + CreateHostConditionalAccessStatusFuncInvoked bool + + SetHostConditionalAccessStatusFunc SetHostConditionalAccessStatusFunc + SetHostConditionalAccessStatusFuncInvoked bool + mu sync.Mutex } @@ -5250,6 +5290,13 @@ func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fle return s.GetCalendarPoliciesFunc(ctx, teamID) } +func (s *DataStore) GetPoliciesForConditionalAccess(ctx context.Context, teamID uint) ([]uint, error) { + s.mu.Lock() + s.GetPoliciesForConditionalAccessFuncInvoked = true + s.mu.Unlock() + return s.GetPoliciesForConditionalAccessFunc(ctx, teamID) +} + func (s *DataStore) AsyncBatchInsertPolicyMembership(ctx context.Context, batch []fleet.PolicyMembershipResult) error { s.mu.Lock() s.AsyncBatchInsertPolicyMembershipFuncInvoked = true @@ -8077,3 +8124,52 @@ func (s *DataStore) UpdateScimLastRequest(ctx context.Context, lastRequest *flee s.mu.Unlock() return s.UpdateScimLastRequestFunc(ctx, lastRequest) } + +func (s *DataStore) ConditionalAccessMicrosoftCreateIntegration(ctx context.Context, tenantID string, proxyServerSecret string) error { + s.mu.Lock() + s.ConditionalAccessMicrosoftCreateIntegrationFuncInvoked = true + s.mu.Unlock() + return s.ConditionalAccessMicrosoftCreateIntegrationFunc(ctx, tenantID, proxyServerSecret) +} + +func (s *DataStore) ConditionalAccessMicrosoftGet(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + s.mu.Lock() + s.ConditionalAccessMicrosoftGetFuncInvoked = true + s.mu.Unlock() + return s.ConditionalAccessMicrosoftGetFunc(ctx) +} + +func (s *DataStore) ConditionalAccessMicrosoftMarkSetupDone(ctx context.Context) error { + s.mu.Lock() + s.ConditionalAccessMicrosoftMarkSetupDoneFuncInvoked = true + s.mu.Unlock() + return s.ConditionalAccessMicrosoftMarkSetupDoneFunc(ctx) +} + +func (s *DataStore) ConditionalAccessMicrosoftDelete(ctx context.Context) error { + s.mu.Lock() + s.ConditionalAccessMicrosoftDeleteFuncInvoked = true + s.mu.Unlock() + return s.ConditionalAccessMicrosoftDeleteFunc(ctx) +} + +func (s *DataStore) LoadHostConditionalAccessStatus(ctx context.Context, hostID uint) (*fleet.HostConditionalAccessStatus, error) { + s.mu.Lock() + s.LoadHostConditionalAccessStatusFuncInvoked = true + s.mu.Unlock() + return s.LoadHostConditionalAccessStatusFunc(ctx, hostID) +} + +func (s *DataStore) CreateHostConditionalAccessStatus(ctx context.Context, hostID uint, deviceID string, userPrincipalName string) error { + s.mu.Lock() + s.CreateHostConditionalAccessStatusFuncInvoked = true + s.mu.Unlock() + return s.CreateHostConditionalAccessStatusFunc(ctx, hostID, deviceID, userPrincipalName) +} + +func (s *DataStore) SetHostConditionalAccessStatus(ctx context.Context, hostID uint, managed bool, compliant bool) error { + s.mu.Lock() + s.SetHostConditionalAccessStatusFuncInvoked = true + s.mu.Unlock() + return s.SetHostConditionalAccessStatusFunc(ctx, hostID, managed, compliant) +} diff --git a/server/service/appconfig.go b/server/service/appconfig.go index fadf44c865..00373829c7 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -56,6 +56,8 @@ type appConfigResponseFields struct { Err error `json:"error,omitempty"` AndroidEnabled bool `json:"android_enabled,omitempty"` Partnerships *fleet.Partnerships `json:"partnerships,omitempty"` + // ConditionalAccess holds the Microsoft conditional access configuration. + ConditionalAccess *fleet.ConditionalAccessSettings `json:"conditional_access,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize @@ -136,6 +138,18 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se return nil, err } + var conditionalAccessSettings *fleet.ConditionalAccessSettings + conditionalAccessIntegration, err := svc.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + return nil, err + } + if conditionalAccessIntegration != nil { + conditionalAccessSettings = &fleet.ConditionalAccessSettings{ + MicrosoftEntraTenantID: conditionalAccessIntegration.TenantID, + MicrosoftEntraConnectionConfigured: conditionalAccessIntegration.SetupDone, + } + } + isGlobalAdmin := vc.User.GlobalRole != nil && *vc.User.GlobalRole == fleet.RoleAdmin isAnyTeamAdmin := false if vc.User.Teams != nil { @@ -196,14 +210,15 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se UIGitOpsMode: appConfig.UIGitOpsMode, }, appConfigResponseFields: appConfigResponseFields{ - UpdateInterval: updateIntervalConfig, - Vulnerabilities: vulnConfig, - License: license, - Logging: loggingConfig, - Email: emailConfig, - SandboxEnabled: svc.SandboxEnabled(), - AndroidEnabled: os.Getenv("FLEET_DEV_ANDROID_ENABLED") == "1", // Temporary feature flag that will be removed. - Partnerships: partnerships, + UpdateInterval: updateIntervalConfig, + Vulnerabilities: vulnConfig, + License: license, + Logging: loggingConfig, + Email: emailConfig, + SandboxEnabled: svc.SandboxEnabled(), + AndroidEnabled: os.Getenv("FLEET_DEV_ANDROID_ENABLED") == "1", // Temporary feature flag that will be removed. + Partnerships: partnerships, + ConditionalAccess: conditionalAccessSettings, }, } return response, nil @@ -315,6 +330,8 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle oldAgentOptions = string(*appConfig.AgentOptions) } + oldConditionalAccessEnabled := appConfig.Integrations.ConditionalAccessEnabled + storedJiraByProjectKey, err := fleet.IndexJiraIntegrations(appConfig.Integrations.Jira) if err != nil { return nil, ctxerr.Wrap(ctx, err, "modify AppConfig") @@ -478,6 +495,15 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid) fleet.ValidateEnabledActivitiesWebhook(appConfig.WebhookSettings.ActivitiesWebhook, invalid) + var conditionalAccessNoTeamUpdated bool + if newAppConfig.Integrations.ConditionalAccessEnabled.Set { + if err := fleet.ValidateConditionalAccessIntegration(ctx, svc, oldConditionalAccessEnabled.Value, newAppConfig.Integrations.ConditionalAccessEnabled.Value); err != nil { + return nil, err + } + conditionalAccessNoTeamUpdated = oldConditionalAccessEnabled.Value != newAppConfig.Integrations.ConditionalAccessEnabled.Value + appConfig.Integrations.ConditionalAccessEnabled = newAppConfig.Integrations.ConditionalAccessEnabled + } + if err := svc.validateMDM(ctx, license, &oldAppConfig.MDM, &appConfig.MDM, invalid); err != nil { return nil, ctxerr.Wrap(ctx, err, "validating MDM config") } @@ -944,6 +970,33 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + // Create activity if conditional access was enabled or disabled for "No team". + if conditionalAccessNoTeamUpdated { + if appConfig.Integrations.ConditionalAccessEnabled.Value { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEnabledConditionalAccessAutomations{ + TeamID: nil, + TeamName: "", + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for enabling conditional access") + } + } else { + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDisabledConditionalAccessAutomations{ + TeamID: nil, + TeamName: "", + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for disabling conditional access") + } + } + } + return obfuscatedAppConfig, nil } diff --git a/server/service/client.go b/server/service/client.go index 35583dd463..218ac804fd 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1661,6 +1661,9 @@ func (c *Client) DoGitOps( if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil { integrations.(map[string]interface{})["google_calendar"] = []interface{}{} } + if conditionalAccessEnabled, ok := integrations.(map[string]interface{})["conditional_access_enabled"]; !ok || conditionalAccessEnabled == nil { + integrations.(map[string]interface{})["conditional_access_enabled"] = false + } if ndesSCEPProxy, ok := integrations.(map[string]interface{})["ndes_scep_proxy"]; !ok || ndesSCEPProxy == nil { // Per backend patterns.md, best practice is to clear a JSON config field with `null` integrations.(map[string]interface{})["ndes_scep_proxy"] = nil @@ -1847,6 +1850,7 @@ func (c *Client) DoGitOps( if !ok { return nil, nil, errors.New("team_settings.integrations config is not a map") } + if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil { integrations.(map[string]interface{})["google_calendar"] = map[string]interface{}{} } else { @@ -1856,6 +1860,15 @@ func (c *Client) DoGitOps( } } + if conditionalAccessEnabled, ok := integrations.(map[string]interface{})["conditional_access_enabled"]; !ok || conditionalAccessEnabled == nil { + integrations.(map[string]interface{})["conditional_access_enabled"] = false + } else { + _, ok = conditionalAccessEnabled.(bool) + if !ok { + return nil, nil, errors.New("team_settings.integrations.conditional_access_enabled config is not a bool") + } + } + team["mdm"] = map[string]interface{}{} mdmAppConfig = team["mdm"].(map[string]interface{}) } diff --git a/server/service/conditional_access_microsoft.go b/server/service/conditional_access_microsoft.go new file mode 100644 index 0000000000..554a8748b5 --- /dev/null +++ b/server/service/conditional_access_microsoft.go @@ -0,0 +1,241 @@ +package service + +import ( + "context" + "errors" + + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/log/level" +) + +type conditionalAccessMicrosoftCreateRequest struct { + // MicrosoftTenantID holds the Entra tenant ID. + MicrosoftTenantID string `json:"microsoft_tenant_id"` +} + +type conditionalAccessMicrosoftCreateResponse struct { + // MicrosoftAuthenticationURL holds the URL to redirect the admin to consent access + // to the tenant to Fleet's multi-tenant application. + MicrosoftAuthenticationURL string `json:"microsoft_authentication_url"` + Err error `json:"error,omitempty"` +} + +func (r conditionalAccessMicrosoftCreateResponse) Error() error { return r.Err } + +func conditionalAccessMicrosoftCreateEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*conditionalAccessMicrosoftCreateRequest) + adminConsentURL, err := svc.ConditionalAccessMicrosoftCreateIntegration(ctx, req.MicrosoftTenantID) + if err != nil { + return conditionalAccessMicrosoftCreateResponse{Err: err}, nil + } + return conditionalAccessMicrosoftCreateResponse{ + MicrosoftAuthenticationURL: adminConsentURL, + }, nil +} + +func (svc *Service) ConditionalAccessMicrosoftCreateIntegration(ctx context.Context, tenantID string) (adminConsentURL string, err error) { + // 0. Check user is authorized to create an integration. + if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessMicrosoftIntegration{}, fleet.ActionWrite); err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to authorize") + } + + if !svc.config.MicrosoftCompliancePartner.IsSet() { + return "", &fleet.BadRequestError{Message: "microsoft conditional access configuration not set"} + } + + // Load current integration, if any. + existingIntegration, err := svc.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to load the integration") + } + switch { + case existingIntegration != nil && existingIntegration.TenantID == tenantID: + // Nothing to do, integration with same tenant ID has already been created. + // Retrieve settings of the integration to get the admin consent URL. + getResponse, err := svc.conditionalAccessMicrosoftProxy.Get(ctx, existingIntegration.TenantID, existingIntegration.ProxyServerSecret) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to get the integration settings") + } + return getResponse.AdminConsentURL, nil + case existingIntegration != nil && existingIntegration.SetupDone: + return "", &fleet.BadRequestError{Message: "integration already setup"} + } + + // + // At this point we have two scenarios: + // - There's no integration yet, so we need to create a new one. + // - There's an integration already with a different TenantID and has not been setup. + // + + // Create integration on the proxy. + proxyCreateResponse, err := svc.conditionalAccessMicrosoftProxy.Create(ctx, tenantID) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to create integration in proxy") + } + + // Create integration in datastore. + if err := svc.ds.ConditionalAccessMicrosoftCreateIntegration(ctx, proxyCreateResponse.TenantID, proxyCreateResponse.Secret); err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to create integration in datastore") + } + + // Retrieve settings of the integration to get the admin consent URL. + getResponse, err := svc.conditionalAccessMicrosoftProxy.Get(ctx, proxyCreateResponse.TenantID, proxyCreateResponse.Secret) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "failed to get the integration settings") + } + return getResponse.AdminConsentURL, nil +} + +type conditionalAccessMicrosoftConfirmRequest struct{} + +type conditionalAccessMicrosoftConfirmResponse struct { + ConfigurationCompleted bool `json:"configuration_completed"` + Err error `json:"error,omitempty"` +} + +func (r conditionalAccessMicrosoftConfirmResponse) Error() error { return r.Err } + +func conditionalAccessMicrosoftConfirmEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + _ = request.(*conditionalAccessMicrosoftConfirmRequest) + configurationCompleted, err := svc.ConditionalAccessMicrosoftConfirm(ctx) + if err != nil { + return conditionalAccessMicrosoftConfirmResponse{Err: err}, nil + } + return conditionalAccessMicrosoftConfirmResponse{ + ConfigurationCompleted: configurationCompleted, + }, nil +} + +func (svc *Service) ConditionalAccessMicrosoftConfirm(ctx context.Context) (configurationCompleted bool, err error) { + // Check user is authorized to write integrations. + if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessMicrosoftIntegration{}, fleet.ActionWrite); err != nil { + return false, ctxerr.Wrap(ctx, err, "failed to authorize") + } + + if !svc.config.MicrosoftCompliancePartner.IsSet() { + return false, &fleet.BadRequestError{Message: "microsoft conditional access configuration not set"} + } + + // Load current integration. + integration, err := svc.ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "failed to load the integration") + } + + if integration.SetupDone { + return true, nil + } + + getResponse, err := svc.conditionalAccessMicrosoftProxy.Get(ctx, integration.TenantID, integration.ProxyServerSecret) + if err != nil { + level.Error(svc.logger).Log("msg", "failed to get integration settings from proxy", "err", err) + return false, nil + } + + if !getResponse.SetupDone { + return false, nil + } + + if err := svc.ds.ConditionalAccessMicrosoftMarkSetupDone(ctx); err != nil { + return false, ctxerr.Wrap(ctx, err, "failed to mark setup_done=true") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeAddedConditionalAccessIntegrationMicrosoft{}, + ); err != nil { + return false, ctxerr.Wrap(ctx, err, "create activity for conditional access integration microsoft") + } + + return true, nil +} + +type conditionalAccessMicrosoftDeleteRequest struct{} + +type conditionalAccessMicrosoftDeleteResponse struct { + Err error `json:"error,omitempty"` +} + +func (r conditionalAccessMicrosoftDeleteResponse) Error() error { return r.Err } + +func conditionalAccessMicrosoftDeleteEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + _ = request.(*conditionalAccessMicrosoftDeleteRequest) + if err := svc.ConditionalAccessMicrosoftDelete(ctx); err != nil { + return conditionalAccessMicrosoftDeleteResponse{Err: err}, nil + } + return conditionalAccessMicrosoftDeleteResponse{}, nil +} + +func (svc *Service) ConditionalAccessMicrosoftDelete(ctx context.Context) error { + // Check user is authorized to delete an integration. + if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessMicrosoftIntegration{}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err, "failed to authorize") + } + + if !svc.config.MicrosoftCompliancePartner.IsSet() { + return &fleet.BadRequestError{Message: "microsoft conditional access configuration not set"} + } + + // Load current integration. + integration, err := svc.ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + if fleet.IsNotFound(err) { + return &fleet.BadRequestError{Message: "integration not found"} + } + return ctxerr.Wrap(ctx, err, "failed to load the integration") + } + + // Delete integration on the proxy. + deleteResponse, err := svc.conditionalAccessMicrosoftProxy.Delete(ctx, integration.TenantID, integration.ProxyServerSecret) + if err != nil { + if fleet.IsNotFound(err) { + // In case there's an issue on the Proxy database we want to make sure to + // allow deleting the integration in Fleet, so we continue. + svc.logger.Log("msg", "delete returned not found, continuing...") + } else { + return ctxerr.Wrap(ctx, err, "failed to delete the integration on the proxy") + } + } else if deleteResponse.Error != "" { + return ctxerr.Wrap(ctx, errors.New(deleteResponse.Error), "delete on the proxy failed") + } + + // Delete integration in datastore. + if err := svc.ds.ConditionalAccessMicrosoftDelete(ctx); err != nil { + return ctxerr.Wrap(ctx, err, "failed to delete integration in datastore") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDeletedConditionalAccessIntegrationMicrosoft{}, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for deletion of conditional access integration microsoft") + } + + return nil +} + +func (svc *Service) ConditionalAccessMicrosoftGet(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + // Check user is authorized to read app config (which is where expose integration information) + if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to authorize") + } + + if !svc.config.MicrosoftCompliancePartner.IsSet() { + return nil, nil + } + + // Load current integration. + integration, err := svc.ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + if fleet.IsNotFound(err) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "failed to load the integration") + } + + return integration, nil +} diff --git a/server/service/conditional_access_microsoft_proxy/conditional_access_microsoft_proxy.go b/server/service/conditional_access_microsoft_proxy/conditional_access_microsoft_proxy.go new file mode 100644 index 0000000000..4a17a69d62 --- /dev/null +++ b/server/service/conditional_access_microsoft_proxy/conditional_access_microsoft_proxy.go @@ -0,0 +1,310 @@ +// Package conditional_access_microsoft_proxy is the client HTTP package to operate on Entra through Fleet's MS proxy. +package conditional_access_microsoft_proxy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" +) + +// Proxy holds functionality to send requests to Entra via Fleet's MS proxy. +type Proxy struct { + uri string + apiKey string + originGetter func() (string, error) + + c *http.Client +} + +// New creates a Proxy that will use the given URI and API key. +func New(uri string, apiKey string, originGetter func() (string, error)) (*Proxy, error) { + if _, err := url.Parse(uri); err != nil { + return nil, fmt.Errorf("parse uri: %w", err) + } + return &Proxy{ + uri: uri, + apiKey: apiKey, + + originGetter: originGetter, + + c: fleethttp.NewClient(), + }, nil +} + +type createRequest struct { + TenantID string `json:"entraTenantId"` +} + +// CreateResponse returns the tenant ID and the secret of the created integration +// Such credentials are used to authenticate all requests. +type CreateResponse struct { + TenantID string `json:"entra_tenant_id"` + Secret string `json:"fleet_server_secret"` +} + +// Create creates the integration on the MS proxy and returns the consent URL. +func (p *Proxy) Create(ctx context.Context, tenantID string) (*CreateResponse, error) { + var createResponse CreateResponse + if err := p.post( + "/api/v1/microsoft-compliance-partner", + createRequest{TenantID: tenantID}, + &createResponse, + ); err != nil { + return nil, fmt.Errorf("create integration failed: %w", err) + } + return &createResponse, nil +} + +// GetResponse holds the settings of the current integration. +type GetResponse struct { + TenantID string `json:"entra_tenant_id"` + SetupDone bool `json:"setup_done"` + AdminConsentURL string `json:"admin_consent_url"` + SetupError *string `json:"setup_error"` +} + +// Get returns the integration settings. +func (p *Proxy) Get(ctx context.Context, tenantID string, secret string) (*GetResponse, error) { + var getResponse GetResponse + if err := p.get( + "/api/v1/microsoft-compliance-partner/settings", + fmt.Sprintf("entraTenantId=%s&fleetServerSecret=%s", tenantID, secret), + &getResponse, + ); err != nil { + return nil, fmt.Errorf("get integration settings failed: %w", err) + } + return &getResponse, nil +} + +// DeleteResponse contains an error detail if any. +type DeleteResponse struct { + Error string `json:"error"` +} + +// Delete deprovisions the tenant on Microsoft and deletes the integration in the proxy service. +// Returns a fleet.IsNotFound error if the integration doesn't exist. +func (p *Proxy) Delete(ctx context.Context, tenantID string, secret string) (*DeleteResponse, error) { + var deleteResponse DeleteResponse + if err := p.delete( + "/api/v1/microsoft-compliance-partner", + fmt.Sprintf("entraTenantId=%s&fleetServerSecret=%s", tenantID, secret), + &deleteResponse, + ); err != nil { + return nil, fmt.Errorf("delete integration failed: %w", err) + } + return &deleteResponse, nil +} + +type setComplianceStatusRequest struct { + TenantID string `json:"entraTenantId"` + Secret string `json:"fleetServerSecret"` + + DeviceID string `json:"deviceId"` + UserPrincipalName string `json:"userPrincipalName"` + + DeviceManagementState bool `json:"deviceManagementState"` + DeviceName string `json:"deviceName"` + OS string `json:"os"` + OSVersion string `json:"osVersion"` + Compliant bool `json:"compliant"` + LastCheckInTime int `json:"lastCheckInTime"` +} + +// SetComplianceStatusResponse holds the MessageID to query the status of the "compliance set" operation. +type SetComplianceStatusResponse struct { + // MessageID holds the ID to use when querying the status of the "compliance set" operation. + MessageID string `json:"message_id"` +} + +// SetComplianceStatus sets the inventory and compliance status of a host. +// Returns the message ID to query the status of the operation (MS has an asynchronous API). +func (p *Proxy) SetComplianceStatus( + ctx context.Context, + tenantID string, secret string, + deviceID string, + userPrincipalName string, + mdmEnrolled bool, + deviceName, osName, osVersion string, + compliant bool, + lastCheckInTime time.Time, +) (*SetComplianceStatusResponse, error) { + var setComplianceStatusResponse SetComplianceStatusResponse + if err := p.post( + "/api/v1/microsoft-compliance-partner/device", + setComplianceStatusRequest{ + TenantID: tenantID, + Secret: secret, + + DeviceID: deviceID, + UserPrincipalName: userPrincipalName, + + DeviceManagementState: mdmEnrolled, + DeviceName: deviceName, + OS: osName, + OSVersion: osVersion, + Compliant: compliant, + LastCheckInTime: int(lastCheckInTime.Unix()), + }, + &setComplianceStatusResponse, + ); err != nil { + return nil, fmt.Errorf("set compliance status response failed: %w", err) + } + return &setComplianceStatusResponse, nil +} + +// MessageStatusCompleted is the value returned when a "compliance set" operation has been successfully applied. +const MessageStatusCompleted = "Completed" + +// GetMessageStatusResponse returns the status of a "compliance set" operation. +type GetMessageStatusResponse struct { + // MessageID is the ID of the operation. + MessageID string `json:"message_id"` + // Status of the operation. + Status string `json:"status"` + // Detail has some error description when Status is not "Completed". + Detail *string `json:"detail"` +} + +// GetMessageStatus returns the status of the operation (MS has an asynchronous API). +func (p *Proxy) GetMessageStatus( + ctx context.Context, + tenantID string, secret string, + messageID string, +) (*GetMessageStatusResponse, error) { + var getMessageStatusResponse GetMessageStatusResponse + if err := p.get( + "/api/v1/microsoft-compliance-partner/device/message", + fmt.Sprintf("entraTenantId=%s&fleetServerSecret=%s&messageId=%s", tenantID, secret, messageID), + &getMessageStatusResponse, + ); err != nil { + return nil, fmt.Errorf("get message status response failed: %w", err) + } + return &getMessageStatusResponse, nil +} + +func (p *Proxy) post(path string, request interface{}, response interface{}) error { + b, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + postRequest, err := http.NewRequest("POST", p.uri+path, nil) + if err != nil { + return fmt.Errorf("post create request: %w", err) + } + if err := p.setHeaders(postRequest); err != nil { + return fmt.Errorf("post set headers: %w", err) + } + postRequest.Header.Add("Content-Type", "application/json") + postRequest.Body = io.NopCloser(bytes.NewBuffer(b)) + resp, err := p.c.Do(postRequest) + if err != nil { + return fmt.Errorf("post request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("post request failed: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("post read response body: %w", err) + } + if err := json.Unmarshal(body, response); err != nil { + return fmt.Errorf("post unmarshal response: %w", err) + } + return nil +} + +func (p *Proxy) get(path string, query string, response interface{}) error { + getURL := p.uri + path + if query != "" { + getURL += "?" + url.PathEscape(query) + } + getRequest, err := http.NewRequest("GET", getURL, nil) + if err != nil { + return fmt.Errorf("get create request: %w", err) + } + if err := p.setHeaders(getRequest); err != nil { + return fmt.Errorf("get set headers: %w", err) + } + resp, err := p.c.Do(getRequest) + if err != nil { + return fmt.Errorf("get request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("get request failed: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("get read response body: %w", err) + } + if err := json.Unmarshal(body, response); err != nil { + return fmt.Errorf("get unmarshal response: %w", err) + } + return nil +} + +func (p *Proxy) delete(path string, query string, response interface{}) error { + deleteURL := p.uri + path + if query != "" { + deleteURL += "?" + url.PathEscape(query) + } + deleteRequest, err := http.NewRequest("DELETE", deleteURL, nil) + if err != nil { + return fmt.Errorf("delete create request: %w", err) + } + if err := p.setHeaders(deleteRequest); err != nil { + return fmt.Errorf("delete set headers: %w", err) + } + resp, err := p.c.Do(deleteRequest) + if err != nil { + return fmt.Errorf("delete request: %w", err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + // OK + case http.StatusNotFound: + return ¬FoundError{} + default: + return fmt.Errorf("delete request failed: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("delete read response body: %w", err) + } + if err := json.Unmarshal(body, response); err != nil { + return fmt.Errorf("delete unmarshal response: %w", err) + } + return nil +} + +type notFoundError struct{} + +func (e *notFoundError) Error() string { + return "not found" +} + +func (e *notFoundError) IsNotFound() bool { + return true +} + +func (p *Proxy) setHeaders(r *http.Request) error { + origin, err := p.originGetter() + if err != nil { + return fmt.Errorf("get origin: %w", err) + } + if origin == "" { + return fmt.Errorf("missing origin: %w", err) + } + r.Header.Add("MS-API-Key", p.apiKey) + r.Header.Add("Origin", origin) + return nil +} diff --git a/server/service/global_schedule.go b/server/service/global_schedule.go index fdb1925a92..86761ba939 100644 --- a/server/service/global_schedule.go +++ b/server/service/global_schedule.go @@ -166,7 +166,6 @@ func deleteGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc return deleteGlobalScheduleResponse{}, nil } -// TODO(lucas): Document new behavior. func (svc *Service) DeleteGlobalScheduledQueries(ctx context.Context, id uint) error { return svc.DeleteQueryByID(ctx, id) } diff --git a/server/service/handler.go b/server/service/handler.go index 01e87b41a2..1fe0f2fbeb 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -509,6 +509,11 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Scim details ue.GET("/api/_version_/fleet/scim/details", getScimDetailsEndpoint, nil) + // Microsoft Compliance Partner + ue.POST("/api/_version_/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateEndpoint, conditionalAccessMicrosoftCreateRequest{}) + ue.POST("/api/_version_/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmEndpoint, conditionalAccessMicrosoftConfirmRequest{}) + ue.DELETE("/api/_version_/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteEndpoint, conditionalAccessMicrosoftDeleteRequest{}) + // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update // `service.mdmConfigurationRequiredEndpoints` when you add an diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index dc7c513318..9184d6809b 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -7295,7 +7295,6 @@ func (s *integrationTestSuite) TestAppConfig() { assert.Contains(t, errMsg, "missing or invalid license") } -// TODO(lucas): Add tests here. func (s *integrationTestSuite) TestQuerySpecs() { t := s.T() @@ -10554,7 +10553,7 @@ func (s *integrationTestSuite) TestDirectIngestScheduledQueryStats() { App: config.AppConfig{ EnableScheduledQueryStats: true, }, - }, appConfig, &appConfig.Features) + }, appConfig, &appConfig.Features, osquery_utils.Integrations{}) task := async.NewTask(s.ds, nil, clock.C, config.OsqueryConfig{}) err = detailQueries["scheduled_query_stats"].DirectTaskIngestFunc( context.Background(), @@ -10709,7 +10708,7 @@ func (s *integrationTestSuite) TestDirectIngestSoftwareWithLongFields() { "installed_path": "C:\\Program Files\\Wireshark", }, } - detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features) + detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features, osquery_utils.Integrations{}) err = detailQueries["software_windows"].DirectIngestFunc( context.Background(), log.NewNopLogger(), @@ -10845,7 +10844,7 @@ func (s *integrationTestSuite) TestDirectIngestSoftwareWithInvalidFields() { } var w1 bytes.Buffer logger1 := log.NewJSONLogger(&w1) - detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features) + detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features, osquery_utils.Integrations{}) err = detailQueries["software_windows"].DirectIngestFunc( context.Background(), logger1, @@ -10880,7 +10879,7 @@ func (s *integrationTestSuite) TestDirectIngestSoftwareWithInvalidFields() { "last_opened_at": "foobar", }, } - detailQueries = osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features) + detailQueries = osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features, osquery_utils.Integrations{}) var w2 bytes.Buffer logger2 := log.NewJSONLogger(&w2) err = detailQueries["software_windows"].DirectIngestFunc( @@ -10920,7 +10919,7 @@ func (s *integrationTestSuite) TestDirectIngestSoftwareWithInvalidFields() { } var w3 bytes.Buffer logger3 := log.NewJSONLogger(&w3) - detailQueries = osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features) + detailQueries = osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, appConfig, &appConfig.Features, osquery_utils.Integrations{}) err = detailQueries["software_windows"].DirectIngestFunc( context.Background(), logger3, @@ -13450,3 +13449,23 @@ func (s *integrationTestSuite) TestHostReenrollWithSameHostRowRefetchOsquery() { require.Equal(t, oldHosts[i].ID, h.ID) } } + +func (s *integrationTestSuite) TestConditionalAccessOnlyCloud() { + t := s.T() + + var resp appConfigResponse + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &resp) + require.False(t, resp.License.ManagedCloud) + + // Microsoft compliance partner APIs should fail if the setting is not set (only set on Cloud). + var r conditionalAccessMicrosoftCreateResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "foobar", + }, http.StatusBadRequest, &r) + var c conditionalAccessMicrosoftConfirmResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, + http.StatusBadRequest, &c) + var d conditionalAccessMicrosoftDeleteResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, + http.StatusBadRequest, &d) +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index ad42b331d3..ca3d8bdf6c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -45,6 +45,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/pubsub" commonCalendar "github.com/fleetdm/fleet/v4/server/service/calendar" + "github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy" "github.com/fleetdm/fleet/v4/server/service/redis_lock" "github.com/fleetdm/fleet/v4/server/service/schedule" "github.com/fleetdm/fleet/v4/server/test" @@ -115,7 +116,8 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { } }, }, - SoftwareInstallStore: softwareInstallStore, + SoftwareInstallStore: softwareInstallStore, + ConditionalAccessMicrosoftProxy: mockedConditionalAccessMicrosoftProxyInstance, } if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { config.Logger = kitlog.NewNopLogger() @@ -9732,6 +9734,36 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareAuth() { s.token = s.getTestAdminToken() } +func genDistributedReqWithPolicyResults(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim { + var ( + results = make(map[string]json.RawMessage) + statuses = make(map[string]interface{}) + messages = make(map[string]string) + ) + for policyID, policyResult := range policyResults { + distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID) + switch { + case policyResult == nil: + results[distributedQueryName] = json.RawMessage(`[]`) + statuses[distributedQueryName] = 1 + messages[distributedQueryName] = "policy failed execution" + case *policyResult: + results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`) + statuses[distributedQueryName] = 0 + case !*policyResult: + results[distributedQueryName] = json.RawMessage(`[]`) + statuses[distributedQueryName] = 0 + } + } + return submitDistributedQueryResultsRequestShim{ + NodeKey: *host.NodeKey, + Results: results, + Statuses: statuses, + Messages: messages, + Stats: map[string]*fleet.Stats{}, + } +} + func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { ctx := context.Background() t := s.T() @@ -9819,36 +9851,6 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { ) require.NoError(t, err) - genDistributedReqWithPolicyResults := func(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim { - var ( - results = make(map[string]json.RawMessage) - statuses = make(map[string]interface{}) - messages = make(map[string]string) - ) - for policyID, policyResult := range policyResults { - distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID) - switch { - case policyResult == nil: - results[distributedQueryName] = json.RawMessage(`[]`) - statuses[distributedQueryName] = 1 - messages[distributedQueryName] = "policy failed execution" - case *policyResult: - results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`) - statuses[distributedQueryName] = 0 - case !*policyResult: - results[distributedQueryName] = json.RawMessage(`[]`) - statuses[distributedQueryName] = 0 - } - } - return submitDistributedQueryResultsRequestShim{ - NodeKey: *host.NodeKey, - Results: results, - Statuses: statuses, - Messages: messages, - Stats: map[string]*fleet.Stats{}, - } - } - // host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global). distributedResp := submitDistributedQueryResultsResponse{} s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( @@ -13353,32 +13355,14 @@ func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) return id } -func genDistributedReqWithPolicyResults(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim { - var ( - results = make(map[string]json.RawMessage) - statuses = make(map[string]interface{}) - messages = make(map[string]string) - ) - for policyID, policyResult := range policyResults { - distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID) - switch { - case policyResult == nil: - results[distributedQueryName] = json.RawMessage(`[]`) - statuses[distributedQueryName] = 1 - messages[distributedQueryName] = "policy failed execution" - case *policyResult: - results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`) - statuses[distributedQueryName] = 0 - case !*policyResult: - results[distributedQueryName] = json.RawMessage(`[]`) - statuses[distributedQueryName] = 0 - } - } +func genDistributedReqWithEntraIDDetails(host *fleet.Host, deviceID, userPrincipalName string) submitDistributedQueryResultsRequestShim { + results := make(map[string]json.RawMessage) + results["fleet_detail_query_conditional_access_microsoft_device_id"] = json.RawMessage(fmt.Sprintf(`[{"device_id": "%s", "user_principal_name": "%s"}]`, deviceID, userPrincipalName)) return submitDistributedQueryResultsRequestShim{ NodeKey: *host.NodeKey, Results: results, - Statuses: statuses, - Messages: messages, + Statuses: make(map[string]interface{}), + Messages: make(map[string]string), Stats: map[string]*fleet.Stats{}, } } @@ -17838,3 +17822,571 @@ func (s *integrationEnterpriseTestSuite) TestBatchSoftwareInstallerAndFMACategor }) } } + +type mockedConditionalAccessMicrosoftProxy struct { + createResponse *conditional_access_microsoft_proxy.CreateResponse + getResponse *conditional_access_microsoft_proxy.GetResponse + deleteErr error + deleteResponse *conditional_access_microsoft_proxy.DeleteResponse + + setComplianceStatusFunc func( + ctx context.Context, + tenantID string, secret string, + deviceID string, + userPrincipalName string, + mdmEnrolled bool, + deviceName, osName, osVersion string, + compliant bool, + lastCheckInTime time.Time, + ) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) + + getMessageStatusFunc func( + ctx context.Context, + tenantID string, + secret string, + messageID string, + ) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) +} + +func (m *mockedConditionalAccessMicrosoftProxy) Create(ctx context.Context, tenantID string) (*conditional_access_microsoft_proxy.CreateResponse, error) { + return m.createResponse, nil +} + +func (m *mockedConditionalAccessMicrosoftProxy) Get(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.GetResponse, error) { + return m.getResponse, nil +} + +func (m *mockedConditionalAccessMicrosoftProxy) Delete(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.DeleteResponse, error) { + if m.deleteErr != nil { + return nil, m.deleteErr + } + return m.deleteResponse, nil +} + +func (m *mockedConditionalAccessMicrosoftProxy) SetComplianceStatus( + ctx context.Context, + tenantID string, secret string, + deviceID string, + userPrincipalName string, + mdmEnrolled bool, + deviceName, osName, osVersion string, + compliant bool, + lastCheckInTime time.Time, +) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) { + return m.setComplianceStatusFunc(ctx, tenantID, secret, deviceID, userPrincipalName, mdmEnrolled, deviceName, osName, osVersion, compliant, lastCheckInTime) +} + +func (m *mockedConditionalAccessMicrosoftProxy) GetMessageStatus( + ctx context.Context, tenantID string, secret string, messageID string, +) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) { + return m.getMessageStatusFunc(ctx, tenantID, secret, messageID) +} + +var mockedConditionalAccessMicrosoftProxyInstance = &mockedConditionalAccessMicrosoftProxy{} + +func (s *integrationEnterpriseTestSuite) TestConditionalAccessBasicSetup() { + t := s.T() + + // Test license.managed_cloud is set on Cloud environments. + var acResp appConfigResponse + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.True(t, acResp.License.ManagedCloud) + + // Test global maintainer fails to create the integration. + u := &fleet.User{ + Name: "test maintainer", + Email: "maintainer@example.com", + GlobalRole: ptr.String(fleet.RoleMaintainer), + } + password := test.GoodPassword + require.NoError(t, u.SetPassword(password, 10, 10)) + _, err := s.ds.NewUser(context.Background(), u) + require.NoError(t, err) + s.token = s.getTestToken("maintainer@example.com", password) + var r conditionalAccessMicrosoftCreateResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{}, http.StatusForbidden, &r) + var c conditionalAccessMicrosoftConfirmResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusForbidden, &c) + var d conditionalAccessMicrosoftDeleteResponse + s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusForbidden, &d) + + // Restore token for global admin. + s.token = s.getTestAdminToken() + + // Setup integration. + mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{ + TenantID: "foobar", + SetupDone: false, + AdminConsentURL: "https://example.com", + } + mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{ + TenantID: "foobar", + Secret: "secret", + } + r = conditionalAccessMicrosoftCreateResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "foobar", + }, http.StatusOK, &r) + require.Equal(t, "https://example.com", r.MicrosoftAuthenticationURL) + + // UI uses the /config endpoint to know the status of the integration. + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.NotNil(t, acResp.ConditionalAccess) + require.Equal(t, "foobar", acResp.ConditionalAccess.MicrosoftEntraTenantID) + require.False(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured) + + // Confirm should return that the setup is not done. + c = conditionalAccessMicrosoftConfirmResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c) + require.False(t, c.ConfigurationCompleted) + + // Confirm now should succeed. + mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{ + TenantID: "foobar", + SetupDone: true, + } + c = conditionalAccessMicrosoftConfirmResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c) + require.True(t, c.ConfigurationCompleted) + // Confirm again should succeed because integration is done. + c = conditionalAccessMicrosoftConfirmResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c) + require.True(t, c.ConfigurationCompleted) + // Create will succeed if using the same tenant ID. + r = conditionalAccessMicrosoftCreateResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "foobar", + }, http.StatusOK, &r) + // Create will should fail if using the a different tenant ID (if the setup is done). + r = conditionalAccessMicrosoftCreateResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "zoobar", + }, http.StatusBadRequest, &r) + + // Test app config returns that the configuration is done. + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.NotNil(t, acResp.ConditionalAccess) + require.Equal(t, "foobar", acResp.ConditionalAccess.MicrosoftEntraTenantID) + require.True(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured) + + // Delete endpoint. + mockedConditionalAccessMicrosoftProxyInstance.deleteResponse = &conditional_access_microsoft_proxy.DeleteResponse{} + d = conditionalAccessMicrosoftDeleteResponse{} + s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusOK, &d) + // Deleting again should fail with bad request. + d = conditionalAccessMicrosoftDeleteResponse{} + s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusBadRequest, &d) + + // Test app config returns that the integration was deleted. + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.Nil(t, acResp.ConditionalAccess) + + // Create again with a different tenant. + mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{ + TenantID: "zoobar", + SetupDone: false, + AdminConsentURL: "https://example.com", + } + mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{ + TenantID: "zoobar", + Secret: "secret", + } + r = conditionalAccessMicrosoftCreateResponse{} + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "zoobar", + }, http.StatusOK, &r) + + // Test app config returns that the new integration was created. + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.NotNil(t, acResp.ConditionalAccess) + require.Equal(t, "zoobar", acResp.ConditionalAccess.MicrosoftEntraTenantID) + require.False(t, acResp.ConditionalAccess.MicrosoftEntraConnectionConfigured) + + // Simulate a not found error on the proxy (should allow deletion to start over). + mockedConditionalAccessMicrosoftProxyInstance.deleteErr = ¬FoundError{} + d = conditionalAccessMicrosoftDeleteResponse{} + s.DoJSON("DELETE", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftDeleteRequest{}, http.StatusOK, &d) + + // Test app config returns that the configuration is gone. + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.Nil(t, acResp.ConditionalAccess) +} + +func (s *integrationEnterpriseTestSuite) TestConditionalAccessPolicies() { + t := s.T() + + // Setup integration. + mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{ + TenantID: "foobar", + SetupDone: false, + AdminConsentURL: "https://example.com", + } + mockedConditionalAccessMicrosoftProxyInstance.createResponse = &conditional_access_microsoft_proxy.CreateResponse{ + TenantID: "foobar", + Secret: "secret", + } + var r conditionalAccessMicrosoftCreateResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft", conditionalAccessMicrosoftCreateRequest{ + MicrosoftTenantID: "foobar", + }, http.StatusOK, &r) + mockedConditionalAccessMicrosoftProxyInstance.getResponse = &conditional_access_microsoft_proxy.GetResponse{ + TenantID: "foobar", + SetupDone: true, + } + var c conditionalAccessMicrosoftConfirmResponse + s.DoJSON("POST", "/api/latest/fleet/conditional-access/microsoft/confirm", conditionalAccessMicrosoftConfirmRequest{}, http.StatusOK, &c) + require.True(t, c.ConfigurationCompleted) + + t1, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + Description: "desc team1", + }) + require.NoError(t, err) + + var pr teamPolicyResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{ + Query: "SELECT 1;", + Name: "Compliance check 1", + ConditionalAccessEnabled: true, + }, http.StatusOK, &pr) + cp1 := pr.Policy + pr = teamPolicyResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{ + Query: "SELECT 2;", + Name: "Compliance check 2", + ConditionalAccessEnabled: true, + }, http.StatusOK, &pr) + cp2 := pr.Policy + pr = teamPolicyResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", t1.ID), teamPolicyRequest{ + Query: "SELECT 3;", + Name: "Other policy", + ConditionalAccessEnabled: false, + }, http.StatusOK, &pr) + p3 := pr.Policy + + ctx := context.Background() + newHost := func(name string, teamID *uint) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + + // + // Test A: host h1 in team t1. + // + + h1 := newHost("h1", &t1.ID) + orbitKey := setOrbitEnrollment(t, h1, s.ds) + h1.OrbitNodeKey = &orbitKey + + // Feature is disabled on the host's team. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h1, "entraDeviceID", "entraUserPrincipalName"), http.StatusOK, &distributedResp) + _, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + // Enable feature on team. + var tmResp teamResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", t1.ID), map[string]any{ + "integrations": map[string]any{ + "conditional_access_enabled": true, + }, + }, http.StatusOK, &tmResp) + + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h1, "entraDeviceID", "entraUserPrincipalName"), http.StatusOK, &distributedResp) + h1s, err := s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID", h1s.DeviceID) + require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName) + require.Nil(t, h1s.Managed) + require.Nil(t, h1s.Compliant) + + // override value to reduce test time. + conditionalAccessSetWaitTime = 250 * time.Millisecond + mockedConditionalAccessMicrosoftProxyInstance.setComplianceStatusFunc = func( + ctx context.Context, + tenantID string, secret string, + deviceID string, + userPrincipalName string, + mdmEnrolled bool, + deviceName, osName, osVersion string, + compliant bool, + lastCheckInTime time.Time, + ) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) { + return &conditional_access_microsoft_proxy.SetComplianceStatusResponse{ + MessageID: "messageID", + }, nil + } + + setDone := make(chan struct{}) + + mockedConditionalAccessMicrosoftProxyInstance.getMessageStatusFunc = func( + ctx context.Context, + tenantID string, + secret string, + messageID string, + ) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) { + close(setDone) + return &conditional_access_microsoft_proxy.GetMessageStatusResponse{ + MessageID: "messageID", + Status: "Completed", + }, nil + } + + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h1, + map[uint]*bool{ + cp1.ID: ptr.Bool(true), + cp2.ID: ptr.Bool(true), + p3.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + select { + case <-setDone: + time.Sleep(1 * time.Second) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for compliance to be set") + } + + h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID", h1s.DeviceID) + require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName) + require.NotNil(t, h1s.Managed) + require.False(t, *h1s.Managed) // not MDM enrolled + require.NotNil(t, h1s.Compliant) + require.True(t, *h1s.Compliant) // the two configured policies are passing + + // Enroll to MDM to update managed status. + err = s.ds.SetOrUpdateMDMData(ctx, + h1.ID, false, true /* enrolled */, s.server.URL, false, /* installedFromDEP */ + "Fleet" /* MDM name */, "", /* fleetEnrollmentRef */ + ) + require.NoError(t, err) + + setDone = make(chan struct{}) + + // Publish same policy results. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h1, + map[uint]*bool{ + cp1.ID: ptr.Bool(true), + cp2.ID: ptr.Bool(true), + p3.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + select { + case <-setDone: + time.Sleep(1 * time.Second) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for compliance to be set") + } + + h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID", h1s.DeviceID) + require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName) + require.NotNil(t, h1s.Managed) + require.True(t, *h1s.Managed) // now should be MDM enrolled + require.NotNil(t, h1s.Compliant) + require.True(t, *h1s.Compliant) // the two configured policies are passing + + setDone = make(chan struct{}) + + // Now the host is failing a compliance policy. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h1, + map[uint]*bool{ + cp1.ID: ptr.Bool(true), + cp2.ID: ptr.Bool(false), + p3.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + select { + case <-setDone: + time.Sleep(1 * time.Second) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for compliance to be set") + } + + h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID", h1s.DeviceID) + require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName) + require.NotNil(t, h1s.Managed) + require.True(t, *h1s.Managed) + require.NotNil(t, h1s.Compliant) + require.False(t, *h1s.Compliant) // now the host is non-compliant + + // Now nothing changes so there's no compliance operation on the proxy. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h1, + map[uint]*bool{ + cp1.ID: ptr.Bool(true), + cp2.ID: ptr.Bool(false), + p3.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + time.Sleep(5 * time.Second) + + h1s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h1.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID", h1s.DeviceID) + require.Equal(t, "entraUserPrincipalName", h1s.UserPrincipalName) + require.NotNil(t, h1s.Managed) + require.True(t, *h1s.Managed) + require.NotNil(t, h1s.Compliant) + require.False(t, *h1s.Compliant) + + // + // Test B: host h2 in "No team". + // + + h2 := newHost("h2", nil) + orbitKey2 := setOrbitEnrollment(t, h2, s.ds) + h2.OrbitNodeKey = &orbitKey2 + + // "No team" configuration for conditional access is in global config. + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + "integrations": { + "conditional_access_enabled": true + } + }`), http.StatusOK) + // Test that by not setting it it's not disabled. + s.DoRaw("PATCH", "/api/v1/fleet/config", []byte(`{ + "integrations": {} + }`), http.StatusOK) + acResp := appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.NotNil(t, acResp) + require.True(t, acResp.Integrations.ConditionalAccessEnabled.Set) + require.True(t, acResp.Integrations.ConditionalAccessEnabled.Value) + + pr = teamPolicyResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", fleet.PolicyNoTeamID), teamPolicyRequest{ + Query: "SELECT 1;", + Name: "Compliance check 1", + ConditionalAccessEnabled: true, + }, http.StatusOK, &pr) + cp1 = pr.Policy + pr = teamPolicyResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", fleet.PolicyNoTeamID), teamPolicyRequest{ + Query: "SELECT 2;", + Name: "Other 1", + ConditionalAccessEnabled: false, + }, http.StatusOK, &pr) + p2 := pr.Policy + pr = teamPolicyResponse{} + + // Enroll to MDM to update managed status. + err = s.ds.SetOrUpdateMDMData(ctx, + h2.ID, false, true /* enrolled */, s.server.URL, false, /* installedFromDEP */ + "Fleet" /* MDM name */, "", /* fleetEnrollmentRef */ + ) + require.NoError(t, err) + + // Ingest device ID and user principal name. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h2, "entraDeviceID2", "entraUserPrincipalName2"), http.StatusOK, &distributedResp) + h2s, err := s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID2", h2s.DeviceID) + require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName) + require.Nil(t, h2s.Managed) + require.Nil(t, h2s.Compliant) + + setDone = make(chan struct{}) + + // Now the host is failing a the compliance policy. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h2, + map[uint]*bool{ + cp1.ID: ptr.Bool(false), + p2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + select { + case <-setDone: + time.Sleep(1 * time.Second) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for compliance to be set") + } + + h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID2", h2s.DeviceID) + require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName) + require.NotNil(t, h2s.Managed) + require.True(t, *h2s.Managed) + require.NotNil(t, h2s.Compliant) + require.False(t, *h2s.Compliant) // host is non-compliant + + // Delete compliance policy, now there should be no compliance policies so host should be compliant. + _, err = s.ds.DeleteTeamPolicies(ctx, fleet.PolicyNoTeamID, []uint{cp1.ID}) + require.NoError(t, err) + + setDone = make(chan struct{}) + + // Now the host is failing a compliance policy. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + h2, + map[uint]*bool{ + p2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + select { + case <-setDone: + time.Sleep(1 * time.Second) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for compliance to be set") + } + + h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID2", h2s.DeviceID) + require.Equal(t, "entraUserPrincipalName2", h2s.UserPrincipalName) + require.NotNil(t, h2s.Managed) + require.True(t, *h2s.Managed) + require.NotNil(t, h2s.Compliant) + require.True(t, *h2s.Compliant) // now the host is compliant + + // A change of device ID and user principal name should update and clear the managed and compliant values. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithEntraIDDetails(h2, "entraDeviceID3", "entraUserPrincipalName3"), http.StatusOK, &distributedResp) + h2s, err = s.ds.LoadHostConditionalAccessStatus(ctx, h2.ID) + require.NoError(t, err) + require.Equal(t, "entraDeviceID3", h2s.DeviceID) + require.Equal(t, "entraUserPrincipalName3", h2s.UserPrincipalName) + require.Nil(t, h2s.Managed) + require.Nil(t, h2s.Compliant) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index cdcc5f06d9..389cd21558 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -5438,7 +5438,7 @@ func (s *integrationMDMTestSuite) TestSSO() { ac, err := s.ds.AppConfig(context.Background()) require.NoError(t, err) - detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, ac, &ac.Features) + detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, ac, &ac.Features, osquery_utils.Integrations{}) // simulate osquery reporting mdm information rows := []map[string]string{ @@ -5766,7 +5766,7 @@ func (s *integrationMDMTestSuite) TestSSOWithSCIM() { ac, err := s.ds.AppConfig(context.Background()) require.NoError(t, err) - detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, ac, &ac.Features) + detailQueries := osquery_utils.GetDetailQueries(context.Background(), config.FleetConfig{}, ac, &ac.Features, osquery_utils.Integrations{}) // simulate osquery reporting mdm information, doesn't change anything rows := []map[string]string{ diff --git a/server/service/osquery.go b/server/service/osquery.go index b9f659b320..d989db8c18 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -22,6 +22,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/pubsub" + "github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy" "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils" "github.com/fleetdm/fleet/v4/server/service/osquery_utils" kithttp "github.com/go-kit/kit/transport/http" @@ -150,7 +151,9 @@ func (svc *Service) EnrollAgent(ctx context.Context, enrollSecret, hostIdentifie } // Save enrollment details if provided - detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features) + detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features, osquery_utils.Integrations{ + ConditionalAccessMicrosoft: false, // here we are just using a few ingestion functions, so no need to set. + }) save := false if r, ok := hostDetails["os_version"]; ok { err := detailQueries["os_version"].IngestFunc(ctx, svc.logger, host, []map[string]string{r}) @@ -697,7 +700,9 @@ func (svc *Service) detailQueriesForHost(ctx context.Context, host *fleet.Host) queries = make(map[string]string) discovery = make(map[string]string) - detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features) + detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features, osquery_utils.Integrations{ + ConditionalAccessMicrosoft: svc.hostRequiresConditionalAccessMicrosoftIngestion(ctx, host), + }) for name, query := range detailQueries { if criticalQueriesOnly && !criticalDetailQueries[name] { continue @@ -743,6 +748,24 @@ func (svc *Service) detailQueriesForHost(ctx context.Context, host *fleet.Host) return queries, discovery, nil } +func (svc *Service) hostRequiresConditionalAccessMicrosoftIngestion(ctx context.Context, host *fleet.Host) bool { + if host.Platform != "darwin" { + return false + } + + conditionalAccessConfigured, conditionalAccessEnabledForTeam, err := svc.conditionalAccessConfiguredAndEnabledForTeam(ctx, host.TeamID) + if err != nil { + level.Error(svc.logger).Log( + "msg", "load conditional access configured and enabled, skipping ingestion", + "host_id", host.ID, + "err", err, + ) + return false + } + + return conditionalAccessConfigured && conditionalAccessEnabledForTeam +} + func (svc *Service) shouldUpdate(lastUpdated time.Time, interval time.Duration, hostID uint) bool { svc.jitterMu.Lock() defer svc.jitterMu.Unlock() @@ -1006,7 +1029,6 @@ func (svc *Service) SubmitDistributedQueryResults( } if len(policyResults) > 0 { - if err := processCalendarPolicies(ctx, svc.ds, ac, host, policyResults, svc.logger); err != nil { logging.WithErr(ctx, err) } @@ -1015,6 +1037,12 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if host.Platform == "darwin" { + if err := svc.processConditionalAccessForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.OrbitNodeKey, policyResults); err != nil { + logging.WithErr(ctx, err) + } + } + if host.Platform == "darwin" && svc.EnterpriseOverrides != nil { // NOTE: if the installers for the policies here are not scoped to the host via labels, we update the policy status here to stop it from showing up as "failed" in the // host details. @@ -1518,7 +1546,9 @@ func (svc *Service) directIngestDetailQuery(ctx context.Context, host *fleet.Hos return false, newOsqueryError("ingest detail query: " + err.Error()) } - detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features) + detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features, osquery_utils.Integrations{ + ConditionalAccessMicrosoft: svc.hostRequiresConditionalAccessMicrosoftIngestion(ctx, host), + }) query, ok := detailQueries[name] if !ok { return false, newOsqueryError("unknown detail query " + name) @@ -1660,7 +1690,9 @@ func (svc *Service) ingestDetailQuery(ctx context.Context, host *fleet.Host, nam return newOsqueryError("ingest detail query: " + err.Error()) } - detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features) + detailQueries := osquery_utils.GetDetailQueries(ctx, svc.config, appConfig, features, osquery_utils.Integrations{ + ConditionalAccessMicrosoft: svc.hostRequiresConditionalAccessMicrosoftIngestion(ctx, host), + }) query, ok := detailQueries[name] if !ok { return newOsqueryError("unknown detail query " + name) @@ -2192,6 +2224,238 @@ func (svc *Service) processScriptsForNewlyFailingPolicies( return nil } +func (svc *Service) conditionalAccessConfiguredAndEnabledForTeam(ctx context.Context, hostTeamID *uint) (configured bool, enabledForTeam bool, err error) { + // Check if the needed server configuration for Conditional Access is set. + if !svc.config.MicrosoftCompliancePartner.IsSet() { + return false, false, nil + } + + // Check if the integration is fully configured. + integration, err := svc.ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + if fleet.IsNotFound(err) { + return false, false, nil + } + return false, false, ctxerr.Wrap(ctx, err, "failed to load the integration") + } + if !integration.SetupDone { + return false, false, nil + } + + if hostTeamID == nil { + // Configuration for "No team" is stored in the main appconfig. + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return false, false, ctxerr.Wrap(ctx, err, "failed to load appconfig") + } + var conditionalAccessEnabled bool + if cfg.Integrations.ConditionalAccessEnabled.Set { + conditionalAccessEnabled = cfg.Integrations.ConditionalAccessEnabled.Value + } + return true, conditionalAccessEnabled, nil + } + + // Host belongs to a team, thus we load the team configuration. + team, err := svc.ds.Team(ctx, *hostTeamID) + if err != nil { + return false, false, ctxerr.Wrap(ctx, err, "failed to load team config") + } + var teamConditionalAccessEnabled bool + if team.Config.Integrations.ConditionalAccessEnabled.Set { + teamConditionalAccessEnabled = team.Config.Integrations.ConditionalAccessEnabled.Value + } + return true, teamConditionalAccessEnabled, nil +} + +func (svc *Service) processConditionalAccessForNewlyFailingPolicies( + ctx context.Context, + hostID uint, + hostTeamID *uint, + hostOrbitNodeKey *string, + incomingPolicyResults map[uint]*bool, +) error { + if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" { + // Vanilla osquery hosts cannot do conditional access. + return nil + } + + configured, enabledForTeam, err := svc.conditionalAccessConfiguredAndEnabledForTeam(ctx, hostTeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to check for conditional access configuration") + } + + if !configured || !enabledForTeam { + // Nothing to do, feature not configured or not enabled for this host's team. + return nil + } + + hostConditionalAccessStatus, err := svc.ds.LoadHostConditionalAccessStatus(ctx, hostID) + if err != nil { + if fleet.IsNotFound(err) { + // Nothing to do because Fleet hasn't ingested the Entra's "Device ID" or + // "User Principal Name" from the device yet (we cannot perform any actions + // for the host on Entra without it). + return nil + } + return ctxerr.Wrap(ctx, err, "failed to load host conditional access status") + } + + var policyTeamID uint + if hostTeamID == nil { + policyTeamID = fleet.PolicyNoTeamID + } else { + policyTeamID = *hostTeamID + } + + var mdmEnrolled bool + hostMDM, err := svc.ds.GetHostMDM(ctx, hostID) + if err != nil { + // If GetHostMDM returns not found then it means that + // the host may not be MDM enrolled yet. + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "failed to get host mdm") + } + } else { + mdmEnrolled = hostMDM.Enrolled + } + + // Get policies configured for conditional access. + conditionalAccessPolicyIDs, err := svc.ds.GetPoliciesForConditionalAccess(ctx, policyTeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get policies with conditional access") + } + + hostIsCompliantInFleet := true + conditionalAccessPolicyIDsSet := make(map[uint]struct{}, len(conditionalAccessPolicyIDs)) + for _, policyID := range conditionalAccessPolicyIDs { + conditionalAccessPolicyIDsSet[policyID] = struct{}{} + } + for incomingPolicyID, incomingPolicyResult := range incomingPolicyResults { + if _, ok := conditionalAccessPolicyIDsSet[incomingPolicyID]; !ok { + // Ignore results for policies that are not for conditional access. + continue + } + if incomingPolicyResult != nil && !*incomingPolicyResult { + hostIsCompliantInFleet = false + break + } + } + + if hostConditionalAccessStatus.Managed != nil && mdmEnrolled == *hostConditionalAccessStatus.Managed && + hostConditionalAccessStatus.Compliant != nil && hostIsCompliantInFleet == *hostConditionalAccessStatus.Compliant { + // Nothing to do, nothing has changed. + return nil + } + + svc.setHostConditionalAccessAsync(hostID, hostConditionalAccessStatus, mdmEnrolled, hostIsCompliantInFleet) + + return nil +} + +func (svc *Service) setHostConditionalAccessAsync( + hostID uint, + hostConditionalAccessStatus *fleet.HostConditionalAccessStatus, + managed bool, + compliant bool, +) { + go func() { + logger := log.With(svc.logger, + "msg", "set host conditional access", + "host_id", hostID, + "managed", managed, + "compliant", compliant, + ) + start := time.Now() + if err := svc.setHostConditionalAccess(hostID, hostConditionalAccessStatus, managed, compliant); err != nil { + level.Error(logger).Log("took", time.Since(start), "err", err) + } + level.Debug(logger).Log("took", time.Since(start)) + }() +} + +// conditionalAccessSetWaitTime is the interval to check for message status. +// It's a global variable to be set in tests. +var conditionalAccessSetWaitTime = 10 * time.Second + +func (svc *Service) setHostConditionalAccess( + hostID uint, + hostConditionalAccessStatus *fleet.HostConditionalAccessStatus, + managed bool, + compliant bool, +) error { + ctx := context.Background() + + integration, err := svc.ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get integration") + } + logger := log.With(svc.logger, + "msg", "set compliance status", + "host_id", hostID, + "managed", managed, + "compliant", compliant, + ) + level.Debug(logger).Log() + response, err := svc.conditionalAccessMicrosoftProxy.SetComplianceStatus(ctx, + integration.TenantID, + integration.ProxyServerSecret, + + hostConditionalAccessStatus.DeviceID, + hostConditionalAccessStatus.UserPrincipalName, + + managed, + hostConditionalAccessStatus.DisplayName, + "macOS", + hostConditionalAccessStatus.OSVersion, + compliant, + time.Now().UTC(), + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to set compliance status") + } + const ( + timeout = 1 * time.Minute + ) + level.Debug(logger).Log("msg", "set compliance status message sent") + startTime := time.Now() + for range time.Tick(conditionalAccessSetWaitTime) { + if time.Since(startTime) > timeout { + return ctxerr.Errorf(ctx, "timeout waiting for message after %s", time.Since(startTime)) + } + level.Debug(logger).Log("msg", "get compliance status message wait") + messageStatus, err := svc.conditionalAccessMicrosoftProxy.GetMessageStatus(ctx, + integration.TenantID, integration.ProxyServerSecret, response.MessageID, + ) + if err != nil { + // Retry again in case of network or transient errors. + level.Info(logger).Log("msg", "get message status, retrying", "err", err) + continue + } + if messageStatus.Status == conditional_access_microsoft_proxy.MessageStatusCompleted { + level.Debug(logger).Log( + "msg", "set device compliance status completed", + "took", time.Since(startTime), + ) + break + } + detail := "" + if messageStatus.Detail != nil { + detail = *messageStatus.Detail + } + level.Info(logger).Log( + "msg", "get message status, retrying", + "status", messageStatus.Status, + "detail", detail, + ) + } + + if err := svc.ds.SetHostConditionalAccessStatus(ctx, hostID, managed, compliant); err != nil { + return ctxerr.Wrap(ctx, err, "set conditional access status on datastore") + } + + return nil +} + func (svc *Service) maybeDebugHost( ctx context.Context, host *fleet.Host, diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 77064c08e5..e8e92d8626 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -263,6 +263,7 @@ var allDetailQueries = osquery_utils.GetDetailQueries( EnableHostUsers: true, EnableSoftwareInventory: true, }, + osquery_utils.Integrations{}, ) func expectedDetailQueriesForPlatform(platform string) map[string]osquery_utils.DetailQuery { diff --git a/server/service/osquery_utils/gen_queries_doc.go b/server/service/osquery_utils/gen_queries_doc.go index 39ea973a57..6555c2c183 100644 --- a/server/service/osquery_utils/gen_queries_doc.go +++ b/server/service/osquery_utils/gen_queries_doc.go @@ -30,6 +30,9 @@ func main() { EnableSoftwareInventory: true, EnableHostUsers: true, }, + osquery_utils.Integrations{ + ConditionalAccessMicrosoft: true, + }, ) var b strings.Builder diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 3265382528..506913d521 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -859,6 +859,15 @@ var windowsUpdateHistory = DetailQuery{ DirectIngestFunc: directIngestWindowsUpdateHistory, } +// entraIDDetails holds the query and ingestion function for Microsoft "Conditional access" feature. +var entraIDDetails = DetailQuery{ + // The query ingests Entra's Device ID and User Principal Name of the account that logged in to the device (using Company Portal.app). + Query: `SELECT * FROM (SELECT common_name AS device_id FROM certificates WHERE issuer LIKE '/DC=net+DC=windows+CN=MS-Organization-Access+OU%' LIMIT 1) + CROSS JOIN (SELECT label as user_principal_name FROM keychain_items WHERE account = 'com.microsoft.workplacejoin.registeredUserPrincipalName' LIMIT 1);`, + Platforms: []string{"darwin"}, + DirectIngestFunc: directIngestEntraIDDetails, +} + var softwareMacOS = DetailQuery{ // Note that we create the cached_users CTE (the WITH clause) in order to suggest to SQLite // that it generates the users once instead of once for each UNIONed query. We use CROSS JOIN to @@ -1518,6 +1527,34 @@ func directIngestWindowsUpdateHistory( return ds.InsertWindowsUpdates(ctx, host.ID, updates) } +func directIngestEntraIDDetails( + ctx context.Context, + logger log.Logger, + host *fleet.Host, + ds fleet.Datastore, + rows []map[string]string, +) error { + if len(rows) == 0 { + // Device maybe hasn't logged in to Entra ID yet. + return nil + } + row := rows[0] + + deviceID := row["device_id"] + if deviceID == "" { + return ctxerr.New(ctx, "empty Entra ID device_id") + } + userPrincipalName := row["user_principal_name"] + if userPrincipalName == "" { + return ctxerr.New(ctx, "empty Entra ID user_principal_name") + } + + if err := ds.CreateHostConditionalAccessStatus(ctx, host.ID, deviceID, userPrincipalName); err != nil { + return ctxerr.Wrap(ctx, err, "failed to create host conditional access status") + } + return nil +} + func directIngestScheduledQueryStats(ctx context.Context, logger log.Logger, host *fleet.Host, task *async.Task, rows []map[string]string) error { packs := map[string][]fleet.ScheduledQueryStats{} for _, row := range rows { @@ -2231,11 +2268,16 @@ var luksVerifyQueryIngester = func(decrypter func(string) (string, error)) func( //go:generate go run gen_queries_doc.go "../../../docs/Contributing/product-groups/orchestration/understanding-host-vitals.md" +type Integrations struct { + ConditionalAccessMicrosoft bool +} + func GetDetailQueries( ctx context.Context, fleetConfig config.FleetConfig, appConfig *fleet.AppConfig, features *fleet.Features, + integrations Integrations, ) map[string]DetailQuery { generatedMap := make(map[string]DetailQuery) for key, query := range hostDetailQueries { @@ -2281,6 +2323,10 @@ func GetDetailQueries( } } + if integrations.ConditionalAccessMicrosoft { + generatedMap["conditional_access_microsoft_device_id"] = entraIDDetails + } + if appConfig != nil && appConfig.MDM.EnableDiskEncryption.Value { luksVerifyQuery.DirectIngestFunc = luksVerifyQueryIngester(func(privateKey string) func(string) (string, error) { return func(encrypted string) (string, error) { diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 93c74e0006..cd5c06539b 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -38,7 +38,7 @@ func TestDetailQueryNetworkInterfaces(t *testing.T) { var initialHost fleet.Host host := initialHost - ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil)["network_interface_unix"].IngestFunc + ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil, Integrations{})["network_interface_unix"].IngestFunc assert.NoError(t, ingest(context.Background(), log.NewNopLogger(), &host, nil)) assert.Equal(t, initialHost, host) @@ -84,7 +84,7 @@ func TestDetailQueryScheduledQueryStats(t *testing.T) { return nil } - ingest := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, nil)["scheduled_query_stats"].DirectTaskIngestFunc + ingest := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, nil, Integrations{})["scheduled_query_stats"].DirectTaskIngestFunc ctx := context.Background() assert.NoError(t, ingest(ctx, log.NewNopLogger(), &host, task, nil)) @@ -263,7 +263,7 @@ func sortedKeysCompare(t *testing.T, m map[string]DetailQuery, expectedKeys []st } func TestGetDetailQueries(t *testing.T) { - queriesNoConfig := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil) + queriesNoConfig := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil, Integrations{}) baseQueries := []string{ "network_interface_unix", @@ -298,16 +298,16 @@ func TestGetDetailQueries(t *testing.T) { require.Len(t, queriesNoConfig, len(baseQueries)) sortedKeysCompare(t, queriesNoConfig, baseQueries) - queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil) + queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil, Integrations{}) require.Len(t, queriesWithoutWinOSVuln, 26) - queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true}) + queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true}, Integrations{}) qs := baseQueries qs = append(qs, "users", "users_chrome", "scheduled_query_stats") require.Len(t, queriesWithUsers, len(qs)) sortedKeysCompare(t, queriesWithUsers, qs) - queriesWithUsersAndSoftware := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true, EnableSoftwareInventory: true}) + queriesWithUsersAndSoftware := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true, EnableSoftwareInventory: true}, Integrations{}) qs = baseQueries qs = append(qs, "users", "users_chrome", "software_macos", "software_linux", "software_windows", "software_vscode_extensions", "software_chrome", "software_python_packages", "software_python_packages_with_users_dir", "scheduled_query_stats", "software_macos_firefox", "software_macos_codesign") @@ -327,14 +327,14 @@ func TestGetDetailQueries(t *testing.T) { ac := fleet.AppConfig{} ac.MDM.EnabledAndConfigured = true // windows mdm is disabled by default, windows mdm queries should not be present - gotQueries := GetDetailQueries(context.Background(), config.FleetConfig{}, &ac, nil) + gotQueries := GetDetailQueries(context.Background(), config.FleetConfig{}, &ac, nil, Integrations{}) wantQueries := baseQueries wantQueries = append(wantQueries, mdmQueriesBase...) require.Len(t, gotQueries, len(wantQueries)) sortedKeysCompare(t, gotQueries, wantQueries) // enable windows mdm, windows mdm queries should be present ac.MDM.WindowsEnabledAndConfigured = true - gotQueries = GetDetailQueries(context.Background(), config.FleetConfig{}, &ac, nil) + gotQueries = GetDetailQueries(context.Background(), config.FleetConfig{}, &ac, nil, Integrations{}) wantQueries = append(wantQueries, mdmQueriesWindows...) require.Len(t, gotQueries, len(wantQueries)) sortedKeysCompare(t, gotQueries, wantQueries) @@ -344,7 +344,7 @@ func TestDetailQueriesOSVersionUnixLike(t *testing.T) { var initialHost fleet.Host host := initialHost - ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil)["os_version"].IngestFunc + ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil, Integrations{})["os_version"].IngestFunc assert.NoError(t, ingest(context.Background(), log.NewNopLogger(), &host, nil)) assert.Equal(t, initialHost, host) @@ -418,7 +418,7 @@ func TestDetailQueriesOSVersionWindows(t *testing.T) { var initialHost fleet.Host host := initialHost - ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil)["os_version_windows"].IngestFunc + ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil, Integrations{})["os_version_windows"].IngestFunc assert.NoError(t, ingest(context.Background(), log.NewNopLogger(), &host, nil)) assert.Equal(t, initialHost, host) @@ -473,7 +473,7 @@ func TestDetailQueriesOSVersionChrome(t *testing.T) { var initialHost fleet.Host host := initialHost - ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil)["os_version"].IngestFunc + ingest := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, nil, Integrations{})["os_version"].IngestFunc assert.NoError(t, ingest(context.Background(), log.NewNopLogger(), &host, nil)) assert.Equal(t, initialHost, host) @@ -1289,29 +1289,29 @@ func TestDirectIngestOSUnixLike(t *testing.T) { } func TestAppConfigReplaceQuery(t *testing.T) { - queries := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true}) + queries := GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true}, Integrations{}) originalQuery := queries["users"].Query replacementMap := make(map[string]*string) replacementMap["users"] = ptr.String("select 1 from blah") - queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}) + queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}, Integrations{}) assert.NotEqual(t, originalQuery, queries["users"].Query) assert.Equal(t, "select 1 from blah", queries["users"].Query) replacementMap["users"] = nil - queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}) + queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}, Integrations{}) _, exists := queries["users"] assert.False(t, exists) // put the query back again replacementMap["users"] = ptr.String("select 1 from blah") - queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}) + queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}, Integrations{}) assert.NotEqual(t, originalQuery, queries["users"].Query) assert.Equal(t, "select 1 from blah", queries["users"].Query) // empty strings are also ignored replacementMap["users"] = ptr.String("") - queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}) + queries = GetDetailQueries(context.Background(), config.FleetConfig{}, nil, &fleet.Features{EnableHostUsers: true, DetailQueryOverrides: replacementMap}, Integrations{}) _, exists = queries["users"] assert.False(t, exists) } diff --git a/server/service/service.go b/server/service/service.go index 3b10648491..b3cfa86e41 100644 --- a/server/service/service.go +++ b/server/service/service.go @@ -19,6 +19,7 @@ import ( nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" nanomdm_storage "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage" "github.com/fleetdm/fleet/v4/server/service/async" + "github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy" "github.com/fleetdm/fleet/v4/server/sso" kitlog "github.com/go-kit/log" ) @@ -63,6 +64,33 @@ type Service struct { wstepCertManager microsoft_mdm.CertManager scepConfigService fleet.SCEPConfigService digiCertService fleet.DigiCertService + + conditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy +} + +// ConditionalAccessMicrosoftProxy is the interface of the Microsoft compliance proxy. +type ConditionalAccessMicrosoftProxy interface { + // Create creates the integration on the MS proxy and returns the consent URL. + Create(ctx context.Context, tenantID string) (*conditional_access_microsoft_proxy.CreateResponse, error) + // Get returns the integration settings. + Get(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.GetResponse, error) + // Delete deprovisions the tenant on Microsoft and deletes the integration in the proxy service. + // Returns a fleet.IsNotFound error if the integration doesn't exist. + Delete(ctx context.Context, tenantID string, secret string) (*conditional_access_microsoft_proxy.DeleteResponse, error) + // SetComplianceStatus sets the inventory and compliance status of a host. + // Returns the message ID to query the status of the operation (MS has an asynchronous API). + SetComplianceStatus( + ctx context.Context, + tenantID string, secret string, + deviceID string, + userPrincipalName string, + mdmEnrolled bool, + deviceName, osName, osVersion string, + compliant bool, + lastCheckInTime time.Time, + ) (*conditional_access_microsoft_proxy.SetComplianceStatusResponse, error) + // GetMessageStatusResponse returns the status of a "compliance set" operation. + GetMessageStatus(ctx context.Context, tenantID string, secret string, messageID string) (*conditional_access_microsoft_proxy.GetMessageStatusResponse, error) } func (svc *Service) LookupGeoIP(ctx context.Context, ip string) *fleet.GeoLocation { @@ -109,6 +137,7 @@ func NewService( wstepCertManager microsoft_mdm.CertManager, scepConfigService fleet.SCEPConfigService, digiCertService fleet.DigiCertService, + conditionalAccessProxy ConditionalAccessMicrosoftProxy, ) (fleet.Service, error) { authorizer, err := authz.NewAuthorizer() if err != nil { @@ -144,6 +173,8 @@ func NewService( wstepCertManager: wstepCertManager, scepConfigService: scepConfigService, digiCertService: digiCertService, + + conditionalAccessMicrosoftProxy: conditionalAccessProxy, } return validationMiddleware{svc, ds, sso}, nil } diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index d61331f357..ae61c91afd 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -85,6 +85,14 @@ func (svc *Service) License(ctx context.Context) (*fleet.LicenseInfo, error) { } lic, _ := license.FromContext(ctx) + + // Currently we use the presence of Microsoft Compliance Partner settings + // (only configured in cloud instances) to determine if a Fleet instance + // is a cloud managed instance. + if svc.config.MicrosoftCompliancePartner.IsSet() { + lic.ManagedCloud = true + } + return lic, nil } diff --git a/server/service/team_policies.go b/server/service/team_policies.go index c055fba979..1f0fc1786e 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -20,19 +20,20 @@ import ( ///////////////////////////////////////////////////////////////////////////////// type teamPolicyRequest struct { - TeamID uint `url:"team_id"` - QueryID *uint `json:"query_id"` - Query string `json:"query"` - Name string `json:"name"` - Description string `json:"description"` - Resolution string `json:"resolution"` - Platform string `json:"platform"` - Critical bool `json:"critical" premium:"true"` - CalendarEventsEnabled bool `json:"calendar_events_enabled"` - SoftwareTitleID *uint `json:"software_title_id"` - ScriptID *uint `json:"script_id"` - LabelsIncludeAny []string `json:"labels_include_any"` - LabelsExcludeAny []string `json:"labels_exclude_any"` + TeamID uint `url:"team_id"` + QueryID *uint `json:"query_id"` + Query string `json:"query"` + Name string `json:"name"` + Description string `json:"description"` + Resolution string `json:"resolution"` + Platform string `json:"platform"` + Critical bool `json:"critical" premium:"true"` + CalendarEventsEnabled bool `json:"calendar_events_enabled"` + SoftwareTitleID *uint `json:"software_title_id"` + ScriptID *uint `json:"script_id"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` + ConditionalAccessEnabled bool `json:"conditional_access_enabled"` } type teamPolicyResponse struct { @@ -45,18 +46,19 @@ func (r teamPolicyResponse) Error() error { return r.Err } func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*teamPolicyRequest) resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{ - QueryID: req.QueryID, - Name: req.Name, - Query: req.Query, - Description: req.Description, - Resolution: req.Resolution, - Platform: req.Platform, - Critical: req.Critical, - CalendarEventsEnabled: req.CalendarEventsEnabled, - SoftwareTitleID: req.SoftwareTitleID, - ScriptID: req.ScriptID, - LabelsIncludeAny: req.LabelsIncludeAny, - LabelsExcludeAny: req.LabelsExcludeAny, + QueryID: req.QueryID, + Name: req.Name, + Query: req.Query, + Description: req.Description, + Resolution: req.Resolution, + Platform: req.Platform, + Critical: req.Critical, + CalendarEventsEnabled: req.CalendarEventsEnabled, + SoftwareTitleID: req.SoftwareTitleID, + ScriptID: req.ScriptID, + LabelsIncludeAny: req.LabelsIncludeAny, + LabelsExcludeAny: req.LabelsExcludeAny, + ConditionalAccessEnabled: req.ConditionalAccessEnabled, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -155,19 +157,20 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea return fleet.PolicyPayload{}, err } return fleet.PolicyPayload{ - QueryID: p.QueryID, - Name: p.Name, - Query: p.Query, - Critical: p.Critical, - Description: p.Description, - Resolution: p.Resolution, - Platform: p.Platform, - CalendarEventsEnabled: p.CalendarEventsEnabled, - SoftwareInstallerID: softwareInstallerID, - VPPAppsTeamsID: vppAppsTeamsID, - ScriptID: p.ScriptID, - LabelsIncludeAny: p.LabelsIncludeAny, - LabelsExcludeAny: p.LabelsExcludeAny, + QueryID: p.QueryID, + Name: p.Name, + Query: p.Query, + Critical: p.Critical, + Description: p.Description, + Resolution: p.Resolution, + Platform: p.Platform, + CalendarEventsEnabled: p.CalendarEventsEnabled, + SoftwareInstallerID: softwareInstallerID, + VPPAppsTeamsID: vppAppsTeamsID, + ScriptID: p.ScriptID, + LabelsIncludeAny: p.LabelsIncludeAny, + LabelsExcludeAny: p.LabelsExcludeAny, + ConditionalAccessEnabled: p.ConditionalAccessEnabled, }, nil } @@ -525,6 +528,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.CalendarEventsEnabled != nil { policy.CalendarEventsEnabled = *p.CalendarEventsEnabled } + if p.ConditionalAccessEnabled != nil { + policy.ConditionalAccessEnabled = *p.ConditionalAccessEnabled + } if removeStats { policy.FailingHostCount = 0 policy.PassingHostCount = 0 diff --git a/server/service/team_schedule.go b/server/service/team_schedule.go index 6f938f91b5..6db344afd1 100644 --- a/server/service/team_schedule.go +++ b/server/service/team_schedule.go @@ -155,7 +155,6 @@ func modifyTeamScheduleEndpoint(ctx context.Context, request interface{}, svc fl return modifyTeamScheduleResponse{}, nil } -// TODO(lucas): Document new behavior. // teamID is not used because of mismatch between old internal representation and API. func (svc Service) ModifyTeamScheduledQueries( ctx context.Context, @@ -195,7 +194,6 @@ func deleteTeamScheduleEndpoint(ctx context.Context, request interface{}, svc fl return deleteTeamScheduleResponse{}, nil } -// TODO(lucas): Document new behavior. // teamID is not used because of mismatch between old internal representation and API. func (svc Service) DeleteTeamScheduledQueries(ctx context.Context, teamID uint, scheduledQueryID uint) error { return svc.DeleteQueryByID(ctx, scheduledQueryID) diff --git a/server/service/teams_test.go b/server/service/teams_test.go index 9960093391..7156d9f101 100644 --- a/server/service/teams_test.go +++ b/server/service/teams_test.go @@ -72,6 +72,9 @@ func TestTeamAuth(t *testing.T) { return &fleet.Team{ID: 2}, nil } } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } testCases := []struct { name string @@ -286,6 +289,9 @@ func TestApplyTeamSpecs(t *testing.T) { require.Len(t, act.Teams, 1) return nil } + ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) { + return nil, ¬FoundError{} + } _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{{Name: "team1", Features: tt.spec}}, fleet.ApplyTeamSpecOptions{}) require.NoError(t, err) diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 1ebc643943..33a32c92ac 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -64,13 +64,14 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf logger := kitlog.NewNopLogger() var ( - failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet() - enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{} - depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{} - mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }} - c clock.Clock = clock.C - scepConfigService = eeservice.NewSCEPConfigService(logger, nil) - digiCertService = digicert.NewService(digicert.WithLogger(logger)) + failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet() + enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{} + depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{} + mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }} + c clock.Clock = clock.C + scepConfigService = eeservice.NewSCEPConfigService(logger, nil) + digiCertService = digicert.NewService(digicert.WithLogger(logger)) + conditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy mdmStorage fleet.MDMAppleStore mdmPusher nanomdm_push.Pusher @@ -165,6 +166,10 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf if len(opts) > 0 && opts[0].DigiCertService != nil { digiCertService = opts[0].DigiCertService } + if len(opts) > 0 && opts[0].ConditionalAccessMicrosoftProxy != nil { + conditionalAccessMicrosoftProxy = opts[0].ConditionalAccessMicrosoftProxy + fleetConfig.MicrosoftCompliancePartner.ProxyAPIKey = "insecure" // setting this so the feature is "enabled". + } var wstepManager microsoft_mdm.CertManager if fleetConfig.MDM.WindowsWSTEPIdentityCert != "" && fleetConfig.MDM.WindowsWSTEPIdentityKey != "" { @@ -200,6 +205,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf wstepManager, scepConfigService, digiCertService, + conditionalAccessMicrosoftProxy, ) if err != nil { panic(err) @@ -324,38 +330,39 @@ func (svc *mockMailService) CanSendEmail(smtpSettings fleet.SMTPSettings) bool { type TestNewScheduleFunc func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc type TestServerOpts struct { - Logger kitlog.Logger - License *fleet.LicenseInfo - SkipCreateTestUsers bool - Rs fleet.QueryResultStore - Lq fleet.LiveQueryStore - Pool fleet.RedisPool - FailingPolicySet fleet.FailingPolicySet - Clock clock.Clock - Task *async.Task - EnrollHostLimiter fleet.EnrollHostLimiter - Is fleet.InstallerStore - FleetConfig *config.FleetConfig - MDMStorage fleet.MDMAppleStore - DEPStorage nanodep_storage.AllDEPStorage - SCEPStorage scep_depot.Depot - MDMPusher nanomdm_push.Pusher - HTTPServerConfig *http.Server - StartCronSchedules []TestNewScheduleFunc - UseMailService bool - APNSTopic string - ProfileMatcher fleet.ProfileMatcher - EnableCachedDS bool - NoCacheDatastore bool - SoftwareInstallStore fleet.SoftwareInstallerStore - BootstrapPackageStore fleet.MDMBootstrapPackageStore - KeyValueStore fleet.KeyValueStore - EnableSCEPProxy bool - WithDEPWebview bool - FeatureRoutes []endpoint_utils.HandlerRoutesFunc - SCEPConfigService fleet.SCEPConfigService - DigiCertService fleet.DigiCertService - EnableSCIM bool + Logger kitlog.Logger + License *fleet.LicenseInfo + SkipCreateTestUsers bool + Rs fleet.QueryResultStore + Lq fleet.LiveQueryStore + Pool fleet.RedisPool + FailingPolicySet fleet.FailingPolicySet + Clock clock.Clock + Task *async.Task + EnrollHostLimiter fleet.EnrollHostLimiter + Is fleet.InstallerStore + FleetConfig *config.FleetConfig + MDMStorage fleet.MDMAppleStore + DEPStorage nanodep_storage.AllDEPStorage + SCEPStorage scep_depot.Depot + MDMPusher nanomdm_push.Pusher + HTTPServerConfig *http.Server + StartCronSchedules []TestNewScheduleFunc + UseMailService bool + APNSTopic string + ProfileMatcher fleet.ProfileMatcher + EnableCachedDS bool + NoCacheDatastore bool + SoftwareInstallStore fleet.SoftwareInstallerStore + BootstrapPackageStore fleet.MDMBootstrapPackageStore + KeyValueStore fleet.KeyValueStore + EnableSCEPProxy bool + WithDEPWebview bool + FeatureRoutes []endpoint_utils.HandlerRoutesFunc + SCEPConfigService fleet.SCEPConfigService + DigiCertService fleet.DigiCertService + EnableSCIM bool + ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) { diff --git a/server/webhooks/failing_policies_test.go b/server/webhooks/failing_policies_test.go index 8cf2829f31..74538169db 100644 --- a/server/webhooks/failing_policies_test.go +++ b/server/webhooks/failing_policies_test.go @@ -125,7 +125,8 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) { "failing_host_count": 2, "host_count_updated_at": null, "critical": true, - "calendar_events_enabled": false + "calendar_events_enabled": false, + "conditional_access_enabled": false }, "hosts": [ { @@ -312,7 +313,8 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) { "failing_host_count": 1, "host_count_updated_at": null, "critical": false, - "calendar_events_enabled": true + "calendar_events_enabled": true, + "conditional_access_enabled": false }, "hosts": [ { diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 4cb68ec6b9..1a612a3a53 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -121,6 +121,10 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration Name string github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration URL string github.com/fleetdm/fleet/v4/server/fleet/CustomSCEPProxyIntegration Challenge string +github.com/fleetdm/fleet/v4/server/fleet/Integrations ConditionalAccessEnabled optjson.Bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM github.com/fleetdm/fleet/v4/server/fleet/MDM AppleServerURL string github.com/fleetdm/fleet/v4/server/fleet/MDM DeprecatedAppleBMDefaultTeam string @@ -162,9 +166,6 @@ github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.Str github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually optjson.Bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Script optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup Software optjson.Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[*github.com/fleetdm/fleet/v4/server/fleet.MacOSSetupSoftware] Set bool diff --git a/tools/msal/README.md b/tools/msal/README.md new file mode 100644 index 0000000000..c43b8f52b9 --- /dev/null +++ b/tools/msal/README.md @@ -0,0 +1,5 @@ +# MSAL sample app + +This is a sample Objective-C application to retrieve Entra's "Device ID" as documented by Microsoft. +We found a way to retrieve this information with osquery via detail queries (using `certificates` and `keychain_items` tables). +We are keeping this here for future reference in case we need to iterate the detail queries. diff --git a/tools/msal/main.m b/tools/msal/main.m new file mode 100644 index 0000000000..56dfa065af --- /dev/null +++ b/tools/msal/main.m @@ -0,0 +1,34 @@ +#import +#import + +void run(void) { + NSError *error = nil; + MSALPublicClientApplicationConfig *config = [[MSALPublicClientApplicationConfig alloc] + initWithClientId:@"" + redirectUri:nil + authority:nil]; + + MSALPublicClientApplication *application = [[MSALPublicClientApplication alloc] initWithConfiguration:config error:&error]; + + if (error) { + NSLog(@"Failed to create application: %@", error); + return; + } + + [application getDeviceInformationWithParameters:nil + completionBlock:^(MSALDeviceInformation * _Nullable deviceInformation, __unused NSError * _Nullable error) { + NSString *deviceId = deviceInformation.extraDeviceInformation[MSAL_PRIMARY_REGISTRATION_DEVICE_ID]; + NSString *upn = deviceInformation.extraDeviceInformation[MSAL_PRIMARY_REGISTRATION_UPN]; + + NSLog(@"deviceId = %s, upn = %s", (char*)[deviceId UTF8String], (char*)[upn UTF8String]); + + }]; + +} + +int main(int argc, const char * argv[]) { + @autoreleasepool { + run(); + } + return 0; +} diff --git a/webpack.config.js b/webpack.config.js index 8786f73cd1..e99b2b67ca 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -27,9 +27,6 @@ let plugins = [ new webpack.DefinePlugin({ featureFlags: { // e.g.: allowGitOpsMode: JSON.stringify(process.env.ALLOW_GITOPS_MODE), - allowConditionalAccess: JSON.stringify( - process.env.ALLOW_CONDITIONAL_ACCESS - ), }, }), ];