mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
import React, { useContext, useEffect, useState } from "react";
|
|
|
|
import paths from "router/paths";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
|
|
import conditionalAccessAPI, {
|
|
ConfirmMSConditionalAccessResponse,
|
|
} from "services/entities/conditional_access";
|
|
import configAPI from "services/entities/config";
|
|
|
|
import CustomLink from "components/CustomLink";
|
|
import SectionHeader from "components/SectionHeader";
|
|
import Icon from "components/Icon";
|
|
import { IconNames } from "components/icons";
|
|
|
|
import {
|
|
DEFAULT_USE_QUERY_OPTIONS,
|
|
LEARN_MORE_ABOUT_BASE_LINK,
|
|
} from "utilities/constants";
|
|
import Button from "components/buttons/Button";
|
|
import Checkbox from "components/forms/fields/Checkbox";
|
|
import { AppContext } from "context/app";
|
|
import Spinner from "components/Spinner";
|
|
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
|
import { useQuery } from "react-query";
|
|
import DataError from "components/DataError";
|
|
import Modal from "components/Modal";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
import { IConfig, isOktaConditionalAccessConfigured } from "interfaces/config";
|
|
import { IInputFieldParseTarget } from "interfaces/form_field";
|
|
|
|
import SectionCard from "../MdmSettings/components/SectionCard";
|
|
import EntraConditionalAccessModal from "./components/EntraConditionalAccessModal";
|
|
import OktaConditionalAccessModal from "./components/OktaConditionalAccessModal";
|
|
|
|
const baseClass = "conditional-access";
|
|
|
|
interface IDeleteConditionalAccessModal {
|
|
toggleDeleteConditionalAccessModal: () => void;
|
|
onDelete: (config: IConfig) => void;
|
|
provider: "microsoft-entra" | "okta";
|
|
config: IConfig | null;
|
|
}
|
|
|
|
const DeleteConditionalAccessModal = ({
|
|
toggleDeleteConditionalAccessModal,
|
|
onDelete,
|
|
provider,
|
|
config,
|
|
}: IDeleteConditionalAccessModal) => {
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const providerName =
|
|
provider === "microsoft-entra" ? "Microsoft Entra" : "Okta";
|
|
|
|
const handleDelete = async () => {
|
|
setIsDeleting(true);
|
|
try {
|
|
let updatedConfig;
|
|
if (provider === "microsoft-entra") {
|
|
await conditionalAccessAPI.deleteMicrosoftConditionalAccess();
|
|
updatedConfig = await configAPI.loadAll();
|
|
} else {
|
|
// For Okta, clear all fields via config API
|
|
updatedConfig = await configAPI.update({
|
|
conditional_access: {
|
|
okta_idp_id: "",
|
|
okta_assertion_consumer_service_url: "",
|
|
okta_audience_uri: "",
|
|
okta_certificate: "",
|
|
// Preserve existing Microsoft Entra settings
|
|
microsoft_entra_tenant_id:
|
|
config?.conditional_access?.microsoft_entra_tenant_id || "",
|
|
microsoft_entra_connection_configured:
|
|
config?.conditional_access
|
|
?.microsoft_entra_connection_configured || false,
|
|
},
|
|
});
|
|
}
|
|
renderFlash("success", `Successfully disconnected from ${providerName}.`);
|
|
toggleDeleteConditionalAccessModal();
|
|
onDelete(updatedConfig);
|
|
} catch {
|
|
renderFlash(
|
|
"error",
|
|
`Could not disconnect from ${providerName}, please try again.`
|
|
);
|
|
}
|
|
setIsDeleting(false);
|
|
};
|
|
|
|
const copy =
|
|
provider === "microsoft-entra" ? (
|
|
<>
|
|
<p>
|
|
Before you delete, first unblock all end users.{" "}
|
|
<CustomLink
|
|
text="Learn how"
|
|
url={`${LEARN_MORE_ABOUT_BASE_LINK}/disable-entra-conditional-access`}
|
|
newTab
|
|
/>
|
|
</p>
|
|
<p>
|
|
If you don't, end users will stay blocked even after deleting
|
|
Entra.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p>
|
|
Before you delete, first unblock all end users.{" "}
|
|
<CustomLink
|
|
text="Learn how"
|
|
url={`${LEARN_MORE_ABOUT_BASE_LINK}/disable-okta-conditional-access`}
|
|
newTab
|
|
/>
|
|
</p>
|
|
<p>
|
|
If you don't, end users will stay blocked even after deleting
|
|
Okta.
|
|
</p>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
title="Delete"
|
|
onExit={toggleDeleteConditionalAccessModal}
|
|
onEnter={handleDelete}
|
|
>
|
|
<>
|
|
{copy}
|
|
<div className="modal-cta-wrap">
|
|
<Button
|
|
type="button"
|
|
variant="alert"
|
|
onClick={handleDelete}
|
|
isLoading={isDeleting}
|
|
disabled={isDeleting}
|
|
>
|
|
Delete
|
|
</Button>
|
|
<Button
|
|
onClick={toggleDeleteConditionalAccessModal}
|
|
variant="inverse-alert"
|
|
disabled={isDeleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
enum EntraPhase {
|
|
NotConfigured = "not-configured",
|
|
ConfirmingConfigured = "confirming-configured",
|
|
ConfirmationError = "confirmation-error",
|
|
AwaitingOAuth = "awaiting-oauth",
|
|
Configured = "configured",
|
|
ConsentMissing = "consent-missing",
|
|
}
|
|
|
|
const ConditionalAccess = () => {
|
|
// HOOKS
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
|
|
const { isPremiumTier, setConfig, config } = useContext(AppContext);
|
|
|
|
const [entraPhase, setEntraPhase] = useState<EntraPhase>(
|
|
EntraPhase.NotConfigured
|
|
);
|
|
|
|
// Modal states
|
|
const [showEntraModal, setShowEntraModal] = useState(false);
|
|
const [showOktaModal, setShowOktaModal] = useState(false);
|
|
const [providerToDelete, setProviderToDelete] = useState<
|
|
"microsoft-entra" | "okta" | null
|
|
>(null);
|
|
|
|
// Bypass disabled state
|
|
const [bypassDisabled, setBypassDisabled] = useState(
|
|
config?.conditional_access?.bypass_disabled || false
|
|
);
|
|
const [isUpdatingBypass, setIsUpdatingBypass] = useState(false);
|
|
|
|
// "loading" state here is encompassed by phase === Phase.ConfirmingConfigured state, don't need
|
|
// to use useQuery's
|
|
// "error" state handled by onError callback
|
|
// success state handled by onSuccess callback
|
|
useQuery<
|
|
ConfirmMSConditionalAccessResponse,
|
|
Error,
|
|
ConfirmMSConditionalAccessResponse
|
|
>(["confirmAccess"], conditionalAccessAPI.confirmMicrosoftConditionalAccess, {
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
// only make this call at the appropriate UI phase
|
|
enabled: entraPhase === EntraPhase.ConfirmingConfigured && isPremiumTier,
|
|
onSuccess: ({ configuration_completed, setup_error }) => {
|
|
if (configuration_completed) {
|
|
setEntraPhase(EntraPhase.Configured);
|
|
renderFlash(
|
|
"success",
|
|
"Successfully verified Microsoft Entra conditional access integration"
|
|
);
|
|
} else {
|
|
setEntraPhase(EntraPhase.ConsentMissing);
|
|
if (
|
|
// IT admin did not complete the consent.
|
|
!setup_error ||
|
|
// IT admin clicked "Cancel" in the consent dialog.
|
|
setup_error.includes(
|
|
"A Microsoft Entra admin did not consent to the permissions requested by the conditional access integration"
|
|
)
|
|
) {
|
|
renderFlash(
|
|
"error",
|
|
"Couldn't update. Fleet didn't get permissions for Entra. Please try again and accept the permissions."
|
|
);
|
|
} else if (
|
|
setup_error.includes(
|
|
'No "Fleet conditional access" Entra ID group was found'
|
|
)
|
|
) {
|
|
renderFlash(
|
|
"error",
|
|
`Couldn't connect. The "Fleet conditional access" group doesn't exist in Entra. Please create the group and try again.`
|
|
);
|
|
} else {
|
|
// For other kind of errors we just show a generic error.
|
|
// We won't render the error as is because the error comes from the MS proxy and they may be too big or unformatted
|
|
// to display in the banner.
|
|
//
|
|
// For troubleshooting:
|
|
// - The API response contains the setup_error.
|
|
// - The Fleet server logs the error.
|
|
// - The MS proxy stores the error in its database.
|
|
renderFlash(
|
|
"error",
|
|
"Couldn't connect. Please contact your Fleet administrator."
|
|
);
|
|
}
|
|
}
|
|
},
|
|
onError: () => {
|
|
// distinct from successful confirmation response of `false`, this handles an API error
|
|
setEntraPhase(EntraPhase.ConfirmationError);
|
|
},
|
|
});
|
|
|
|
const {
|
|
microsoft_entra_tenant_id: entraTenantId,
|
|
microsoft_entra_connection_configured: entraConfigured,
|
|
} = config?.conditional_access || {};
|
|
|
|
const oktaConfigured = isOktaConditionalAccessConfigured(config);
|
|
|
|
// Check if this is a managed cloud deployment (Microsoft Entra requires proxy infrastructure)
|
|
const isManagedCloud = config?.license?.managed_cloud || false;
|
|
|
|
// Check Entra configuration state
|
|
// Note: entraPhase is intentionally included in the dependency array to allow
|
|
// manual phase overrides (e.g., AwaitingOAuth) to persist until config changes
|
|
useEffect(() => {
|
|
const finalStates = [
|
|
EntraPhase.AwaitingOAuth, // Don't check config if we're in AwaitingOAuth phase
|
|
EntraPhase.ConfirmationError, // Don't do confirm call if we are in a final error state
|
|
EntraPhase.ConsentMissing, // Don't do confirm call if after tenant ID provided, something went wrong
|
|
];
|
|
|
|
if (finalStates.includes(entraPhase)) {
|
|
return;
|
|
}
|
|
|
|
// Don't override if we just successfully confirmed (phase is Configured but config not yet updated)
|
|
// However, if the tenant ID is removed (deleted), we should still update to NotConfigured
|
|
if (
|
|
entraPhase === EntraPhase.Configured &&
|
|
!entraConfigured &&
|
|
entraTenantId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (entraTenantId) {
|
|
if (!entraConfigured) {
|
|
setEntraPhase(EntraPhase.ConfirmingConfigured);
|
|
} else {
|
|
// tenant id is present and connection is configured
|
|
setEntraPhase(EntraPhase.Configured);
|
|
}
|
|
} else {
|
|
setEntraPhase(EntraPhase.NotConfigured);
|
|
}
|
|
}, [entraTenantId, entraConfigured, entraPhase]);
|
|
|
|
if (!isPremiumTier) {
|
|
return <PremiumFeatureMessage />;
|
|
}
|
|
|
|
// HANDLERS
|
|
|
|
const toggleDeleteModal = () => {
|
|
setProviderToDelete(null);
|
|
};
|
|
|
|
const toggleEntraModal = () => {
|
|
setShowEntraModal(!showEntraModal);
|
|
};
|
|
|
|
const handleEntraModalSuccess = () => {
|
|
setShowEntraModal(false);
|
|
// Set phase to awaiting OAuth instead of immediately refetching config
|
|
// Config will be checked when user refreshes the page
|
|
setEntraPhase(EntraPhase.AwaitingOAuth);
|
|
};
|
|
|
|
const onDeleteConditionalAccess = (updatedConfig: IConfig) => {
|
|
setConfig(updatedConfig);
|
|
};
|
|
|
|
const toggleOktaModal = () => {
|
|
setShowOktaModal(!showOktaModal);
|
|
};
|
|
|
|
const handleOktaModalSuccess = (updatedConfig: IConfig) => {
|
|
setShowOktaModal(false);
|
|
setConfig(updatedConfig);
|
|
};
|
|
|
|
const handleEntraDelete = () => {
|
|
setProviderToDelete("microsoft-entra");
|
|
};
|
|
|
|
const handleOktaDelete = () => {
|
|
setProviderToDelete("okta");
|
|
};
|
|
|
|
const onBypassDisabledChange = ({
|
|
value,
|
|
}: IInputFieldParseTarget<boolean>) => {
|
|
setBypassDisabled(value);
|
|
};
|
|
|
|
const handleSaveBypassSettings = async (evt: React.FormEvent) => {
|
|
evt.preventDefault();
|
|
setIsUpdatingBypass(true);
|
|
try {
|
|
const updatedConfig = await configAPI.update({
|
|
conditional_access: {
|
|
bypass_disabled: bypassDisabled,
|
|
// Preserve existing settings
|
|
okta_idp_id: config?.conditional_access?.okta_idp_id || "",
|
|
okta_assertion_consumer_service_url:
|
|
config?.conditional_access?.okta_assertion_consumer_service_url ||
|
|
"",
|
|
okta_audience_uri:
|
|
config?.conditional_access?.okta_audience_uri || "",
|
|
okta_certificate: config?.conditional_access?.okta_certificate || "",
|
|
microsoft_entra_tenant_id:
|
|
config?.conditional_access?.microsoft_entra_tenant_id || "",
|
|
},
|
|
});
|
|
setConfig(updatedConfig);
|
|
renderFlash(
|
|
"success",
|
|
"Successfully updated conditional access settings."
|
|
);
|
|
} catch {
|
|
renderFlash("error", "Could not update conditional access settings.");
|
|
}
|
|
setIsUpdatingBypass(false);
|
|
};
|
|
|
|
// RENDER
|
|
|
|
const renderOktaContent = () => {
|
|
return (
|
|
<SectionCard
|
|
header={oktaConfigured ? undefined : "Okta"}
|
|
iconName={oktaConfigured ? "success" : undefined}
|
|
cta={
|
|
oktaConfigured ? (
|
|
<Button variant="text-icon" onClick={handleOktaDelete}>
|
|
Delete
|
|
<Icon name="trash" color="ui-fleet-black-75" />
|
|
</Button>
|
|
) : (
|
|
<Button onClick={toggleOktaModal}>Connect</Button>
|
|
)
|
|
}
|
|
>
|
|
{oktaConfigured
|
|
? "Okta conditional access configured"
|
|
: "Connect Okta to enable conditional access."}
|
|
</SectionCard>
|
|
);
|
|
};
|
|
|
|
const renderEntraContent = () => {
|
|
if (entraPhase === EntraPhase.ConfirmationError) {
|
|
return (
|
|
<SectionCard header="Microsoft Entra">
|
|
<DataError />
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
if (entraPhase === EntraPhase.ConfirmingConfigured) {
|
|
return (
|
|
<SectionCard
|
|
header="Microsoft Entra"
|
|
cta={
|
|
<Button isLoading disabled>
|
|
Connect
|
|
</Button>
|
|
}
|
|
>
|
|
Please wait until Microsoft Entra configuration is confirmed.
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
// Compute Entra card props to avoid nested ternaries
|
|
const entraIsConfigured = entraPhase === EntraPhase.Configured;
|
|
const entraIsAwaitingOAuth = entraPhase === EntraPhase.AwaitingOAuth;
|
|
|
|
let entraIconName: IconNames | undefined;
|
|
if (entraIsConfigured) {
|
|
entraIconName = "success";
|
|
} else if (entraIsAwaitingOAuth) {
|
|
entraIconName = "pending-outline";
|
|
}
|
|
|
|
let entraCta: React.JSX.Element | undefined;
|
|
if (entraIsConfigured) {
|
|
entraCta = (
|
|
<Button variant="text-icon" onClick={handleEntraDelete}>
|
|
Delete
|
|
<Icon name="trash" color="ui-fleet-black-75" />
|
|
</Button>
|
|
);
|
|
} else if (!entraIsAwaitingOAuth) {
|
|
entraCta = <Button onClick={toggleEntraModal}>Connect</Button>;
|
|
}
|
|
|
|
let entraContent: string;
|
|
if (entraIsConfigured) {
|
|
entraContent = "Microsoft Entra conditional access configured";
|
|
} else if (entraIsAwaitingOAuth) {
|
|
entraContent =
|
|
"To complete your integration, follow the instructions in the other tab, then refresh this page to verify.";
|
|
} else {
|
|
entraContent = "Connect Entra to enable conditional access.";
|
|
}
|
|
|
|
return (
|
|
<SectionCard
|
|
header={
|
|
entraIsConfigured || entraIsAwaitingOAuth
|
|
? undefined
|
|
: "Microsoft Entra"
|
|
}
|
|
iconName={entraIconName}
|
|
cta={entraCta}
|
|
>
|
|
{entraContent}
|
|
</SectionCard>
|
|
);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
return (
|
|
<div className={`${baseClass}__cards`}>
|
|
{renderOktaContent()}
|
|
{isManagedCloud && renderEntraContent()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={baseClass}>
|
|
<div className={`${baseClass}__connections`}>
|
|
<SectionHeader title="Conditional access" />
|
|
<p className={`${baseClass}__page-description`}>
|
|
Block hosts failing policies from logging in with single sign-on. Once
|
|
connected, enable or disable on the{" "}
|
|
<CustomLink url={paths.MANAGE_POLICIES} text="Policies" /> page.
|
|
</p>
|
|
{renderContent()}
|
|
{showEntraModal && (
|
|
<EntraConditionalAccessModal
|
|
onCancel={toggleEntraModal}
|
|
onSuccess={handleEntraModalSuccess}
|
|
/>
|
|
)}
|
|
{showOktaModal && (
|
|
<OktaConditionalAccessModal
|
|
onCancel={toggleOktaModal}
|
|
onSuccess={handleOktaModalSuccess}
|
|
/>
|
|
)}
|
|
{providerToDelete && (
|
|
<DeleteConditionalAccessModal
|
|
onDelete={onDeleteConditionalAccess}
|
|
toggleDeleteConditionalAccessModal={toggleDeleteModal}
|
|
provider={providerToDelete}
|
|
config={config}
|
|
/>
|
|
)}
|
|
</div>
|
|
{oktaConfigured && (
|
|
<div className={`${baseClass}__end-user-experience`}>
|
|
<SectionHeader title="End user experience" />
|
|
<form onSubmit={handleSaveBypassSettings}>
|
|
<Checkbox
|
|
onChange={onBypassDisabledChange}
|
|
name="bypassDisabled"
|
|
value={bypassDisabled}
|
|
parseTarget
|
|
>
|
|
<TooltipWrapper
|
|
tipContent={
|
|
<>
|
|
When enabled, end users will have the option to bypass Okta
|
|
conditional access if they are unable to resolve failing
|
|
policies.{" "}
|
|
<em>
|
|
(Default: <strong>Off</strong>)
|
|
</em>
|
|
<br />
|
|
<br />
|
|
Bypassing is valid for a single login attempt and is tracked
|
|
in audit logs.
|
|
</>
|
|
}
|
|
showArrow={false}
|
|
>
|
|
Disable bypass
|
|
</TooltipWrapper>
|
|
</Checkbox>
|
|
<Button
|
|
type="submit"
|
|
isLoading={isUpdatingBypass}
|
|
disabled={
|
|
bypassDisabled ===
|
|
(config?.conditional_access?.bypass_disabled ?? false) ||
|
|
!config
|
|
}
|
|
>
|
|
Save
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ConditionalAccess;
|