Files
Checkmate/server/src/middleware/AddUserContext.ts
T

160 lines
4.1 KiB
TypeScript

import { Request, Response, NextFunction } from "express";
import { OrgMembership, TeamMembership, IRole } from "@/db/models/index.js";
import ApiError from "@/utils/ApiError.js";
import { EntitlementsFactory } from "@/services/system/EntitlementsService.js";
import type { Entitlements } from "@/types/entitlements.js";
import { getChildLogger } from "@/logger/Logger.js";
const logger = getChildLogger("AddUserContext");
interface IRolesObj {
orgRole?: IRole;
teamRole: IRole;
}
interface IRolesCacheData {
data: IRolesObj;
expiresAt: number;
}
interface ITeamIdsCacheData {
data: string[];
expiresAt: number;
}
const CACHE_TTL = 5 * 60 * 1000;
const rolesCache = new Map<string, IRolesCacheData>();
const teamIdsCache = new Map<string, ITeamIdsCacheData>();
export const invalidateCachesForUser = (userId: string) => {
for (const key of rolesCache.keys()) {
if (key.startsWith(userId + ":")) {
rolesCache.delete(key);
}
}
for (const key of teamIdsCache.keys()) {
if (key.startsWith(userId)) {
teamIdsCache.delete(key);
}
}
};
const getTeamIds = async (userId: string): Promise<string[]> => {
// Try to get from cache
if (teamIdsCache.has(userId)) {
const cached = teamIdsCache.get(userId);
if (!cached) {
throw new ApiError("TeamID cache retrieval error", 500);
}
if (cached.expiresAt > Date.now()) {
return cached.data;
}
}
// If not in cache or expired, fetch from DB
const teamMemberships = await TeamMembership.find({ userId })
.select("teamId")
.lean();
const teamIds = teamMemberships.map((t) => t.teamId.toString());
teamIdsCache.set(userId, {
data: teamIds,
expiresAt: Date.now() + CACHE_TTL,
});
return teamIds;
};
const getRoles = async (
userId: string,
orgId: string,
currentTeamId: string
) => {
// Try to get from cache
if (rolesCache.has(`${userId}:${orgId}:${currentTeamId}`)) {
const cached = rolesCache.get(`${userId}:${orgId}:${currentTeamId}`);
if (!cached) {
throw new ApiError("Roles cache retrieval error", 500);
}
if (cached.expiresAt > Date.now()) {
return cached.data;
}
}
const [orgRole, teamRole] = await Promise.all([
OrgMembership.findOne({ userId, orgId })
.populate<{ roleId: IRole }>("roleId")
.select("roleId")
.lean(),
TeamMembership.findOne({ userId, teamId: currentTeamId })
.populate<{ roleId: IRole }>("roleId")
.select("roleId")
.lean(),
]);
if (!orgRole) {
throw new ApiError("User has no role in the organization", 403);
}
if (!teamRole) {
throw new ApiError("User has no role in the team", 403);
}
const cacheData = { orgRole: orgRole.roleId, teamRole: teamRole.roleId };
rolesCache.set(`${userId}:${orgId}:${currentTeamId}`, {
data: cacheData,
expiresAt: Date.now() + CACHE_TTL,
});
return cacheData;
};
export const addUserContext = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
if (!req.user) {
throw new ApiError("User not authenticated", 401);
}
// Get identifiers
const sub = req.user.sub;
const orgId = req.user.orgId;
const currentTeamId = req.headers["x-team-id"] as string;
if (!currentTeamId || String(currentTeamId).trim().length === 0) {
throw new ApiError("X-Team-Id header required", 400);
}
// Get user team IDs
const teamIds = await getTeamIds(sub);
// Short circuit if user doesn't belong to selected team
if (!teamIds.includes(currentTeamId)) {
throw new ApiError("User does not belong to the selected team", 403);
}
// Get user roles to context
const { orgRole, teamRole } = await getRoles(sub, orgId, currentTeamId);
// Populate user context
req.user.teamIds = teamIds;
req.user.roles = {
orgRole,
teamRole,
};
req.user.currentTeamId = currentTeamId;
// Attach entitlements for the org
const provider = EntitlementsFactory.create();
const entitlements: Entitlements = await provider.getForOrg(orgId);
req.entitlements = entitlements;
return next();
} catch (error) {
next(error);
}
};