enforce resource session length

This commit is contained in:
miloschwartz
2025-10-26 16:52:15 -07:00
parent 1227b3c11a
commit 44316731c0
8 changed files with 90 additions and 112 deletions

View File

@@ -50,7 +50,8 @@ export async function createResourceSession(opts: {
doNotExtend: opts.doNotExtend || false, doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null, accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false, isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime()
}; };
await db.insert(resourceSessions).values(session); await db.insert(resourceSessions).values(session);

View File

@@ -447,7 +447,8 @@ export const resourceSessions = pgTable("resourceSessions", {
{ {
onDelete: "cascade" onDelete: "cascade"
} }
) ),
issuedAt: bigint("issuedAt", { mode: "number" })
}); });
export const resourceWhitelist = pgTable("resourceWhitelist", { export const resourceWhitelist = pgTable("resourceWhitelist", {

View File

@@ -1,4 +1,4 @@
import { db, loginPage, LoginPage, loginPageOrg } from "@server/db"; import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db";
import { import {
Resource, Resource,
ResourcePassword, ResourcePassword,
@@ -23,6 +23,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
org: Org;
}; };
export type UserSessionWithUser = { export type UserSessionWithUser = {
@@ -51,6 +52,10 @@ export async function getResourceByDomain(
resourceHeaderAuth, resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId) eq(resourceHeaderAuth.resourceId, resources.resourceId)
) )
.innerJoin(
orgs,
eq(orgs.orgId, resources.orgId)
)
.where(eq(resources.fullDomain, domain)) .where(eq(resources.fullDomain, domain))
.limit(1); .limit(1);
@@ -62,7 +67,8 @@ export async function getResourceByDomain(
resource: result.resources, resource: result.resources,
pincode: result.resourcePincode, pincode: result.resourcePincode,
password: result.resourcePassword, password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth headerAuth: result.resourceHeaderAuth,
org: result.orgs
}; };
} }

View File

@@ -587,7 +587,8 @@ export const resourceSessions = sqliteTable("resourceSessions", {
{ {
onDelete: "cascade" onDelete: "cascade"
} }
) ),
issuedAt: integer("issuedAt")
}); });
export const resourceWhitelist = sqliteTable("resourceWhitelist", { export const resourceWhitelist = sqliteTable("resourceWhitelist", {

View File

@@ -1,4 +1,4 @@
import { Org, Session, User } from "@server/db"; import { Org, ResourceSession, Session, User } from "@server/db";
export type CheckOrgAccessPolicyProps = { export type CheckOrgAccessPolicyProps = {
orgId?: string; orgId?: string;
@@ -27,6 +27,13 @@ export type CheckOrgAccessPolicyResult = {
}; };
}; };
export async function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
): Promise<{ valid: boolean; error?: string }> {
return { valid: true };
}
export async function checkOrgAccessPolicy( export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps props: CheckOrgAccessPolicyProps
): Promise<{ ): Promise<{

View File

@@ -12,7 +12,14 @@
*/ */
import { build } from "@server/build"; import { build } from "@server/build";
import { db, Org, orgs, sessions, User, users } from "@server/db"; import {
db,
Org,
orgs,
ResourceSession,
sessions,
users
} from "@server/db";
import { getOrgTierData } from "#private/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import license from "#private/license/license"; import license from "#private/license/license";
@@ -23,6 +30,35 @@ import {
} from "@server/lib/checkOrgAccessPolicy"; } from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
export async function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
): Promise<{ valid: boolean; error?: string }> {
if (org.maxSessionLengthHours) {
const sessionIssuedAt = resourceSession.issuedAt; // may be null
const maxSessionLengthHours = org.maxSessionLengthHours;
if (sessionIssuedAt) {
const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000;
const sessionAgeMs = Date.now() - sessionIssuedAt;
if (sessionAgeMs > maxSessionLengthMs) {
return {
valid: false,
error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)`
};
}
} else {
return {
valid: false,
error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)`
};
}
}
return { valid: true };
}
export async function checkOrgAccessPolicy( export async function checkOrgAccessPolicy(
props: CheckOrgAccessPolicyProps props: CheckOrgAccessPolicyProps
): Promise<CheckOrgAccessPolicyResult> { ): Promise<CheckOrgAccessPolicyResult> {
@@ -43,15 +79,6 @@ export async function checkOrgAccessPolicy(
return { allowed: false, error: "Session ID is required" }; return { allowed: false, error: "Session ID is required" };
} }
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
// if not subscribed, don't check the policies
if (!subscribed) {
return { allowed: true };
}
}
if (build === "enterprise") { if (build === "enterprise") {
const isUnlocked = await license.isUnlocked(); const isUnlocked = await license.isUnlocked();
// if not licensed, don't check the policies // if not licensed, don't check the policies

View File

@@ -73,8 +73,6 @@ import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
// Zod schemas for request validation // Zod schemas for request validation
const getResourceByDomainParamsSchema = z const getResourceByDomainParamsSchema = z
@@ -1585,90 +1583,3 @@ hybridRouter.post(
} }
} }
); );
const getOrgAccessPolicyParamsSchema = z
.object({
orgId: z.string().min(1),
userId: z.string().min(1)
})
.strict();
const getOrgAccessPolicyBodySchema = z
.object({
sessionId: z.string().min(1)
})
.strict();
hybridRouter.get(
"/org/:orgId/user/:userId/access",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getOrgAccessPolicyParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = getOrgAccessPolicyBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, userId } = parsedParams.data;
const { sessionId } = parsedBody.data;
const remoteExitNode = req.remoteExitNode;
if (!remoteExitNode?.exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node not found"
)
);
}
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
// If the exit node is not allowed for the org, return an error
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
const accessPolicy = await checkOrgAccessPolicy({
orgId,
userId,
sessionId
});
return response<CheckOrgAccessPolicyResult>(res, {
data: accessPolicy,
success: true,
error: false,
message: "Org access policy retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to get org login page"
)
);
}
}
);

View File

@@ -16,12 +16,14 @@ import {
} from "@server/db/queries/verifySessionQueries"; } from "@server/db/queries/verifySessionQueries";
import { import {
LoginPage, LoginPage,
Org,
Resource, Resource,
ResourceAccessToken, ResourceAccessToken,
ResourceHeaderAuth, ResourceHeaderAuth,
ResourcePassword, ResourcePassword,
ResourcePincode, ResourcePincode,
ResourceRule ResourceRule,
resourceSessions
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
@@ -37,7 +39,10 @@ import { getCountryCodeForIp } from "@server/lib/geoip";
import { getOrgTierData } from "#dynamic/lib/billing"; import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import {
checkOrgAccessPolicy,
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
// We'll see if this speeds anything up // We'll see if this speeds anything up
const cache = new NodeCache({ const cache = new NodeCache({
@@ -142,6 +147,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
org: Org;
} }
| undefined = cache.get(resourceCacheKey); | undefined = cache.get(resourceCacheKey);
@@ -380,6 +386,22 @@ export async function verifyResourceSession(
} }
if (resourceSession) { if (resourceSession) {
// only run this check if not SSO sesion; SSO session length is checked later
if (!(resourceSessions.userSessionId && sso)) {
const accessPolicy = await enforceResourceSessionLength(
resourceSession,
resourceData.org
);
if (!accessPolicy.valid) {
logger.debug(
"Resource session invalid due to org policy:",
accessPolicy.error
);
return notAllowed(res, redirectPath, resource.orgId);
}
}
if (pincode && resourceSession.pincodeId) { if (pincode && resourceSession.pincodeId) {
logger.debug( logger.debug(
"Resource allowed because pincode session is valid" "Resource allowed because pincode session is valid"
@@ -422,7 +444,8 @@ export async function verifyResourceSession(
if (allowedUserData === undefined) { if (allowedUserData === undefined) {
allowedUserData = await isUserAllowedToAccessResource( allowedUserData = await isUserAllowedToAccessResource(
resourceSession.userSessionId, resourceSession.userSessionId,
resource resource,
resourceData.org
); );
cache.set(userAccessCacheKey, allowedUserData); cache.set(userAccessCacheKey, allowedUserData);
@@ -564,7 +587,8 @@ function allowed(res: Response, userData?: BasicUserData) {
async function isUserAllowedToAccessResource( async function isUserAllowedToAccessResource(
userSessionId: string, userSessionId: string,
resource: Resource resource: Resource,
org: Org
): Promise<BasicUserData | null> { ): Promise<BasicUserData | null> {
const result = await getUserSessionWithUser(userSessionId); const result = await getUserSessionWithUser(userSessionId);
@@ -592,9 +616,9 @@ async function isUserAllowedToAccessResource(
} }
const accessPolicy = await checkOrgAccessPolicy({ const accessPolicy = await checkOrgAccessPolicy({
orgId: resource.orgId, org,
userId: user.userId, user,
sessionId: session.sessionId session
}); });
if (!accessPolicy.allowed || accessPolicy.error) { if (!accessPolicy.allowed || accessPolicy.error) {
logger.debug(`User not allowed by org access policy because`, { logger.debug(`User not allowed by org access policy because`, {