1
22import { Inject, Injectable } from "@nestjs/common";
23import type { Request } from "express";
24import type { PrismaClient } from "@prisma/client";
25import { LIMITS } from "@factory/shared";
26import { PrismaService } from "../../common/prisma.service.js";
27import { RedisService } from "../../common/redis.service.js";
28import { AuditService } from "../../common/audit.service.js";
29import { requireUser } from "../../common/types.js";
30import { assertOrgAdmin } from "./guards.js";
31import { PLAN_USAGE_LIMITS } from "../files/quota.js";
32import {
33 OrgPolicySchema,
34 parseStoredPolicy,
35 type OrgPolicy
36} from "./policy.schema.js";
37
38const CACHE_TTL_SEC = 60;
39const cacheKey = (orgId: string) => `org:policy:${orgId}`;
40
41
42export function planMaxFileBytes(billingPlan: string): number {
43 return (PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!).maxFileSize;
44}
45
46
51export function effectiveMaxFileBytes(stored: OrgPolicy | null, billingPlan: string): number {
52 const storedBytes = stored?.maxFileSizeMB != null ? stored.maxFileSizeMB * 1024 * 1024 : Infinity;
53 return Math.min(storedBytes, planMaxFileBytes(billingPlan), LIMITS.MAX_FILE_SIZE);
54}
55
56
57
58type PolicyPrisma = Pick<PrismaClient, "organization" | "organizationMember" | "workspace" | "user">;
59
60
61export async function resolveOrgPolicy(prisma: PolicyPrisma, orgId: string): Promise<OrgPolicy> {
62 const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { policy: true } });
63 return parseStoredPolicy(org?.policy);
64}
65
66
70export async function workspaceOrgPolicy(
71 prisma: PolicyPrisma,
72 workspaceId: string
73): Promise<{ orgId: string | null; policy: OrgPolicy | null }> {
74 const ws = await prisma.workspace.findUnique({
75 where: { id: workspaceId },
76 select: { organizationId: true }
77 });
78 if (!ws?.organizationId) return { orgId: null, policy: null };
79 return { orgId: ws.organizationId, policy: await resolveOrgPolicy(prisma, ws.organizationId) };
80}
81
82export interface UserPolicyResolution {
83
84 require2FA: boolean;
85
87 sessionLifetimeHours: number | null;
88}
89
90
95export async function resolveUserPolicy(
96 prisma: PolicyPrisma,
97 email: string
98): Promise<UserPolicyResolution> {
99 const user = await prisma.user.findUnique({
100 where: { email: email.toLowerCase() },
101 select: { id: true, orgMemberships: { select: { organizationId: true } } }
102 });
103 if (!user || user.orgMemberships.length === 0) return { require2FA: false, sessionLifetimeHours: null };
104 const orgs = await prisma.organization.findMany({
105 where: { id: { in: user.orgMemberships.map((m) => m.organizationId) } },
106 select: { policy: true }
107 });
108 let require2FA = false;
109 let sessionLifetimeHours: number | null = null;
110 for (const o of orgs) {
111 const p = parseStoredPolicy(o.policy);
112 if (p.require2FA) require2FA = true;
113 if (p.sessionLifetimeHours != null && (sessionLifetimeHours == null || p.sessionLifetimeHours > sessionLifetimeHours)) {
114 sessionLifetimeHours = p.sessionLifetimeHours;
115 }
116 }
117 return { require2FA, sessionLifetimeHours };
118}
119
120
125export async function resolveSessionLifetimeHours(
126 prisma: PolicyPrisma,
127 userId: string
128): Promise<number | null> {
129 const memberships = await prisma.organizationMember.findMany({
130 where: { userId },
131 select: { organizationId: true }
132 });
133 if (memberships.length === 0) return null;
134 const orgs = await prisma.organization.findMany({
135 where: { id: { in: memberships.map((m) => m.organizationId) } },
136 select: { policy: true }
137 });
138 let max: number | null = null;
139 for (const o of orgs) {
140 const p = parseStoredPolicy(o.policy);
141 if (p.sessionLifetimeHours != null && (max == null || p.sessionLifetimeHours > max)) max = p.sessionLifetimeHours;
142 }
143 return max;
144}
145
146
147
148@Injectable()
149export class OrgPolicyService {
150 constructor(
151 @Inject(PrismaService) private readonly prisma: PrismaService,
152 @Inject(RedisService) private readonly redis: RedisService,
153 @Inject(AuditService) private readonly audit: AuditService
154 ) {}
155
156
157 async invalidate(orgId: string): Promise<void> {
158 try {
159 await this.redis.getRedis().del(cacheKey(orgId));
160 } catch {
161
162 }
163 }
164
165
166 async cached(orgId: string, fresh = false): Promise<OrgPolicy> {
167 if (!fresh) {
168 try {
169 const hit = await this.redis.getRedis().get(cacheKey(orgId));
170 if (hit) return OrgPolicySchema.parse(JSON.parse(hit));
171 } catch {
172
173 }
174 }
175 const policy = await resolveOrgPolicy(this.prisma, orgId);
176 if (!fresh) {
177 this.redis
178 .getRedis()
179 .set(cacheKey(orgId), JSON.stringify(policy), "EX", CACHE_TTL_SEC)
180 .catch(() => undefined);
181 }
182 return policy;
183 }
184
185
186 async get(orgId: string, req: Request) {
187 const userId = requireUser(req);
188 await assertOrgAdmin(this.prisma, orgId, userId);
189 const policy = await this.cached(orgId);
190
191
192 const ws = await this.prisma.workspace.findFirst({
193 where: { organizationId: orgId, isPersonal: false },
194 orderBy: { createdAt: "asc" },
195 select: { billingPlan: true }
196 });
197 const billingPlan = ws?.billingPlan ?? "free";
198 return {
199 policy,
200 effective: {
201 maxFileSizeMB: Math.floor(effectiveMaxFileBytes(policy, billingPlan) / (1024 * 1024)),
202 billingPlan
203 }
204 };
205 }
206
207
208 async update(orgId: string, body: Record<string, unknown>, req: Request) {
209 const userId = requireUser(req);
210 await assertOrgAdmin(this.prisma, orgId, userId);
211 const current = await this.cached(orgId, true);
212 const stored = (
213 await this.prisma.organization.findUnique({ where: { id: orgId }, select: { policy: true } })
214 )?.policy as Record<string, unknown> | null;
215 const next = { ...(stored ?? {}) };
216
217
218 const changes: Record<string, { from: unknown; to: unknown }> = {};
219 for (const [k, v] of Object.entries(body)) {
220 const prev = current[k as keyof OrgPolicy];
221 if (JSON.stringify(prev) !== JSON.stringify(v)) changes[k] = { from: prev, to: v };
222 if (v === null) delete next[k];
223 else next[k] = v;
224 }
225
226 await this.prisma.organization.update({
227 where: { id: orgId },
228 data: { policy: next as object }
229 });
230 await this.invalidate(orgId);
231
232
233 if (body.ssoEnforced !== undefined) {
234 await this.prisma.ssoProvider
235 .updateMany({ where: { organizationId: orgId }, data: { enforce: Boolean(body.ssoEnforced) } })
236 .catch(() => undefined);
237 }
238 await this.audit.audit(req, req.auth, {
239 action: "org.policy.updated",
240 resourceType: "organization",
241 resourceId: orgId,
242 metadata: { changes }
243 });
244 return { policy: await this.cached(orgId, true), changes };
245 }
246}
247