From 76c3f1c152f07388627130d12f5a4e397b488ba3 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 16 Apr 2025 23:58:20 +0800 Subject: [PATCH] misc: made bypass opt-in --- ...6145120_add-enable-bypass-org-auth-flag.ts | 19 ++++ backend/src/db/schemas/organizations.ts | 3 +- .../ee/services/permission/permission-dal.ts | 9 +- .../ee/services/permission/permission-fns.ts | 6 +- .../services/permission/permission-service.ts | 14 ++- .../server/routes/v1/organization-router.ts | 3 +- backend/src/services/org/org-schema.ts | 3 +- backend/src/services/org/org-service.ts | 6 +- backend/src/services/org/org-types.ts | 1 + .../src/hooks/api/organization/queries.tsx | 6 +- frontend/src/hooks/api/organization/types.ts | 2 + .../auth/SelectOrgPage/SelectOrgPage.tsx | 5 +- .../OrgAuthTab/OrgGeneralAuthSection.tsx | 88 ++++++++++++------- .../components/OrgAuthTab/OrgOIDCSection.tsx | 88 ++++++++++++------- 14 files changed, 180 insertions(+), 73 deletions(-) create mode 100644 backend/src/db/migrations/20250416145120_add-enable-bypass-org-auth-flag.ts 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..ef2957545 --- /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, "enableBypassOrgAuth"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("enableBypassOrgAuth").defaultTo(false).notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "enableBypassOrgAuth")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("enableBypassOrgAuth"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a18e258c7..3e475fda1 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(), + enableBypassOrgAuth: 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 b76403400..410e4d48e 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -54,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("enableBypassOrgAuth").withSchema(TableName.Organization).as("enableBypassOrgAuth"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), db.ref("groupName").withSchema("userGroups"), @@ -72,6 +73,7 @@ export const permissionDALFactory = (db: TDbClient) => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + enableBypassOrgAuth: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() }).parse(el), @@ -676,6 +678,7 @@ 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("enableBypassOrgAuth").withSchema(TableName.Organization).as("enableBypassOrgAuth"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), db.ref("type").withSchema(TableName.Project).as("projectType"), @@ -698,7 +701,8 @@ export const permissionDALFactory = (db: TDbClient) => { groupMembershipUpdatedAt, membershipUpdatedAt, projectType, - shouldUseNewPrivilegeSystem + shouldUseNewPrivilegeSystem, + enableBypassOrgAuth }) => ({ orgId, orgAuthEnforced, @@ -710,7 +714,8 @@ export const permissionDALFactory = (db: TDbClient) => { id: membershipId || groupMembershipId, createdAt: membershipCreatedAt || groupMembershipCreatedAt, updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt, - shouldUseNewPrivilegeSystem + shouldUseNewPrivilegeSystem, + enableBypassOrgAuth }), childrenMapper: [ { diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 410fc5819..e532afe05 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,14 +121,18 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgSsoBypassEnabled: TOrganizations["enableBypassOrgAuth"], orgRole: OrgMembershipRole ) { if (actorAuthMethod === undefined) { throw new UnauthorizedError({ name: "No auth method defined" }); } + if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + return; + } + if ( - orgRole !== OrgMembershipRole.Admin && isOrgSsoEnforced && actorAuthMethod !== null && !isAuthMethodSaml(actorAuthMethod) && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 0a63931aa..69dadec00 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, membership.role as OrgMembershipRole); + validateOrgSSO( + authMethod, + membership.orgAuthEnforced, + membership.enableBypassOrgAuth, + 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, userProjectPermission.orgRole); + validateOrgSSO( + authMethod, + userProjectPermission.orgAuthEnforced, + userProjectPermission.enableBypassOrgAuth, + 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 c6391d4f7..25c3ea7f2 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -260,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(), + enableBypassOrgAuth: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ef49d8178..7b5825e34 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, + enableBypassOrgAuth: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 98b35f68f..a5df934d2 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, + enableBypassOrgAuth } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -429,7 +430,8 @@ export const orgServiceFactory = ({ defaultMembershipRole, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + enableBypassOrgAuth }); 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..136e6cc84 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; + enableBypassOrgAuth: boolean; }>; } & TOrgPermission; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 90e525cdf..5bc6c2c7b 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, + enableBypassOrgAuth }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -120,7 +121,8 @@ export const useUpdateOrg = () => { defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod, - allowSecretSharingOutsideOrganization + allowSecretSharingOutsideOrganization, + enableBypassOrgAuth }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 6eeebd819..302b635de 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; + enableBypassOrgAuth: boolean; orgAuthMethod: string; scimEnabled: boolean; slug: string; @@ -30,6 +31,7 @@ export type UpdateOrgDTO = { enforceMfa?: boolean; selectedMfaMethod?: MfaMethod; allowSecretSharingOutsideOrganization?: boolean; + enableBypassOrgAuth?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index 7dbf8d457..601ce08b9 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -69,7 +69,10 @@ export const SelectOrganizationPage = () => { const handleSelectOrganization = useCallback( async (organization: Organization) => { - if (organization.authEnforced && organization.userRole !== OrgMembershipRole.Admin) { + const canBypassOrgAuth = + organization.enableBypassOrgAuth && organization.userRole === OrgMembershipRole.Admin; + + 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/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx index 291934208..462fd75d7 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGeneralAuthSection.tsx @@ -55,6 +55,28 @@ export const OrgGeneralAuthSection = () => { } }; + const handleEnableBypassOrgAuthToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + enableBypassOrgAuth: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, + type: "success" + }); + } catch (err) { + console.error(err); + } + }; + return ( <> {/*
@@ -77,35 +99,6 @@ export const OrgGeneralAuthSection = () => {
Enforce SAML SSO - - - Login enforcement is only applied to non-admin users in order to prevent total - lockout from the organization when the SAML provider is unavailable. - - -

- In case of a lockout, use the admin login portal{" "} - - here. - -

-
- } - > - -
{(isAllowed) => ( @@ -119,9 +112,44 @@ export const OrgGeneralAuthSection = () => {

- Enforce non-admin users to authenticate via SAML to access this organization + Enforce users to authenticate via SAML to access this organization

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ + Allow organization admins to bypass OIDC 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 a494115a3..33fbd4aeb 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, + enableBypassOrgAuth: 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; @@ -160,35 +182,6 @@ export const OrgOIDCSection = (): JSX.Element => {
Enforce OIDC SSO - - - Login enforcement is only applied to non-admin users in order to prevent total - lockout from the organization when the OIDC provider is unavailable. - - -

- In case of a lockout, use the admin login portal{" "} - - here. - -

-
- } - > - -
{(isAllowed) => ( @@ -202,9 +195,44 @@ export const OrgOIDCSection = (): JSX.Element => {

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

+ {currentOrg?.authEnforced && ( +
+
+
+ Enable Admin SSO Bypass + + + +
+ + {(isAllowed) => ( + handleEnableBypassOrgAuthToggle(value)} + isDisabled={!isAllowed} + /> + )} + +
+

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

+
+ )}