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
This commit is contained in:
113
src/lib/middleware/authorize.ts
Normal file
113
src/lib/middleware/authorize.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user