diff --git a/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts b/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts new file mode 100644 index 000000000..fb9a12625 --- /dev/null +++ b/backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + if (await knex.schema.hasColumn(TableName.Organization, "bypassOrgAuthEnabled")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("bypassOrgAuthEnabled"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a18e258c7..eea1808e0 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -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; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9b23d6113..891d7193e 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -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(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: [ { diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 88bace1f0..d645e2bec 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -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 && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 299f509d7..0082c3d17 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -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({ diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 93376d261..90ca3d255 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -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({ diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 8dfe69643..0e0f999dd 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -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; } diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 85d348854..02bf58321 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -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) { diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ef49d8178..2aa793c04 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -16,5 +16,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ allowSecretSharingOutsideOrganization: true, shouldUseNewPrivilegeSystem: true, privilegeUpgradeInitiatedByUsername: true, - privilegeUpgradeInitiatedAt: true + privilegeUpgradeInitiatedAt: true, + bypassOrgAuthEnabled: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 98b35f68f..c83a1a802 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -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; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 9d14b092e..8a1698015 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -73,6 +73,7 @@ export type TUpdateOrgDTO = { enforceMfa: boolean; selectedMfaMethod: MfaMethod; allowSecretSharingOutsideOrganization: boolean; + bypassOrgAuthEnabled: boolean; }>; } & TOrgPermission; diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index 4f8691b9b..bde87f42f 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -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. - + + 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. + diff --git a/docs/documentation/platform/sso/auth0-saml.mdx b/docs/documentation/platform/sso/auth0-saml.mdx index b77c733f7..b426d1aae 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -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. + + + 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. + diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 21236cd5b..282cddae5 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -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. + + 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. + diff --git a/docs/documentation/platform/sso/general-oidc.mdx b/docs/documentation/platform/sso/general-oidc.mdx index 7e3a76ff0..11216b893 100644 --- a/docs/documentation/platform/sso/general-oidc.mdx +++ b/docs/documentation/platform/sso/general-oidc.mdx @@ -66,6 +66,9 @@ Prerequisites: We recommend ensuring that your account is provisioned using the identity provider prior to enforcing OIDC SSO to prevent any unintended issues. + + 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. + diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 7e47d1137..87ffa8412 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -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. + + 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. + diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index ec876b9da..6ca20c752 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -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. + + 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. + diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 36be3f013..6d8f4e4c8 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -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. - + + 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. + diff --git a/docs/documentation/platform/sso/keycloak-saml.mdx b/docs/documentation/platform/sso/keycloak-saml.mdx index 86352bb9d..7e4004122 100644 --- a/docs/documentation/platform/sso/keycloak-saml.mdx +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -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. + + 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. + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 9f28f4c4e..1abd03d6f 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -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. + + 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. + diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index 4e26e1b15..dcf80d452 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -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" diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 90e525cdf..902bb6b09 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -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: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index c563d8c43..e0687922d 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -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 = { diff --git a/frontend/src/pages/auth/AdminLoginPage/route.tsx b/frontend/src/pages/auth/AdminLoginPage/route.tsx new file mode 100644 index 000000000..221c6a34e --- /dev/null +++ b/frontend/src/pages/auth/AdminLoginPage/route.tsx @@ -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: () => +}); diff --git a/frontend/src/pages/auth/LoginPage/Login.utils.tsx b/frontend/src/pages/auth/LoginPage/Login.utils.tsx index 11fcf0d2d..87164fc5d 100644 --- a/frontend/src/pages/auth/LoginPage/Login.utils.tsx +++ b/frontend/src/pages/auth/LoginPage/Login.utils.tsx @@ -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 + } }); }; diff --git a/frontend/src/pages/auth/LoginPage/LoginPage.tsx b/frontend/src/pages/auth/LoginPage/LoginPage.tsx index 74ced95ef..97bb528c6 100644 --- a/frontend/src/pages/auth/LoginPage/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage/LoginPage.tsx @@ -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 ( 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) => { 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 (
{ 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(); diff --git a/frontend/src/pages/auth/SelectOrgPage/route.tsx b/frontend/src/pages/auth/SelectOrgPage/route.tsx index 5d617ff5a..445479b3f 100644 --- a/frontend/src/pages/auth/SelectOrgPage/route.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/route.tsx @@ -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 }) + ] } }); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx index 5e04f173e..21c440957 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx @@ -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 ( <> {/*
@@ -72,7 +97,9 @@ export const OrgGeneralAuthSection = () => {
*/}
-

Enforce SAML SSO

+
+ Enforce SAML SSO +
{(isAllowed) => ( {

- Enforce members to authenticate via SAML to access this organization + Enforce users to authenticate via SAML to access this organization

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + When this is enabled, we strongly recommend enforcing MFA at the organization + level. + +

+ In case of a lockout, admins can use the admin login portal at{" "} + + {window.location.origin}/login/admin + +

+
+ } + > + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ + Allow organization admins to bypass SAML enforcement when SSO is unavailable, + misconfigured, or inaccessible. + +

+ + )} handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx index bec65bd8f..f9d7b939c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -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 => { )}
-

Enforce OIDC SSO

+
+ Enforce OIDC SSO +
{(isAllowed) => ( {

- Enforce members to authenticate via OIDC to access this organization + Enforce users to authenticate via OIDC to access this organization.

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + When this is enabled, we strongly recommend enforcing MFA at the organization + level. + +

+ In case of a lockout, admins can use the admin login portal at{" "} + + {window.location.origin}/login/admin + +

+
+ } + > + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ + Allow organization admins to bypass OIDC enforcement when SSO is unavailable, + misconfigured, or inaccessible. + +

+ + )}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7cd0b5ea9..e3ab6df7d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 47f2d6ab1..d6793ea1a 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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"),