Conditional Access Bypass Device UI and backend change (#38939)
**Related issue:** Resolves #37281
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+13
@@ -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 "Action required" 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"
|
||||
|
||||
+8
-4
@@ -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),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
+9
@@ -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>
|
||||
|
||||
+36
-13
@@ -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 "Action
|
||||
required" 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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user