- 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
114 lines
3.4 KiB
TypeScript
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);
|
|
}
|