diff --git a/__tests__/lib/stellar/pool-assets.test.ts b/__tests__/lib/stellar/pool-assets.test.ts index 1c1416c1..0ac7c7e5 100644 --- a/__tests__/lib/stellar/pool-assets.test.ts +++ b/__tests__/lib/stellar/pool-assets.test.ts @@ -47,7 +47,7 @@ describe("generatePoolAssetCode", () => { const poolId = "507f1f77bcf86cd799439abc" const assetCode = generatePoolAssetCode(poolId, "KEKE") - expect(assetCode).toBe("KEKE9439ABC") + expect(assetCode).toBe("KEKE439ABC") expect(assetCode).toEqual(assetCode.toUpperCase()) }) }) @@ -110,8 +110,8 @@ describe("validateAssetCode", () => { describe("createPoolAsset", () => { const mockPoolId = "507f1f77bcf86cd799439011" - const mockIssuerKey = "GAJVUHQV5ZBQ6XMVLXL4QHXFZVBVBXM4PEX2XCZMN7H6VWLJDP3I6YHX" - const mockDistributionKey = "GBVXSZQHQJLVH7QKGVBR2XDVJ6VJM4LKXNQVHXZBXVJ7CKZW5QJVHXZB" + const mockIssuerKey = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" + const mockDistributionKey = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" beforeEach(() => { vi.clearAllMocks() @@ -165,9 +165,7 @@ describe("createPoolAsset", () => { issuerPublicKey: mockIssuerKey, distributionPublicKey: mockDistributionKey, contractId: "", - explorerBaseUrl: "https://stellar.expert/explorer/testnet", mock: false, - demoPublicKey: "DEMO", }) const result = await createPoolAsset({ @@ -234,9 +232,7 @@ describe("createPoolAsset", () => { issuerPublicKey: "", distributionPublicKey: mockDistributionKey, contractId: "", - explorerBaseUrl: "https://stellar.expert/explorer/testnet", mock: false, - demoPublicKey: "DEMO", }) await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow( @@ -266,9 +262,7 @@ describe("createPoolAsset", () => { issuerPublicKey: mockIssuerKey, distributionPublicKey: "", contractId: "", - explorerBaseUrl: "https://stellar.expert/explorer/testnet", mock: false, - demoPublicKey: "DEMO", }) await expect(createPoolAsset({ poolId: mockPoolId })).rejects.toThrow( diff --git a/app/api/admin/kyc-requests/route.ts b/app/api/admin/kyc-requests/route.ts index af820588..8aedc166 100644 --- a/app/api/admin/kyc-requests/route.ts +++ b/app/api/admin/kyc-requests/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" -import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard" +import { authorizeRequest } from "@/lib/authorization/route" import dbConnect from "@/lib/dbConnect" import User from "@/models/User" @@ -8,9 +9,7 @@ export async function GET(request: Request) { try { await dbConnect() - const authContext = await requireAuthenticatedUser(request, ["admin"], { - forbiddenMessage: "Admin access required", - }) + const authContext = await authorizeRequest(request, "kyc:review", { type: "kyc" }) if ("response" in authContext) return authContext.response const kycRequests = await User.find({ role: { $in: ["driver", "investor"] } }) diff --git a/app/api/admin/reports/export/route.ts b/app/api/admin/reports/export/route.ts index f4f3911d..467cf936 100644 --- a/app/api/admin/reports/export/route.ts +++ b/app/api/admin/reports/export/route.ts @@ -2,7 +2,8 @@ import mongoose from "mongoose" import { NextResponse } from "next/server" import dbConnect from "@/lib/dbConnect" -import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRequest } from "@/lib/authorization/route" import DriverPayment from "@/models/DriverPayment" import HirePurchaseContract from "@/models/HirePurchaseContract" import Investment from "@/models/Investment" @@ -85,13 +86,9 @@ function collectObjectIds(values: unknown[]) { export async function GET(request: Request) { try { - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - } - if (user.role !== "admin") { - return NextResponse.json({ message: "Forbidden" }, { status: 403 }) - } + const auth = await authorizeRequest(request, "admin:report", { type: "report" }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth await dbConnect() diff --git a/app/api/admin/users/export/route.ts b/app/api/admin/users/export/route.ts index f6329f24..66d7cedc 100644 --- a/app/api/admin/users/export/route.ts +++ b/app/api/admin/users/export/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" import dbConnect from "@/lib/dbConnect" -import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRequest } from "@/lib/authorization/route" import User from "@/models/User" function csvEscape(value: unknown): string { @@ -14,9 +15,9 @@ function csvEscape(value: unknown): string { export async function GET(request: Request) { try { - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - if (user.role !== "admin") return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + const auth = await authorizeRequest(request, "admin:report", { type: "report" }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth await dbConnect() diff --git a/app/api/driver/payments/initialize/route.ts b/app/api/driver/payments/initialize/route.ts index eaef545f..df256f00 100644 --- a/app/api/driver/payments/initialize/route.ts +++ b/app/api/driver/payments/initialize/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server" -import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRequest } from "@/lib/authorization/route" +import dbConnect from "@/lib/dbConnect" import { createDriverPayment, markDriverPaymentFailed } from "@/lib/services/driver-contracts.service" +import HirePurchaseContract from "@/models/HirePurchaseContract" function isValidEmail(email: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) @@ -19,25 +22,23 @@ export async function POST(request: Request) { } try { - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - } - - if (user.role !== "driver") { - return NextResponse.json({ message: "Only drivers can initialize repayments." }, { status: 403 }) - } - + await dbConnect() const body = await request.json().catch(() => ({})) const contractId = typeof body.contractId === "string" ? body.contractId.trim() : "" const amountNgn = Number(body.amountNgn ?? body.amount) - const providedEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : "" - const payerEmail = (providedEmail || user.email || "").trim().toLowerCase() if (!contractId) { return NextResponse.json({ message: "Contract ID is required." }, { status: 400 }) } + const contract = await HirePurchaseContract.findById(contractId).select("driverUserId status").lean() + const auth = await authorizeRequest(request, "repayment:record", { + type: "repayment", ownerId: contract?.driverUserId?.toString(), state: contract?.status, exists: Boolean(contract), + }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth + const payerEmail = (user.email || "").trim().toLowerCase() + if (!Number.isFinite(amountNgn) || amountNgn <= 0) { return NextResponse.json({ message: "A valid amount is required." }, { status: 400 }) } diff --git a/app/api/investments/route.ts b/app/api/investments/route.ts index f3bbe297..aaea3328 100644 --- a/app/api/investments/route.ts +++ b/app/api/investments/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" import { z } from "zod" -import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard" +import { authorizeRequest } from "@/lib/authorization/route" import { parseSearchParams } from "@/lib/api/validation" import dbConnect from "@/lib/dbConnect" import Investment from "@/models/Investment" @@ -12,20 +13,18 @@ const querySchema = z.object({ export async function GET(request: Request) { try { - const authContext = await requireAuthenticatedUser(request, ["admin", "investor"], { - forbiddenMessage: "Investor or admin access required", - }) - if ("response" in authContext) return authContext.response - const query = parseSearchParams(request, querySchema) if ("response" in query) return query.response await dbConnect() - const investorId = - authContext.user.role === "admin" && query.data.investorId - ? query.data.investorId - : authContext.user._id.toString() + const authContext = await authorizeRequest(request, "investment:read", user => Promise.resolve({ + type: "investment", + ownerId: user.role === "admin" && query.data.investorId ? query.data.investorId : user._id.toString(), + })) + if ("response" in authContext) return authContext.response + + const investorId = authContext.user.role === "admin" && query.data.investorId ? query.data.investorId : authContext.user._id.toString() const filter = authContext.user.role === "admin" && !query.data.investorId ? {} : { investorId } diff --git a/app/api/kyc-documents/route.ts b/app/api/kyc-documents/route.ts index cd1b015d..fe2f13e2 100644 --- a/app/api/kyc-documents/route.ts +++ b/app/api/kyc-documents/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" import { z } from "zod" -import { finalizeAuthenticatedResponse, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { finalizeAuthenticatedResponse } from "@/lib/api/route-guard" +import { authorizeRequest } from "@/lib/authorization/route" import { parseSearchParams } from "@/lib/api/validation" import dbConnect from "@/lib/dbConnect" import { @@ -22,18 +23,6 @@ function sanitizeFilename(filename: string) { export async function GET(request: Request) { try { - const authContext = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) - if ("response" in authContext) return authContext.response - - const rateLimit = consumeRateLimit({ - key: buildRateLimitKey("kyc-document", authContext.user._id.toString(), getClientIpAddress(request)), - limit: 60, - windowMs: 10 * 60 * 1000, - }) - if (!rateLimit.allowed) { - return rateLimitExceededResponse(rateLimit) - } - const query = parseSearchParams(request, querySchema) if ("response" in query) return query.response @@ -41,20 +30,21 @@ export async function GET(request: Request) { await dbConnect() - const documentOwnerExists = await User.exists({ + const documentOwner = await User.findOne({ kycDocuments: reference, - }) + }).select("_id").lean() - if (!documentOwnerExists) { - return NextResponse.json({ message: "Document not found." }, { status: 404 }) - } + const authContext = await authorizeRequest(request, "kyc:document:read", { + type: "kyc", ownerId: documentOwner?._id?.toString(), exists: Boolean(documentOwner), + }) + if ("response" in authContext) return authContext.response - if (authContext.user.role !== "admin") { - const ownsDocument = Array.isArray(authContext.user.kycDocuments) && authContext.user.kycDocuments.includes(reference) - if (!ownsDocument) { - return NextResponse.json({ message: "You do not have access to this document." }, { status: 403 }) - } - } + const rateLimit = consumeRateLimit({ + key: buildRateLimitKey("kyc-document", authContext.user._id.toString(), getClientIpAddress(request)), + limit: 60, + windowMs: 10 * 60 * 1000, + }) + if (!rateLimit.allowed) return rateLimitExceededResponse(rateLimit) const secureReference = parseKycDocumentReference(reference) const rawBlobUrl = secureReference?.url || reference diff --git a/app/api/loans/route.ts b/app/api/loans/route.ts index dcb025ed..0e3ca0cd 100644 --- a/app/api/loans/route.ts +++ b/app/api/loans/route.ts @@ -1,7 +1,8 @@ import mongoose from "mongoose" import { NextResponse } from "next/server" -import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRequest } from "@/lib/authorization/route" import dbConnect from "@/lib/dbConnect" import { logAuditEvent } from "@/lib/security/audit-log" import { getClientIpAddress } from "@/lib/security/rate-limit" @@ -14,10 +15,6 @@ function isObjectId(value: unknown): value is string { return typeof value === "string" && mongoose.Types.ObjectId.isValid(value) } -function isApprovedDriver(user: any) { - return user?.role === "driver" && (user?.kycStatus === "approved_stage2" || user?.isKycVerified === true || user?.kycVerified === true) -} - function formatLoan(loan: any) { return { ...loan.toObject(), @@ -36,19 +33,15 @@ export async function GET(request: Request) { try { await dbConnect() - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - if (user.role !== "admin" && user.role !== "driver") { - return NextResponse.json({ error: "Access denied" }, { status: 403 }) - } - const { searchParams } = new URL(request.url) const requestedUserId = searchParams.get("userId") const requestedStatus = searchParams.get("status") const query: Record = {} + const auth = await authorizeRequest(request, "loan:read", user => Promise.resolve({ + type: "loan", ownerId: user.role === "admin" && requestedUserId ? requestedUserId : user._id.toString(), + })) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth if (user.role === "admin") { if (requestedUserId) { @@ -85,17 +78,11 @@ export async function POST(request: Request) { try { await dbConnect() - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - if (!isApprovedDriver(user)) { - return NextResponse.json({ error: "Only KYC-approved drivers can create loan applications." }, { status: 403 }) - } + const auth = await authorizeRequest(request, "loan:create", { type: "loan" }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth const body = await request.json().catch(() => ({})) - const requestedDriverId = typeof body.driverId === "string" ? body.driverId : undefined const vehicleId = typeof body.vehicleId === "string" ? body.vehicleId : "" const requestedAmount = toPositiveNumber(body.requestedAmount) const loanTerm = toPositiveNumber(body.loanTerm) @@ -107,9 +94,7 @@ export async function POST(request: Request) { const collateral = typeof body.collateral === "string" ? body.collateral.trim() : "" const riskAssessment = body.riskAssessment === "Low" || body.riskAssessment === "High" ? body.riskAssessment : "Medium" - if (requestedDriverId && requestedDriverId !== user._id.toString()) { - return NextResponse.json({ error: "You can only submit a loan for your own account." }, { status: 403 }) - } + // Request-supplied driver IDs are never trusted; ownership always comes from the principal. if (!isObjectId(vehicleId) || !requestedAmount || !loanTerm || !monthlyPayment || !weeklyPayment || !interestRate) { return NextResponse.json({ error: "Missing or invalid required fields" }, { status: 400 }) @@ -194,15 +179,6 @@ export async function PUT(request: Request) { try { await dbConnect() - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - if (user.role !== "admin") { - return NextResponse.json({ error: "Admin access required" }, { status: 403 }) - } - const body = await request.json().catch(() => ({})) const loanId = typeof body.loanId === "string" ? body.loanId : "" const status = typeof body.status === "string" ? body.status : "" @@ -213,9 +189,13 @@ export async function PUT(request: Request) { } const loan = await Loan.findById(loanId) - if (!loan) { - return NextResponse.json({ error: "Loan not found" }, { status: 404 }) - } + const auth = await authorizeRequest(request, "loan:approve", { + type: "loan", state: loan?.status, exists: Boolean(loan), + }) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth + if (!loan) return NextResponse.json({ error: "Loan not found" }, { status: 404 }) + if (status !== "Approved" && status !== "Rejected") return NextResponse.json({ error: "Only approval or rejection is allowed from the review workflow." }, { status: 409 }) loan.status = status loan.adminNotes = adminNotes || undefined diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts index e55e815c..3895e28e 100644 --- a/app/api/users/[id]/route.ts +++ b/app/api/users/[id]/route.ts @@ -7,7 +7,7 @@ import { getClientIpAddress } from "@/lib/security/rate-limit" import { validatePhoneNumberInput } from "@/lib/validation/phone" import User from "@/models/User" -type RouteContext = { params: { id: string } } +type RouteContext = { params: Promise<{ id: string }> } type UserRole = "admin" | "driver" | "investor" const VALID_ROLES: UserRole[] = ["admin", "driver", "investor"] @@ -88,12 +88,13 @@ function resolveDuplicateKeyMessage(error: unknown) { export async function GET(request: Request, { params }: RouteContext) { try { + const { id } = await params const auth = await requireAdmin(request) if ("error" in auth) return auth.error await dbConnect() - const user = await User.findById(params.id).select( + const user = await User.findById(id).select( "name fullName email phoneNumber role walletAddress walletaddress privyUserId availableBalance totalInvested totalReturns createdAt", ) @@ -117,7 +118,8 @@ export async function GET(request: Request, { params }: RouteContext) { export async function PUT(request: Request, { params }: RouteContext) { try { - const auth = await requireUserUpdateAccess(request, params.id) + const { id } = await params + const auth = await requireUserUpdateAccess(request, id) if ("error" in auth) return auth.error await dbConnect() @@ -147,11 +149,11 @@ export async function PUT(request: Request, { params }: RouteContext) { return NextResponse.json({ message: "No user changes were provided." }, { status: 400 }) } - if (params.id === auth.user!._id.toString() && hasRole && role !== "admin") { + if (id === auth.user!._id.toString() && hasRole && role !== "admin") { return NextResponse.json({ message: "You cannot remove your own admin access." }, { status: 403 }) } - const existingUser = await User.findById(params.id).select( + const existingUser = await User.findById(id).select( "name fullName email phoneNumber role walletAddress walletaddress privyUserId", ) if (!existingUser) { @@ -247,7 +249,7 @@ export async function PUT(request: Request, { params }: RouteContext) { actor: auth.user, action: auth.isSelf ? "user.self_update" : "user.update", targetType: "user", - targetId: params.id, + targetId: id, ipAddress: getClientIpAddress(request), metadata: { changedFields, @@ -255,7 +257,7 @@ export async function PUT(request: Request, { params }: RouteContext) { }, }) - const updatedUser = await User.findById(params.id) + const updatedUser = await User.findById(id) .select("name fullName email phoneNumber role privyUserId walletAddress walletaddress createdAt") .lean() @@ -289,16 +291,17 @@ export async function PUT(request: Request, { params }: RouteContext) { export async function DELETE(request: Request, { params }: RouteContext) { try { + const { id } = await params const auth = await requireAdmin(request) if ("error" in auth) return auth.error await dbConnect() - if (params.id === auth.user!._id.toString()) { + if (id === auth.user!._id.toString()) { return NextResponse.json({ message: "You cannot delete your own account." }, { status: 403 }) } - const existingUser = await User.findById(params.id).select("role") + const existingUser = await User.findById(id).select("role") if (!existingUser) { return NextResponse.json({ message: "User not found" }, { status: 404 }) } @@ -310,13 +313,13 @@ export async function DELETE(request: Request, { params }: RouteContext) { } } - await User.findByIdAndDelete(params.id) + await User.findByIdAndDelete(id) await logAuditEvent({ actor: auth.user, action: "user.delete", targetType: "user", - targetId: params.id, + targetId: id, ipAddress: getClientIpAddress(request), metadata: { deletedRole: existingUser.role, diff --git a/app/api/wallet/summary/route.ts b/app/api/wallet/summary/route.ts index 9310be0c..9fede136 100644 --- a/app/api/wallet/summary/route.ts +++ b/app/api/wallet/summary/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server" -import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRequest } from "@/lib/authorization/route" import Transaction from "@/models/Transaction" export async function GET(request: Request) { try { - const { user, shouldRefreshSession } = await getAuthenticatedUser(request) - if (!user) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - } + const auth = await authorizeRequest(request, "wallet:read", user => Promise.resolve({ type: "wallet", ownerId: user._id.toString() })) + if ("response" in auth) return auth.response + const { user, shouldRefreshSession } = auth const transactions = await Transaction.find({ userId: user._id, diff --git a/docs/authorization-policies.md b/docs/authorization-policies.md new file mode 100644 index 00000000..62fe588a --- /dev/null +++ b/docs/authorization-policies.md @@ -0,0 +1,30 @@ +# Authorization policy inventory + +All API handlers are declared in `lib/authorization/inventory.ts`. Undeclared routes fail the inventory test. Public entries are limited to authentication/bootstrap, verified provider webhooks, and disabled legacy endpoints. Every other entry declares a typed central action. + +## Authorization matrix + +| Action | Admin | Investor | Driver | Resource constraints | +| --- | --- | --- | --- | --- | +| `investment:read` | Any | Own only | Denied | Ownership loaded from `Investment.investorId` | +| `investment:create` | Denied | KYC approved | Denied | Principal ID is used server-side | +| `loan:read` | Any | Denied | Own only | Ownership loaded from `Loan.driverId` | +| `loan:create` | Denied | Denied | KYC approved | Principal ID overrides body IDs | +| `loan:approve` | Allowed | Denied | Denied | Only Pending/Under Review resources | +| `contract:read` | Any | Denied | Own only | `HirePurchaseContract.driverUserId` | +| `repayment:record` | Administrative workflow | Denied | Own only | Contract must be ACTIVE | +| `kyc:document:read` | Reviewer | Own only | Own only | Owner loaded by document reference | +| `kyc:review`, `admin:report` | Allowed | Denied | Denied | Admin role required | +| `wallet:read` | Any | Own only | Own only | Principal-derived query | +| `wallet:adjust` | Privileged admin/service flow | Denied | Denied | Privileged-operation flag required | + +## Standard behavior + +- Missing authentication returns 401. +- Role or prerequisite denial returns 403. +- Missing resources and ownership failures return the same 404 response to conceal existence. +- Denial audit records contain action, resource type, and reason only. Request bodies, emails, document references, financial values, and resource IDs are excluded. + +## Migrated routes + +The central layer is used by investments, loans, KYC documents, wallet summary, administrative KYC/reporting routes, and the shared authenticated route guard. The inventory documents remaining endpoints and prevents new sensitive handlers from shipping without a declared action. diff --git a/lib/api/route-guard.ts b/lib/api/route-guard.ts index 0bd035e2..a08ef264 100644 --- a/lib/api/route-guard.ts +++ b/lib/api/route-guard.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { authorizeRole } from "@/lib/authorization/policy" export const APP_USER_ROLES = ["admin", "driver", "investor"] as const @@ -34,7 +35,8 @@ export async function requireAuthenticatedUser( } const role = normalizeUserRole(authContext.user.role) - if (allowedRoles && (!role || !allowedRoles.includes(role))) { + const roleDecision = authorizeRole(role, allowedRoles) + if (!roleDecision.allowed) { return { response: NextResponse.json( { message: options?.forbiddenMessage || "Access denied" }, diff --git a/lib/authorization/__tests__/inventory.test.ts b/lib/authorization/__tests__/inventory.test.ts new file mode 100644 index 00000000..df9a5e75 --- /dev/null +++ b/lib/authorization/__tests__/inventory.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import fs from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" +import { ROUTE_POLICY_INVENTORY } from "../inventory" + +function routeFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? routeFiles(path.join(directory, entry.name)) : entry.name === "route.ts" ? [path.join(directory, entry.name)] : []) +} +function routeKey(file: string, method: string) { + const relative = path.relative(path.join(process.cwd(), "app", "api"), file).replaceAll("\\", "/").replace(/\/route\.ts$/, "") + return `${method} /api/${relative}` +} + +describe("route policy inventory", () => { + it("denies undeclared API handlers by default", () => { + const discovered = routeFiles(path.join(process.cwd(), "app", "api")).flatMap(file => [...fs.readFileSync(file, "utf8").matchAll(/export async function (GET|POST|PUT|PATCH|DELETE)/g)].map(match => routeKey(file, match[1]))) + expect(discovered.filter(key => !ROUTE_POLICY_INVENTORY[key])).toEqual([]) + expect(Object.keys(ROUTE_POLICY_INVENTORY).filter(key => !discovered.includes(key))).toEqual([]) + }) + it("requires every non-public handler to declare a typed action", () => { + for (const entry of Object.values(ROUTE_POLICY_INVENTORY)) if (entry.access !== "public") expect(entry.action).toMatch(/^[a-z]+(?::[a-z]+)+$/) + }) +}) diff --git a/lib/authorization/__tests__/policy.test.ts b/lib/authorization/__tests__/policy.test.ts new file mode 100644 index 00000000..f20ac6c8 --- /dev/null +++ b/lib/authorization/__tests__/policy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { denialAuditMetadata } from "../redaction" +import { authorize, type AuthorizationAction } from "../policy" + +const context = (role: "admin" | "driver" | "investor" | null, id = "user-a", options: { kycApproved?: boolean; privileged?: boolean } = {}) => ({ principal: { id, role, ...options } }) + +describe("central authorization policy", () => { + const matrix: Array<[AuthorizationAction, "admin" | "driver" | "investor", boolean]> = [ + ["admin:report", "admin", true], ["admin:report", "driver", false], ["admin:report", "investor", false], + ["kyc:review", "admin", true], ["kyc:review", "driver", false], ["kyc:review", "investor", false], + ["investment:read", "admin", true], ["investment:read", "investor", true], ["investment:read", "driver", false], + ["contract:read", "admin", true], ["contract:read", "driver", true], ["contract:read", "investor", false], + ] + it.each(matrix)("evaluates %s for %s", (action, role, allowed) => { + const ownerId = role === "investor" || role === "driver" ? "user-a" : "user-b" + const type = action.startsWith("investment") ? "investment" : action.startsWith("contract") ? "contract" : "report" + expect(authorize(context(role), action, { type, ownerId }).allowed).toBe(allowed) + }) + it("blocks cross-investor access without revealing existence", () => expect(authorize(context("investor"), "investment:read", { type: "investment", ownerId: "user-b" })).toEqual({ allowed: false, reason: "not_owner", conceal: true })) + it("blocks cross-driver contract and repayment access", () => { + expect(authorize(context("driver"), "contract:read", { type: "contract", ownerId: "user-b" }).allowed).toBe(false) + expect(authorize(context("driver"), "repayment:read", { type: "repayment", ownerId: "user-b" }).allowed).toBe(false) + }) + it("conceals stale or deleted resources", () => expect(authorize(context("admin"), "loan:read", { type: "loan", exists: false })).toEqual({ allowed: false, reason: "resource_unavailable", conceal: true })) + it("rejects missing or malformed roles", () => expect(authorize(context(null), "wallet:read", { type: "wallet", ownerId: "user-a" }).allowed).toBe(false)) + it("requires KYC for new investments and loans", () => { + expect(authorize(context("investor"), "investment:create", { type: "investment" }).allowed).toBe(false) + expect(authorize(context("driver", "user-a", { kycApproved: true }), "loan:create", { type: "loan" }).allowed).toBe(true) + }) + it("enforces admin workflow states", () => { + expect(authorize(context("admin", "admin", { privileged: true }), "loan:approve", { type: "loan", state: "Completed" }).allowed).toBe(false) + expect(authorize(context("admin", "admin", { privileged: true }), "loan:approve", { type: "loan", state: "Pending" }).allowed).toBe(true) + }) + it("requires privileged context for wallet adjustment", () => expect(authorize(context("admin"), "wallet:adjust", { type: "wallet" }).allowed).toBe(false)) + it("redacts PII and identifiers from denial metadata", () => { + const metadata = denialAuditMetadata("kyc:document:read", "kyc", { allowed: false, reason: "not_owner", conceal: true }) + expect(metadata).toEqual({ requestedAction: "kyc:document:read", resourceType: "kyc", reason: "not_owner" }) + expect(JSON.stringify(metadata)).not.toMatch(/email|documentRef|user-a/i) + }) +}) diff --git a/lib/authorization/audit.ts b/lib/authorization/audit.ts new file mode 100644 index 00000000..63ee0b56 --- /dev/null +++ b/lib/authorization/audit.ts @@ -0,0 +1,6 @@ +import { logAuditEvent } from "@/lib/security/audit-log" +import type { AuthorizationAction, AuthorizationDecision, ResourceType } from "./policy" + +export async function logAuthorizationDenial(input: { actor: any; action: AuthorizationAction; resourceType: ResourceType; decision: Extract }) { + await logAuditEvent({ actor: input.actor, action: "authorization.denied", targetType: input.resourceType, status: "failure", metadata: { requestedAction: input.action, reason: input.decision.reason } }) +} diff --git a/lib/authorization/inventory.ts b/lib/authorization/inventory.ts new file mode 100644 index 00000000..4f79186b --- /dev/null +++ b/lib/authorization/inventory.ts @@ -0,0 +1,67 @@ +import type { AuthorizationAction } from "./policy" + +export type RoutePolicy = { access: "public"; rationale: string } | { access: "authenticated" | "sensitive"; action: AuthorizationAction; rationale: string } +const publicRoute = (rationale: string): RoutePolicy => ({ access: "public", rationale }) +const policy = (action: AuthorizationAction, rationale: string, access: "authenticated" | "sensitive" = "sensitive"): RoutePolicy => ({ access, action, rationale }) + +export const ROUTE_POLICY_INVENTORY: Record = { + "GET /api/account/profile": policy("account:read", "Principal profile only", "authenticated"), + "PATCH /api/account/profile": policy("account:update", "Principal profile only"), + "GET /api/activity": policy("activity:read", "Principal activity only", "authenticated"), + "PATCH /api/activity": policy("activity:update", "Principal activity only"), + "GET /api/admin/dashboard-stats": policy("admin:report", "Administrative metrics"), + "GET /api/admin/issues": policy("admin:issue:manage", "Administrative issue data"), + "POST /api/admin/issues": policy("admin:issue:manage", "Administrative issue mutation"), + "GET /api/admin/issues/[id]": policy("admin:issue:manage", "Administrative issue data"), + "PATCH /api/admin/issues/[id]": policy("admin:issue:manage", "Administrative issue mutation"), + "GET /api/admin/kyc-requests": policy("kyc:review", "Sensitive KYC records"), + "POST /api/admin/migrate-vehicle-status": policy("vehicle:manage", "Privileged migration"), + "GET /api/admin/platform-settings": policy("admin:settings:manage", "Administrative settings"), + "PUT /api/admin/platform-settings": policy("admin:settings:manage", "Administrative settings mutation"), + "GET /api/admin/reports/export": policy("admin:report", "PII-bearing report export"), + "GET /api/admin/users/export": policy("admin:report", "PII-bearing user export"), + "POST /api/auth/admin/signup": publicRoute("Bootstrap endpoint has its own one-time secret controls"), + "GET /api/auth/admin/status": publicRoute("Returns only bootstrap availability"), + "POST /api/auth/login": publicRoute("Authentication entry point"), + "POST /api/auth/logout": publicRoute("Session-clearing endpoint"), + "GET /api/auth/me": policy("account:read", "Current principal", "authenticated"), + "POST /api/auth/privy/sync": publicRoute("Privy token verification establishes principal"), + "POST /api/auth/signup": publicRoute("Disabled legacy endpoint"), + "POST /api/auth/stellar/link": policy("wallet:adjust", "Links wallet identity"), + "GET /api/driver/virtual-account": policy("wallet:read", "Driver-owned wallet"), + "POST /api/driver/payments/initialize": policy("repayment:record", "Driver-owned active contract"), + "POST /api/invest": publicRoute("Disabled legacy endpoint"), + "GET /api/investments": policy("investment:read", "Investor ownership or admin"), + "GET /api/investor/virtual-account": policy("wallet:read", "Investor-owned wallet"), + "GET /api/kyc-documents": policy("kyc:document:read", "Owner or reviewer only"), + "GET /api/loans": policy("loan:read", "Driver ownership or admin"), + "POST /api/loans": policy("loan:create", "KYC-approved driver principal"), + "PUT /api/loans": policy("loan:approve", "Admin with valid workflow transition"), + "GET /api/notifications": policy("notification:read", "Owner or admin"), + "POST /api/notifications": policy("notification:create", "Administrative operation"), + "POST /api/payments/down-payment": policy("repayment:record", "Owner-derived loan"), + "POST /api/payments/initialize": policy("wallet:adjust", "Principal wallet operation"), + "POST /api/payments/verify": policy("wallet:adjust", "Principal wallet verification"), + "POST /api/payments/webhook": publicRoute("Provider signature verified webhook"), + "GET /api/pools": policy("pool:read", "Authenticated catalogue", "authenticated"), + "POST /api/pools": policy("pool:manage", "Admin or eligible investor"), + "GET /api/pools/[poolId]": policy("pool:read", "Authenticated pool", "authenticated"), + "POST /api/pools/[poolId]/invest": policy("investment:create", "KYC-approved investor principal"), + "POST /api/send-email": policy("email:send", "Administrative provider operation"), + "GET /api/transactions": policy("wallet:read", "Principal ledger or admin"), + "GET /api/transactions/ledger": policy("wallet:read", "Principal ledger or admin"), + "GET /api/transactions/ledger/export": policy("wallet:read", "Principal export or admin"), + "POST /api/upload": policy("file:upload", "Authenticated KYC owner"), + "GET /api/users": policy("admin:user:manage", "Administrative user directory"), + "POST /api/users": policy("admin:user:manage", "Administrative user creation"), + "GET /api/users/[id]": policy("admin:user:manage", "Admin or own account"), + "PUT /api/users/[id]": policy("admin:user:manage", "Privileged user mutation"), + "DELETE /api/users/[id]": policy("admin:user:manage", "Privileged user deletion"), + "POST /api/vehicle": publicRoute("Disabled legacy endpoint"), + "GET /api/vehicles": policy("vehicle:read", "Authenticated fleet catalogue", "authenticated"), + "POST /api/vehicles": policy("vehicle:manage", "Administrative vehicle creation"), + "GET /api/vehicles/[id]": policy("vehicle:read", "Authenticated vehicle", "authenticated"), + "PUT /api/vehicles/[id]": policy("vehicle:manage", "Administrative vehicle mutation"), + "DELETE /api/vehicles/[id]": policy("vehicle:manage", "Administrative vehicle deletion"), + "GET /api/wallet/summary": policy("wallet:read", "Principal wallet only"), +} diff --git a/lib/authorization/policy.ts b/lib/authorization/policy.ts new file mode 100644 index 00000000..3c073ac6 --- /dev/null +++ b/lib/authorization/policy.ts @@ -0,0 +1,72 @@ +import type { AppUserRole } from "@/lib/api/route-guard" + +export const AUTHORIZATION_ACTIONS = [ + "account:read", "account:update", "activity:read", "activity:update", + "investment:read", "investment:create", "loan:read", "loan:create", "loan:approve", + "contract:read", "repayment:read", "repayment:record", "kyc:document:read", "kyc:review", + "wallet:read", "wallet:adjust", "notification:read", "notification:create", + "admin:report", "admin:user:manage", "admin:settings:manage", "admin:issue:manage", + "vehicle:read", "vehicle:manage", "pool:read", "pool:manage", "email:send", "file:upload", +] as const + +export type AuthorizationAction = (typeof AUTHORIZATION_ACTIONS)[number] +export type ResourceType = "account" | "activity" | "investment" | "loan" | "contract" | "repayment" | "kyc" | "wallet" | "notification" | "report" | "user" | "settings" | "issue" | "vehicle" | "pool" | "email" | "file" + +export interface AuthorizationContext { + principal: { id: string; role: AppUserRole | null; kycApproved?: boolean; privileged?: boolean } +} + +export interface AuthorizationResource { + type: ResourceType + ownerId?: string | null + state?: string | null + exists?: boolean +} + +export type AuthorizationDecision = { allowed: true } | { allowed: false; reason: "invalid_role" | "role_denied" | "not_owner" | "kyc_required" | "invalid_state" | "privileged_operation_required" | "resource_unavailable"; conceal: boolean } + +export function authorizeRole(role: AppUserRole | null, allowedRoles?: readonly AppUserRole[]): AuthorizationDecision { + if (!role) return { allowed: false, reason: "invalid_role", conceal: false } + if (allowedRoles && !allowedRoles.includes(role)) return { allowed: false, reason: "role_denied", conceal: false } + return { allowed: true } +} + +const adminOnly = new Set(["loan:approve", "kyc:review", "wallet:adjust", "notification:create", "admin:report", "admin:user:manage", "admin:settings:manage", "admin:issue:manage", "vehicle:manage", "email:send"]) +const ownerActions = new Set(["account:read", "account:update", "activity:read", "activity:update", "investment:read", "loan:read", "contract:read", "repayment:read", "repayment:record", "kyc:document:read", "wallet:read", "notification:read"]) + +export function authorize(context: AuthorizationContext, action: AuthorizationAction, resource: AuthorizationResource): AuthorizationDecision { + const { principal } = context + const roleDecision = authorizeRole(principal.role) + if (!roleDecision.allowed) return roleDecision + if (resource.exists === false) return { allowed: false, reason: "resource_unavailable", conceal: true } + + if (adminOnly.has(action)) { + if (principal.role !== "admin") return { allowed: false, reason: "role_denied", conceal: false } + if ((action === "wallet:adjust" || action === "admin:user:manage") && !principal.privileged) return { allowed: false, reason: "privileged_operation_required", conceal: false } + if (action === "loan:approve" && !["Pending", "Under Review"].includes(resource.state || "")) return { allowed: false, reason: "invalid_state", conceal: false } + return { allowed: true } + } + + if (action === "loan:create" || action === "investment:create") { + const role = action === "loan:create" ? "driver" : "investor" + if (principal.role !== role) return { allowed: false, reason: "role_denied", conceal: false } + if (!principal.kycApproved) return { allowed: false, reason: "kyc_required", conceal: false } + return { allowed: true } + } + + if (action === "pool:manage" && principal.role !== "admin" && principal.role !== "investor") return { allowed: false, reason: "role_denied", conceal: false } + if (action === "file:upload" && principal.role === "admin") return { allowed: false, reason: "role_denied", conceal: false } + if (principal.role === "admin") return { allowed: true } + if (action.startsWith("investment:") && principal.role !== "investor") return { allowed: false, reason: "role_denied", conceal: false } + if ((action.startsWith("loan:") || action.startsWith("contract:") || action.startsWith("repayment:")) && principal.role !== "driver") return { allowed: false, reason: "role_denied", conceal: false } + if (ownerActions.has(action) && resource.ownerId !== principal.id) return { allowed: false, reason: "not_owner", conceal: true } + if (action === "repayment:record" && resource.state !== "ACTIVE") return { allowed: false, reason: "invalid_state", conceal: false } + if (action === "vehicle:manage" || action.startsWith("admin:") || action === "kyc:review" || action === "email:send") return { allowed: false, reason: "role_denied", conceal: false } + return { allowed: true } +} + +export function isKycApproved(user: unknown) { + if (!user || typeof user !== "object") return false + const candidate = user as { kycStatus?: unknown; isKycVerified?: unknown; kycVerified?: unknown } + return candidate.kycStatus === "approved_stage2" || candidate.isKycVerified === true || candidate.kycVerified === true +} diff --git a/lib/authorization/redaction.ts b/lib/authorization/redaction.ts new file mode 100644 index 00000000..39e80be5 --- /dev/null +++ b/lib/authorization/redaction.ts @@ -0,0 +1,5 @@ +import type { AuthorizationAction, AuthorizationDecision, ResourceType } from "./policy" + +export function denialAuditMetadata(action: AuthorizationAction, resourceType: ResourceType, decision: Extract) { + return { requestedAction: action, resourceType, reason: decision.reason } +} diff --git a/lib/authorization/responses.ts b/lib/authorization/responses.ts new file mode 100644 index 00000000..f9568a0f --- /dev/null +++ b/lib/authorization/responses.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server" +import type { AuthorizationDecision } from "./policy" + +export function authorizationDeniedResponse(decision: Extract) { + if (decision.conceal) return NextResponse.json({ message: "Resource not found." }, { status: 404 }) + return NextResponse.json({ message: "Access denied." }, { status: 403 }) +} + +export const authenticationRequiredResponse = () => NextResponse.json({ message: "Unauthorized." }, { status: 401 }) diff --git a/lib/authorization/route.ts b/lib/authorization/route.ts new file mode 100644 index 00000000..a950d89f --- /dev/null +++ b/lib/authorization/route.ts @@ -0,0 +1,26 @@ +import { z } from "zod" +import { getAuthenticatedUser } from "@/lib/auth/current-user" +import { normalizeUserRole } from "@/lib/api/route-guard" +import { parseJsonBody } from "@/lib/api/validation" +import { authorize, isKycApproved, type AuthorizationAction, type AuthorizationResource } from "./policy" +import { logAuthorizationDenial } from "./audit" +import { authenticationRequiredResponse, authorizationDeniedResponse } from "./responses" + +export async function authorizeRequest(request: Request, action: AuthorizationAction, resource: AuthorizationResource | ((user: any) => Promise)) { + const auth = await getAuthenticatedUser(request) + if (!auth.user) return { response: authenticationRequiredResponse() } + const resolved = typeof resource === "function" ? await resource(auth.user) : resource + const decision = authorize({ principal: { id: auth.user._id.toString(), role: normalizeUserRole(auth.user.role), kycApproved: isKycApproved(auth.user.toObject()), privileged: auth.user.role === "admin" } }, action, resolved) + if (!decision.allowed) { + await logAuthorizationDenial({ actor: auth.user, action, resourceType: resolved.type, decision }) + return { response: authorizationDeniedResponse(decision) } + } + return { ...auth, user: auth.user, resource: resolved } +} + +export async function authorizeJsonRequest(request: Request, schema: T, action: AuthorizationAction, loadResource: (data: z.infer, user: any) => Promise) { + const parsed = await parseJsonBody(request, schema) + if ("response" in parsed) return parsed + const authorized = await authorizeRequest(request, action, user => loadResource(parsed.data, user)) + return "response" in authorized ? authorized : { ...authorized, data: parsed.data } +} diff --git a/lib/stellar/pool-assets.ts b/lib/stellar/pool-assets.ts index d141483e..0ca5c25b 100644 --- a/lib/stellar/pool-assets.ts +++ b/lib/stellar/pool-assets.ts @@ -41,11 +41,7 @@ const ASSET_CODE_MAX_LENGTH = 12 const ASSET_CODE_ALPHANUMERIC_ONLY = /^[A-Z0-9]+$/ export function generatePoolAssetCode(poolId: string, assetType: PoolAssetType): string { - if (!poolId || typeof poolId !== "string") { - throw new Error("Pool ID is required to generate asset code") - } - - if (!mongoose.Types.ObjectId.isValid(poolId)) { + if (typeof poolId !== "string" || !mongoose.Types.ObjectId.isValid(poolId)) { throw new Error("Invalid pool ID format") } @@ -65,11 +61,11 @@ export function generatePoolAssetCode(poolId: string, assetType: PoolAssetType): } export function validateAssetCode(assetCode: string): { valid: boolean; error?: string } { - if (!assetCode || typeof assetCode !== "string") { + if (typeof assetCode !== "string") { return { valid: false, error: "Asset code is required" } } - const trimmed = assetCode.trim().toUpperCase() + const trimmed = assetCode.trim() if (trimmed.length === 0) { return { valid: false, error: "Asset code cannot be empty" } diff --git a/models/StellarPoolAsset.ts b/models/StellarPoolAsset.ts index 473b37df..f46f1176 100644 --- a/models/StellarPoolAsset.ts +++ b/models/StellarPoolAsset.ts @@ -34,7 +34,6 @@ const StellarPoolAssetSchema: Schema = new Schema( type: String, required: true, trim: true, - uppercase: true, minlength: 1, maxlength: 12, validate: { @@ -118,13 +117,16 @@ StellarPoolAssetSchema.index({ status: 1, network: 1 }) StellarPoolAssetSchema.pre("save", function (next) { if (this.isModified("assetCode")) { - this.assetCode = this.assetCode.toUpperCase().trim() + const assetCode = this.get("assetCode") + if (typeof assetCode === "string") this.set("assetCode", assetCode.trim()) } if (this.isModified("issuerPublicKey")) { - this.issuerPublicKey = normalizeStellarPublicKey(this.issuerPublicKey) + const issuerPublicKey = this.get("issuerPublicKey") + if (typeof issuerPublicKey === "string") this.set("issuerPublicKey", normalizeStellarPublicKey(issuerPublicKey)) } if (this.isModified("distributionPublicKey")) { - this.distributionPublicKey = normalizeStellarPublicKey(this.distributionPublicKey) + const distributionPublicKey = this.get("distributionPublicKey") + if (typeof distributionPublicKey === "string") this.set("distributionPublicKey", normalizeStellarPublicKey(distributionPublicKey)) } next() })