misc: allow org admins to bypass sso enforcement

This commit is contained in:
Sheen Capadngan
2025-04-16 01:35:39 +08:00
parent 32bca651df
commit d0db5c00e8
11 changed files with 45 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { TDbClient } from "@app/db";
import {
IdentityProjectMembershipRoleSchema,
OrgMembershipRole,
OrgMembershipsSchema,
TableName,
TProjectRoles,
@@ -571,6 +572,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 +676,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("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 +690,7 @@ export const permissionDALFactory = (db: TDbClient) => {
orgId,
username,
orgAuthEnforced,
orgRole,
membershipId,
groupMembershipId,
membershipCreatedAt,
@@ -694,6 +702,7 @@ export const permissionDALFactory = (db: TDbClient) => {
}) => ({
orgId,
orgAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId,
projectId,
username,

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,12 +118,17 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
].includes(actorAuthMethod);
}
function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"]) {
function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
orgRole: OrgMembershipRole
) {
if (actorAuthMethod === undefined) {
throw new UnauthorizedError({ name: "No auth method defined" });
}
if (
orgRole !== OrgMembershipRole.Admin &&
isOrgSsoEnforced &&
actorAuthMethod !== null &&
!isAuthMethodSaml(actorAuthMethod) &&

View File

@@ -139,7 +139,7 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, membership.orgAuthEnforced);
validateOrgSSO(authMethod, membership.orgAuthEnforced, membership.role as OrgMembershipRole);
const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat(
membership?.groups?.map(({ role, customRolePermission }) => ({
@@ -226,7 +226,7 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
}
validateOrgSSO(authMethod, userProjectPermission.orgAuthEnforced);
validateOrgSSO(authMethod, userProjectPermission.orgAuthEnforced, 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()
})

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

@@ -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

@@ -17,6 +17,7 @@ export type Organization = {
selectedMfaMethod?: MfaMethod;
shouldUseNewPrivilegeSystem: boolean;
allowSecretSharingOutsideOrganization?: boolean;
userRole: string;
};
export type UpdateOrgDTO = {

View File

@@ -34,6 +34,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const [isOtherLoginMethodsSelected, setIsOtherLoginMethodsSelected] = useState(false);
const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search);
const [captchaToken, setCaptchaToken] = useState("");
@@ -160,7 +161,11 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
setIsLoading(false);
};
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) {
if (
config.defaultAuthOrgAuthEnforced &&
config.defaultAuthOrgAuthMethod &&
!isOtherLoginMethodsSelected
) {
return (
<form
onSubmit={handleLogin}
@@ -196,6 +201,14 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
</Button>
</div>
)}
<Button
colorSchema="gray"
variant="link"
className="mt-6 font-normal text-mineshaft-400 transition-all duration-75 hover:text-mineshaft-300"
onClick={() => setIsOtherLoginMethodsSelected(true)}
>
Continue with other login methods
</Button>
</form>
);
}

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,
@@ -68,7 +69,7 @@ export const SelectOrganizationPage = () => {
const handleSelectOrganization = useCallback(
async (organization: Organization) => {
if (organization.authEnforced) {
if (organization.authEnforced && organization.userRole !== OrgMembershipRole.Admin) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();

View File

@@ -85,7 +85,7 @@ export const OrgGeneralAuthSection = () => {
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via SAML to access this organization
Enforce non-admin users to authenticate via SAML to access this organization
</p>
</div>
<UpgradePlanModal

View File

@@ -171,7 +171,7 @@ export const OrgOIDCSection = (): JSX.Element => {
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via OIDC to access this organization
Enforce non-admin users to authenticate via OIDC to access this organization.
</p>
</div>
<div className="py-4">