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:
Lucas Manuel Rodriguez 2025-06-11 14:22:46 -03:00 committed by GitHub
parent f72237678f
commit 1c5700a8c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 3069 additions and 288 deletions

View file

@ -0,0 +1 @@
* Added support for Microsoft "Conditional acccess" as a compliance partner.

View file

@ -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")

View file

@ -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, &notFoundError{}
}
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, &notFoundError{}
}
// 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, &notFoundError{}
}
// 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, &notFoundError{}
}
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 ""
}

View file

@ -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 {

View file

@ -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,

View file

@ -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)
}

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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",

View file

@ -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: ""

View file

@ -193,6 +193,7 @@
"group_id": 123456789
}
],
"conditional_access_enabled": true,
"google_calendar": [
{
"domain": "fleetdm.com",

View file

@ -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

View file

@ -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

View file

@ -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: {}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -11,6 +11,7 @@ spec:
enable_software_inventory: false
integrations:
google_calendar: null
conditional_access_enabled: null
mdm:
enable_disk_encryption: false
macos_settings:

View file

@ -10,6 +10,7 @@ spec:
host_expiry_window: 0
integrations:
google_calendar: null
conditional_access_enabled: null
mdm:
enable_disk_encryption: false
macos_settings:

View file

@ -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 {

View file

@ -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).

View file

@ -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

View file

@ -4726,7 +4726,7 @@ None.
```json
{
"admin_consented": false
"configuration_completed": false
}
```

View file

@ -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">

View file

@ -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{}

View file

@ -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
}

View file

@ -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",

View file

@ -51,7 +51,7 @@ const getIntegrationSettingsNavItems = (
},
];
if (isManagedCloud && featureFlags.allowConditionalAccess === "true") {
if (isManagedCloud) {
items.push({
title: "Conditional access",
urlSection: "conditional-access",

View file

@ -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);

View file

@ -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",

View file

@ -45,7 +45,7 @@ const config: Config = {
transformIgnorePatterns: [`/node_modules/(?!(${esModules})/)`],
globals: {
TransformStream,
featureFlags: { allowConditionalAccess: "true" },
featureFlags: {},
},
};

View file

@ -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>

View file

@ -0,0 +1,3 @@
#!/bin/bash
open "/Applications/Company Portal.app" --args -r

View file

@ -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

View file

@ -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
}

View file

@ -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

View 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
}

View 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)
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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"
}`
}

View file

@ -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 {

View 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
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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.

View file

@ -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 {

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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{})
}

View 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
}

View file

@ -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 &notFoundError{}
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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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 = &notFoundError{}
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)
}

View file

@ -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{

View file

@ -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,

View file

@ -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 {

View file

@ -30,6 +30,9 @@ func main() {
EnableSoftwareInventory: true,
EnableHostUsers: true,
},
osquery_utils.Integrations{
ConditionalAccessMicrosoft: true,
},
)
var b strings.Builder

View file

@ -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) {

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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, &notFoundError{}
}
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, &notFoundError{}
}
_, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{{Name: "team1", Features: tt.spec}}, fleet.ApplyTeamSpecOptions{})
require.NoError(t, err)

View file

@ -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) {

View file

@ -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": [
{

View file

@ -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
View 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
View 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;
}

View file

@ -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
),
},
}),
];