diff --git a/payment_router/Cargo.lock b/payment_router/Cargo.lock index 454db545..1af4f078 100644 --- a/payment_router/Cargo.lock +++ b/payment_router/Cargo.lock @@ -86,6 +86,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -186,7 +207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -355,7 +376,7 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "zeroize", @@ -380,7 +401,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -392,6 +413,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -402,13 +433,19 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" name = "ethnum" version = "1.5.0" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -478,6 +515,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gimli" version = "0.28.1" @@ -491,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -650,6 +710,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" @@ -745,6 +811,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "derive_arbitrary", + "proptest", "soroban-sdk", ] @@ -804,6 +871,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.33" @@ -813,6 +905,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -820,8 +924,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -831,7 +945,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -840,9 +964,33 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.11", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -868,12 +1016,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -995,7 +1168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1059,15 +1232,15 @@ dependencies = [ "backtrace", "curve25519-dalek", "ed25519-dalek", - "getrandom", + "getrandom 0.2.11", "hex-literal", "hmac", "k256", "num-derive", "num-integer", "num-traits", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1116,7 +1289,7 @@ dependencies = [ "bytes-lit", "ctor", "ed25519-dalek", - "rand", + "rand 0.8.5", "serde", "serde_json", "soroban-env-guest", @@ -1259,6 +1432,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1316,6 +1502,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1328,12 +1520,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1474,6 +1684,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.7.35" 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 4c9190ca..665c7786 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. @@ -65,6 +73,21 @@ model Webhook { @@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") +} + // Dead-letter queue: holds webhook delivery attempts that have permanently // failed (exhausted all retries). Admin endpoints can list and manually // replay individual entries so no critical event is lost. @@ -151,4 +174,3 @@ model AuditLog { @@index([action]) @@map("audit_logs") } - diff --git a/stellar-payment-platform/prismaClient.js b/stellar-payment-platform/prismaClient.js index f5ebef0d..7592f520 100644 --- a/stellar-payment-platform/prismaClient.js +++ b/stellar-payment-platform/prismaClient.js @@ -33,6 +33,13 @@ try { create: async () => ({}), count: async () => 0, }, + admin: { + update: async () => { + const e = new Error('P2025: mock - record not found'); + e.code = 'P2025'; + throw e; + }, + }, webhookDLQ: { findMany: async () => [], findUnique: async () => null, diff --git a/stellar-payment-platform/resolve_admin.js b/stellar-payment-platform/resolve_admin.js new file mode 100644 index 00000000..f292a999 --- /dev/null +++ b/stellar-payment-platform/resolve_admin.js @@ -0,0 +1,52 @@ +const fs = require('fs'); + +const path = 'stellar-payment-platform/src/routes/v1/adminRoutes.js'; +let c = fs.readFileSync(path, 'utf8'); + +// The conflict starts with: +// <<<<<<< HEAD +// // Authenticate + authorize. Support/Viewer cannot mutate. +// const adminRbac = requireRole(); +// ======= + +// And ends with: +// await invalidateStatsCache(redisClient); +// >>>>>>> origin/main + +let match = c.match(/<<<<<<< HEAD\r?\n\s*\/\/ Authenticate \+ authorize.*?>>>>>>> origin\/main\r?\n/s); +if (match) { + let block = match[0]; + let mainCode = block.split(/=======\r?\n/)[1].replace(/>>>>>>> origin\/main\r?\n/, ''); + + // In mainCode, we have adminAuth definition, /admin/export, and /admin/block (partial). + // We want to KEEP /admin/export (and change adminAuth to adminRbac). + // We want to DELETE adminAuth definition. + // We want to DELETE /admin/block from mainCode. + + // Find where /admin/block starts in mainCode + let exportRoute = mainCode.split(/router\.post\('\/admin\/block', adminAuth/)[0]; + + // Replace adminAuth with adminRbac in exportRoute + exportRoute = exportRoute.replace(/adminAuth/g, 'adminRbac'); + // Also remove the adminAuth definition from the top of mainCode + exportRoute = exportRoute.replace(/\s*const adminAuth = \(req, res, next\) => \{[\s\S]*?next\(\);\r?\n\s*\};\r?\n/, ''); + + let resolvedBlock = ` // Authenticate + authorize. Support/Viewer cannot mutate. + const adminRbac = requireRole(); +` + exportRoute; + + c = c.replace(match[0], resolvedBlock); +} + +// Now we need to add invalidateStatsCache to the HEAD's /admin/block +// Look for: +// await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username); +c = c.replace( + /await invalidateFederationCache\(redisClient, updatedUser\.address, updatedUser\.username\);/g, + `await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username);\n await invalidateStatsCache(redisClient);` +); + +// We also have trailing whitespace conflicts from git diff --check: +c = c.replace(/[ \t]+(\r?\n)/g, '$1'); + +fs.writeFileSync(path, c); 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/adminRoutes.js b/stellar-payment-platform/src/routes/v1/adminRoutes.js index 50ceabd2..807c5259 100644 --- a/stellar-payment-platform/src/routes/v1/adminRoutes.js +++ b/stellar-payment-platform/src/routes/v1/adminRoutes.js @@ -4,6 +4,8 @@ const express = require('express'); const { invalidateFederationCache } = require('../../federationCache'); const { invalidateStatsCache } = require('../../cache/statsCache'); const { asyncHandler } = require('../../middleware/asyncHandler'); +const { requireRole, ROLES } = require('../../middleware/rbac'); +const { ApiError } = require('../../errors'); const { auditLogMiddleware } = require('../../middleware/auditLog'); const { idempotencyMiddleware } = require('../../../middleware/idempotency'); const { logger } = require('../../logger'); @@ -15,7 +17,7 @@ const { cursorPaginatedResponse, keysetWhereDesc } = require('../../pagination'); -const { listDLQEntries, replayFromDLQ } = require('../../webhookWorker'); + // PAGE_SIZE for the admin export cursor-based pagination const EXPORT_PAGE_SIZE = 500; @@ -34,19 +36,14 @@ module.exports = (redisClient) => { return require('../../../prismaClient').prisma; }; - 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(); - }; + // Authenticate + authorize. Support/Viewer cannot mutate. + const adminRbac = requireRole(); // ── GET /admin/export ────────────────────────────────────────────────────── // Streams all payment records as CSV (default) or NDJSON. // Supports optional startDate / endDate query params for filtering. // Paginates internally using cursor-based pages so memory stays bounded. - router.get('/admin/export', adminAuth, asyncHandler(async (req, res, next) => { + router.get('/admin/export', adminRbac, asyncHandler(async (req, res, next) => { const { format = 'csv', startDate, endDate } = req.query; // Validate date range when provided @@ -128,36 +125,85 @@ module.exports = (redisClient) => { } })); - router.post('/admin/block', adminAuth, asyncHandler(async (req, res, next) => { - const prisma = getPrisma(); - const { address } = req.body; + 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, + }); + }), + ); - 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' }); + } - await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username); - await invalidateStatsCache(redisClient); + try { + const updatedUser = await prisma.user.update({ + where: { address }, + data: { flaggedAt: new Date() }, + }); - 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' }); + await invalidateFederationCache(redisClient, updatedUser.address, updatedUser.username); + await invalidateStatsCache(redisClient); + + 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 next(error); - } - })); + }), + ); + + 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); + } + }), + ); // ── Dead Letter Queue (DLQ) ──────────────────────────────────────────── @@ -301,7 +347,6 @@ module.exports = (redisClient) => { } else { // Offset pagination const { page: parsedPage, limit, skip } = parsePagination(req.query); - const [totalCount, users] = await prisma.$transaction([ prisma.user.count({ where }), prisma.user.findMany({ @@ -317,7 +362,6 @@ module.exports = (redisClient) => { } }) ]); - return res.status(200).json(paginatedResponse(users, totalCount, { page: parsedPage, limit })); } })); 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); + }); +});