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
@@ -3,6 +3,7 @@ import { z } from "zod";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { import {
IdentityProjectMembershipRoleSchema, IdentityProjectMembershipRoleSchema,
OrgMembershipRole,
OrgMembershipsSchema, OrgMembershipsSchema,
TableName, TableName,
TProjectRoles, TProjectRoles,
@@ -571,6 +572,11 @@ export const permissionDALFactory = (db: TDbClient) => {
}) })
.join<TProjects>(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) .join<TProjects>(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId]))
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .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) => { .leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder void queryBuilder
.on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) .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("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
db.ref("orgId").withSchema(TableName.Project), db.ref("orgId").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project).as("projectType"), db.ref("type").withSchema(TableName.Project).as("projectType"),
db.ref("id").withSchema(TableName.Project).as("projectId"), db.ref("id").withSchema(TableName.Project).as("projectId"),
@@ -683,6 +690,7 @@ export const permissionDALFactory = (db: TDbClient) => {
orgId, orgId,
username, username,
orgAuthEnforced, orgAuthEnforced,
orgRole,
membershipId, membershipId,
groupMembershipId, groupMembershipId,
membershipCreatedAt, membershipCreatedAt,
@@ -694,6 +702,7 @@ export const permissionDALFactory = (db: TDbClient) => {
}) => ({ }) => ({
orgId, orgId,
orgAuthEnforced, orgAuthEnforced,
orgRole: orgRole as OrgMembershipRole,
userId, userId,
projectId, projectId,
username, username,
@@ -2,7 +2,7 @@
import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability"; import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability";
import { z } from "zod"; 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 { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type";
@@ -118,12 +118,17 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
].includes(actorAuthMethod); ].includes(actorAuthMethod);
} }
function validateOrgSSO(actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"]) { function validateOrgSSO(
actorAuthMethod: ActorAuthMethod,
isOrgSsoEnforced: TOrganizations["authEnforced"],
orgRole: OrgMembershipRole
) {
if (actorAuthMethod === undefined) { if (actorAuthMethod === undefined) {
throw new UnauthorizedError({ name: "No auth method defined" }); throw new UnauthorizedError({ name: "No auth method defined" });
} }
if ( if (
orgRole !== OrgMembershipRole.Admin &&
isOrgSsoEnforced && isOrgSsoEnforced &&
actorAuthMethod !== null && actorAuthMethod !== null &&
!isAuthMethodSaml(actorAuthMethod) && !isAuthMethodSaml(actorAuthMethod) &&
@@ -139,7 +139,7 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); 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( const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat(
membership?.groups?.map(({ role, customRolePermission }) => ({ membership?.groups?.map(({ role, customRolePermission }) => ({
@@ -226,7 +226,7 @@ export const permissionServiceFactory = ({
throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); 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) { if (actionProjectType !== ActionProjectType.Any && actionProjectType !== userProjectPermission.projectType) {
throw new BadRequestError({ throw new BadRequestError({
@@ -31,7 +31,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
200: z.object({ 200: z.object({
organizations: sanitizedOrganizationSchema organizations: sanitizedOrganizationSchema
.extend({ .extend({
orgAuthMethod: z.string() orgAuthMethod: z.string(),
userRole: z.string()
}) })
.array() .array()
}) })
+5 -2
View File
@@ -96,7 +96,9 @@ export const orgDALFactory = (db: TDbClient) => {
}; };
// special query // special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => { const findAllOrgsByUserId = async (
userId: string
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => {
try { try {
const org = (await db const org = (await db
.replicaNode()(TableName.OrgMembership) .replicaNode()(TableName.OrgMembership)
@@ -117,6 +119,7 @@ export const orgDALFactory = (db: TDbClient) => {
); );
}) })
.select(selectAllTableCols(TableName.Organization)) .select(selectAllTableCols(TableName.Organization))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole"))
.select( .select(
db.raw(` db.raw(`
CASE CASE
@@ -125,7 +128,7 @@ export const orgDALFactory = (db: TDbClient) => {
ELSE '' ELSE ''
END as "orgAuthMethod" END as "orgAuthMethod"
`) `)
)) as (TOrganizations & { orgAuthMethod: string })[]; )) as (TOrganizations & { orgAuthMethod: string; userRole: string })[];
return org; return org;
} catch (error) { } catch (error) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
enum OrgMembershipRole { export enum OrgMembershipRole {
Admin = "admin", Admin = "admin",
Member = "member", Member = "member",
NoAccess = "no-access" NoAccess = "no-access"
@@ -17,6 +17,7 @@ export type Organization = {
selectedMfaMethod?: MfaMethod; selectedMfaMethod?: MfaMethod;
shouldUseNewPrivilegeSystem: boolean; shouldUseNewPrivilegeSystem: boolean;
allowSecretSharingOutsideOrganization?: boolean; allowSecretSharingOutsideOrganization?: boolean;
userRole: string;
}; };
export type UpdateOrgDTO = { export type UpdateOrgDTO = {
@@ -34,6 +34,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false); const [loginError, setLoginError] = useState(false);
const [isOtherLoginMethodsSelected, setIsOtherLoginMethodsSelected] = useState(false);
const { config } = useServerConfig(); const { config } = useServerConfig();
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
const [captchaToken, setCaptchaToken] = useState(""); const [captchaToken, setCaptchaToken] = useState("");
@@ -160,7 +161,11 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
setIsLoading(false); setIsLoading(false);
}; };
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) { if (
config.defaultAuthOrgAuthEnforced &&
config.defaultAuthOrgAuthMethod &&
!isOtherLoginMethodsSelected
) {
return ( return (
<form <form
onSubmit={handleLogin} onSubmit={handleLogin}
@@ -196,6 +201,14 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
</Button> </Button>
</div> </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> </form>
); );
} }
@@ -14,6 +14,7 @@ import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Spinner } from "@app/components/v2"; import { Button, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const"; import { SessionStorageKeys } from "@app/const";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { import {
useGetOrganizations, useGetOrganizations,
@@ -68,7 +69,7 @@ export const SelectOrganizationPage = () => {
const handleSelectOrganization = useCallback( const handleSelectOrganization = useCallback(
async (organization: Organization) => { async (organization: Organization) => {
if (organization.authEnforced) { if (organization.authEnforced && organization.userRole !== OrgMembershipRole.Admin) {
// org has an org-level auth method enabled (e.g. SAML) // org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO // -> logout + redirect to SAML SSO
await logout.mutateAsync(); await logout.mutateAsync();
@@ -85,7 +85,7 @@ export const OrgGeneralAuthSection = () => {
</OrgPermissionCan> </OrgPermissionCan>
</div> </div>
<p className="text-sm text-mineshaft-300"> <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> </p>
</div> </div>
<UpgradePlanModal <UpgradePlanModal
@@ -171,7 +171,7 @@ export const OrgOIDCSection = (): JSX.Element => {
</OrgPermissionCan> </OrgPermissionCan>
</div> </div>
<p className="text-sm text-mineshaft-300"> <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> </p>
</div> </div>
<div className="py-4"> <div className="py-4">