Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 239 additions & 14 deletions payment_router/Cargo.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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");
24 changes: 23 additions & 1 deletion stellar-payment-platform/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -151,4 +174,3 @@ model AuditLog {
@@index([action])
@@map("audit_logs")
}

7 changes: 7 additions & 0 deletions stellar-payment-platform/prismaClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions stellar-payment-platform/resolve_admin.js
Original file line number Diff line number Diff line change
@@ -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);
147 changes: 147 additions & 0 deletions stellar-payment-platform/src/middleware/rbac.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading