diff --git a/admin-dashboard/app/admin/users/page.tsx b/admin-dashboard/app/admin/users/page.tsx new file mode 100644 index 0000000..869220a --- /dev/null +++ b/admin-dashboard/app/admin/users/page.tsx @@ -0,0 +1,121 @@ +import Link from "next/link"; +import { auth } from "@/auth"; +import { AdminUsersTable } from "@/components/dashboard/AdminUsersTable"; +import type { AdminUser } from "@/components/dashboard/AdminUsersTable"; +import { ADMIN_ROLES, ROLE_LABELS, ROLE_DESCRIPTIONS } from "@/lib/permissions"; + +async function fetchUsers(adminJwt: string): Promise { + const serverUrl = process.env.FLUID_SERVER_URL; + const adminToken = process.env.FLUID_ADMIN_TOKEN; + + if (!serverUrl || !adminToken) return []; + + try { + const res = await fetch(`${serverUrl}/admin/users`, { + headers: { + "x-admin-token": adminToken, + "x-admin-jwt": adminJwt, + }, + cache: "no-store", + }); + if (!res.ok) return []; + return await res.json(); + } catch { + return []; + } +} + +const ROLE_KEY_PERMISSIONS: Record = { + SUPER_ADMIN: ["Manage users", "Full operational control", "Billing & payments", "Config changes"], + ADMIN: ["Full operational control", "View billing", "Config changes", "Manage API keys & tenants"], + READ_ONLY: ["View transactions, analytics, signers", "View API keys & tenants", "View SAR & audit logs", "View billing"], + BILLING: ["View transactions & analytics", "View tenants", "Manage billing & payments"], +}; + +export default async function AdminUsersPage() { + const session = await auth(); + const users = await fetchUsers(session?.user?.adminJwt ?? ""); + + return ( +
+
+
+
+
+

+ Fluid Admin — Access Control +

+

Admin Users

+

+ Manage admin accounts and role assignments. +

+
+
+
+
{session?.user?.email}
+
{session?.user?.role ?? "Unknown role"}
+
+ + Back to dashboard + +
+
+
+
+ +
+ + + {/* Role permissions reference */} +
+

Role Permissions Reference

+
+
+ + + + + + + + + + {ADMIN_ROLES.map(role => ( + + + + + + ))} + +
RoleDescriptionKey Permissions
+ + {ROLE_LABELS[role]} + + {ROLE_DESCRIPTIONS[role]} +
    + {ROLE_KEY_PERMISSIONS[role].map(perm => ( +
  • + {perm} +
  • + ))} +
+
+
+
+
+
+
+ ); +} diff --git a/admin-dashboard/app/api/admin/users/[id]/role/route.ts b/admin-dashboard/app/api/admin/users/[id]/role/route.ts new file mode 100644 index 0000000..55ce645 --- /dev/null +++ b/admin-dashboard/app/api/admin/users/[id]/role/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; + +async function adminHeaders(): Promise> { + const session = await auth(); + const headers: Record = { + "Content-Type": "application/json", + "x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "", + }; + if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt; + return headers; +} + +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, ""); + if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured"); + const body = await req.json(); + const response = await fetch(`${serverUrl}/admin/users/${params.id}/role`, { + method: "PATCH", + headers: await adminHeaders(), + body: JSON.stringify(body), + }); + return NextResponse.json(await response.json(), { status: response.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to update role" }, + { status: 500 } + ); + } +} diff --git a/admin-dashboard/app/api/admin/users/[id]/route.ts b/admin-dashboard/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..79c8b55 --- /dev/null +++ b/admin-dashboard/app/api/admin/users/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; + +async function adminHeaders(): Promise> { + const session = await auth(); + const headers: Record = { + "x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "", + }; + if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt; + return headers; +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, ""); + if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured"); + const response = await fetch(`${serverUrl}/admin/users/${params.id}`, { + method: "DELETE", + headers: await adminHeaders(), + }); + return NextResponse.json(await response.json(), { status: response.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to deactivate user" }, + { status: 500 } + ); + } +} diff --git a/admin-dashboard/app/api/admin/users/route.ts b/admin-dashboard/app/api/admin/users/route.ts new file mode 100644 index 0000000..e2bd6b8 --- /dev/null +++ b/admin-dashboard/app/api/admin/users/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; + +function getServerConfig() { + const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, ""); + const adminToken = process.env.FLUID_ADMIN_TOKEN?.trim(); + if (!serverUrl || !adminToken) { + throw new Error("FLUID_SERVER_URL and FLUID_ADMIN_TOKEN must be configured"); + } + return { serverUrl, adminToken }; +} + +async function adminHeaders(): Promise> { + const session = await auth(); + const headers: Record = { + "Content-Type": "application/json", + "x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "", + }; + if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt; + return headers; +} + +export async function GET() { + try { + const { serverUrl } = getServerConfig(); + const response = await fetch(`${serverUrl}/admin/users`, { + cache: "no-store", + headers: await adminHeaders(), + }); + return NextResponse.json(await response.json(), { status: response.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch users" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const { serverUrl } = getServerConfig(); + const body = await req.json(); + const response = await fetch(`${serverUrl}/admin/users`, { + method: "POST", + headers: await adminHeaders(), + body: JSON.stringify(body), + }); + return NextResponse.json(await response.json(), { status: response.status }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create user" }, + { status: 500 } + ); + } +} diff --git a/admin-dashboard/auth.ts b/admin-dashboard/auth.ts index 9792963..092d22a 100644 --- a/admin-dashboard/auth.ts +++ b/admin-dashboard/auth.ts @@ -1,74 +1,102 @@ import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; +import type { AdminRole } from "./lib/permissions"; + +declare module "next-auth" { + interface User { + role?: string; + adminJwt?: string; + } + interface Session { + user: { + email?: string | null; + role?: string; + adminJwt?: string; + }; + } +} + +declare module "next-auth/jwt" { + interface JWT { + role?: string; + adminJwt?: string; + } +} export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" } + password: { label: "Password", type: "password" }, }, async authorize(credentials) { - if (!credentials?.email || !credentials?.password) { - return null; + if (!credentials?.email || !credentials?.password) return null; + + const email = credentials.email as string; + const password = credentials.password as string; + + // 1. Try DB-based admin users via the backend login endpoint + const serverUrl = process.env.FLUID_SERVER_URL; + const adminToken = process.env.FLUID_ADMIN_TOKEN; + + if (serverUrl && adminToken) { + try { + const resp = await fetch(`${serverUrl}/admin/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (resp.ok) { + const data = await resp.json(); + return { + id: email, + email, + role: data.role as AdminRole, + adminJwt: data.token, + }; + } + } catch { + // Backend unreachable — fall through to env-var auth + } } + // 2. Env-var fallback (single-admin / bootstrap deployments) const adminEmail = process.env.ADMIN_EMAIL; const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH; - if (!adminEmail || !adminPasswordHash) { - console.error("Admin credentials not configured"); - return null; - } + if (!adminEmail || !adminPasswordHash) return null; - // Timing-safe comparison to prevent timing attacks const emailMatch = await new Promise((resolve) => { - const isEqual = credentials.email === adminEmail; - // Add artificial delay to prevent timing attacks + const isEqual = email === adminEmail; setTimeout(() => resolve(isEqual), Math.random() * 10); }); + if (!emailMatch) return null; - if (!emailMatch) { - return null; - } - - const passwordMatch = await bcrypt.compare( - credentials.password as string, - adminPasswordHash - ); + const passwordMatch = await bcrypt.compare(password, adminPasswordHash); + if (!passwordMatch) return null; - if (!passwordMatch) { - return null; - } - - return { - id: "1", - email: adminEmail, - role: "admin" - }; - } - }) + return { id: "env-admin", email: adminEmail, role: "SUPER_ADMIN" }; + }, + }), ], - session: { - strategy: "jwt", - maxAge: 8 * 60 * 60, // 8 hours - }, + session: { strategy: "jwt", maxAge: 8 * 60 * 60 }, callbacks: { jwt: async ({ token, user }) => { if (user) { token.role = user.role; + token.adminJwt = user.adminJwt; } return token; }, session: async ({ session, token }) => { if (session.user) { session.user.role = token.role as string; + session.user.adminJwt = token.adminJwt as string | undefined; } return session; - } + }, }, - pages: { - signIn: "/login" - } + pages: { signIn: "/login" }, }); diff --git a/admin-dashboard/components/dashboard/AdminUsersTable.tsx b/admin-dashboard/components/dashboard/AdminUsersTable.tsx new file mode 100644 index 0000000..cada668 --- /dev/null +++ b/admin-dashboard/components/dashboard/AdminUsersTable.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useState } from "react"; +import type { AdminRole } from "@/lib/permissions"; +import { ADMIN_ROLES, ROLE_LABELS } from "@/lib/permissions"; + +export interface AdminUser { + id: string; + email: string; + role: string; + active: boolean; + createdAt: string; +} + +interface AdminUsersTableProps { + users: AdminUser[]; + currentUserRole: string; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +} + +function RoleBadge({ role }: { role: string }) { + const classes: Record = { + SUPER_ADMIN: "bg-purple-50 text-purple-700 ring-purple-200", + ADMIN: "bg-blue-50 text-blue-700 ring-blue-200", + READ_ONLY: "bg-slate-100 text-slate-600 ring-slate-200", + BILLING: "bg-emerald-50 text-emerald-700 ring-emerald-200", + }; + const cls = classes[role] ?? "bg-slate-100 text-slate-600 ring-slate-200"; + return ( + + {ROLE_LABELS[role as AdminRole] ?? role} + + ); +} + +function StatusBadge({ active }: { active: boolean }) { + return active ? ( + + Active + + ) : ( + + Inactive + + ); +} + +interface CreateUserModalProps { + onClose: () => void; + onCreated: (user: AdminUser) => void; +} + +function CreateUserModal({ onClose, onCreated }: CreateUserModalProps) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("READ_ONLY"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const res = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, role }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.message ?? "Failed to create user"); + } + const created: AdminUser = await res.json(); + onCreated(created); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create user"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+

Create Admin User

+

Add a new admin account with an assigned role.

+
+
+
+ {error && ( +

+ {error} +

+ )} +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Minimum 8 characters" + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500" + /> +
+
+ + +
+
+
+ + +
+
+
+
+ ); +} + +export function AdminUsersTable({ users: initialUsers, currentUserRole }: AdminUsersTableProps) { + const [users, setUsers] = useState(initialUsers); + const [showCreateModal, setShowCreateModal] = useState(false); + const [toast, setToast] = useState<{ message: string; kind: "success" | "error" } | null>(null); + const [changingRole, setChangingRole] = useState(null); + const [deactivating, setDeactivating] = useState(null); + + const isSuperAdmin = currentUserRole === "SUPER_ADMIN"; + + function showToast(message: string, kind: "success" | "error" = "success") { + setToast({ message, kind }); + setTimeout(() => setToast(null), 3500); + } + + function handleUserCreated(user: AdminUser) { + setUsers(prev => [user, ...prev]); + setShowCreateModal(false); + showToast(`User ${user.email} created.`); + } + + async function handleRoleChange(userId: string, newRole: string) { + setChangingRole(userId); + try { + const res = await fetch(`/api/admin/users/${userId}/role`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: newRole }), + }); + if (!res.ok) throw new Error("Failed to update role"); + setUsers(prev => + prev.map(u => u.id === userId ? { ...u, role: newRole } : u) + ); + showToast("Role updated."); + } catch { + showToast("Failed to update role.", "error"); + } finally { + setChangingRole(null); + } + } + + async function handleDeactivate(userId: string, email: string) { + setDeactivating(userId); + try { + const res = await fetch(`/api/admin/users/${userId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to deactivate user"); + setUsers(prev => + prev.map(u => u.id === userId ? { ...u, active: false } : u) + ); + showToast(`${email} deactivated.`); + } catch { + showToast("Failed to deactivate user.", "error"); + } finally { + setDeactivating(null); + } + } + + return ( +
+ {/* Header row */} +
+

+ {users.length} {users.length === 1 ? "user" : "users"} +

+ {isSuperAdmin && ( + + )} +
+ + {/* Table */} +
+ {users.length === 0 ? ( +
+ No admin users found. +
+ ) : ( +
+ + + + + + + + + + + + {users.map(user => ( + + + + + + + + ))} + +
EmailRoleStatusCreatedActions
+ {user.email} + + + + + + {formatDate(user.createdAt)} + +
+ + {user.active && isSuperAdmin && ( + + )} +
+
+
+ )} +
+ + {/* Create user modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onCreated={handleUserCreated} + /> + )} + + {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} +
+ ); +} diff --git a/admin-dashboard/lib/permissions.ts b/admin-dashboard/lib/permissions.ts new file mode 100644 index 0000000..97cd407 --- /dev/null +++ b/admin-dashboard/lib/permissions.ts @@ -0,0 +1,74 @@ +export const ADMIN_ROLES = ["SUPER_ADMIN", "ADMIN", "READ_ONLY", "BILLING"] as const; +export type AdminRole = (typeof ADMIN_ROLES)[number]; + +export const ROLE_LABELS: Record = { + SUPER_ADMIN: "Super Admin", + ADMIN: "Admin", + READ_ONLY: "Read Only", + BILLING: "Billing", +}; + +export const ROLE_DESCRIPTIONS: Record = { + SUPER_ADMIN: "Full access including user management", + ADMIN: "Full operational control except user and billing management", + READ_ONLY: "View-only access across all sections", + BILLING: "Billing and payment operations", +}; + +export type Permission = + | "view_transactions" | "view_analytics" + | "view_api_keys" | "manage_api_keys" + | "view_tenants" | "manage_tenants" + | "view_signers" | "manage_signers" + | "manage_config" + | "view_audit_logs" + | "view_sar" | "manage_sar" + | "view_billing" | "manage_billing" + | "manage_users"; + +const ROLE_PERMISSIONS: Record> = { + SUPER_ADMIN: new Set([ + "view_transactions", "view_analytics", + "view_api_keys", "manage_api_keys", + "view_tenants", "manage_tenants", + "view_signers", "manage_signers", + "manage_config", + "view_audit_logs", + "view_sar", "manage_sar", + "view_billing", "manage_billing", + "manage_users", + ]), + ADMIN: new Set([ + "view_transactions", "view_analytics", + "view_api_keys", "manage_api_keys", + "view_tenants", "manage_tenants", + "view_signers", "manage_signers", + "manage_config", + "view_audit_logs", + "view_sar", "manage_sar", + "view_billing", + ]), + READ_ONLY: new Set([ + "view_transactions", "view_analytics", + "view_api_keys", + "view_tenants", + "view_signers", + "view_audit_logs", + "view_sar", + "view_billing", + ]), + BILLING: new Set([ + "view_transactions", "view_analytics", + "view_tenants", + "view_billing", "manage_billing", + ]), +}; + +export function hasPermission(role: AdminRole | string | undefined, permission: Permission): boolean { + if (!role || !(role in ROLE_PERMISSIONS)) return false; + return ROLE_PERMISSIONS[role as AdminRole].has(permission); +} + +export function isValidRole(role: string): role is AdminRole { + return (ADMIN_ROLES as readonly string[]).includes(role); +} diff --git a/server/.env.example b/server/.env.example index cce5e66..d118f87 100644 --- a/server/.env.example +++ b/server/.env.example @@ -154,6 +154,11 @@ TERMS_OF_SERVICE_VERSION=2026-03-29 # ensure the admin-dashboard sets FLUID_ADMIN_TOKEN to the same value. FLUID_ADMIN_TOKEN= +# --- RBAC (Role-Based Access Control) --- +# Secret used to sign admin JWT tokens issued by POST /admin/auth/login. +# Must be a long random string (e.g. openssl rand -hex 32). Required in production. +FLUID_ADMIN_JWT_SECRET= + # Sandbox environment # URL of the local Stellar Quickstart instance used for sandbox API keys. # In Docker Compose this is automatically set to http://stellar-quickstart:8000 diff --git a/server/package-lock.json b/server/package-lock.json index ea8e224..af9b48c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,9 +22,12 @@ "@prisma/config": "^7.6.0", "@solana/web3.js": "^1.98.4", "@stellar/stellar-sdk": "^11.3.0", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@wormhole-foundation/sdk": "^4.14.1", + "bcryptjs": "^3.0.3", "bullmq": "^5.71.1", "cors": "^2.8.6", "decimal.js": "^10.6.0", @@ -33,6 +36,7 @@ "express-rate-limit": "^8.3.1", "firebase-admin": "^13.7.0", "ioredis": "^5.10.1", + "jsonwebtoken": "^9.0.3", "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pino": "^10.3.1", @@ -4874,6 +4878,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -6533,6 +6543,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bech32": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", diff --git a/server/package.json b/server/package.json index 7fb6578..cf39f0a 100644 --- a/server/package.json +++ b/server/package.json @@ -54,9 +54,12 @@ "@prisma/config": "^7.6.0", "@solana/web3.js": "^1.98.4", "@stellar/stellar-sdk": "^11.3.0", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@wormhole-foundation/sdk": "^4.14.1", + "bcryptjs": "^3.0.3", "bullmq": "^5.71.1", "cors": "^2.8.6", "decimal.js": "^10.6.0", @@ -65,6 +68,7 @@ "express-rate-limit": "^8.3.1", "firebase-admin": "^13.7.0", "ioredis": "^5.10.1", + "jsonwebtoken": "^9.0.3", "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pino": "^10.3.1", diff --git a/server/prisma/migrations/20260330000000_add_admin_users/migration.sql b/server/prisma/migrations/20260330000000_add_admin_users/migration.sql new file mode 100644 index 0000000..e70c159 --- /dev/null +++ b/server/prisma/migrations/20260330000000_add_admin_users/migration.sql @@ -0,0 +1,15 @@ +-- AdminUser table for role-based access control +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'READ_ONLY', + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email"); +CREATE INDEX "AdminUser_email_idx" ON "AdminUser"("email"); +CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role"); +CREATE INDEX "AdminUser_active_idx" ON "AdminUser"("active"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3388aa4..7dc1805 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -532,3 +532,18 @@ model CrossChainSettlement { @@index([sourceTxHash]) @@index([timeoutAt]) } + +// Admin users with role-based access control +model AdminUser { + id String @id @default(uuid()) + email String @unique + passwordHash String + role String @default("READ_ONLY") // SUPER_ADMIN | ADMIN | READ_ONLY | BILLING + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([role]) + @@index([active]) +} diff --git a/server/src/handlers/adminUsers.test.ts b/server/src/handlers/adminUsers.test.ts new file mode 100644 index 0000000..764580b --- /dev/null +++ b/server/src/handlers/adminUsers.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; + +// ── Mock Prisma ────────────────────────────────────────────────────────────── +vi.mock("../utils/db", () => ({ + default: { + adminUser: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("../services/auditLogger", () => ({ + logAuditEvent: vi.fn(), + getAuditActor: vi.fn().mockReturnValue("test-actor"), +})); + +import prisma from "../utils/db"; +import { + listAdminUsersHandler, + createAdminUserHandler, + updateAdminUserRoleHandler, + deactivateAdminUserHandler, + adminLoginHandler, +} from "./adminUsers"; + +const adminUser = (prisma as any).adminUser; + +function makeReq(body: any = {}, params: any = {}): Request { + return { body, params, header: vi.fn() } as unknown as Request; +} + +function makeRes(): { res: Response; json: ReturnType; status: ReturnType } { + const json = vi.fn().mockReturnThis(); + const status = vi.fn().mockReturnValue({ json }); + const res = { json, status } as unknown as Response; + return { res, json, status }; +} + +// ── listAdminUsersHandler ──────────────────────────────────────────────────── + +describe("listAdminUsersHandler", () => { + it("returns a list of users with sensitive fields omitted", async () => { + adminUser.findMany.mockResolvedValueOnce([ + { id: "1", email: "a@test.com", role: "ADMIN", active: true, passwordHash: "SECRET", createdAt: new Date() }, + ]); + const { res, json } = makeRes(); + await listAdminUsersHandler(makeReq(), res); + expect(json).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ email: "a@test.com", role: "ADMIN" }), + ]) + ); + // passwordHash must NOT be leaked + const result = json.mock.calls[0][0]; + expect(result[0]).not.toHaveProperty("passwordHash"); + }); + + it("returns 500 on DB error", async () => { + adminUser.findMany.mockRejectedValueOnce(new Error("db error")); + const { res, status, json } = makeRes(); + (res as any).status = status; + await listAdminUsersHandler(makeReq(), res); + expect(status).toHaveBeenCalledWith(500); + }); +}); + +// ── createAdminUserHandler ─────────────────────────────────────────────────── + +describe("createAdminUserHandler", () => { + beforeEach(() => { + adminUser.findUnique.mockResolvedValue(null); + adminUser.create.mockImplementation(async ({ data }: any) => ({ + ...data, + createdAt: new Date(), + })); + }); + + it("creates a user and returns 201", async () => { + const { res, status } = makeRes(); + (res as any).status = status; + await createAdminUserHandler( + makeReq({ email: "new@test.com", password: "secure123!", role: "ADMIN" }), + res + ); + expect(status).toHaveBeenCalledWith(201); + const created = status.mock.results[0].value.json.mock.calls[0][0]; + expect(created.email).toBe("new@test.com"); + expect(created.role).toBe("ADMIN"); + expect(created).not.toHaveProperty("passwordHash"); + }); + + it("rejects invalid role with 400", async () => { + const { res, status } = makeRes(); + (res as any).status = status; + await createAdminUserHandler( + makeReq({ email: "x@test.com", password: "pass", role: "GOD_MODE" }), + res + ); + expect(status).toHaveBeenCalledWith(400); + }); + + it("returns 409 when email already exists", async () => { + adminUser.findUnique.mockResolvedValueOnce({ id: "1" }); + const { res, status } = makeRes(); + (res as any).status = status; + await createAdminUserHandler( + makeReq({ email: "exists@test.com", password: "pass", role: "ADMIN" }), + res + ); + expect(status).toHaveBeenCalledWith(409); + }); + + it("returns 400 when required fields are missing", async () => { + const { res, status } = makeRes(); + (res as any).status = status; + await createAdminUserHandler(makeReq({ email: "only@test.com" }), res); + expect(status).toHaveBeenCalledWith(400); + }); +}); + +// ── updateAdminUserRoleHandler ─────────────────────────────────────────────── + +describe("updateAdminUserRoleHandler", () => { + it("updates role and returns updated user", async () => { + adminUser.findUnique.mockResolvedValueOnce({ id: "1", email: "u@test.com", role: "READ_ONLY" }); + adminUser.update.mockResolvedValueOnce({ id: "1", email: "u@test.com", role: "ADMIN", active: true }); + + const { res, json } = makeRes(); + await updateAdminUserRoleHandler(makeReq({ role: "ADMIN" }, { id: "1" }), res); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ role: "ADMIN" })); + }); + + it("returns 404 when user does not exist", async () => { + adminUser.findUnique.mockResolvedValueOnce(null); + const { res, status } = makeRes(); + (res as any).status = status; + await updateAdminUserRoleHandler(makeReq({ role: "ADMIN" }, { id: "ghost" }), res); + expect(status).toHaveBeenCalledWith(404); + }); + + it("returns 400 for invalid role", async () => { + const { res, status } = makeRes(); + (res as any).status = status; + await updateAdminUserRoleHandler(makeReq({ role: "INVALID" }, { id: "1" }), res); + expect(status).toHaveBeenCalledWith(400); + }); +}); + +// ── deactivateAdminUserHandler ─────────────────────────────────────────────── + +describe("deactivateAdminUserHandler", () => { + it("sets active=false and returns the user", async () => { + adminUser.findUnique.mockResolvedValueOnce({ id: "1", email: "u@test.com", active: true }); + adminUser.update.mockResolvedValueOnce({ id: "1", email: "u@test.com", active: false }); + + const { res, json } = makeRes(); + await deactivateAdminUserHandler(makeReq({}, { id: "1" }), res); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ active: false })); + }); + + it("returns 404 when user does not exist", async () => { + adminUser.findUnique.mockResolvedValueOnce(null); + const { res, status } = makeRes(); + (res as any).status = status; + await deactivateAdminUserHandler(makeReq({}, { id: "ghost" }), res); + expect(status).toHaveBeenCalledWith(404); + }); +}); + +// ── adminLoginHandler ──────────────────────────────────────────────────────── + +describe("adminLoginHandler", () => { + it("returns 400 when email or password missing", async () => { + const { res, status } = makeRes(); + (res as any).status = status; + await adminLoginHandler(makeReq({ email: "x@test.com" }), res); + expect(status).toHaveBeenCalledWith(400); + }); + + it("returns 401 for unknown email", async () => { + adminUser.findUnique.mockResolvedValueOnce(null); + const { res, status } = makeRes(); + (res as any).status = status; + await adminLoginHandler(makeReq({ email: "unknown@test.com", password: "pw" }), res); + expect(status).toHaveBeenCalledWith(401); + }); + + it("returns 401 for inactive user", async () => { + adminUser.findUnique.mockResolvedValueOnce({ id: "1", email: "u@test.com", passwordHash: "$x", active: false }); + const { res, status } = makeRes(); + (res as any).status = status; + await adminLoginHandler(makeReq({ email: "u@test.com", password: "pw" }), res); + expect(status).toHaveBeenCalledWith(401); + }); +}); diff --git a/server/src/handlers/adminUsers.ts b/server/src/handlers/adminUsers.ts new file mode 100644 index 0000000..97df5f4 --- /dev/null +++ b/server/src/handlers/adminUsers.ts @@ -0,0 +1,186 @@ +import { Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import prisma from "../utils/db"; +import { requirePermission, signAdminJwt } from "../utils/adminAuth"; +import { isValidRole, AdminRole } from "../utils/permissions"; +import { getAuditActor, logAuditEvent } from "../services/auditLogger"; +import { AppError } from "../errors/AppError"; + +const adminUserModel = (prisma as any).adminUser as { + findMany: (args?: any) => Promise; + findUnique: (args: any) => Promise; + create: (args: any) => Promise; + update: (args: any) => Promise; +}; + +// ── POST /admin/auth/login ──────────────────────────────────────────────────── + +export async function adminLoginHandler(req: Request, res: Response) { + const { email, password } = req.body ?? {}; + + if (!email || !password) { + return res.status(400).json({ error: "email and password are required" }); + } + + try { + // 1. Try DB-based admin user + const user = await adminUserModel.findUnique({ where: { email } }); + + if (user && user.active) { + const match = await bcrypt.compare(password, user.passwordHash); + if (match) { + const token = signAdminJwt({ sub: user.id, email: user.email, role: user.role }); + void logAuditEvent("ADMIN_LOGIN", user.email, { source: "db" }); + return res.json({ token, role: user.role, email: user.email }); + } + } + + // 2. Env-var fallback (bootstrap / single-admin deployments) + const envEmail = process.env.ADMIN_EMAIL; + const envHash = process.env.ADMIN_PASSWORD_HASH; + if (envEmail && envHash && email === envEmail) { + const match = await bcrypt.compare(password, envHash); + if (match) { + const token = signAdminJwt({ sub: "env-admin", email: envEmail, role: "SUPER_ADMIN" }); + void logAuditEvent("ADMIN_LOGIN", envEmail, { source: "env" }); + return res.json({ token, role: "SUPER_ADMIN", email: envEmail }); + } + } + + return res.status(401).json({ error: "Invalid credentials" }); + } catch (err) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +// ── GET /admin/users ────────────────────────────────────────────────────────── + +export async function listAdminUsersHandler(req: Request, res: Response) { + try { + const users = await adminUserModel.findMany({ + orderBy: { createdAt: "desc" }, + }); + return res.json( + users.map((u: any) => ({ + id: u.id, + email: u.email, + role: u.role, + active: u.active, + createdAt: u.createdAt, + })) + ); + } catch (err) { + return res.status(500).json({ error: "Failed to list admin users" }); + } +} + +// ── POST /admin/users ───────────────────────────────────────────────────────── + +export async function createAdminUserHandler(req: Request, res: Response) { + const { email, password, role } = req.body ?? {}; + + if (!email || !password || !role) { + return res.status(400).json({ error: "email, password, and role are required" }); + } + if (!isValidRole(role)) { + return res.status(400).json({ error: `Invalid role. Must be one of: SUPER_ADMIN, ADMIN, READ_ONLY, BILLING` }); + } + + try { + const existing = await adminUserModel.findUnique({ where: { email } }); + if (existing) { + return res.status(409).json({ error: "An admin user with that email already exists" }); + } + + const passwordHash = await bcrypt.hash(password, 12); + const user = await adminUserModel.create({ + data: { + id: require("crypto").randomUUID(), + email, + passwordHash, + role, + active: true, + }, + }); + + void logAuditEvent("ADMIN_ACTION", getAuditActor(req), { + action: "create_admin_user", + targetEmail: email, + role, + }); + + return res.status(201).json({ + id: user.id, + email: user.email, + role: user.role, + active: user.active, + createdAt: user.createdAt, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to create admin user" }); + } +} + +// ── PATCH /admin/users/:id/role ─────────────────────────────────────────────── + +export async function updateAdminUserRoleHandler(req: Request, res: Response) { + const { id } = req.params; + const { role } = req.body ?? {}; + + if (!role || !isValidRole(role)) { + return res.status(400).json({ error: `Invalid role. Must be one of: SUPER_ADMIN, ADMIN, READ_ONLY, BILLING` }); + } + + try { + const user = await adminUserModel.findUnique({ where: { id } }); + if (!user) return res.status(404).json({ error: "Admin user not found" }); + + const updated = await adminUserModel.update({ + where: { id }, + data: { role, updatedAt: new Date() }, + }); + + void logAuditEvent("ADMIN_ACTION", getAuditActor(req), { + action: "update_admin_role", + targetId: id, + targetEmail: updated.email, + newRole: role, + previousRole: user.role, + }); + + return res.json({ + id: updated.id, + email: updated.email, + role: updated.role, + active: updated.active, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to update role" }); + } +} + +// ── DELETE /admin/users/:id ─────────────────────────────────────────────────── + +export async function deactivateAdminUserHandler(req: Request, res: Response) { + const { id } = req.params; + + try { + const user = await adminUserModel.findUnique({ where: { id } }); + if (!user) return res.status(404).json({ error: "Admin user not found" }); + + const updated = await adminUserModel.update({ + where: { id }, + data: { active: false, updatedAt: new Date() }, + }); + + void logAuditEvent("ADMIN_ACTION", getAuditActor(req), { + action: "deactivate_admin_user", + targetId: id, + targetEmail: updated.email, + }); + + return res.json({ id: updated.id, email: updated.email, active: false }); + } catch (err) { + return res.status(500).json({ error: "Failed to deactivate admin user" }); + } +} diff --git a/server/src/index.ts b/server/src/index.ts index ce431fd..da9b657 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -102,6 +102,14 @@ import { import { initializeFeeManager } from "./services/feeManager"; import { initializeOFACScreening, stopOFACScreening } from "./services/ofacScreening"; import { initializeRegionalDbs, DEFAULT_REGION } from "./services/regionRouter"; +import { requirePermission } from "./utils/adminAuth"; +import { + adminLoginHandler, + listAdminUsersHandler, + createAdminUserHandler, + updateAdminUserRoleHandler, + deactivateAdminUserHandler, +} from "./handlers/adminUsers"; import { listTransactionsHandler } from "./handlers/adminTransactions"; import { listSARReportsHandler, @@ -404,34 +412,49 @@ app.post( }, ); -app.get("/admin/api-keys", listApiKeysHandler); -app.post("/admin/api-keys", upsertApiKeyHandler); -app.patch("/admin/api-keys/:key/revoke", revokeApiKeyHandler); -app.patch("/admin/api-keys/:key/chains", updateApiKeyChainsHandler); -app.delete("/admin/api-keys/:key", revokeApiKeyHandler); -app.get("/admin/subscription-tiers", listSubscriptionTiersHandler); +// ── RBAC: Admin user management ─────────────────────────────────────────────── +app.post("/admin/auth/login", adminLoginHandler); +app.get("/admin/users", requirePermission("manage_users"), listAdminUsersHandler); +app.post("/admin/users", requirePermission("manage_users"), createAdminUserHandler); +app.patch("/admin/users/:id/role", requirePermission("manage_users"), updateAdminUserRoleHandler); +app.delete("/admin/users/:id", requirePermission("manage_users"), deactivateAdminUserHandler); + +// ── API keys ────────────────────────────────────────────────────────────────── +app.get("/admin/api-keys", requirePermission("view_api_keys"), listApiKeysHandler); +app.post("/admin/api-keys", requirePermission("manage_api_keys"), upsertApiKeyHandler); +app.patch("/admin/api-keys/:key/revoke", requirePermission("manage_api_keys"), revokeApiKeyHandler); +app.patch("/admin/api-keys/:key/chains", requirePermission("manage_api_keys"), updateApiKeyChainsHandler); +app.delete("/admin/api-keys/:key", requirePermission("manage_api_keys"), revokeApiKeyHandler); + +// ── Tenants & subscription tiers ────────────────────────────────────────────── +app.get("/admin/subscription-tiers", requirePermission("view_tenants"), listSubscriptionTiersHandler); app.patch( "/admin/tenants/:tenantId/subscription-tier", + requirePermission("manage_tenants"), updateTenantSubscriptionTierHandler, ); app.delete("/admin/tenants/:tenantId", (req: Request, res: Response, next: NextFunction) => { void deleteTenantByAdminHandler(req, res, next); }); -app.get("/admin/signers", listSignersHandler(config)); -app.post("/admin/signers", addSignerHandler(config)); -app.delete("/admin/signers/:publicKey", removeSignerHandler(config)); + +// ── Signers ─────────────────────────────────────────────────────────────────── +app.get("/admin/signers", requirePermission("view_signers"), listSignersHandler(config)); +app.post("/admin/signers", requirePermission("manage_signers"), addSignerHandler(config)); +app.delete("/admin/signers/:publicKey", requirePermission("manage_signers"), removeSignerHandler(config)); + +// ── Transactions & analytics ────────────────────────────────────────────────── app.get("/admin/prices", getPriceHandler); -app.get("/admin/transactions", listTransactionsHandler); -app.get("/admin/analytics/spend-forecast", getSpendForecastHandler(config)); -app.get("/admin/fee-multiplier", getFeeMultiplierHandler); -app.get("/admin/multi-chain/stats", getMultiChainStatsHandler(config)); -app.get("/admin/device-tokens", listDeviceTokensHandler); -app.post("/admin/device-tokens", registerDeviceTokenHandler); -app.delete("/admin/device-tokens/:id", deleteDeviceTokenHandler); -app.get("/admin/webhooks/dlq", listDlqHandler); -app.post("/admin/webhooks/dlq/replay", replayDlqHandler); -app.post("/admin/webhooks/dlq/delete", deleteDlqHandler); -app.get("/admin/audit-log/export", exportAuditLogHandler); +app.get("/admin/transactions", requirePermission("view_transactions"), listTransactionsHandler); +app.get("/admin/analytics/spend-forecast", requirePermission("view_analytics"), getSpendForecastHandler(config)); +app.get("/admin/fee-multiplier", requirePermission("manage_config"), getFeeMultiplierHandler); +app.get("/admin/multi-chain/stats", requirePermission("view_analytics"), getMultiChainStatsHandler(config)); +app.get("/admin/device-tokens", requirePermission("view_api_keys"), listDeviceTokensHandler); +app.post("/admin/device-tokens", requirePermission("manage_api_keys"), registerDeviceTokenHandler); +app.delete("/admin/device-tokens/:id", requirePermission("manage_api_keys"), deleteDeviceTokenHandler); +app.get("/admin/webhooks/dlq", requirePermission("view_transactions"), listDlqHandler); +app.post("/admin/webhooks/dlq/replay", requirePermission("manage_config"), replayDlqHandler); +app.post("/admin/webhooks/dlq/delete", requirePermission("manage_config"), deleteDlqHandler); +app.get("/admin/audit-log/export", requirePermission("view_audit_logs"), exportAuditLogHandler); // Bridge settlement admin routes app.get("/admin/bridge-settlements", listBridgeSettlementsHandler); diff --git a/server/src/utils/adminAuth.test.ts b/server/src/utils/adminAuth.test.ts new file mode 100644 index 0000000..6b104d7 --- /dev/null +++ b/server/src/utils/adminAuth.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response, NextFunction } from "express"; +import { signAdminJwt, verifyAdminJwt, resolveAdminRole, requirePermission } from "./adminAuth"; + +vi.mock("../services/auditLogger", () => ({ + logAuditEvent: vi.fn(), + getAuditActor: vi.fn().mockReturnValue("test"), +})); + +function makeReq(headers: Record = {}): Request { + return { header: (name: string) => headers[name.toLowerCase()] } as unknown as Request; +} + +function makeRes() { + const json = vi.fn().mockReturnThis(); + const status = vi.fn().mockReturnValue({ json }); + return { res: { json, status } as unknown as Response, json, status }; +} + +// ── JWT round-trip ──────────────────────────────────────────────────────────── + +describe("signAdminJwt / verifyAdminJwt", () => { + it("produces a token that verifies back to the same payload", () => { + const payload = { sub: "u1", email: "a@test.com", role: "ADMIN" as const }; + const token = signAdminJwt(payload); + const decoded = verifyAdminJwt(token); + expect(decoded?.sub).toBe("u1"); + expect(decoded?.role).toBe("ADMIN"); + }); + + it("returns null for a tampered token", () => { + const token = signAdminJwt({ sub: "u1", email: "a@test.com", role: "ADMIN" }); + expect(verifyAdminJwt(token + "tampered")).toBeNull(); + }); + + it("returns null for a token with an unknown role", () => { + // Forge a token with an invalid role using a different secret + // — verifyAdminJwt should reject it + const result = verifyAdminJwt("not.a.jwt"); + expect(result).toBeNull(); + }); +}); + +// ── resolveAdminRole ────────────────────────────────────────────────────────── + +describe("resolveAdminRole", () => { + beforeEach(() => { + vi.stubEnv("FLUID_ADMIN_TOKEN", "static-token"); + vi.stubEnv("FLUID_ADMIN_JWT_SECRET", "test-secret"); + }); + + it("resolves role from a valid x-admin-jwt header", () => { + const token = signAdminJwt({ sub: "u1", email: "a@test.com", role: "READ_ONLY" }); + const req = makeReq({ "x-admin-jwt": token }); + expect(resolveAdminRole(req)).toBe("READ_ONLY"); + }); + + it("falls back to SUPER_ADMIN when static token matches", () => { + const req = makeReq({ "x-admin-token": "static-token" }); + expect(resolveAdminRole(req)).toBe("SUPER_ADMIN"); + }); + + it("returns null when neither header is present", () => { + const req = makeReq({}); + expect(resolveAdminRole(req)).toBeNull(); + }); + + it("returns null for an invalid JWT even if static token matches", () => { + // JWT takes priority; if it's invalid → null (don't fall through to static token) + const req = makeReq({ "x-admin-jwt": "invalid.jwt.here", "x-admin-token": "static-token" }); + expect(resolveAdminRole(req)).toBeNull(); + }); +}); + +// ── requirePermission middleware ────────────────────────────────────────────── + +describe("requirePermission", () => { + beforeEach(() => { + vi.stubEnv("FLUID_ADMIN_TOKEN", "static-token"); + vi.stubEnv("FLUID_ADMIN_JWT_SECRET", "test-secret"); + }); + + it("calls next() when role has the required permission", () => { + const token = signAdminJwt({ sub: "u1", email: "a@test.com", role: "ADMIN" }); + const req = makeReq({ "x-admin-jwt": token }); + const { res } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("manage_api_keys")(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it("returns 403 when role lacks the permission", () => { + const token = signAdminJwt({ sub: "u1", email: "a@test.com", role: "READ_ONLY" }); + const req = makeReq({ "x-admin-jwt": token }); + const { res, status } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("manage_api_keys")(req, res, next); + expect(status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 when no auth header provided", () => { + const req = makeReq({}); + const { res, status } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("view_transactions")(req, res, next); + expect(status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("SUPER_ADMIN via static token passes any permission check", () => { + const req = makeReq({ "x-admin-token": "static-token" }); + const { res } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("manage_users")(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it("BILLING role can access manage_billing", () => { + const token = signAdminJwt({ sub: "u2", email: "b@test.com", role: "BILLING" }); + const req = makeReq({ "x-admin-jwt": token }); + const { res } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("manage_billing")(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it("BILLING role cannot access manage_api_keys", () => { + const token = signAdminJwt({ sub: "u2", email: "b@test.com", role: "BILLING" }); + const req = makeReq({ "x-admin-jwt": token }); + const { res, status } = makeRes(); + const next = vi.fn() as NextFunction; + + requirePermission("manage_api_keys")(req, res, next); + expect(status).toHaveBeenCalledWith(403); + }); +}); diff --git a/server/src/utils/adminAuth.ts b/server/src/utils/adminAuth.ts index 0d6522d..de595ce 100644 --- a/server/src/utils/adminAuth.ts +++ b/server/src/utils/adminAuth.ts @@ -1,5 +1,42 @@ -import { Request, Response } from "express"; +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; import { getAuditActor, logAuditEvent } from "../services/auditLogger"; +import { + AdminRole, + Permission, + hasPermission, + isValidRole, +} from "./permissions"; + +export interface AdminJwtPayload { + sub: string; // AdminUser id + email: string; + role: AdminRole; + iat?: number; + exp?: number; +} + +function getJwtSecret(): string { + return process.env.FLUID_ADMIN_JWT_SECRET ?? "dev-admin-jwt-secret"; +} + +// ── JWT helpers ────────────────────────────────────────────────────────────── + +export function signAdminJwt(payload: Omit): string { + return jwt.sign(payload, getJwtSecret(), { expiresIn: "8h" }); +} + +export function verifyAdminJwt(token: string): AdminJwtPayload | null { + try { + const decoded = jwt.verify(token, getJwtSecret()) as AdminJwtPayload; + if (!isValidRole(decoded.role)) return null; + return decoded; + } catch { + return null; + } +} + +// ── Legacy static-token helpers (backward compat) ──────────────────────────── export function isAdminTokenAuthority(req: Request): boolean { const token = req.header("x-admin-token"); @@ -20,3 +57,63 @@ export function requireAdminToken(req: Request, res: Response): boolean { return true; } + +// ── Role resolution ─────────────────────────────────────────────────────────── + +/** + * Resolve the admin role for an incoming request. + * + * Priority: + * 1. x-admin-jwt header → verified JWT → role from payload + * 2. x-admin-token matches FLUID_ADMIN_TOKEN → SUPER_ADMIN (backward compat) + * 3. No valid auth → null + */ +export function resolveAdminRole(req: Request): AdminRole | null { + const jwtHeader = req.header("x-admin-jwt"); + if (jwtHeader) { + // JWT header present — must be valid; do NOT fall through to static token + // so a tampered JWT can't be silently upgraded to SUPER_ADMIN. + const payload = verifyAdminJwt(jwtHeader); + return payload ? payload.role : null; + } + + if (isAdminTokenAuthority(req)) return "SUPER_ADMIN"; + + return null; +} + +// ── requirePermission middleware factory ────────────────────────────────────── + +/** + * Express middleware that enforces a specific permission. + * + * Usage: + * app.post("/admin/api-keys", requirePermission("manage_api_keys"), handler) + */ +export function requirePermission(permission: Permission) { + return (req: Request, res: Response, next: NextFunction): void => { + const role = resolveAdminRole(req); + + if (!role) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + if (!hasPermission(role, permission)) { + res.status(403).json({ + error: "Forbidden", + detail: `Role '${role}' does not have permission '${permission}'`, + }); + return; + } + + void logAuditEvent("ADMIN_LOGIN", getAuditActor(req), { + path: req.path, + method: req.method, + role, + permission, + }); + + next(); + }; +} diff --git a/server/src/utils/permissions.test.ts b/server/src/utils/permissions.test.ts new file mode 100644 index 0000000..39d95b8 --- /dev/null +++ b/server/src/utils/permissions.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { hasPermission, isValidRole, ROLE_PERMISSIONS, ADMIN_ROLES } from "./permissions"; + +describe("hasPermission", () => { + it("SUPER_ADMIN has every permission", () => { + expect(hasPermission("SUPER_ADMIN", "manage_users")).toBe(true); + expect(hasPermission("SUPER_ADMIN", "manage_billing")).toBe(true); + expect(hasPermission("SUPER_ADMIN", "manage_config")).toBe(true); + }); + + it("ADMIN cannot manage users", () => { + expect(hasPermission("ADMIN", "manage_users")).toBe(false); + }); + + it("ADMIN can manage api keys and config", () => { + expect(hasPermission("ADMIN", "manage_api_keys")).toBe(true); + expect(hasPermission("ADMIN", "manage_config")).toBe(true); + }); + + it("ADMIN cannot manage billing", () => { + expect(hasPermission("ADMIN", "manage_billing")).toBe(false); + }); + + it("READ_ONLY can view transactions but not manage them", () => { + expect(hasPermission("READ_ONLY", "view_transactions")).toBe(true); + expect(hasPermission("READ_ONLY", "manage_api_keys")).toBe(false); + expect(hasPermission("READ_ONLY", "manage_config")).toBe(false); + expect(hasPermission("READ_ONLY", "manage_users")).toBe(false); + }); + + it("BILLING can manage billing but not signers or API keys", () => { + expect(hasPermission("BILLING", "manage_billing")).toBe(true); + expect(hasPermission("BILLING", "view_billing")).toBe(true); + expect(hasPermission("BILLING", "manage_api_keys")).toBe(false); + expect(hasPermission("BILLING", "manage_signers")).toBe(false); + expect(hasPermission("BILLING", "view_audit_logs")).toBe(false); + }); +}); + +describe("isValidRole", () => { + it("accepts all defined roles", () => { + for (const role of ADMIN_ROLES) { + expect(isValidRole(role)).toBe(true); + } + }); + + it("rejects unknown roles", () => { + expect(isValidRole("GOD_MODE")).toBe(false); + expect(isValidRole("")).toBe(false); + expect(isValidRole("super_admin")).toBe(false); // case-sensitive + }); +}); + +describe("ROLE_PERMISSIONS coverage", () => { + it("every role has at least one permission", () => { + for (const role of ADMIN_ROLES) { + expect(ROLE_PERMISSIONS[role].size).toBeGreaterThan(0); + } + }); + + it("SUPER_ADMIN permission set is a superset of ADMIN", () => { + const superAdminPerms = ROLE_PERMISSIONS["SUPER_ADMIN"]; + const adminPerms = ROLE_PERMISSIONS["ADMIN"]; + for (const perm of adminPerms) { + expect(superAdminPerms.has(perm)).toBe(true); + } + }); + + it("READ_ONLY permissions are all view_ prefixed", () => { + for (const perm of ROLE_PERMISSIONS["READ_ONLY"]) { + expect(perm.startsWith("view_")).toBe(true); + } + }); +}); diff --git a/server/src/utils/permissions.ts b/server/src/utils/permissions.ts new file mode 100644 index 0000000..7cefd95 --- /dev/null +++ b/server/src/utils/permissions.ts @@ -0,0 +1,88 @@ +/** + * RBAC permission definitions for the admin dashboard. + * + * Roles (least → most privileged): + * READ_ONLY – view-only access across all sections + * BILLING – billing/payment operations + limited read access + * ADMIN – full operational control except user management + * SUPER_ADMIN – unrestricted access including user management + */ + +export const ADMIN_ROLES = ["SUPER_ADMIN", "ADMIN", "READ_ONLY", "BILLING"] as const; +export type AdminRole = (typeof ADMIN_ROLES)[number]; + +export const PERMISSIONS = [ + // Transactions & analytics + "view_transactions", + "view_analytics", + // API keys + "view_api_keys", + "manage_api_keys", + // Tenants & subscription tiers + "view_tenants", + "manage_tenants", + // Signing pool + "view_signers", + "manage_signers", + // Configuration (fee multiplier, rate limits, chains) + "manage_config", + // Audit logs + "view_audit_logs", + // SAR / flagged events + "view_sar", + "manage_sar", + // Billing / payments + "view_billing", + "manage_billing", + // Admin user management (SUPER_ADMIN only) + "manage_users", +] as const; + +export type Permission = (typeof PERMISSIONS)[number]; + +export const ROLE_PERMISSIONS: Record> = { + SUPER_ADMIN: new Set(PERMISSIONS), + + ADMIN: new Set([ + "view_transactions", + "view_analytics", + "view_api_keys", + "manage_api_keys", + "view_tenants", + "manage_tenants", + "view_signers", + "manage_signers", + "manage_config", + "view_audit_logs", + "view_sar", + "manage_sar", + "view_billing", + ]), + + READ_ONLY: new Set([ + "view_transactions", + "view_analytics", + "view_api_keys", + "view_tenants", + "view_signers", + "view_audit_logs", + "view_sar", + "view_billing", + ]), + + BILLING: new Set([ + "view_transactions", + "view_analytics", + "view_tenants", + "view_billing", + "manage_billing", + ]), +}; + +export function hasPermission(role: AdminRole, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.has(permission) ?? false; +} + +export function isValidRole(role: string): role is AdminRole { + return (ADMIN_ROLES as readonly string[]).includes(role); +}