Skip to content
Merged
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
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ VAULT_CONTRACT_ID=
DATABASE_URL=
DATABASE_REPLICA_URL=
DATABASE_POOL_SIZE=10
PRISMA_POOL_MAX=10
PRISMA_POOL_TIMEOUT_MS=10000
PRISMA_QUERY_TIMEOUT_MS=5000

# Admin audit log persistence mode: memory | prisma | hybrid
ADMIN_AUDIT_LOG_STORAGE=hybrid

# Prisma runtime connection settings
PRISMA_POOL_SIZE=10
Expand Down
26 changes: 26 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Express.js backend server for YieldVault Stellar RWA platform with rate limiting
- **Readiness Endpoint** (`/ready`) - Dependency status for deployment orchestration
- **Rate Limiting** - Per-IP and per-API-key rate limiting to prevent abuse
- **Dependency Monitoring** - Checks for cache and Stellar RPC availability
- **Admin Audit Logs** - Tracks privileged admin actions via `/admin/audit-logs`
- **Background Job Dashboard** - Monitoring views at `/admin/jobs/dashboard` and `/admin/jobs/dashboard/view`
- **Prisma Runtime Tuning** - Configurable pooling and query timeouts
- **Error Handling** - Consistent JSON error responses
- **TypeScript** - Full type safety with TypeScript

Expand Down Expand Up @@ -55,6 +58,10 @@ Rate limiting and other settings are configurable via environment variables:
| `API_RATE_LIMIT_WINDOW_MS` | 60000 | API rate limit window (1 min) |
| `API_RATE_LIMIT_MAX_REQUESTS` | 30 | API requests per window |
| `STELLAR_RPC_URL` | https://soroban-testnet.stellar.org | Stellar RPC endpoint |
| `PRISMA_POOL_MAX` | 10 | Prisma connection pool max size |
| `PRISMA_POOL_TIMEOUT_MS` | 10000 | Prisma pool wait timeout in ms |
| `PRISMA_QUERY_TIMEOUT_MS` | 5000 | Max Prisma query time in ms |
| `ADMIN_AUDIT_LOG_STORAGE` | hybrid | Audit log storage mode (`memory`, `prisma`, `hybrid`) |

## API Endpoints

Expand Down Expand Up @@ -101,6 +108,25 @@ Returns service readiness state. Checks all critical dependencies before reporti
}
```

### Admin Audit Logs

```
GET /admin/audit-logs
Authorization: ApiKey <admin-key>
```

Returns recent admin activities with optional filters: `action`, `actor`, `statusCode`, and `limit`.

### Background Job Dashboard

```
GET /admin/jobs/dashboard
GET /admin/jobs/dashboard/view
Authorization: ApiKey <admin-key>
```

Exposes dead-letter metrics, recurring failures, job runtime telemetry, and health status.

**Response (503 Unavailable - Not Ready):**
```json
{
Expand Down
18 changes: 18 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,21 @@ model Transaction {
type String
timestamp DateTime @default(now())
}

model AdminAuditLog {
id String @id
action String
method String
path String
statusCode Int
actor String
apiKeyHash String
ipAddress String
userAgent String
metadata String
createdAt DateTime @default(now())

@@index([createdAt])
@@index([action])
@@index([actor])
}
55 changes: 52 additions & 3 deletions backend/src/__tests__/governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import request from 'supertest';
import app from '../index';
import { idempotencyStore } from '../idempotency';
import { getJobMetrics, resetJobGovernance, runJobWithRetry } from '../jobGovernance';
import { clearAdminAuditLogsForTests } from '../adminAudit';
import { registerApiKey } from '../middleware/apiKeyAuth';

describe('Backend governance', () => {
const adminApiKey = 'admin-test-key';

beforeEach(() => {
idempotencyStore.clear();
resetJobGovernance();
clearAdminAuditLogsForTests();
process.env.ADMIN_AUDIT_LOG_STORAGE = 'memory';
registerApiKey(adminApiKey);
});

it('redirects unversioned API routes to v1', async () => {
it('marks unversioned summary route as deprecated while preserving compatibility', async () => {
const response = await request(app).get('/api/vault/summary');

expect(response.status).toBe(308);
expect(response.headers.location).toBe('/api/v1/vault/summary');
expect([200, 429]).toContain(response.status);
expect(response.headers.deprecation).toBe('true');
});

Expand Down Expand Up @@ -92,4 +98,47 @@ describe('Backend governance', () => {
attempts: 3,
});
});

it('exposes a background jobs monitoring dashboard for admins', async () => {
await expect(
runJobWithRetry(
'priceRefresh',
async () => {
throw new Error('job failed');
},
{
payload: { source: 'test-suite' },
sleep: async () => undefined,
}
)
).rejects.toThrow('job failed');

const response = await request(app)
.get('/admin/jobs/metrics')
.set('Authorization', `ApiKey ${adminApiKey}`);

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('summary');
expect(response.body).toHaveProperty('metrics');
expect(response.body).toHaveProperty('prisma');
expect(response.body.metrics.totalDeadLetters).toBeGreaterThanOrEqual(1);
});

it('returns admin audit logs for authenticated admins', async () => {
await request(app)
.get('/admin/cache/stats')
.set('Authorization', `ApiKey ${adminApiKey}`)
.set('x-admin-id', 'ops-user-1');

const response = await request(app)
.get('/admin/audit-logs')
.set('Authorization', `ApiKey ${adminApiKey}`);

expect(response.status).toBe(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
expect(response.body.data[0]).toHaveProperty('action');
expect(response.body.data[0]).toHaveProperty('method');
expect(response.body.data[0]).toHaveProperty('statusCode');
});
});
179 changes: 179 additions & 0 deletions backend/src/adminAudit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { Request } from 'express';
import { prisma } from './prisma';
import { resetAuditLogs } from './auditLog';

type AuditStorageMode = 'memory' | 'prisma' | 'hybrid';

export interface AdminAuditLogRecord {
id: string;
action: string;
method: string;
path: string;
statusCode: number;
actor: string;
apiKeyHash: string;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
createdAt: string;
}

export interface AuditLogFilters {
action?: string;
actor?: string;
statusCode?: number;
limit: number;
}

const inMemoryLogs: AdminAuditLogRecord[] = [];

function normalizeStorageMode(raw: string | undefined): AuditStorageMode {
if (raw === 'prisma' || raw === 'memory' || raw === 'hybrid') {
return raw;
}
return 'hybrid';
}

export async function recordAdminAuditLog(
req: Request,
action: string,
statusCode: number,
metadata: Record<string, unknown> = {},
): Promise<void> {
const storageMode = normalizeStorageMode(process.env.ADMIN_AUDIT_LOG_STORAGE);
const entry: AdminAuditLogRecord = {
id: createLogId(),
action,
method: req.method,
path: req.path,
statusCode,
actor: resolveActor(req),
apiKeyHash: req.authApiKeyHash || 'unknown',
ipAddress: req.ip || 'unknown',
userAgent: req.get('user-agent') || 'unknown',
metadata,
createdAt: new Date().toISOString(),
};

if (storageMode === 'memory') {
addInMemory(entry);
return;
}

try {
await prisma.adminAuditLog.create({
data: {
id: entry.id,
action: entry.action,
method: entry.method,
path: entry.path,
statusCode: entry.statusCode,
actor: entry.actor,
apiKeyHash: entry.apiKeyHash,
ipAddress: entry.ipAddress,
userAgent: entry.userAgent,
metadata: JSON.stringify(entry.metadata),
},
});
} catch {
if (storageMode === 'prisma') {
throw new Error('Failed to persist admin audit log to Prisma storage');
}

addInMemory(entry);
}
}

export async function listAdminAuditLogs(filters: AuditLogFilters): Promise<AdminAuditLogRecord[]> {
const storageMode = normalizeStorageMode(process.env.ADMIN_AUDIT_LOG_STORAGE);
if (storageMode === 'memory') {
return listFromMemory(filters);
}

try {
const rows = await prisma.adminAuditLog.findMany({
where: {
...(filters.action ? { action: filters.action } : {}),
...(filters.actor ? { actor: filters.actor } : {}),
...(typeof filters.statusCode === 'number' ? { statusCode: filters.statusCode } : {}),
},
orderBy: {
createdAt: 'desc',
},
take: filters.limit,
});

return rows.map((row) => ({
id: row.id,
action: row.action,
method: row.method,
path: row.path,
statusCode: row.statusCode,
actor: row.actor,
apiKeyHash: row.apiKeyHash,
ipAddress: row.ipAddress,
userAgent: row.userAgent,
metadata: safeParseMetadata(row.metadata),
createdAt: row.createdAt.toISOString(),
}));
} catch {
if (storageMode === 'prisma') {
throw new Error('Failed to read admin audit logs from Prisma storage');
}
return listFromMemory(filters);
}
}

function addInMemory(entry: AdminAuditLogRecord): void {
inMemoryLogs.unshift(entry);
if (inMemoryLogs.length > 1000) {
inMemoryLogs.length = 1000;
}
}

function listFromMemory(filters: AuditLogFilters): AdminAuditLogRecord[] {
return inMemoryLogs
.filter((row) => {
if (filters.action && row.action !== filters.action) {
return false;
}
if (filters.actor && row.actor !== filters.actor) {
return false;
}
if (typeof filters.statusCode === 'number' && row.statusCode !== filters.statusCode) {
return false;
}
return true;
})
.slice(0, filters.limit);
}

function createLogId(): string {
return `audit_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}

function resolveActor(req: Request): string {
return (
req.get('x-admin-id') ||
req.get('x-admin-email') ||
req.get('x-wallet-address') ||
'unknown'
);
}

function safeParseMetadata(raw: string): Record<string, unknown> {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
return {};
} catch {
return {};
}
}

export function clearAdminAuditLogsForTests(): void {
inMemoryLogs.length = 0;
resetAuditLogs();
}
Loading
Loading