/** * 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"; import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js"; export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"]; 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 } }); } export interface ProjectAuthContext extends OrgAuthContext { readonly projectId: string; } /** * Resolve an org member (any role) viewing/mutating a project in their org, then * enforce project-level permission via the PermissionGrant authorizer. * * `allowOrgAdminOversight=true` lets org OWNER/ADMIN through without a project * grant — reserved for *read* oversight (listing/viewing). Mutations that the * spec pins to project `manage` (e.g. `collaborator.manage`) must pass * `allowOrgAdminOversight=false`: org role alone is not a project authorization * root (spec `Permission.lean` / ADR-0004; the only out-of-role override is * platform-admin force-release `RequiresAdmin`, not org admin). */ export async function requireProjectPermission( request: FastifyRequest, reply: FastifyReply, deps: GuardDeps, options: { readonly orgSlug: string; readonly projectId: string; readonly action: AuthorizationAction; readonly allowOrgAdminOversight: boolean; }, ): Promise { const auth = await requireOrgRole(request, reply, deps, { orgSlug: options.orgSlug, roles: ANY_ORG_ROLE, }); if (auth === null) return null; await requireOrgProject(deps, auth.organization.id, options.projectId); if (options.allowOrgAdminOversight && ORG_ADMIN_ROLES.includes(auth.membershipRole)) { return { ...auth, projectId: options.projectId }; } const decision = await createPermissionAuthorizer(deps.prisma).can({ actor: { feishuOpenId: auth.feishuOpenId }, action: options.action, resource: { type: "PROJECT", id: options.projectId }, }); if (!decision.allowed) { await sendError( reply, 403, "forbidden", `project ${options.projectId} requires ${decision.requiredRole} (${options.action}): ${decision.reason}`, ); return null; } return { ...auth, projectId: options.projectId }; }