mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Microsoft Compliance Partner backend changes (#29540)
For #27042. Ready for review, just missing integration tests that I will be writing today. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features. - [X] If database migrations are included, checked table schema to confirm autoupdate - For new Fleet configuration settings - [X] Verified that the setting can be managed via GitOps, or confirmed that the setting is explicitly being excluded from GitOps. If managing via Gitops: - [X] Verified that the setting is exported via `fleetctl generate-gitops` - [X] Added the setting to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [X] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [X] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Added/updated automated tests - [X] Manual QA for all new/changed functionality --------- Co-authored-by: jacobshandling <61553566+jacobshandling@users.noreply.github.com> Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
parent
f72237678f
commit
1c5700a8c4
92 changed files with 3069 additions and 288 deletions
1
changes/27042-microsoft-compliance-partner-backend
Normal file
1
changes/27042-microsoft-compliance-partner-backend
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added support for Microsoft "Conditional acccess" as a compliance partner.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@
|
|||
"group_id": 123456789
|
||||
}
|
||||
],
|
||||
"conditional_access_enabled": true,
|
||||
"google_calendar": [
|
||||
{
|
||||
"domain": "fleetdm.com",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
resolution: Do a global thing
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
yara_rules: {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
host_percentage: 2
|
||||
|
|
|
|||
|
|
@ -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
|
||||
host_percentage: 2
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ spec:
|
|||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
conditional_access_enabled: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ spec:
|
|||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
conditional_access_enabled: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4726,7 +4726,7 @@ None.
|
|||
|
||||
```json
|
||||
{
|
||||
"admin_consented": false
|
||||
"configuration_completed": false
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
<meta name="title" value="Audit logs">
|
||||
<meta name="pageOrderInSection" value="1400">
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const getIntegrationSettingsNavItems = (
|
|||
},
|
||||
];
|
||||
|
||||
if (isManagedCloud && featureFlags.allowConditionalAccess === "true") {
|
||||
if (isManagedCloud) {
|
||||
items.push({
|
||||
title: "Conditional access",
|
||||
urlSection: "conditional-access",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const config: Config = {
|
|||
transformIgnorePatterns: [`/node_modules/(?!(${esModules})/)`],
|
||||
globals: {
|
||||
TransformStream,
|
||||
featureFlags: { allowConditionalAccess: "true" },
|
||||
featureFlags: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>ExtensionData</key>
|
||||
<dict>
|
||||
<key>Enable_SSO_On_All_ManagedApps</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>ExtensionIdentifier</key>
|
||||
<string>com.microsoft.CompanyPortalMac.ssoextension</string>
|
||||
<key>Hosts</key>
|
||||
<array/>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Company Portal Single Sign-On Extension</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.apple.extensiblesso.F82C8673-439F-4751-B562-42517A5FD990</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.extensiblesso</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>F82C8673-439F-4751-B562-42517A5FD990</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>TeamIdentifier</key>
|
||||
<string>UBF8T346G9</string>
|
||||
<key>Type</key>
|
||||
<string>Redirect</string>
|
||||
<key>URLs</key>
|
||||
<array>
|
||||
<string>https://login.microsoftonline.com</string>
|
||||
<string>https://login.microsoft.com</string>
|
||||
<string>https://sts.windows.net</string>
|
||||
<string>https://login.partner.microsoftonline.cn</string>
|
||||
<string>https://login.chinacloudapi.cn</string>
|
||||
<string>https://login.microsoftonline.us</string>
|
||||
<string>https://login-us.microsoftonline.com</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Company Portal Single Sign-On Extension</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.D4DB9649-BA4A-4FA1-AA4F-D1CF606308B1</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<true/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>com.fleetdm.D4DB9649-BA4A-4FA1-AA4F-D1CF606308B1</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
|
||||
open "/Applications/Company Portal.app" --args -r
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
148
server/datastore/mysql/conditional_access_microsoft.go
Normal file
148
server/datastore/mysql/conditional_access_microsoft.go
Normal file
|
|
@ -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
|
||||
}
|
||||
187
server/datastore/mysql/conditional_access_microsoft_test.go
Normal file
187
server/datastore/mysql/conditional_access_microsoft_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
46
server/fleet/conditional_access_microsoft.go
Normal file
46
server/fleet/conditional_access_microsoft.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
}
|
||||
|
|
|
|||
241
server/service/conditional_access_microsoft.go
Normal file
241
server/service/conditional_access_microsoft.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ func main() {
|
|||
EnableSoftwareInventory: true,
|
||||
EnableHostUsers: true,
|
||||
},
|
||||
osquery_utils.Integrations{
|
||||
ConditionalAccessMicrosoft: true,
|
||||
},
|
||||
)
|
||||
var b strings.Builder
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
tools/msal/README.md
Normal file
5
tools/msal/README.md
Normal file
|
|
@ -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.
|
||||
34
tools/msal/main.m
Normal file
34
tools/msal/main.m
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#import <Foundation/Foundation.h>
|
||||
#import <MSAL/MSAL.h>
|
||||
|
||||
void run(void) {
|
||||
NSError *error = nil;
|
||||
MSALPublicClientApplicationConfig *config = [[MSALPublicClientApplicationConfig alloc]
|
||||
initWithClientId:@"<CLIENT_ID>"
|
||||
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;
|
||||
}
|
||||
|
|
@ -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
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue