From 90b5ee4fd4f227b2ed9f65da7328ccec7896a93b Mon Sep 17 00:00:00 2001 From: UmehMichael495 Date: Mon, 24 Aug 2026 23:31:49 +0000 Subject: [PATCH 1/2] #488 Implement role-based access control (RBAC) for the admin dashboard FIX --- .../migration.sql | 26 +++ stellar-payment-platform/prisma/schema.prisma | 23 +++ stellar-payment-platform/prismaClient.js | 12 ++ .../src/middleware/rbac.js | 147 +++++++++++++++ .../src/routes/v1/index.js | 2 + .../tests/middleware/rbac.test.js | 168 ++++++++++++++++++ 6 files changed, 378 insertions(+) create mode 100644 stellar-payment-platform/prisma/migrations/20260825000000_add_admin_rbac/migration.sql create mode 100644 stellar-payment-platform/src/middleware/rbac.js create mode 100644 stellar-payment-platform/tests/middleware/rbac.test.js diff --git a/stellar-payment-platform/prisma/migrations/20260825000000_add_admin_rbac/migration.sql b/stellar-payment-platform/prisma/migrations/20260825000000_add_admin_rbac/migration.sql new file mode 100644 index 00000000..e132bf9c --- /dev/null +++ b/stellar-payment-platform/prisma/migrations/20260825000000_add_admin_rbac/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "AdminRole" AS ENUM ('SuperAdmin', 'Viewer', 'Support'); + +-- CreateTable +CREATE TABLE "admins" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "api_key" TEXT NOT NULL, + "role" "AdminRole" NOT NULL DEFAULT 'Viewer', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "admins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "admins_email_key" ON "admins"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "admins_api_key_key" ON "admins"("api_key"); + +-- CreateIndex +CREATE INDEX "admins_api_key_idx" ON "admins"("api_key"); + +-- CreateIndex +CREATE INDEX "admins_role_idx" ON "admins"("role"); diff --git a/stellar-payment-platform/prisma/schema.prisma b/stellar-payment-platform/prisma/schema.prisma index a74b306a..b42f5f14 100644 --- a/stellar-payment-platform/prisma/schema.prisma +++ b/stellar-payment-platform/prisma/schema.prisma @@ -14,6 +14,14 @@ generator client { previewFeatures = ["metrics"] } +// Granular admin dashboard roles. SuperAdmin has full access; Viewer is +// read-only; Support can view and assist but cannot mutate data. +enum AdminRole { + SuperAdmin + Viewer + Support +} + // Federation registry mapping a human-readable username (e.g. "lekan*localhost") // to its Stellar address. Maps to the legacy "username_registry" table so the // data shape is preserved across the SQLite -> PostgreSQL migration. @@ -49,3 +57,18 @@ model Webhook { @@index([lastSentAt]) @@map("webhooks") } + +// Platform operators for the admin dashboard. Authenticated via API key; +// authorization is enforced by RBAC middleware using `role`. +model Admin { + id String @id @default(uuid()) + email String @unique + apiKey String @unique @map("api_key") + role AdminRole @default(Viewer) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([apiKey]) + @@index([role]) + @@map("admins") +} diff --git a/stellar-payment-platform/prismaClient.js b/stellar-payment-platform/prismaClient.js index ed6b2115..6d40b03d 100644 --- a/stellar-payment-platform/prismaClient.js +++ b/stellar-payment-platform/prismaClient.js @@ -33,6 +33,18 @@ try { create: async () => ({}), count: async () => 0, }, + admin: { + update: async () => { + const e = new Error('P2025: mock - record not found'); + e.code = 'P2025'; + throw e; + }, + findUnique: async () => null, + findFirst: async () => null, + findMany: async () => [], + create: async () => ({}), + count: async () => 0, + }, $transaction: async (queries) => Promise.all(queries), $queryRaw: async () => [], }; diff --git a/stellar-payment-platform/src/middleware/rbac.js b/stellar-payment-platform/src/middleware/rbac.js new file mode 100644 index 00000000..1f078b89 --- /dev/null +++ b/stellar-payment-platform/src/middleware/rbac.js @@ -0,0 +1,147 @@ +'use strict'; + +const { ApiError } = require('../errors'); + +const ROLES = Object.freeze({ + SUPER_ADMIN: 'SuperAdmin', + VIEWER: 'Viewer', + SUPPORT: 'Support', +}); + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Permissions granted to each role. + * - SuperAdmin: full access (read + mutate) + * - Viewer: read-only + * - Support: read-only (cannot perform mutating actions) + */ +const ROLE_PERMISSIONS = Object.freeze({ + [ROLES.SUPER_ADMIN]: Object.freeze({ read: true, write: true }), + [ROLES.VIEWER]: Object.freeze({ read: true, write: false }), + [ROLES.SUPPORT]: Object.freeze({ read: true, write: false }), +}); + +function normalizeRole(role) { + if (!role || typeof role !== 'string') return null; + const trimmed = role.trim(); + const match = Object.values(ROLES).find((r) => r.toLowerCase() === trimmed.toLowerCase()); + return match || null; +} + +function canWrite(role) { + const normalized = normalizeRole(role); + return Boolean(normalized && ROLE_PERMISSIONS[normalized]?.write); +} + +function canRead(role) { + const normalized = normalizeRole(role); + return Boolean(normalized && ROLE_PERMISSIONS[normalized]?.read); +} + +/** + * Resolves the acting admin from the request. + * Prefers an already-attached `req.admin`. Falls back to looking up + * `x-api-key` / `api_key` against the Admin table, then to the legacy + * ADMIN_API_KEY env var (treated as SuperAdmin for backwards compatibility). + */ +async function resolveAdmin(req) { + if (req.admin && req.admin.role) { + return req.admin; + } + + const apiKey = req.headers['x-api-key'] || req.query.api_key; + if (!apiKey || typeof apiKey !== 'string') { + return null; + } + + try { + const { prisma } = require('../../prismaClient'); + if (prisma.admin && typeof prisma.admin.findUnique === 'function') { + const admin = await prisma.admin.findUnique({ where: { apiKey } }); + if (admin) return admin; + } + } catch { + // Prisma may be unavailable in some test setups; fall through to env key. + } + + if (process.env.ADMIN_API_KEY && apiKey === process.env.ADMIN_API_KEY) { + return { + id: 'env-admin', + email: 'admin@local', + apiKey, + role: ROLES.SUPER_ADMIN, + }; + } + + return null; +} + +/** + * Express middleware factory. Checks the admin's role against the required + * permission for the current route. + * + * @param {{ permission?: 'read'|'write', roles?: string[] }} [options] + */ +function requireRole(options = {}) { + const requiredPermission = options.permission; + const allowedRoles = Array.isArray(options.roles) + ? options.roles.map(normalizeRole).filter(Boolean) + : null; + + return async function rbacMiddleware(req, res, next) { + try { + const admin = await resolveAdmin(req); + if (!admin) { + return next(new ApiError('UNAUTHENTICATED', 'Unauthorized: Invalid or missing API key')); + } + + const role = normalizeRole(admin.role); + if (!role) { + return next(new ApiError('FORBIDDEN', 'Admin role is not recognized')); + } + + req.admin = { ...admin, role }; + + if (allowedRoles && !allowedRoles.includes(role)) { + return next(new ApiError('FORBIDDEN', `Role ${role} is not permitted for this resource`)); + } + + const method = (req.method || 'GET').toUpperCase(); + const needsWrite = requiredPermission === 'write' || (!requiredPermission && MUTATING_METHODS.has(method)); + + if (needsWrite && !canWrite(role)) { + return next( + new ApiError('FORBIDDEN', `Role ${role} cannot perform mutating actions`), + ); + } + + if (requiredPermission === 'read' && !canRead(role)) { + return next(new ApiError('FORBIDDEN', `Role ${role} cannot access this resource`)); + } + + return next(); + } catch (err) { + return next(err); + } + }; +} + +/** + * Convenience middleware: Support (and Viewer) cannot POST/PUT/PATCH/DELETE. + * SuperAdmin is allowed. Apply after authentication so `req.admin` is set, + * or use alone — it will resolve the admin itself. + */ +const denySupportMutations = requireRole(); + +module.exports = { + ROLES, + ROLE_PERMISSIONS, + MUTATING_METHODS, + normalizeRole, + canWrite, + canRead, + resolveAdmin, + requireRole, + denySupportMutations, +}; diff --git a/stellar-payment-platform/src/routes/v1/index.js b/stellar-payment-platform/src/routes/v1/index.js index 5ed1f75b..49e89102 100644 --- a/stellar-payment-platform/src/routes/v1/index.js +++ b/stellar-payment-platform/src/routes/v1/index.js @@ -9,6 +9,7 @@ const exportRoutes = require('./exportRoutes'); module.exports = (redisClient) => { const router = express.Router(); const federationRoutes = require('./federationRoutes')(redisClient); + const adminRoutes = require('./adminRoutes')(redisClient); router.use('/', userRoutes); router.use('/', federationRoutes); @@ -16,6 +17,7 @@ module.exports = (redisClient) => { router.use('/', historyRoutes); router.use('/', exportRoutes); router.use('/', statsRoutes); + router.use('/', adminRoutes); return router; }; diff --git a/stellar-payment-platform/tests/middleware/rbac.test.js b/stellar-payment-platform/tests/middleware/rbac.test.js new file mode 100644 index 00000000..b6dd76f0 --- /dev/null +++ b/stellar-payment-platform/tests/middleware/rbac.test.js @@ -0,0 +1,168 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); + +const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; +jest.mock('../../src/logger', () => ({ logger: mockLogger })); + +const { + ROLES, + normalizeRole, + canWrite, + canRead, + requireRole, +} = require('../../src/middleware/rbac'); +const { buildErrorHandler, notFoundHandler } = require('../../src/middleware/errorHandler'); + +const mockAdminStore = new Map(); + +jest.mock('../../prismaClient', () => ({ + prisma: { + admin: { + findUnique: async ({ where }) => mockAdminStore.get(where.apiKey) || null, + update: async ({ where, data }) => { + for (const admin of mockAdminStore.values()) { + if (admin.id === where.id) { + Object.assign(admin, data); + return admin; + } + } + const e = new Error('not found'); + e.code = 'P2025'; + throw e; + }, + }, + user: { + update: async ({ where, data }) => { + if (where.address === 'GVALID') { + return { + username: 'alice', + address: where.address, + flaggedAt: data.flaggedAt, + }; + } + const e = new Error('not found'); + e.code = 'P2025'; + throw e; + }, + }, + }, + isPrismaConnectionError: () => false, +})); + +function seedAdmin({ apiKey, role, email = `${role.toLowerCase()}@example.com`, id = role }) { + mockAdminStore.set(apiKey, { id, email, apiKey, role }); +} + +function buildApp() { + const app = express(); + app.use((req, res, next) => { + req.correlationId = 'test-correlation-id'; + next(); + }); + app.use(express.json()); + const adminRoutes = require('../../src/routes/v1/adminRoutes')(null); + app.use('/v1', adminRoutes); + app.use(notFoundHandler); + app.use(buildErrorHandler(() => false)); + return app; +} + +describe('RBAC helpers', () => { + test('normalizes known roles case-insensitively', () => { + expect(normalizeRole('superadmin')).toBe(ROLES.SUPER_ADMIN); + expect(normalizeRole('Viewer')).toBe(ROLES.VIEWER); + expect(normalizeRole('SUPPORT')).toBe(ROLES.SUPPORT); + expect(normalizeRole('hacker')).toBeNull(); + }); + + test('Support and Viewer cannot write; SuperAdmin can', () => { + expect(canWrite(ROLES.SUPPORT)).toBe(false); + expect(canWrite(ROLES.VIEWER)).toBe(false); + expect(canWrite(ROLES.SUPER_ADMIN)).toBe(true); + expect(canRead(ROLES.SUPPORT)).toBe(true); + expect(canRead(ROLES.VIEWER)).toBe(true); + }); +}); + +describe('RBAC middleware on admin routes', () => { + let app; + + beforeEach(() => { + mockAdminStore.clear(); + delete process.env.ADMIN_API_KEY; + seedAdmin({ apiKey: 'super-key', role: ROLES.SUPER_ADMIN }); + seedAdmin({ apiKey: 'viewer-key', role: ROLES.VIEWER }); + seedAdmin({ apiKey: 'support-key', role: ROLES.SUPPORT }); + app = buildApp(); + }); + + test('rejects missing API key', async () => { + const res = await request(app).get('/v1/admin/me'); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHENTICATED'); + }); + + test('rejects unknown API key', async () => { + const res = await request(app).get('/v1/admin/me').set('x-api-key', 'nope'); + expect(res.status).toBe(401); + }); + + test('Support can read /admin/me', async () => { + const res = await request(app).get('/v1/admin/me').set('x-api-key', 'support-key'); + expect(res.status).toBe(200); + expect(res.body.role).toBe(ROLES.SUPPORT); + }); + + test('Support cannot POST mutating admin actions', async () => { + const res = await request(app) + .post('/v1/admin/block') + .set('x-api-key', 'support-key') + .send({ address: 'GVALID' }); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('FORBIDDEN'); + expect(res.body.error.message).toMatch(/mutating/i); + }); + + test('Viewer cannot PUT', async () => { + const res = await request(app) + .put('/v1/admin/admins/Support/role') + .set('x-api-key', 'viewer-key') + .send({ role: 'Viewer' }); + expect(res.status).toBe(403); + }); + + test('SuperAdmin can POST /admin/block', async () => { + const res = await request(app) + .post('/v1/admin/block') + .set('x-api-key', 'super-key') + .send({ address: 'GVALID' }); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Address successfully blocked'); + }); + + test('legacy ADMIN_API_KEY is treated as SuperAdmin', async () => { + process.env.ADMIN_API_KEY = 'legacy-secret'; + const res = await request(app) + .post('/v1/admin/block') + .set('x-api-key', 'legacy-secret') + .send({ address: 'GVALID' }); + expect(res.status).toBe(200); + }); + + test('requireRole can restrict to SuperAdmin only', async () => { + const mini = express(); + mini.use(express.json()); + mini.delete('/secret', requireRole({ roles: [ROLES.SUPER_ADMIN] }), (req, res) => { + res.json({ ok: true }); + }); + mini.use(buildErrorHandler(() => false)); + + const denied = await request(mini).delete('/secret').set('x-api-key', 'support-key'); + expect(denied.status).toBe(403); + + const allowed = await request(mini).delete('/secret').set('x-api-key', 'super-key'); + expect(allowed.status).toBe(200); + }); +}); From 426eeaf04fbc6f34a60d9b52fda9a180d9f8f747 Mon Sep 17 00:00:00 2001 From: UmehMichael495 Date: Mon, 24 Aug 2026 23:41:25 +0000 Subject: [PATCH 2/2] #488 Implement role-based access control (RBAC) for the admin dashboard FIX --- .../src/routes/v1/adminRoutes.js | 126 +++++++++++------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/stellar-payment-platform/src/routes/v1/adminRoutes.js b/stellar-payment-platform/src/routes/v1/adminRoutes.js index 43782fda..1075b20b 100644 --- a/stellar-payment-platform/src/routes/v1/adminRoutes.js +++ b/stellar-payment-platform/src/routes/v1/adminRoutes.js @@ -1,6 +1,8 @@ const express = require('express'); const { invalidateFederationCache } = require('../../federationCache'); const { asyncHandler } = require('../../middleware/asyncHandler'); +const { requireRole, ROLES } = require('../../middleware/rbac'); +const { ApiError } = require('../../errors'); module.exports = (redisClient) => { const router = express.Router(); @@ -9,65 +11,87 @@ module.exports = (redisClient) => { return require('../../../prismaClient').prisma; }; -const { invalidateFederationCache } = require('../../cache'); + // Authenticate + authorize. Support/Viewer cannot mutate. + const adminRbac = requireRole(); -const adminAuth = (req, res, next) => { - const apiKey = req.headers['x-api-key'] || req.query.api_key; - if (!apiKey || apiKey !== process.env.ADMIN_API_KEY) { - return res.status(401).json({ error: 'Unauthorized: Invalid or missing API key' }); - } - next(); -}; + router.get( + '/admin/me', + adminRbac, + asyncHandler(async (req, res) => { + return res.status(200).json({ + id: req.admin.id, + email: req.admin.email, + role: req.admin.role, + }); + }), + ); -router.post('/admin/block', adminAuth, asyncHandler(async (req, res, next) => { - const prisma = getPrisma(); - const { address } = req.body; - - if (!address || typeof address !== 'string') { - return res.status(400).json({ error: 'Missing or invalid address' }); - } + router.post( + '/admin/block', + adminRbac, + asyncHandler(async (req, res, next) => { + const prisma = getPrisma(); + const { address } = req.body; - try { - const updatedUser = await prisma.user.update({ - where: { address }, - data: { flaggedAt: new Date() }, - }); + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing or invalid address' }); + } - // Evict federation cache so blocked users are not served from cache - invalidateFederationCache(updatedUser.username, updatedUser.address); - - return res.status(200).json({ - message: 'Address successfully blocked', - username: updatedUser.username, - address: updatedUser.address, - flaggedAt: updatedUser.flaggedAt, - }); - } catch (error) { - if (error.code === 'P2025') { - return res.status(404).json({ error: 'Address not found' }); - } + try { + const updatedUser = await prisma.user.update({ + where: { address }, + data: { flaggedAt: new Date() }, + }); - try { - const updatedUser = await prisma.user.update({ - where: { address }, - data: { flaggedAt: new Date() }, - }); + await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username); - await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username); + return res.status(200).json({ + message: 'Address successfully blocked', + username: updatedUser.username, + address: updatedUser.address, + flaggedAt: updatedUser.flaggedAt, + }); + } catch (error) { + if (error.code === 'P2025') { + return res.status(404).json({ error: 'Address not found' }); + } + return next(error); + } + }), + ); - return res.status(200).json({ - message: 'Address successfully blocked', - username: updatedUser.username, - address: updatedUser.address, - flaggedAt: updatedUser.flaggedAt, - }); - } catch (error) { - if (error.code === 'P2025') { - return res.status(404).json({ error: 'Address not found' }); + router.put( + '/admin/admins/:id/role', + requireRole({ roles: [ROLES.SUPER_ADMIN], permission: 'write' }), + asyncHandler(async (req, res, next) => { + const prisma = getPrisma(); + const { role } = req.body || {}; + const { normalizeRole } = require('../../middleware/rbac'); + const nextRole = normalizeRole(role); + if (!nextRole) { + return next(new ApiError('INVALID_INPUT', 'Invalid role')); + } + if (!prisma.admin || typeof prisma.admin.update !== 'function') { + return next(new ApiError('SERVICE_UNAVAILABLE', 'Admin store is not available')); + } + try { + const updated = await prisma.admin.update({ + where: { id: req.params.id }, + data: { role: nextRole }, + }); + return res.status(200).json({ + id: updated.id, + email: updated.email, + role: updated.role, + }); + } catch (error) { + if (error.code === 'P2025') { + return next(new ApiError('NOT_FOUND', 'Admin not found')); + } + return next(error); } - return next(error); - } - })); + }), + ); return router; };