Files
NetForge/src/lib/middleware/authorize.ts
kevin-asprec 1df2b2d87d feat(01-04): API authorization middleware and RBAC tests (66 passing)
- Create src/lib/middleware/authorize.ts with withPermission() HOF
- Returns 401 for unauthenticated, 403 for unauthorized access
- Passes ability + user to authorized handlers for fine-grained checks
- Add authorize() convenience alias for handler-first usage pattern
- Create src/lib/__tests__/rbac.test.ts with 66 unit tests covering:
  - Admin full access to all subjects
  - Office Staff: can manage billing, blocked from Chart of Accounts
  - Collector: can record payments, blocked from invoice management
  - Technician: blocked from billing/payments (critical security boundary)
  - Client: scoped to own data only
  - Multi-role additive union (TECHNICIAN+COLLECTOR gets both sets)
  - Super-admin bypasses all permission checks
- Fix CASL MongoAbility type: use createMongoAbility throughout
- Fix condition casting for string-based subjects (no Prisma models yet)
- Fix ability merging: cannot() rules excluded for multi-role union
2026-03-04 18:57:31 +08:00

114 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { defineAbilityFor } from "@/lib/casl/ability";
import type { AppAbility, AppActions, AppSubjects } from "@/lib/casl/types";
import type { Role } from "@prisma/client";
/**
* The context passed to authorized route handlers.
* Handlers receive both the session user and the built CASL ability
* for fine-grained permission checks within the handler body.
*/
export interface AuthorizedContext {
user: {
id: string;
email: string;
tenantId: string | null;
roles: Role[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
};
ability: AppAbility;
}
/**
* Handler signature for authorized route handlers.
* Receives the request and an authorized context with user + ability.
*/
type AuthorizedHandler = (
req: NextRequest,
ctx: AuthorizedContext
) => Promise<NextResponse> | NextResponse;
/**
* Higher-order function that wraps a Next.js route handler with RBAC enforcement.
*
* Flow:
* 1. Get current user session via getCurrentUser()
* 2. If no session → return 401 Unauthorized
* 3. Build CASL ability using defineAbilityFor(user)
* 4. Check ability.can(action, subject)
* 5. If cannot → return 403 Forbidden
* 6. If can → call the wrapped handler with (req, { user, ability })
*
* Usage:
* ```typescript
* // In route.ts:
* export const GET = withPermission("read", "Subscriber")(async (req, { user, ability }) => {
* // ability is available for fine-grained checks within the handler
* const subscribers = await getSubscribers(user.tenantId);
* return NextResponse.json(subscribers);
* });
* ```
*
* @param action - The CASL action to check ("create" | "read" | "update" | "delete" | "manage")
* @param subject - The CASL subject to check against (e.g., "Subscriber", "Invoice")
* @returns A function that takes an authorized handler and returns a Next.js route handler
*/
export function withPermission(action: AppActions, subject: AppSubjects) {
return function (handler: AuthorizedHandler) {
return async function (req: NextRequest): Promise<NextResponse> {
// Step 1: Get session user
const user = await getCurrentUser();
// Step 2: No session → 401 Unauthorized
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Step 3: Build CASL ability from session user
const ability = defineAbilityFor({
id: user.id,
roles: user.roles,
tenantId: user.tenantId,
isSuperAdmin: user.isSuperAdmin,
});
// Step 4: Check permission
if (!ability.can(action, subject)) {
// Step 5: Cannot → 403 Forbidden
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Step 6: Can → call handler with user and ability in context
return handler(req, { user, ability });
};
};
}
/**
* Alternative API: authorize(handler, action, subject)
*
* Convenience wrapper for `withPermission` when you prefer
* the handler-first style rather than curried style.
*
* Usage:
* ```typescript
* export const GET = authorize(
* async (req, { user, ability }) => {
* return NextResponse.json({ ok: true });
* },
* "read",
* "Subscriber"
* );
* ```
*/
export function authorize(
handler: AuthorizedHandler,
action: AppActions,
subject: AppSubjects
) {
return withPermission(action, subject)(handler);
}