Merge pull request #3424 from Infisical/misc/allow-org-admins-to-bypass-sso-enforcement

misc: allow org admins to bypass sso enforcement
This commit is contained in:
Daniel Hougaard
2025-04-17 00:22:18 +04:00
committed by GitHub
33 changed files with 385 additions and 55 deletions

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.Organization, "bypassOrgAuthEnabled"))) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.boolean("bypassOrgAuthEnabled").defaultTo(false).notNullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Organization, "bypassOrgAuthEnabled")) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("bypassOrgAuthEnabled");
});
}
}

View File

@@ -26,7 +26,8 @@ export const OrganizationsSchema = z.object({
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
privilegeUpgradeInitiatedAt: z.date().nullable().optional()
privilegeUpgradeInitiatedAt: z.date().nullable().optional(),
bypassOrgAuthEnabled: z.boolean().default(false)
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { TDbClient } from "@app/db";
import {
IdentityProjectMembershipRoleSchema,
OrgMembershipRole,
OrgMembershipsSchema,
TableName,
TProjectRoles,
@@ -53,6 +54,7 @@ export const permissionDALFactory = (db: TDbClient) => {
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.OrgRoles),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("groupId").withSchema("userGroups"),
db.ref("groupOrgId").withSchema("userGroups"),
db.ref("groupName").withSchema("userGroups"),
@@ -71,6 +73,7 @@ export const permissionDALFactory = (db: TDbClient) => {
OrgMembershipsSchema.extend({
permissions: z.unknown(),
orgAuthEnforced: z.boolean().optional().nullable(),
bypassOrgAuthEnabled: z.boolean(),
customRoleSlug: z.string().optional().nullable(),
shouldUseNewPrivilegeSystem: z.boolean()
}).parse(el),
@@ -571,6 +574,11 @@ export const permissionDALFactory = (db: TDbClient) => {
})
.join<TProjects>(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId]))
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.join(TableName.OrgMembership, (qb) => {
void qb
.on(`${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.andOn(`${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`);
})
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`)
@@ -670,6 +678,8 @@ export const permissionDALFactory = (db: TDbClient) => {
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project).as("projectType"),
db.ref("id").withSchema(TableName.Project).as("projectId"),
@@ -683,6 +693,7 @@ export const permissionDALFactory = (db: TDbClient) => {
orgId,
username,
orgAuthEnforced,
orgRole,
membershipId,
groupMembershipId,
membershipCreatedAt,
@@ -690,10 +701,12 @@ export const permissionDALFactory = (db: TDbClient) => {
groupMembershipUpdatedAt,
membershipUpdatedAt,
projectType,
shouldUseNewPrivilegeSystem
shouldUseNewPrivilegeSystem,
bypassOrgAuthEnabled
}) => ({
orgId,
orgAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId,
projectId,
username,
@@ -701,7 +714,8 @@ export const permissionDALFactory = (db: TDbClient) => {
id: membershipId || groupMembershipId,
createdAt: membershipCreatedAt || groupMembershipCreatedAt,
updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt,
shouldUseNewPrivilegeSystem
shouldUseNewPrivilegeSystem,
bypassOrgAuthEnabled
}),
childrenMapper: [
{

View File

@@ -2,7 +2,7 @@
import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability";
import { z } from "zod";
import { TOrganizations } from "@app/db/schemas";
import { OrgMembershipRole, TOrganizations } from "@app/db/schemas";
import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type";
@@ -118,11 +118,20 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
].includes(actorAuthMethod);
}
function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"]) {
function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
orgRole: OrgMembershipRole
) {
if (actorAuthMethod === undefined) {
throw new UnauthorizedError({ name: "No auth method defined" });
}
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
return;
}
if (
isOrgSsoEnforced &&
actorAuthMethod !== null &&

View File

@@ -139,7 +139,12 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, membership.orgAuthEnforced);
validateOrgSSO(
authMethod,
membership.orgAuthEnforced,
membership.bypassOrgAuthEnabled,
membership.role as OrgMembershipRole
);
const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat(
membership?.groups?.map(({ role, customRolePermission }) => ({
@@ -226,7 +231,12 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, userProjectPermission.orgAuthEnforced);
validateOrgSSO(
authMethod,
userProjectPermission.orgAuthEnforced,
userProjectPermission.bypassOrgAuthEnabled,
userProjectPermission.orgRole
);
if (actionProjectType !== ActionProjectType.Any && actionProjectType !== userProjectPermission.projectType) {
throw new BadRequestError({

View File

@@ -31,7 +31,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
200: z.object({
organizations: sanitizedOrganizationSchema
.extend({
orgAuthMethod: z.string()
orgAuthMethod: z.string(),
userRole: z.string()
})
.array()
})
@@ -259,7 +260,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
enforceMfa: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional(),
allowSecretSharingOutsideOrganization: z.boolean().optional()
allowSecretSharingOutsideOrganization: z.boolean().optional(),
bypassOrgAuthEnabled: z.boolean().optional()
}),
response: {
200: z.object({

View File

@@ -2,7 +2,7 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Knex } from "knex";
import { TUsers, UserDeviceSchema } from "@app/db/schemas";
import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas";
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
@@ -174,20 +174,25 @@ export const authLoginServiceFactory = ({
const userEnc = await userDAL.findUserEncKeyByUsername({
username: email
});
const serverCfg = await getServerCfg();
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
}
if (
serverCfg.enabledLoginMethods &&
!serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) &&
!providerAuthToken
) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(userEnc.userId);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
}
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
@@ -573,28 +578,40 @@ export const authLoginServiceFactory = ({
switch (authMethod) {
case AuthMethod.GITHUB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GOOGLE: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GITLAB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}

View File

@@ -96,7 +96,9 @@ export const orgDALFactory = (db: TDbClient) => {
};
// special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
const findAllOrgsByUserId = async (
userId: string
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => {
try {
const org = (await db
.replicaNode()(TableName.OrgMembership)
@@ -117,6 +119,7 @@ export const orgDALFactory = (db: TDbClient) => {
);
})
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole"))
.select(
db.raw(`
CASE
@@ -125,7 +128,7 @@ export const orgDALFactory = (db: TDbClient) => {
ELSE ''
END as "orgAuthMethod"
`)
)) as (TOrganizations & { orgAuthMethod: string })[];
)) as (TOrganizations & { orgAuthMethod: string; userRole: string })[];
return org;
} catch (error) {

View File

@@ -16,5 +16,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
allowSecretSharingOutsideOrganization: true,
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true
privilegeUpgradeInitiatedAt: true,
bypassOrgAuthEnabled: true
});

View File

@@ -349,7 +349,8 @@ export const orgServiceFactory = ({
defaultMembershipRoleSlug,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -429,7 +430,8 @@ export const orgServiceFactory = ({
defaultMembershipRole,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;

View File

@@ -73,6 +73,7 @@ export type TUpdateOrgDTO = {
enforceMfa: boolean;
selectedMfaMethod: MfaMethod;
allowSecretSharingOutsideOrganization: boolean;
bypassOrgAuthEnabled: boolean;
}>;
} & TOrgPermission;

View File

@@ -65,7 +65,9 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO."
We recommend ensuring that your account is provisioned using the application in Auth0
prior to enforcing OIDC SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -72,6 +72,10 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO."
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Auth0 user with Infisical;
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -106,6 +106,9 @@ description: "Learn how to configure Microsoft Entra ID for Infisical SSO."
We recommend ensuring that your account is provisioned the application in Azure
prior to enforcing SAML SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -66,6 +66,9 @@ Prerequisites:
<Warning>
We recommend ensuring that your account is provisioned using the identity provider prior to enforcing OIDC SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -81,6 +81,9 @@ description: "Learn how to configure Google SAML for Infisical SSO."
We recommend ensuring that your account is provisioned the application in Google
prior to enforcing SAML SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -86,6 +86,9 @@ description: "Learn how to configure JumpCloud SAML for Infisical SSO."
We recommend ensuring that your account is provisioned the application in JumpCloud
prior to enforcing SAML SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -92,7 +92,9 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO."
We recommend ensuring that your account is provisioned using the application in Keycloak
prior to enforcing OIDC SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -127,6 +127,9 @@ description: "Learn how to configure Keycloak SAML for Infisical SSO."
We recommend ensuring that your account is provisioned the application in Keycloak
prior to enforcing SAML SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -94,6 +94,9 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO."
We recommend ensuring that your account is provisioned the application in Okta
prior to enforcing SAML SSO to prevent any unintended issues.
</Warning>
<Info>
In case of a lockout, an organization admin can use the admin login portal in the `/login/admin` path e.g. https://app.infisical.com/login/admin.
</Info>
</Step>
</Steps>

View File

@@ -1,6 +1,6 @@
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
enum OrgMembershipRole {
export enum OrgMembershipRole {
Admin = "admin",
Member = "member",
NoAccess = "no-access"

View File

@@ -110,7 +110,8 @@ export const useUpdateOrg = () => {
defaultMembershipRoleSlug,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
@@ -120,7 +121,8 @@ export const useUpdateOrg = () => {
defaultMembershipRoleSlug,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
});
},
onSuccess: () => {

View File

@@ -9,6 +9,7 @@ export type Organization = {
createAt: string;
updatedAt: string;
authEnforced: boolean;
bypassOrgAuthEnabled: boolean;
orgAuthMethod: string;
scimEnabled: boolean;
slug: string;
@@ -17,6 +18,7 @@ export type Organization = {
selectedMfaMethod?: MfaMethod;
shouldUseNewPrivilegeSystem: boolean;
allowSecretSharingOutsideOrganization?: boolean;
userRole: string;
};
export type UpdateOrgDTO = {
@@ -29,6 +31,7 @@ export type UpdateOrgDTO = {
enforceMfa?: boolean;
selectedMfaMethod?: MfaMethod;
allowSecretSharingOutsideOrganization?: boolean;
bypassOrgAuthEnabled?: boolean;
};
export type BillingDetails = {

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginPage } from "../LoginPage/LoginPage";
export const Route = createFileRoute("/_restrict-login-signup/login/admin")({
component: () => <LoginPage isAdmin />
});

View File

@@ -33,14 +33,21 @@ export const useNavigateToSelectOrganization = () => {
const { config } = useServerConfig();
const navigate = useNavigate();
const navigateToSelectOrganization = async (cliCallbackPort?: string) => {
const navigateToSelectOrganization = async (
cliCallbackPort?: string,
isFromAdminLogin?: boolean
) => {
if (!config.defaultAuthOrgId) {
queryClient.invalidateQueries({ queryKey: userKeys.getUser });
}
navigate({
to: "/login/select-organization",
search: { callback_port: cliCallbackPort, org_id: config.defaultAuthOrgId }
search: {
callback_port: cliCallbackPort,
org_id: config.defaultAuthOrgId,
is_admin_login: isFromAdminLogin
}
});
};

View File

@@ -8,7 +8,7 @@ import { isLoggedIn } from "@app/hooks/api/reactQuery";
import { InitialStep, SSOStep } from "./components";
import { useNavigateToSelectOrganization } from "./Login.utils";
export const LoginPage = () => {
export const LoginPage = ({ isAdmin }: { isAdmin?: boolean }) => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [email, setEmail] = useState("");
@@ -44,6 +44,7 @@ export const LoginPage = () => {
case 0:
return (
<InitialStep
isAdmin={isAdmin}
setStep={setStep}
email={email}
setEmail={setEmail}

View File

@@ -26,9 +26,17 @@ type Props = {
setEmail: (email: string) => void;
password: string;
setPassword: (email: string) => void;
isAdmin?: boolean;
};
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
export const InitialStep = ({
setStep,
email,
setEmail,
password,
setPassword,
isAdmin
}: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
@@ -62,7 +70,9 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
};
useEffect(() => {
if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug);
if (serverDetails?.samlDefaultOrgSlug && !isAdmin) {
redirectToSaml(serverDetails.samlDefaultOrgSlug);
}
}, [serverDetails?.samlDefaultOrgSlug]);
const handleSaml = () => {
@@ -82,7 +92,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
};
const shouldDisplayLoginMethod = (method: LoginMethod) =>
!config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
isAdmin || !config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -120,7 +130,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
if (isLoginSuccessful && isLoginSuccessful.success) {
// case: login was successful
navigateToSelectOrganization();
navigateToSelectOrganization(undefined, isAdmin);
createNotification({
text: "Successfully logged in",
type: "success"
@@ -160,7 +170,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
setIsLoading(false);
};
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) {
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod && !isAdmin) {
return (
<form
onSubmit={handleLogin}

View File

@@ -14,6 +14,7 @@ import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks";
import {
useGetOrganizations,
@@ -53,6 +54,7 @@ export const SelectOrganizationPage = () => {
const queryParams = new URLSearchParams(window.location.search);
const orgId = queryParams.get("org_id");
const callbackPort = queryParams.get("callback_port");
const isAdminLogin = queryParams.get("is_admin_login") === "true";
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
const logout = useLogoutUser(true);
@@ -68,7 +70,12 @@ export const SelectOrganizationPage = () => {
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
if (organization.authEnforced) {
const canBypassOrgAuth =
organization.bypassOrgAuthEnabled &&
organization.userRole === OrgMembershipRole.Admin &&
isAdminLogin;
if (organization.authEnforced && !canBypassOrgAuth) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();

View File

@@ -6,13 +6,16 @@ import { SelectOrganizationPage } from "./SelectOrgPage";
export const SelectOrganizationPageQueryParams = z.object({
org_id: z.string().optional().catch(""),
callback_port: z.coerce.number().optional().catch(undefined)
callback_port: z.coerce.number().optional().catch(undefined),
is_admin_login: z.boolean().optional().catch(false)
});
export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({
component: SelectOrganizationPage,
validateSearch: zodValidator(SelectOrganizationPageQueryParams),
search: {
middlewares: [stripSearchParams({ org_id: "", callback_port: undefined })]
middlewares: [
stripSearchParams({ org_id: "", callback_port: undefined, is_admin_login: false })
]
}
});

View File

@@ -1,7 +1,10 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch } from "@app/components/v2";
import { Switch, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -52,6 +55,28 @@ export const OrgGeneralAuthSection = () => {
}
};
const handleEnableBypassOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
bypassOrgAuthEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
return (
<>
{/* <div className="py-4">
@@ -72,7 +97,9 @@ export const OrgGeneralAuthSection = () => {
</div> */}
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce SAML SSO</h3>
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enforce SAML SSO</span>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
@@ -85,9 +112,62 @@ export const OrgGeneralAuthSection = () => {
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via SAML to access this organization
Enforce users to authenticate via SAML to access this organization
</p>
</div>
{currentOrg?.authEnforced && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
<Tooltip
className="max-w-lg"
content={
<div>
<span>
When this is enabled, we strongly recommend enforcing MFA at the organization
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the admin login portal at{" "}
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href={`${window.location.origin}/login/admin`}
>
{window.location.origin}/login/admin
</a>
</p>
</div>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-admin-bypass"
isChecked={currentOrg?.bypassOrgAuthEnabled ?? false}
onCheckedChange={(value) => handleEnableBypassOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
<span>
Allow organization admins to bypass SAML enforcement when SSO is unavailable,
misconfigured, or inaccessible.
</span>
</p>
</div>
)}
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}

View File

@@ -82,6 +82,28 @@ export const OrgOIDCSection = (): JSX.Element => {
}
};
const handleEnableBypassOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await updateOrg({
orgId: currentOrg?.id,
bypassOrgAuthEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
const handleOIDCGroupManagement = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
@@ -158,7 +180,9 @@ export const OrgOIDCSection = (): JSX.Element => {
)}
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce OIDC SSO</h3>
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enforce OIDC SSO</span>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
@@ -171,9 +195,62 @@ export const OrgOIDCSection = (): JSX.Element => {
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via OIDC to access this organization
<span>Enforce users to authenticate via OIDC to access this organization.</span>
</p>
</div>
{currentOrg?.authEnforced && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="flex items-center gap-1">
<span className="text-md text-mineshaft-100">Enable Admin SSO Bypass</span>
<Tooltip
className="max-w-lg"
content={
<div>
<span>
When this is enabled, we strongly recommend enforcing MFA at the organization
level.
</span>
<p className="mt-4">
In case of a lockout, admins can use the admin login portal at{" "}
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href={`${window.location.origin}/login/admin`}
>
{window.location.origin}/login/admin
</a>
</p>
</div>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-admin-bypass"
isChecked={currentOrg?.bypassOrgAuthEnabled ?? false}
onCheckedChange={(value) => handleEnableBypassOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
<span>
Allow organization admins to bypass OIDC enforcement when SSO is unavailable,
misconfigured, or inaccessible.
</span>
</p>
</div>
)}
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="text-md flex items-center text-mineshaft-100">

View File

@@ -33,6 +33,7 @@ import { Route as authSignUpSsoPageRouteImport } from './pages/auth/SignUpSsoPag
import { Route as authLoginSsoPageRouteImport } from './pages/auth/LoginSsoPage/route'
import { Route as authSelectOrgPageRouteImport } from './pages/auth/SelectOrgPage/route'
import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPage/route'
import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route'
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route'
import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route'
@@ -393,6 +394,12 @@ const authLoginLdapPageRouteRoute = authLoginLdapPageRouteImport.update({
getParentRoute: () => RestrictLoginSignupLoginRoute,
} as any)
const authAdminLoginPageRouteRoute = authAdminLoginPageRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => RestrictLoginSignupLoginRoute,
} as any)
const adminSignUpPageRouteRoute = adminSignUpPageRouteImport.update({
id: '/admin/signup',
path: '/admin/signup',
@@ -1775,6 +1782,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof adminSignUpPageRouteImport
parentRoute: typeof middlewaresRestrictLoginSignupImport
}
'/_restrict-login-signup/login/admin': {
id: '/_restrict-login-signup/login/admin'
path: '/admin'
fullPath: '/login/admin'
preLoaderRoute: typeof authAdminLoginPageRouteImport
parentRoute: typeof RestrictLoginSignupLoginImport
}
'/_restrict-login-signup/login/ldap': {
id: '/_restrict-login-signup/login/ldap'
path: '/ldap'
@@ -3655,6 +3669,7 @@ const middlewaresAuthenticateRouteWithChildren =
interface RestrictLoginSignupLoginRouteChildren {
authLoginPageRouteRoute: typeof authLoginPageRouteRoute
authAdminLoginPageRouteRoute: typeof authAdminLoginPageRouteRoute
authLoginLdapPageRouteRoute: typeof authLoginLdapPageRouteRoute
authSelectOrgPageRouteRoute: typeof authSelectOrgPageRouteRoute
authLoginSsoPageRouteRoute: typeof authLoginSsoPageRouteRoute
@@ -3665,6 +3680,7 @@ interface RestrictLoginSignupLoginRouteChildren {
const RestrictLoginSignupLoginRouteChildren: RestrictLoginSignupLoginRouteChildren =
{
authLoginPageRouteRoute: authLoginPageRouteRoute,
authAdminLoginPageRouteRoute: authAdminLoginPageRouteRoute,
authLoginLdapPageRouteRoute: authLoginLdapPageRouteRoute,
authSelectOrgPageRouteRoute: authSelectOrgPageRouteRoute,
authLoginSsoPageRouteRoute: authLoginSsoPageRouteRoute,
@@ -3739,6 +3755,7 @@ export interface FileRoutesByFullPath {
'/signup/': typeof authSignUpPageRouteRoute
'/organization/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute
'/login/admin': typeof authAdminLoginPageRouteRoute
'/login/ldap': typeof authLoginLdapPageRouteRoute
'/login/select-organization': typeof authSelectOrgPageRouteRoute
'/login/sso': typeof authLoginSsoPageRouteRoute
@@ -3918,6 +3935,7 @@ export interface FileRoutesByTo {
'/signup': typeof authSignUpPageRouteRoute
'/organization/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute
'/login/admin': typeof authAdminLoginPageRouteRoute
'/login/ldap': typeof authLoginLdapPageRouteRoute
'/login/select-organization': typeof authSelectOrgPageRouteRoute
'/login/sso': typeof authLoginSsoPageRouteRoute
@@ -4096,6 +4114,7 @@ export interface FileRoutesById {
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
'/_authenticate/organization/none': typeof organizationNoOrgPageRouteRoute
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
'/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute
'/_restrict-login-signup/login/ldap': typeof authLoginLdapPageRouteRoute
'/_restrict-login-signup/login/select-organization': typeof authSelectOrgPageRouteRoute
'/_restrict-login-signup/login/sso': typeof authLoginSsoPageRouteRoute
@@ -4286,6 +4305,7 @@ export interface FileRouteTypes {
| '/signup/'
| '/organization/none'
| '/admin/signup'
| '/login/admin'
| '/login/ldap'
| '/login/select-organization'
| '/login/sso'
@@ -4464,6 +4484,7 @@ export interface FileRouteTypes {
| '/signup'
| '/organization/none'
| '/admin/signup'
| '/login/admin'
| '/login/ldap'
| '/login/select-organization'
| '/login/sso'
@@ -4640,6 +4661,7 @@ export interface FileRouteTypes {
| '/_restrict-login-signup/signup/'
| '/_authenticate/organization/none'
| '/_restrict-login-signup/admin/signup'
| '/_restrict-login-signup/login/admin'
| '/_restrict-login-signup/login/ldap'
| '/_restrict-login-signup/login/select-organization'
| '/_restrict-login-signup/login/sso'
@@ -4928,6 +4950,7 @@ export const routeTree = rootRoute
"parent": "/_restrict-login-signup",
"children": [
"/_restrict-login-signup/login/",
"/_restrict-login-signup/login/admin",
"/_restrict-login-signup/login/ldap",
"/_restrict-login-signup/login/select-organization",
"/_restrict-login-signup/login/sso",
@@ -4959,6 +4982,10 @@ export const routeTree = rootRoute
"filePath": "admin/SignUpPage/route.tsx",
"parent": "/_restrict-login-signup"
},
"/_restrict-login-signup/login/admin": {
"filePath": "auth/AdminLoginPage/route.tsx",
"parent": "/_restrict-login-signup/login"
},
"/_restrict-login-signup/login/ldap": {
"filePath": "auth/LoginLdapPage/route.tsx",
"parent": "/_restrict-login-signup/login"

View File

@@ -328,6 +328,7 @@ export const routes = rootRoute("root.tsx", [
route("/admin/signup", "admin/SignUpPage/route.tsx"),
route("/login", [
index("auth/LoginPage/route.tsx"),
route("/admin", "auth/AdminLoginPage/route.tsx"),
route("/select-organization", "auth/SelectOrgPage/route.tsx"),
route("/sso", "auth/LoginSsoPage/route.tsx"),
route("/ldap", "auth/LoginLdapPage/route.tsx"),