2fa policy check working

This commit is contained in:
miloschwartz
2025-10-24 14:31:50 -07:00
parent ddcf77a62d
commit 629f17294a
16 changed files with 724 additions and 88 deletions

View File

@@ -1,12 +1,20 @@
import { Org, User } from "@server/db";
type CheckOrgAccessPolicyProps = {
export type CheckOrgAccessPolicyProps = {
orgId?: string;
org?: Org;
userId?: string;
user?: User;
};
export type CheckOrgAccessPolicyResult = {
allowed: boolean;
error?: string;
policies?: {
requiredTwoFactor?: boolean;
};
};
export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps
): Promise<{

View File

@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import logger from "@server/logger";
export async function verifyOrgAccess(
req: Request,
@@ -51,7 +52,9 @@ export async function verifyOrgAccess(
userId
});
if (!policyCheck.success || policyCheck.error) {
logger.debug("Org check policy result", { policyCheck });
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,

View File

@@ -17,28 +17,26 @@ import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import license from "#private/license/license";
import { eq } from "drizzle-orm";
type CheckOrgAccessPolicyProps = {
orgId?: string;
org?: Org;
userId?: string;
user?: User;
};
import {
CheckOrgAccessPolicyProps,
CheckOrgAccessPolicyResult
} from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes";
export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps
): Promise<{
success: boolean;
error?: string;
}> {
): Promise<CheckOrgAccessPolicyResult> {
const userId = props.userId || props.user?.userId;
const orgId = props.orgId || props.org?.orgId;
if (!orgId) {
return { success: false, error: "Organization ID is required" };
return {
allowed: false,
error: "Organization ID is required"
};
}
if (!userId) {
return { success: false, error: "User ID is required" };
return { allowed: false, error: "User ID is required" };
}
if (build === "saas") {
@@ -46,7 +44,7 @@ export async function checkOrgAccessPolicy(
const subscribed = tier === TierId.STANDARD;
// if not subscribed, don't check the policies
if (!subscribed) {
return { success: true };
return { allowed: true };
}
}
@@ -54,7 +52,7 @@ export async function checkOrgAccessPolicy(
const isUnlocked = await license.isUnlocked();
// if not licensed, don't check the policies
if (!isUnlocked) {
return { success: true };
return { allowed: true };
}
}
@@ -67,7 +65,7 @@ export async function checkOrgAccessPolicy(
.where(eq(orgs.orgId, orgId));
props.org = orgQuery;
if (!props.org) {
return { success: false, error: "Organization not found" };
return { allowed: false, error: "Organization not found" };
}
}
@@ -78,18 +76,22 @@ export async function checkOrgAccessPolicy(
.where(eq(users.userId, userId));
props.user = userQuery;
if (!props.user) {
return { success: false, error: "User not found" };
return { allowed: false, error: "User not found" };
}
}
// now check the policies
const policies: CheckOrgAccessPolicyResult["policies"] = {};
if (!props.org.requireTwoFactor && !props.user.twoFactorEnabled) {
return {
success: false,
error: "Two-factor authentication is required"
};
// only applies to internal users
if (props.user.type === UserType.Internal && props.org.requireTwoFactor) {
policies.requiredTwoFactor = props.user.twoFactorEnabled || false;
}
return { success: true };
const allowed = Object.values(policies).every((v) => v === true);
return {
allowed,
policies
};
}

View File

@@ -606,6 +606,7 @@ authenticated.post(
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
authenticated.post(
"/user/:userId/2fa",
@@ -675,8 +676,6 @@ authenticated.post(
idp.updateOidcIdp
);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
@@ -705,7 +704,6 @@ authenticated.get(
idp.listIdpOrgPolicies
);
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);

View File

@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
async function queryUser(orgId: string, userId: string) {
const [user] = await db
.select({
orgId: userOrgs.orgId,
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId,
idpName: idp.name,
idpType: idp.type,
idpVariant: idpOidcConfig.variant,
idpAutoProvision: idp.autoProvision
})
.from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
return user;
}
export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;
const paramsSchema = z.object({
userId: z.string(),
orgId: z.string()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/user/{userId}/check",
description: "Check a user's access in an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: paramsSchema
},
responses: {}
});
export async function checkOrgUserAccess(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
logger.debug("here0 ")
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, userId } = parsedParams.data;
if (userId !== req.user?.userId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to check this user's access"
)
);
}
let user;
user = await queryUser(orgId, userId);
if (!user) {
const [fullUser] = await db
.select()
.from(users)
.where(eq(users.email, userId))
.limit(1);
if (fullUser) {
user = await queryUser(orgId, fullUser.userId);
}
}
if (!user) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`User with ID ${userId} not found in org`
)
);
}
const policyCheck = await checkOrgAccessPolicy({
orgId,
userId
});
// if we get here, the user has an org join, we just don't know if they pass the policies
return response<CheckOrgUserAccessResponse>(res, {
data: policyCheck,
success: true,
error: false,
message: "User access checked successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -8,3 +8,4 @@ export * from "./getOrgOverview";
export * from "./listOrgs";
export * from "./pickOrgDefaults";
export * from "./applyBlueprint";
export * from "./checkOrgUserAccess";

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs } from "@server/db";
import { orgs, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -9,6 +9,11 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { UserType } from "@server/types/UserTypes";
import license from "#dynamic/license/license";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
const updateOrgParamsSchema = z
.object({
@@ -18,7 +23,8 @@ const updateOrgParamsSchema = z
const updateOrgBodySchema = z
.object({
name: z.string().min(1).max(255).optional()
name: z.string().min(1).max(255).optional(),
requireTwoFactor: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@@ -71,10 +77,30 @@ export async function updateOrg(
const { orgId } = parsedParams.data;
const isLicensed = await isLicensedOrSubscribed(orgId);
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
}
if (
req.user &&
req.user.type === UserType.Internal &&
parsedBody.data.requireTwoFactor === true &&
!req.user.twoFactorEnabled
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You must enable two-factor authentication for your account before enforcing it for all users"
)
);
}
const updatedOrg = await db
.update(orgs)
.set({
name: parsedBody.data.name
name: parsedBody.data.name,
requireTwoFactor: parsedBody.data.requireTwoFactor
})
.where(eq(orgs.orgId, orgId))
.returning();
@@ -102,3 +128,22 @@ export async function updateOrg(
);
}
}
async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
const isUnlocked = await license.isUnlocked();
if (!isUnlocked) {
return false;
}
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return false;
}
}
return true;
}

View File

@@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { generateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib/response";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
const getExchangeTokenParams = z
.object({
@@ -74,6 +73,22 @@ export async function getExchangeToken(
);
}
// check org policy here
const hasAccess = await checkOrgAccessPolicy({
orgId: resource[0].orgId,
userId: req.user!.userId
});
if (!hasAccess.allowed || hasAccess.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(hasAccess.error || "Unknown error")
)
);
}
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(ssoSession))
);