/** * TASK-0133 — org policy: admin read/write + the FIVE enforcement points. * * Two layers (kept separate to avoid module cycles, the same pattern as * `sso/enforcement.ts`): * * - The Injectable {@link OrgPolicyService} (Prisma + Redis + Audit) serves * the ADMIN endpoints (`GET/PATCH /orgs/:orgId/admin/policy`), owns the * 60 s Redis cache + invalidation, and writes the `org.policy.updated` * audit (old+new of changed keys only). * - The exported FREE FUNCTIONS (take a Prisma client, no Nest DI) are the * enforcement hooks called from `auth` (2FA + session lifetime), `share` * (guest-public-share block), `files` (max-file-size cap) and the new * embed preview endpoint. They read the policy FRESH (no cache) — a * security check must never ride a 60 s stale cache (recorded decision). * * PLAN CAP: `maxFileSizeMB` is stored as the admin set it; the EFFECTIVE cap * is `min(stored, PLAN_USAGE_LIMITS[plan].maxFileSize, LIMITS.MAX_FILE_SIZE)` * (org policy ∩ plan limits) — the plan ceiling is a floor on security, so * an admin can only tighten, never raise above the plan. */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import type { PrismaClient } from "@prisma/client"; import { LIMITS } from "@factory/shared"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; import { PLAN_USAGE_LIMITS } from "../files/quota.js"; import { OrgPolicySchema, parseStoredPolicy, type OrgPolicy } from "./policy.schema.js"; const CACHE_TTL_SEC = 60; const cacheKey = (orgId: string) => `org:policy:${orgId}`; /** The plan ceiling for a single-file upload (bytes). */ export function planMaxFileBytes(billingPlan: string): number { return (PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!).maxFileSize; } /** * Effective max single-file size (bytes) = min(stored policy MB, plan cap, * hard cap). `stored` is the org policy (or null for a non-org workspace → * plan cap only, preserving current behavior). */ export function effectiveMaxFileBytes(stored: OrgPolicy | null, billingPlan: string): number { const storedBytes = stored?.maxFileSizeMB != null ? stored.maxFileSizeMB * 1024 * 1024 : Infinity; return Math.min(storedBytes, planMaxFileBytes(billingPlan), LIMITS.MAX_FILE_SIZE); } // ─────────────────────────── free resolution (enforcement) ────────────────── type PolicyPrisma = Pick; /** Fresh org policy (defaults applied). `null` org → permissive baseline. */ export async function resolveOrgPolicy(prisma: PolicyPrisma, orgId: string): Promise { const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { policy: true } }); return parseStoredPolicy(org?.policy); } /** * The policy for a WORKSPACE's org (`null` when the workspace has no org — * personal workspaces are outside org policy). Used by share/upload/embed. */ export async function workspaceOrgPolicy( prisma: PolicyPrisma, workspaceId: string ): Promise<{ orgId: string | null; policy: OrgPolicy | null }> { const ws = await prisma.workspace.findUnique({ where: { id: workspaceId }, select: { organizationId: true } }); if (!ws?.organizationId) return { orgId: null, policy: null }; return { orgId: ws.organizationId, policy: await resolveOrgPolicy(prisma, ws.organizationId) }; } export interface UserPolicyResolution { /** True if ANY of the user's orgs requires 2FA (login gate). */ require2FA: boolean; /** Max `sessionLifetimeHours` across the user's orgs (null = platform * refresh TTL — no org set it). "Per-user max across their orgs". */ sessionLifetimeHours: number | null; } /** * Resolve the login-relevant policy flags for a user by email (the login * path has the email before the user row; mirrors `sso/enforcement.ts`). * Unknown email / no orgs → permissive (normal login). */ export async function resolveUserPolicy( prisma: PolicyPrisma, email: string ): Promise { const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() }, select: { id: true, orgMemberships: { select: { organizationId: true } } } }); if (!user || user.orgMemberships.length === 0) return { require2FA: false, sessionLifetimeHours: null }; const orgs = await prisma.organization.findMany({ where: { id: { in: user.orgMemberships.map((m) => m.organizationId) } }, select: { policy: true } }); let require2FA = false; let sessionLifetimeHours: number | null = null; for (const o of orgs) { const p = parseStoredPolicy(o.policy); if (p.require2FA) require2FA = true; if (p.sessionLifetimeHours != null && (sessionLifetimeHours == null || p.sessionLifetimeHours > sessionLifetimeHours)) { sessionLifetimeHours = p.sessionLifetimeHours; } } return { require2FA, sessionLifetimeHours }; } /** * The session-lifetime clamp for a USER (by id — the session-issuance path * has the user id, not the email): the MAX `sessionLifetimeHours` across the * user's orgs (null = no org set it → the platform refresh TTL applies). */ export async function resolveSessionLifetimeHours( prisma: PolicyPrisma, userId: string ): Promise { const memberships = await prisma.organizationMember.findMany({ where: { userId }, select: { organizationId: true } }); if (memberships.length === 0) return null; const orgs = await prisma.organization.findMany({ where: { id: { in: memberships.map((m) => m.organizationId) } }, select: { policy: true } }); let max: number | null = null; for (const o of orgs) { const p = parseStoredPolicy(o.policy); if (p.sessionLifetimeHours != null && (max == null || p.sessionLifetimeHours > max)) max = p.sessionLifetimeHours; } return max; } // ─────────────────────────── admin service (cache + audit) ────────────────── @Injectable() export class OrgPolicyService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} /** Invalidate the 60 s cache (called after a policy update). */ async invalidate(orgId: string): Promise { try { await this.redis.getRedis().del(cacheKey(orgId)); } catch { /* cache is best-effort — a miss just reads fresh */ } } /** Cached raw policy (admin display); `fresh` bypasses the cache. */ async cached(orgId: string, fresh = false): Promise { if (!fresh) { try { const hit = await this.redis.getRedis().get(cacheKey(orgId)); if (hit) return OrgPolicySchema.parse(JSON.parse(hit)); } catch { /* fall through to a fresh read */ } } const policy = await resolveOrgPolicy(this.prisma, orgId); if (!fresh) { this.redis .getRedis() .set(cacheKey(orgId), JSON.stringify(policy), "EX", CACHE_TTL_SEC) .catch(() => undefined); } return policy; } /** `GET /orgs/:orgId/admin/policy` — raw + effective (plan-capped). */ async get(orgId: string, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const policy = await this.cached(orgId); // Plan cap uses the org's PRIMARY (oldest non-personal) workspace plan — // the same workspace a file upload would land in. const ws = await this.prisma.workspace.findFirst({ where: { organizationId: orgId, isPersonal: false }, orderBy: { createdAt: "asc" }, select: { billingPlan: true } }); const billingPlan = ws?.billingPlan ?? "free"; return { policy, effective: { maxFileSizeMB: Math.floor(effectiveMaxFileBytes(policy, billingPlan) / (1024 * 1024)), billingPlan } }; } /** `PATCH /orgs/:orgId/admin/policy` — partial merge + audit. */ async update(orgId: string, body: Record, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const current = await this.cached(orgId, true); const stored = ( await this.prisma.organization.findUnique({ where: { id: orgId }, select: { policy: true } }) )?.policy as Record | null; const next = { ...(stored ?? {}) }; // Diff (old+new of changed keys only) for the audit + the UI summary. const changes: Record = {}; for (const [k, v] of Object.entries(body)) { const prev = current[k as keyof OrgPolicy]; if (JSON.stringify(prev) !== JSON.stringify(v)) changes[k] = { from: prev, to: v }; if (v === null) delete next[k]; else next[k] = v; } await this.prisma.organization.update({ where: { id: orgId }, data: { policy: next as object } }); await this.invalidate(orgId); // Mirror `ssoEnforced` into the SSO provider (TASK-0131 invariant: the // provider's `enforce` flag is the login-time source of truth). if (body.ssoEnforced !== undefined) { await this.prisma.ssoProvider .updateMany({ where: { organizationId: orgId }, data: { enforce: Boolean(body.ssoEnforced) } }) .catch(() => undefined); } await this.audit.audit(req, req.auth, { action: "org.policy.updated", resourceType: "organization", resourceId: orgId, metadata: { changes } }); return { policy: await this.cached(orgId, true), changes }; } }