Skip to content
1 change: 1 addition & 0 deletions changes/39000-ca-bypass-per-policy
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Implemented ability for Fleet users to allow or disallow end-users to bypass conditional access on a per-policy basis.
4 changes: 4 additions & 0 deletions frontend/__mocks__/policyMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = {
next_update_ms: 3600000,
calendar_events_enabled: true,
conditional_access_enabled: false,
conditional_access_bypass_enabled: true,
install_software: {
name: "testSw0",
software_title_id: 1,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const createMockPoliciesResponse = (
next_update_ms: 3600000,
calendar_events_enabled: false,
conditional_access_enabled: false,
conditional_access_bypass_enabled: true,
},
{
id: 29090,
Expand All @@ -84,6 +86,7 @@ export const createMockPoliciesResponse = (
next_update_ms: 3600000,
calendar_events_enabled: false,
conditional_access_enabled: false,
conditional_access_bypass_enabled: true,
},
{
id: 136,
Expand All @@ -109,6 +112,7 @@ export const createMockPoliciesResponse = (
next_update_ms: 3600000,
calendar_events_enabled: false,
conditional_access_enabled: false,
conditional_access_bypass_enabled: true,
},
],
};
Expand Down
2 changes: 2 additions & 0 deletions frontend/interfaces/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface IPolicy {
critical: boolean;
calendar_events_enabled: boolean;
conditional_access_enabled: boolean;
conditional_access_bypass_enabled: boolean;
install_software?: IPolicySoftwareToInstall;
run_script?: Pick<IScript, "id" | "name">;
labels_include_any?: ILabelPolicy[];
Expand Down Expand Up @@ -112,6 +113,7 @@ export interface IPolicyFormData {
id?: number;
calendar_events_enabled?: boolean;
conditional_access_enabled?: boolean;
conditional_access_bypass_enabled?: boolean;
software_title_id?: number | null;
// null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script
script_id?: number | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@ const triggerConditionalAccessHandler = http.post(
}
);

const TEST_TENANT_ID = "abcdefg";
const TEST_OKTA_IDP_ID = "okta-idp-123";

const updateConfigHandler = http.patch(baseUrl("/config"), () => {
return HttpResponse.json(
createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_idp_id: TEST_OKTA_IDP_ID,
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
bypass_disabled: false,
},
})
);
Expand All @@ -43,6 +47,7 @@ const createEmptyConditionalAccessConfig = () =>
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
bypass_disabled: false,
},
});

Expand Down Expand Up @@ -123,7 +128,7 @@ describe("Conditional access", () => {

// Fill in tenant ID
const input = screen.getByRole("textbox");
await user.type(input, "abcdefg");
await user.type(input, TEST_TENANT_ID);

// Submit form
const saveButton = screen.getByRole("button", { name: "Save" });
Expand Down Expand Up @@ -200,7 +205,7 @@ describe("Conditional access", () => {
// Fill in text fields
// Note: First textarea is the read-only User scope profile
const textboxes = screen.getAllByRole("textbox");
await user.type(textboxes[1], "okta-idp-123"); // IdP ID
await user.type(textboxes[1], TEST_OKTA_IDP_ID); // IdP ID
await user.type(textboxes[2], "https://example.com/acs"); // ACS URL
await user.type(textboxes[3], "https://example.com"); // Audience URI

Expand Down Expand Up @@ -241,12 +246,13 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
it("Renders a spinner when Entra tenant id is present but configuration not yet confirmed", () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_tenant_id: TEST_TENANT_ID,
microsoft_entra_connection_configured: false,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
bypass_disabled: false,
},
});

Expand All @@ -270,12 +276,13 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
it("Shows Entra as configured when connection is confirmed", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_tenant_id: TEST_TENANT_ID,
microsoft_entra_connection_configured: true,
okta_idp_id: "",
okta_assertion_consumer_service_url: "",
okta_audience_uri: "",
okta_certificate: "",
bypass_disabled: false,
},
});

Expand All @@ -289,11 +296,19 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
},
});

render(<ConditionalAccess />);
const { user } = render(<ConditionalAccess />);

const entraCopy = screen.getByText(/Microsoft Entra/);
expect(entraCopy).toBeInTheDocument();
expect(
screen.getByText("Microsoft Entra conditional access configured")
screen.getByText(/conditional access connected/)
).toBeInTheDocument();

await user.hover(entraCopy);
await waitFor(() => {
expect(screen.getByText(TEST_TENANT_ID)).toBeInTheDocument();
});

// Should only have Delete button for Entra (no Edit button per Figma design)
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.queryByText("Edit")).not.toBeInTheDocument();
Expand All @@ -304,10 +319,11 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_idp_id: TEST_OKTA_IDP_ID,
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
bypass_disabled: false,
},
});

Expand All @@ -321,22 +337,30 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
},
});

render(<ConditionalAccess />);
const { user } = render(<ConditionalAccess />);

const oktaCopy = screen.getByText(/Okta/);
expect(oktaCopy).toBeInTheDocument();
expect(
screen.getByText("Okta conditional access configured")
screen.getByText(/conditional access connected/)
).toBeInTheDocument();

await user.hover(oktaCopy);
await waitFor(() => {
expect(screen.getByText(TEST_OKTA_IDP_ID)).toBeInTheDocument();
});
});

it("Shows both providers as configured when both are set up", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "abcdefg",
microsoft_entra_tenant_id: TEST_TENANT_ID,
microsoft_entra_connection_configured: true,
okta_idp_id: "okta-idp-123",
okta_idp_id: TEST_OKTA_IDP_ID,
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
bypass_disabled: false,
},
});

Expand All @@ -350,25 +374,38 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
},
});

render(<ConditionalAccess />);
const { user } = render(<ConditionalAccess />);

const entraCopy = screen.getByText(/Microsoft Entra/);
expect(entraCopy).toBeInTheDocument();

await user.hover(entraCopy);
await waitFor(() => {
expect(screen.getByText(TEST_TENANT_ID)).toBeInTheDocument();
});

const oktaCopy = screen.getByText(/Okta/);
expect(oktaCopy).toBeInTheDocument();
await user.hover(oktaCopy);
await waitFor(() => {
expect(screen.getByText(TEST_OKTA_IDP_ID)).toBeInTheDocument();
});

expect(
screen.getByText("Okta conditional access configured")
).toBeInTheDocument();
expect(
screen.getByText("Microsoft Entra conditional access configured")
).toBeInTheDocument();
screen.queryAllByText(/conditional access connected/)
).toHaveLength(2);
});

it("Shows delete confirmation modal when clicking Delete on Okta", async () => {
const mockConfig = createMockConfig({
conditional_access: {
microsoft_entra_tenant_id: "",
microsoft_entra_connection_configured: false,
okta_idp_id: "okta-idp-123",
okta_idp_id: TEST_OKTA_IDP_ID,
okta_assertion_consumer_service_url: "https://example.com/acs",
okta_audience_uri: "https://example.com",
okta_certificate: "cert-data",
bypass_disabled: false,
},
});

Expand All @@ -384,11 +421,17 @@ BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX

const { user } = render(<ConditionalAccess />);

// Should show configured state
const oktaCopy = screen.getByText(/Okta/);
expect(oktaCopy).toBeInTheDocument();
expect(
screen.getByText("Okta conditional access configured")
screen.getByText(/conditional access connected/)
).toBeInTheDocument();

await user.hover(oktaCopy);
await waitFor(() => {
expect(screen.getByText(TEST_OKTA_IDP_ID)).toBeInTheDocument();
});

// Click Delete button (first one is for Okta)
const deleteButton = screen.getAllByText("Delete")[0];
await user.click(deleteButton);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,22 @@ const ConditionalAccess = () => {
)
}
>
{oktaConfigured
? "Okta conditional access configured"
: "Connect Okta to enable conditional access."}
{oktaConfigured ? (
<>
<TooltipWrapper
tipContent={
<>
<b>IdP ID:</b> {config?.conditional_access?.okta_idp_id}
</>
}
>
Okta
</TooltipWrapper>{" "}
conditional access connected.
</>
) : (
"Connect Okta to enable conditional access."
)}
</SectionCard>
);
};
Expand Down Expand Up @@ -447,9 +460,22 @@ const ConditionalAccess = () => {
entraCta = <Button onClick={toggleEntraModal}>Connect</Button>;
}

let entraContent: string;
let entraContent: React.ReactNode;
if (entraIsConfigured) {
entraContent = "Microsoft Entra conditional access configured";
entraContent = (
<>
<TooltipWrapper
tipContent={
<>
<b>Tenant ID:</b> {entraTenantId}
</>
}
>
Microsoft Entra
</TooltipWrapper>{" "}
conditional access connected.
</>
);
Comment thread
jacobshandling marked this conversation as resolved.
} else if (entraIsAwaitingOAuth) {
entraContent =
"To complete your integration, follow the instructions in the other tab, then refresh this page to verify.";
Expand Down Expand Up @@ -525,9 +551,8 @@ const ConditionalAccess = () => {
<TooltipWrapper
tipContent={
<>
When enabled, end users will have the option to bypass Okta
conditional access if they are unable to resolve failing
policies.{" "}
When enabled, disables the per-policy setting to allow
bypassing Okta conditional access.{" "}
<em>
(Default: <strong>Off</strong>)
</em>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ describe("Device User Page", () => {
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: true,
conditional_access_bypass_enabled: true,
response: "fail",
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,8 @@ const DeviceUserPage = ({
policy={selectedPolicy}
onResolveLater={
globalConfig?.features?.enable_conditional_access &&
globalConfig.features?.enable_conditional_access_bypass
globalConfig.features?.enable_conditional_access_bypass &&
selectedPolicy?.conditional_access_bypass_enabled
? () => {
onCancelPolicyDetailsModal();
setShowBypassModal(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,8 @@ const ManagePolicyPage = ({
return teamPoliciesAPI.update(changedPolicy.id, {
conditional_access_enabled:
changedPolicy.conditional_access_enabled,
conditional_access_bypass_enabled:
changedPolicy.conditional_access_bypass_enabled,
team_id: teamIdForApi,
});
})
Expand Down
Loading
Loading