/** * HTTP guards for org admin APIs (ADR-0021). * * Platform admin is a separate control plane — not modeled here. */ import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client"; import type { FastifyReply, FastifyRequest } from "fastify"; import { SESSION_COOKIE_NAME, verifySession, type SessionPayload, } from "./session.js"; export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; export interface AuthContext { readonly session: SessionPayload; readonly user: User; readonly feishuOpenId: string; readonly authenticationOrganizationId?: string | undefined; } export interface OrgAuthContext extends AuthContext { readonly organization: Organization; readonly membershipRole: OrganizationMemberRole; } export interface GuardDeps { readonly prisma: PrismaClient; readonly sessionSecret: string; } export class HttpError extends Error { readonly statusCode: number; readonly code: string; constructor(statusCode: number, code: string, message: string) { super(message); this.name = "HttpError"; this.statusCode = statusCode; this.code = code; } } export async function requireSession( request: FastifyRequest, reply: FastifyReply, deps: GuardDeps, ): Promise { const raw = request.cookies[SESSION_COOKIE_NAME]; if (raw === undefined || raw === "") { await sendError(reply, 401, "unauthenticated", "login required"); return null; } const session = verifySession(raw, deps.sessionSecret); if (session === null) { await sendError(reply, 401, "unauthenticated", "session invalid or expired"); return null; } if ("feishuIdentityId" in session) { const identity = await deps.prisma.feishuUserIdentity.findUnique({ where: { id: session.feishuIdentityId }, include: { user: true, connection: { select: { id: true, status: true, activeSecretVersion: { select: { connectionId: true, retiredAt: true } }, organization: { select: { id: true, status: true } }, }, }, }, }); if (identity === null || identity.userId !== session.userId || identity.connectionId !== session.feishuConnectionId || identity.connection.organization.id !== session.feishuOrganizationId || identity.connection.status !== "ACTIVE" || identity.connection.activeSecretVersion === null || identity.connection.activeSecretVersion.connectionId !== identity.connection.id || identity.connection.activeSecretVersion.retiredAt !== null || identity.connection.organization.status !== "ACTIVE") { await sendError(reply, 401, "unauthenticated", "session identity not found or inactive"); return null; } return { session, user: identity.user, feishuOpenId: identity.openId, authenticationOrganizationId: identity.connection.organization.id, }; } const user = await deps.prisma.user.findUnique({ where: { id: session.userId } }); if (user === null || user.feishuOpenId !== session.feishuOpenId) { await sendError(reply, 401, "unauthenticated", "session user not found"); return null; } return { session, user, feishuOpenId: user.feishuOpenId }; } export async function requireOrgRole( request: FastifyRequest, reply: FastifyReply, deps: GuardDeps, options: { readonly orgSlug: string; readonly roles?: readonly OrganizationMemberRole[]; }, ): Promise { const auth = await requireSession(request, reply, deps); if (auth === null) return null; const allowed = options.roles ?? ORG_ADMIN_ROLES; const organization = await deps.prisma.organization.findUnique({ where: { slug: options.orgSlug }, }); if (organization === null) { await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`); return null; } if (organization.status !== "ACTIVE") { await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`); return null; } if (auth.authenticationOrganizationId !== undefined && auth.authenticationOrganizationId !== organization.id) { await sendError(reply, 403, "forbidden", "session is scoped to a different organization"); return null; } const membership = await deps.prisma.organizationMembership.findFirst({ where: { organizationId: organization.id, userId: auth.user.id, revokedAt: null, }, select: { role: true }, }); if (membership === null) { await sendError(reply, 403, "forbidden", "not a member of this organization"); return null; } if (!allowed.includes(membership.role)) { await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`); return null; } return { ...auth, organization, membershipRole: membership.role, }; } export async function requireOrgProject( deps: GuardDeps, organizationId: string, projectId: string, ): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> { const project = await deps.prisma.project.findUnique({ where: { id: projectId }, select: { id: true, organizationId: true, name: true, archivedAt: true }, }); if (project === null || project.organizationId !== organizationId) { throw new HttpError(404, "project_not_found", `project not found: ${projectId}`); } return project; } export async function sendError( reply: FastifyReply, statusCode: number, code: string, message: string, ): Promise { await reply.status(statusCode).send({ error: { code, message } }); }