diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 23b6695ad6..93a9afd54d 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -206,6 +206,8 @@ const DEFAULT_CONFIG_MOCK: IConfig = { features: { enable_host_users: true, enable_software_inventory: true, + enable_conditional_access: true, + enable_conditional_access_bypass: true, }, fleet_desktop: { transparency_url: "https://fleetdm.com/transparency", diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 8a9cd86a86..b58c51ab4b 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -142,6 +142,7 @@ export enum ActivityType { DeletedMSEntraConditionalAccess = "deleted_conditional_access_integration_microsoft", AddedConditionalAccessOkta = "added_conditional_access_okta", DeletedConditionalAccessOkta = "deleted_conditional_access_okta", + HostBypassedConditionalAccess = "host_bypassed_conditional_access", UpdatedConditionalAccessBypass = "update_conditional_access_bypass", // enable/disable above feature for a team EnabledConditionalAccessAutomations = "enabled_conditional_access_automations", @@ -272,6 +273,7 @@ export interface IActivityDetails { webhook_url?: string; custom_variable_name?: string; host_idp_username?: string; + idp_full_name?: string; } // maps activity types to their corresponding label to use when filtering activites via the dropdown @@ -422,6 +424,8 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { [ActivityType.EditedAndroidCertificate]: "GitOps: edited certificate templates: Android", [ActivityType.AddedConditionalAccessOkta]: "Added conditional access: Okta", + [ActivityType.HostBypassedConditionalAccess]: + "Host bypassed conditional access", [ActivityType.UpdatedConditionalAccessBypass]: "Updated conditional access experience", [ActivityType.DeletedConditionalAccessOkta]: diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 041abaa045..d2c0b5423c 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -96,7 +96,12 @@ export interface IDeviceGlobalConfig { enabled_and_configured: boolean; require_all_software_macos: boolean | null; }; - features: Pick; + features: Pick< + IConfigFeatures, + | "enable_software_inventory" + | "enable_conditional_access" + | "enable_conditional_access_bypass" + >; } export interface IFleetDesktopSettings { @@ -107,6 +112,8 @@ export interface IFleetDesktopSettings { export interface IConfigFeatures { enable_host_users: boolean; enable_software_inventory: boolean; + enable_conditional_access: boolean; + enable_conditional_access_bypass: boolean; } export interface IConfigServerSettings { diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 66fb8eeabd..567ce32666 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1379,6 +1379,16 @@ const TAGGED_TEMPLATES = { deletedConditionalAccessOkta: () => ( <> deleted Okta conditional access configuration. ), + hostBypassedConditionalAccess: (activity: IActivity) => { + const idpFullName = activity.details?.idp_full_name; + const hostDisplayName = activity.details?.host_display_name; + return ( + <> + {idpFullName} temporarily bypassed conditional access + for {hostDisplayName}. + + ); + }, updatedConditionalAccessBypass: () => ( <> edited conditional access end user experience. ), @@ -2032,6 +2042,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.EnabledConditionalAccessAutomations: { return TAGGED_TEMPLATES.enabledConditionalAccessAutomations(activity); } + case ActivityType.HostBypassedConditionalAccess: { + return TAGGED_TEMPLATES.hostBypassedConditionalAccess(activity); + } case ActivityType.DisabledConditionalAccessAutomations: { return TAGGED_TEMPLATES.disabledConditionalAccessAutomations(activity); } diff --git a/frontend/pages/hosts/details/DeviceUserPage/BypassModal/BypassModal.tsx b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/BypassModal.tsx new file mode 100644 index 0000000000..5fda91a380 --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/BypassModal.tsx @@ -0,0 +1,39 @@ +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; +import React from "react"; + +const baseClass = "device-bypass-modal"; + +interface IBypassModal { + onCancel: () => void; + onResolveLater: () => void; + isLoading: boolean; +} + +const BypassModal = ({ onCancel, onResolveLater, isLoading }: IBypassModal) => { + return ( + + <> +

+ This will allow you to log in with Okta once. +
+
+ Please resolve all policies marked "Action required" to + restore access for subsequent logins. +

+
+ +
+ +
+ ); +}; + +export default BypassModal; diff --git a/frontend/pages/hosts/details/DeviceUserPage/BypassModal/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/_styles.scss new file mode 100644 index 0000000000..a424d72a5c --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/_styles.scss @@ -0,0 +1,3 @@ +.bypass-modal { + +} diff --git a/frontend/pages/hosts/details/DeviceUserPage/BypassModal/index.ts b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/index.ts new file mode 100644 index 0000000000..379a9d4b7d --- /dev/null +++ b/frontend/pages/hosts/details/DeviceUserPage/BypassModal/index.ts @@ -0,0 +1 @@ +export { default } from "./BypassModal"; diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx index 5bd9a08368..e718a227d3 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tests.tsx @@ -9,6 +9,8 @@ import createMockLicense from "__mocks__/licenseMock"; import { IGetSetupExperienceStatusesResponse } from "services/entities/device_user"; +import { IHostPolicy } from "interfaces/policy"; + import { customDeviceHandler, defaultDeviceCertificatesHandler, @@ -17,6 +19,7 @@ import { emptySetupExperienceHandler, } from "test/handlers/device-handler"; import DeviceUserPage from "./DeviceUserPage"; +import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal"; const mockRouter = createMockRouter(); @@ -312,7 +315,11 @@ describe("Device User Page", () => { { host, global_config: { - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, mdm: { enabled_and_configured: true, require_all_software_macos: true, @@ -359,7 +366,11 @@ describe("Device User Page", () => { { host, global_config: { - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, mdm: { enabled_and_configured: true, require_all_software_macos: true, @@ -440,7 +451,11 @@ describe("Device User Page", () => { enabled_and_configured: true, require_all_software_macos: false, }, - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, }, }); @@ -460,7 +475,11 @@ describe("Device User Page", () => { enabled_and_configured: true, require_all_software_macos: false, }, - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, }, }); @@ -480,7 +499,11 @@ describe("Device User Page", () => { enabled_and_configured: false, require_all_software_macos: false, }, - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, }, }); @@ -501,7 +524,11 @@ describe("Device User Page", () => { enabled_and_configured: true, require_all_software_macos: false, }, - features: { enable_software_inventory: true }, + features: { + enable_software_inventory: true, + enable_conditional_access: false, + enable_conditional_access_bypass: false, + }, }, }); @@ -509,4 +536,92 @@ describe("Device User Page", () => { expect(btn).toBeNull(); }); }); + + describe("Conditional access feature flags", () => { + // Test PolicyDetailsModal directly to verify the onResolveLater behavior + // which is controlled by enable_conditional_access and enable_conditional_access_bypass flags + const createFailingConditionalAccessPolicy = (): IHostPolicy => ({ + id: 1, + name: "Test Policy", + query: "SELECT 1", + description: "Test description", + author_id: 1, + author_name: "Test Author", + author_email: "test@example.com", + resolution: "Fix the issue", + platform: "darwin", + team_id: null, + created_at: "2022-01-01T12:00:00Z", + updated_at: "2022-01-02T12:00:00Z", + critical: false, + calendar_events_enabled: false, + conditional_access_enabled: true, + response: "fail", + }); + + it("shows 'Resolve later' button when onResolveLater is provided and policy is failing conditional access", () => { + createCustomRenderer({})( + + ); + + expect( + screen.getByRole("button", { name: "Resolve later" }) + ).toBeInTheDocument(); + }); + + it("does not show 'Resolve later' button when onResolveLater is not provided", () => { + createCustomRenderer({})( + + ); + + expect( + screen.queryByRole("button", { name: "Resolve later" }) + ).not.toBeInTheDocument(); + }); + + it("does not show 'Resolve later' button when policy is passing", () => { + const passingPolicy = { + ...createFailingConditionalAccessPolicy(), + response: "pass" as const, + }; + + createCustomRenderer({})( + + ); + + expect( + screen.queryByRole("button", { name: "Resolve later" }) + ).not.toBeInTheDocument(); + }); + + it("does not show 'Resolve later' button when policy does not have conditional_access_enabled", () => { + const nonConditionalPolicy = { + ...createFailingConditionalAccessPolicy(), + conditional_access_enabled: false, + }; + + createCustomRenderer({})( + + ); + + expect( + screen.queryByRole("button", { name: "Resolve later" }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index caf7de07a8..4df937fbba 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -97,6 +97,7 @@ import { REFETCH_HOST_DETAILS_POLLING_INTERVAL } from "../HostDetailsPage/HostDe import SettingUpYourDevice from "./components/SettingUpYourDevice"; import InfoButton from "./components/InfoButton"; +import BypassModal from "./BypassModal"; const baseClass = "device-user"; @@ -148,6 +149,7 @@ const DeviceUserPage = ({ NotificationContext ); + const [showBypassModal, setShowBypassModal] = useState(false); const [showBitLockerPINModal, setShowBitLockerPINModal] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false); const [showEnrollMdmModal, setShowEnrollMdmModal] = useState(false); @@ -434,6 +436,14 @@ const DeviceUserPage = ({ } ); + const { bypassConditionalAccess } = deviceUserAPI; + + const [isLoadingBypass, setIsLoadingBypass] = useState(false); + + const toggleShowBypassModal = useCallback(() => { + setShowBypassModal(!showBypassModal); + }, [showBypassModal, setShowBypassModal]); + const toggleInfoModal = useCallback(() => { setShowInfoModal(!showInfoModal); }, [showInfoModal, setShowInfoModal]); @@ -815,6 +825,9 @@ const DeviceUserPage = ({ togglePolicyDetailsModal={togglePolicyDetailsModal} hostPlatform={host?.platform || ""} router={router} + conditionalAccessEnabled={ + globalConfig?.features?.enable_conditional_access + } /> )} @@ -833,6 +846,15 @@ const DeviceUserPage = ({ { + onCancelPolicyDetailsModal(); + setShowBypassModal(true); + } + : undefined + } /> )} {!!host && showOSSettingsModal && ( @@ -936,6 +958,30 @@ const DeviceUserPage = ({
{renderDeviceUserPage()}
)} {showInfoModal && } + {showBypassModal && ( + { + setIsLoadingBypass(true); + try { + await bypassConditionalAccess(deviceAuthToken); + renderFlash( + "success", + "Access has been temporarily restored. You may now attempt to sign in again." + ); + } catch { + renderFlash( + "error", + `Couldn't restore access. Please click "Refetch" and try again.` + ); + } finally { + setIsLoadingBypass(false); + setShowBypassModal(false); + } + }} + isLoading={isLoadingBypass} + /> + )} ); }; diff --git a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx index 952b11e310..4a514e23ff 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx @@ -26,6 +26,7 @@ interface IPoliciesProps { hostPlatform: string; router: InjectedRouter; currentTeamId?: number; + conditionalAccessEnabled?: boolean; } interface IHostPoliciesRowProps extends Row { @@ -40,6 +41,7 @@ const Policies = ({ hostPlatform, router, currentTeamId, + conditionalAccessEnabled, }: IPoliciesProps): JSX.Element => { const tableHeaders = generatePolicyTableHeaders(currentTeamId); if (deviceUser) { @@ -109,11 +111,15 @@ const Policies = ({ return ( <> {failingResponses?.length > 0 && ( - + )} { +const getPolicyStatus = ( + policy: IHostPolicy, + conditionalAccessEnabled: boolean +): PolicyStatus | null => { if (policy.response === "pass") { return "pass"; } if (policy.response === "fail") { - if (policy.conditional_access_enabled) { + if (policy.conditional_access_enabled && conditionalAccessEnabled) { return "actionRequired"; } return "fail"; @@ -137,11 +140,12 @@ const generatePolicyTableHeaders = (currentTeamId?: number): IDataColumn[] => { }; const generatePolicyDataSet = ( - policies: IHostPolicy[] + policies: IHostPolicy[], + conditionalAccessEnabled: boolean ): IEnhancedHostPolicy[] => { return policies.map((policy) => ({ ...policy, - status: getPolicyStatus(policy), + status: getPolicyStatus(policy, conditionalAccessEnabled), })); }; diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx index cad15bb75b..6e4ace9f4a 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyDetailsModal/PolicyDetailsModal.tsx @@ -8,6 +8,7 @@ import ClickableUrls from "components/ClickableUrls/ClickableUrls"; interface IPolicyDetailsProps { onCancel: () => void; policy: IHostPolicy | null; + onResolveLater?: () => void; } const baseClass = "policy-details-modal"; @@ -15,6 +16,7 @@ const baseClass = "policy-details-modal"; const PolicyDetailsModal = ({ onCancel, policy, + onResolveLater, }: IPolicyDetailsProps): JSX.Element => { return ( + {policy?.conditional_access_enabled && + policy.response === "fail" && + onResolveLater && ( + + )} diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx index f1d9034c31..5c201acb98 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/PolicyFailingCount/PolicyFailingCount.tsx @@ -9,33 +9,56 @@ const baseClass = "policy-failing-count"; interface IPolicyFailingCountProps { policyList: IHostPolicy[]; deviceUser?: boolean; + conditionalAccessEnabled?: boolean; } const PolicyFailingCount = ({ policyList, deviceUser, + conditionalAccessEnabled, }: IPolicyFailingCountProps): JSX.Element | null => { const failCount = policyList.reduce((sum, policy) => { return policy.response === "fail" ? sum + 1 : sum; }, 0); + const blockingCount = policyList.reduce((sum, policy) => { + return policy.response === "fail" && policy.conditional_access_enabled + ? sum + 1 + : sum; + }, 0); + + const message = + !conditionalAccessEnabled || blockingCount === 0 ? ( + + + This device is failing + {failCount === 1 ? " 1 policy" : ` ${failCount} policies`} + +
+ Click a policy below to see if there are steps you can take to resolve + the issue + {failCount > 1 ? "s" : ""}. + {deviceUser && " Once resolved, click “Refetch” above to confirm."} +
+ ) : ( + + + {blockingCount === 1 + ? "1 policy is " + : `${blockingCount} policies are `} + blocking login + +
+ To restore access, click on the policies makes "Action + required" and follow the resolution steps. + {deviceUser && ' Once resolved, click "Refetch" to check status.'} +
+ ); return failCount ? ( - - This device is failing - {failCount === 1 ? " 1 policy" : ` ${failCount} policies`} - -
- Click a policy below to see if there are steps you can take to - resolve the issue - {failCount > 1 ? "s" : ""}. - {deviceUser && " Once resolved, click “Refetch” above to confirm."} - - } + message={message} />
) : null; diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index 445161f7e2..093937f15c 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -195,4 +195,9 @@ export default { const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; return sendRequest("GET", DEVICE_USER_MDM_ENROLLMENT_PROFILE(token)); }, + + bypassConditionalAccess: (token: string) => { + const { DEVICE_BYPASS_CONDITIONAL_ACCESS } = endpoints; + return sendRequest("POST", DEVICE_BYPASS_CONDITIONAL_ACCESS(token)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index f1d639d659..9f1198801a 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -69,6 +69,8 @@ export default { }, DEVICE_RESEND_PROFILE: (token: string, profileUUID: string) => `/${API_VERSION}/fleet/device/${token}/configuration_profiles/${profileUUID}/resend`, + DEVICE_BYPASS_CONDITIONAL_ACCESS: (token: string) => + `/${API_VERSION}/fleet/device/${token}/bypass_conditional_access`, // Host endpoints HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 73e0eb3e27..028ea89d6b 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -3081,10 +3081,6 @@ func (a ActivityTypeHostBypassedConditionalAccess) ActivityName() string { return "host_bypassed_conditional_access" } -func (a ActivityTypeHostBypassedConditionalAccess) HostIDs() []uint { - return []uint{a.HostID} -} - func (a ActivityTypeHostBypassedConditionalAccess) Documentation() (activity string, details string, detailsExample string) { return `Generated when a host bypasses conditional access.`, `This activity contains the following fields: diff --git a/server/fleet/app.go b/server/fleet/app.go index a8b1a3c5c3..56e648232b 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1639,7 +1639,9 @@ type DeviceGlobalMDMConfig struct { type DeviceFeatures struct { // EnableSoftwareInventory is the setting used by the device's team (or // globally in the AppConfig if the device is not in any team). - EnableSoftwareInventory bool `json:"enable_software_inventory"` + EnableSoftwareInventory bool `json:"enable_software_inventory"` + EnableConditionalAccess bool `json:"enable_conditional_access"` + EnableConditionalAccessBypass bool `json:"enable_conditional_access_bypass"` } // Version is the authz type used to check access control to the version endpoint. diff --git a/server/service/devices.go b/server/service/devices.go index 9ecaf074e4..495b6cd073 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -192,6 +192,7 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S softwareInventoryEnabled := ac.Features.EnableSoftwareInventory requireAllSoftware := ac.MDM.MacOSSetup.RequireAllSoftware + var conditionalAccessEnabled bool if resp.TeamID != nil { // load the team to get the device's team's software inventory config. tm, err := svc.GetTeam(ctx, *resp.TeamID) @@ -201,6 +202,7 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S if tm != nil { softwareInventoryEnabled = tm.Config.Features.EnableSoftwareInventory // TODO: We should look for opportunities to fix the confusing name of the `global_config` object in the API response. Also, how can we better clarify/document the expected order of precedence for team and global feature flags? requireAllSoftware = tm.Config.MDM.MacOSSetup.RequireAllSoftware + conditionalAccessEnabled = ac.ConditionalAccess.OktaConfigured() && tm.Config.Integrations.ConditionalAccessEnabled.Valid && tm.Config.Integrations.ConditionalAccessEnabled.Value } } @@ -221,7 +223,9 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S RequireAllSoftware: requireAllSoftware, }, Features: fleet.DeviceFeatures{ - EnableSoftwareInventory: softwareInventoryEnabled, + EnableSoftwareInventory: softwareInventoryEnabled, + EnableConditionalAccess: conditionalAccessEnabled, + EnableConditionalAccessBypass: ac.ConditionalAccess != nil && ac.ConditionalAccess.BypassEnabled(), }, } diff --git a/server/service/devices_endpoint_test.go b/server/service/devices_endpoint_test.go index 0b83659030..b2be398482 100644 --- a/server/service/devices_endpoint_test.go +++ b/server/service/devices_endpoint_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/fleet" @@ -253,3 +254,143 @@ func TestGetDeviceHostEndpointNoScrubbingForMacOS(t *testing.T) { assert.Equal(t, 100, deviceResp.License.DeviceCount) assert.False(t, deviceResp.License.Expiration.IsZero()) } + +func TestGetDeviceHostEndpointConditionalAccessBypass(t *testing.T) { + // Tests for EnableConditionalAccessBypass in DeviceFeatures for hosts WITHOUT teams. + // For hosts without a team, EnableConditionalAccess is always false because + // conditional access requires team membership + global Okta config + team config. + // EnableConditionalAccessBypass is controlled solely by AppConfig.ConditionalAccess. + + cases := []struct { + name string + conditionalAccessConfig *fleet.ConditionalAccessSettings + expectedEnableConditionalAccess bool + expectedEnableBypass bool + }{ + { + name: "No ConditionalAccess config", + conditionalAccessConfig: nil, + expectedEnableConditionalAccess: false, + expectedEnableBypass: false, + }, + { + name: "ConditionalAccess set, bypass default (BypassDisabled not set)", + conditionalAccessConfig: &fleet.ConditionalAccessSettings{ + // BypassDisabled not set (Valid=false) -> bypass enabled by default + }, + expectedEnableConditionalAccess: false, + expectedEnableBypass: true, + }, + { + name: "ConditionalAccess set, bypass explicitly disabled", + conditionalAccessConfig: &fleet.ConditionalAccessSettings{ + BypassDisabled: optjson.SetBool(true), + }, + expectedEnableConditionalAccess: false, + expectedEnableBypass: false, + }, + { + name: "ConditionalAccess set, bypass explicitly enabled", + conditionalAccessConfig: &fleet.ConditionalAccessSettings{ + BypassDisabled: optjson.SetBool(false), + }, + expectedEnableConditionalAccess: false, + expectedEnableBypass: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) + + h := &fleet.Host{ + ID: 1, + Hostname: "test-host", + Platform: "darwin", + // TeamID is nil - host has no team + } + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) { + return time.Now(), nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgLogoURL: "http://example.com/logo.png", + }, + ConditionalAccess: tc.conditionalAccessConfig, + }, nil + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error { + return nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) { + return nil, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { + return nil, nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) { + return nil, nil + } + ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { + return false, nil + } + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + return &fleet.HostLockWipeStatus{}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + return nil, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + + // Inject host into context + ctx = host.NewContext(ctx, h) + // Inject authz context + authzCtx := &authz.AuthorizationContext{} + authzCtx.SetAuthnMethod(authz.AuthnDeviceToken) + ctx = authz.NewContext(ctx, authzCtx) + + req := &getDeviceHostRequest{ + Token: "test-token", + } + + resp, err := getDeviceHostEndpoint(ctx, req, svc) + require.NoError(t, err) + + deviceResp, ok := resp.(getDeviceHostResponse) + require.True(t, ok) + require.NoError(t, deviceResp.Err) + + // Verify conditional access features + assert.Equal(t, tc.expectedEnableConditionalAccess, deviceResp.GlobalConfig.Features.EnableConditionalAccess, + "EnableConditionalAccess mismatch") + assert.Equal(t, tc.expectedEnableBypass, deviceResp.GlobalConfig.Features.EnableConditionalAccessBypass, + "EnableConditionalAccessBypass mismatch") + }) + } +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index ff329ee79b..6f9f769848 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3912,6 +3912,155 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { require.True(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory) } +// TestDeviceHostConditionalAccessFeatures tests the EnableConditionalAccess and +// EnableConditionalAccessBypass flags in the device endpoint response for hosts WITH teams. +// EnableConditionalAccess requires: host has team + global Okta configured + team conditional access enabled. +// EnableConditionalAccessBypass is controlled solely by AppConfig.ConditionalAccess.BypassEnabled(). +func (s *integrationEnterpriseTestSuite) TestDeviceHostConditionalAccessFeatures() { + t := s.T() + ctx := t.Context() + + s.clearOktaConditionalAccess() + // Clean up Okta conditional access config at the end + t.Cleanup(func() { + s.clearOktaConditionalAccess() + }) + + // Create a test team + team, err := s.ds.NewTeam(ctx, &fleet.Team{ + Name: "team-conditional-access-test", + Description: "Test team for conditional access", + }) + require.NoError(t, err) + + // Create a host with device token and assign to team + token := "conditional_access_features_test_token" + host := createHostAndDeviceToken(t, s.ds, token) + err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})) + require.NoError(t, err) + + // Helper to call the device endpoint and return the response + getDeviceHost := func() getDeviceHostResponse { + var resp getDeviceHostResponse + res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK) + err := json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + res.Body.Close() + return resp + } + + // Test case 1: No global Okta configured, team conditional access not enabled + // Expected: EnableConditionalAccess=false, EnableConditionalAccessBypass=false (no ConditionalAccess in AppConfig) + t.Run("no_okta_no_team_config", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.False(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should be false when Okta not configured") + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should be true when no ConditionalAccess config") + }) + + // Configure Okta at global level (without bypass settings first) + validCert := `-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQDU+pQ4P2GH3jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjMxMjA2MTYyOTQ0WhcNMjQxMjA1MTYyOTQ0WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC8 +fKEMF69sJR8Ky7Vrt3EfJvL3NlVPgj/OAkFhpKgL9QPrjAAY9qdmkLuU5eBPpSvB +jAqGUEBLHKGUKx8kIJxoBguq5WIWwIBq1b3cmPbUXtDi7GzqoUqPSfPWMzLCrmpl +N5RYPu/pRWg9M4vI2XdhVFaDOE6X1sXhNqYfr7TNbOfxDQ0VPjpNqHY+kiEAqmcr +tJzuJFYN1y8eyevZj4VGTS/dZ3HYHWBpZ6xpVoZ6LWDqdmLPLQkp2ceGLCvoFaG8 +TaMMfz3dfHnMvI9o7IrT8kCbLqVoxhLk1FwT+iLLL9v0rhOUbg/mvNT6BQM0P7rA +wDOx1kIauDmJQVWe9nYPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAChAhSvUqH+u +wksgUXqBpQt7OPQhYmpCtq16eIYb6+LzX1FnTuMdaiT4q9FBnJVNcS2ofxhkv/67 +d7r5SjMqFvCnSZujddP0TGo5Z7wfPhXKG5+X2GQMIX7agh7lfx5y5F5I/TsTQ+DS +AbKIDRWaHD5hPjUobJKtBdZxicXZj7e/K1mPxyQu9K3j/wPqIGkKwmEQQNWw/mFH +A9XUANZP+7e9EbV6AMtJPA/vmg3mSFqX+N2xLBFvpfhxdPMXLDOO3EHKQ8IMWPOC +A3smrFnIVFrVeLPn47FnPVP8HzT8dcMBwGKOGANW1VAMEwlZXHdVlRaGVqd9FbxS +KSCy+VfKBn4= +-----END CERTIFICATE-----` + + s.DoRaw("PATCH", "/api/latest/fleet/config", fmt.Appendf(nil, `{ + "conditional_access": { + "okta_idp_id": "https://www.okta.com/saml2/service-provider/test", + "okta_assertion_consumer_service_url": "https://dev-test.okta.com/sso/saml2/test", + "okta_audience_uri": "https://www.okta.com/saml2/service-provider/test", + "okta_certificate": %q + } + }`, validCert), http.StatusOK) + + // Test case 2: Global Okta configured, team conditional access NOT enabled + // Expected: EnableConditionalAccess=false (team not enabled), EnableConditionalAccessBypass=true (default) + t.Run("okta_configured_team_not_enabled", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.False(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should be false when team conditional access not enabled") + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should be true when bypass not explicitly disabled") + }) + + // Enable conditional access on the team + var tmResp teamResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "integrations": map[string]any{ + "conditional_access_enabled": true, + }, + }, http.StatusOK, &tmResp) + + // Test case 3: Global Okta configured, team conditional access enabled + // Expected: EnableConditionalAccess=true, EnableConditionalAccessBypass=true (default) + t.Run("okta_configured_team_enabled", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should be true when Okta configured and team enabled") + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should be true when bypass not explicitly disabled") + }) + + // Disable bypass explicitly + s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{ + "conditional_access": { + "bypass_disabled": true + } + }`), http.StatusOK) + + // Test case 4: Bypass explicitly disabled + // Expected: EnableConditionalAccess=true, EnableConditionalAccessBypass=false + t.Run("bypass_explicitly_disabled", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should still be true") + assert.False(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should be false when bypass explicitly disabled") + }) + + // Re-enable bypass explicitly + s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{ + "conditional_access": { + "bypass_disabled": false + } + }`), http.StatusOK) + + // Test case 5: Bypass explicitly enabled + // Expected: EnableConditionalAccess=true, EnableConditionalAccessBypass=true + t.Run("bypass_explicitly_enabled", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should still be true") + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should be true when bypass explicitly enabled") + }) + + // Disable conditional access on the team + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "integrations": map[string]any{ + "conditional_access_enabled": false, + }, + }, http.StatusOK, &tmResp) + + // Test case 6: Global Okta configured, team conditional access disabled + // Expected: EnableConditionalAccess=false, EnableConditionalAccessBypass=true + t.Run("okta_configured_team_disabled", func(t *testing.T) { + resp := getDeviceHost() + require.NoError(t, resp.Err) + assert.False(t, resp.GlobalConfig.Features.EnableConditionalAccess, "should be false when team disabled") + assert.True(t, resp.GlobalConfig.Features.EnableConditionalAccessBypass, "should still be true since global bypass is enabled") + }) +} + // TestCustomTransparencyURL tests that Fleet Premium licensees can use custom transparency urls. func (s *integrationEnterpriseTestSuite) TestCustomTransparencyURL() { t := s.T()