Conditional Access Bypass Device UI and backend change (#38939)

**Related issue:** Resolves #37281
This commit is contained in:
Dante Catalfamo
2026-01-29 18:10:07 -05:00
committed by GitHub
parent 4d2c7768c7
commit 79fe1fa744
20 changed files with 603 additions and 32 deletions
+2
View File
@@ -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",
+4
View File
@@ -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, string> = {
[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]:
+8 -1
View File
@@ -96,7 +96,12 @@ export interface IDeviceGlobalConfig {
enabled_and_configured: boolean;
require_all_software_macos: boolean | null;
};
features: Pick<IConfigFeatures, "enable_software_inventory">;
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 {
@@ -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 (
<>
<strong>{idpFullName}</strong> temporarily bypassed conditional access
for <strong>{hostDisplayName}</strong>.
</>
);
},
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);
}
@@ -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 (
<Modal onExit={onCancel} title="Resolve later">
<>
<p>
This will allow you to log in with Okta once.
<br />
<br />
Please resolve all policies marked &quot;Action required&quot; to
restore access for subsequent logins.
</p>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={onResolveLater}
isLoading={isLoading}
disabled={isLoading}
>
Resolve later
</Button>
</div>
</>
</Modal>
);
};
export default BypassModal;
@@ -0,0 +1,3 @@
.bypass-modal {
}
@@ -0,0 +1 @@
export { default } from "./BypassModal";
@@ -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({})(
<PolicyDetailsModal
onCancel={jest.fn()}
policy={createFailingConditionalAccessPolicy()}
onResolveLater={jest.fn()}
/>
);
expect(
screen.getByRole("button", { name: "Resolve later" })
).toBeInTheDocument();
});
it("does not show 'Resolve later' button when onResolveLater is not provided", () => {
createCustomRenderer({})(
<PolicyDetailsModal
onCancel={jest.fn()}
policy={createFailingConditionalAccessPolicy()}
/>
);
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({})(
<PolicyDetailsModal
onCancel={jest.fn()}
policy={passingPolicy}
onResolveLater={jest.fn()}
/>
);
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({})(
<PolicyDetailsModal
onCancel={jest.fn()}
policy={nonConditionalPolicy}
onResolveLater={jest.fn()}
/>
);
expect(
screen.queryByRole("button", { name: "Resolve later" })
).not.toBeInTheDocument();
});
});
});
@@ -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
}
/>
</TabPanel>
)}
@@ -833,6 +846,15 @@ const DeviceUserPage = ({
<PolicyDetailsModal
onCancel={onCancelPolicyDetailsModal}
policy={selectedPolicy}
onResolveLater={
globalConfig?.features?.enable_conditional_access &&
globalConfig.features?.enable_conditional_access_bypass
? () => {
onCancelPolicyDetailsModal();
setShowBypassModal(true);
}
: undefined
}
/>
)}
{!!host && showOSSettingsModal && (
@@ -936,6 +958,30 @@ const DeviceUserPage = ({
<div className={coreWrapperClassnames}>{renderDeviceUserPage()}</div>
)}
{showInfoModal && <InfoModal onCancel={toggleInfoModal} />}
{showBypassModal && (
<BypassModal
onCancel={toggleShowBypassModal}
onResolveLater={async () => {
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}
/>
)}
</div>
);
};
@@ -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 && (
<PolicyFailingCount policyList={policies} deviceUser={deviceUser} />
<PolicyFailingCount
policyList={policies}
deviceUser={deviceUser}
conditionalAccessEnabled={conditionalAccessEnabled}
/>
)}
<TableContainer
columnConfigs={tableHeaders}
data={generatePolicyDataSet(policies)}
data={generatePolicyDataSet(policies, !!conditionalAccessEnabled)}
isLoading={isLoading}
defaultSortHeader="status"
resultsTitle="policies"
@@ -40,12 +40,15 @@ interface IDataColumn {
sortType?: string;
}
const getPolicyStatus = (policy: IHostPolicy): PolicyStatus | null => {
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),
}));
};
@@ -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 (
<Modal
@@ -35,6 +37,13 @@ const PolicyDetailsModal = ({
)}
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
{policy?.conditional_access_enabled &&
policy.response === "fail" &&
onResolveLater && (
<Button onClick={onResolveLater} variant="inverse">
Resolve later
</Button>
)}
</div>
</div>
</Modal>
@@ -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 ? (
<span>
<strong>
This device is failing
{failCount === 1 ? " 1 policy" : ` ${failCount} policies`}
</strong>
<br />
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."}
</span>
) : (
<span>
<strong>
{blockingCount === 1
? "1 policy is "
: `${blockingCount} policies are `}
blocking login
</strong>
<br />
To restore access, click on the policies makes &quot;Action
required&quot; and follow the resolution steps.
{deviceUser && ' Once resolved, click "Refetch" to check status.'}
</span>
);
return failCount ? (
<InfoBanner className={baseClass} color="grey" borderRadius="xlarge">
<IconStatusMessage
iconName="error-outline"
iconColor="ui-fleet-black-50"
message={
<span>
<strong>
This device is failing
{failCount === 1 ? " 1 policy" : ` ${failCount} policies`}
</strong>
<br />
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."}
</span>
}
message={message}
/>
</InfoBanner>
) : null;
@@ -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));
},
};
+2
View File
@@ -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`,
-4
View File
@@ -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:
+3 -1
View File
@@ -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.
+5 -1
View File
@@ -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(),
},
}
+141
View File
@@ -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")
})
}
}
@@ -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()