Skip to content

Commit f67341e

Browse files
Anubhav SinghAnubhav Singh
authored andcommitted
feat: implement four OSS issues - idempotency failover, health summary, admin audit log, bulk import UX
- Add DB-backed idempotency store failover when Redis is unavailable with logging - Add /health/summary endpoint aggregating DB, Redis, Soroban RPC, Reflector health - Add admin_audit_log table with query endpoint and audit logging in admin routes - Add per-row validation, inline editing, and fix-and-retry in BulkPortfolioImport - Add 15 new tests covering all four features
1 parent 05bce47 commit f67341e

11 files changed

Lines changed: 997 additions & 125 deletions

backend/src/api/admin.routes.ts

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { getErrorMessage } from '../utils/helpers.js'
77

88
export const adminRouter = Router()
99

10-
// Predefined safe queries for EXPLAIN ANALYZE
1110
const PREDEFINED_QUERIES: Record<string, string> = {
1211
'get_all_portfolios': 'SELECT * FROM portfolios ORDER BY created_at DESC',
1312
'get_portfolio_count': 'SELECT COUNT(*) as cnt FROM portfolios',
@@ -21,28 +20,14 @@ const PREDEFINED_QUERIES: Record<string, string> = {
2120
'get_portfolio_drafts': 'SELECT * FROM portfolio_drafts WHERE user_address = ?'
2221
}
2322

24-
/**
25-
* POST /api/v1/admin/db/explain
26-
*
27-
* Accepts a named query identifier and returns EXPLAIN ANALYZE output.
28-
* Restricted to admin only.
29-
* Only predefined queries are allowed to prevent SQL injection.
30-
*
31-
* Request body:
32-
* {
33-
* "queryId": "get_all_portfolios",
34-
* "params": [] // Optional parameters for parameterized queries
35-
* }
36-
*
37-
* Response:
38-
* {
39-
* "queryId": "get_all_portfolios",
40-
* "explainPlan": "...",
41-
* "executionTimeMs": 1.23,
42-
* "estimatedRows": 100,
43-
* "actualRows": 95
44-
* }
45-
*/
23+
function logAdminAction(actor: string, action: string, target: string | null, before?: unknown, after?: unknown): void {
24+
try {
25+
databaseService.recordAdminAuditEntry(actor, action, target, before ?? null, after ?? null)
26+
} catch (err) {
27+
logger.warn('[ADMIN] Failed to record audit entry', { error: getErrorMessage(err), action, target })
28+
}
29+
}
30+
4631
adminRouter.post('/db/explain', requireAdmin, async (req: Request, res: Response) => {
4732
try {
4833
const { queryId, params = [] } = req.body
@@ -56,41 +41,39 @@ adminRouter.post('/db/explain', requireAdmin, async (req: Request, res: Response
5641
return fail(res, 400, 'VALIDATION_ERROR', `Unknown query identifier: ${queryId}. Available queries: ${Object.keys(PREDEFINED_QUERIES).join(', ')}`)
5742
}
5843

59-
// Validate params is an array
6044
if (!Array.isArray(params)) {
6145
return fail(res, 400, 'VALIDATION_ERROR', 'params must be an array')
6246
}
6347

64-
logger.info('[ADMIN] EXPLAIN ANALYZE requested', { queryId, adminPublicKey: req.adminPublicKey })
48+
const actor = req.adminPublicKey ?? 'unknown'
49+
logger.info('[ADMIN] EXPLAIN ANALYZE requested', { queryId, adminPublicKey: actor })
6550

6651
const db = (databaseService as any).db
6752
if (!db) {
6853
return fail(res, 500, 'INTERNAL_ERROR', 'Database connection not available')
6954
}
7055

71-
// First, run EXPLAIN ANALYZE on the query
7256
const explainQuery = `EXPLAIN ANALYZE ${query}`
7357
const explainStart = Date.now()
7458

7559
try {
7660
const explainResult = db.prepare(explainQuery).all(...params)
7761
const explainTimeMs = Date.now() - explainStart
7862

79-
// Parse the EXPLAIN ANALYZE output to extract estimated vs actual row counts
8063
const explainPlan = explainResult.map((row: any) => row.detail || JSON.stringify(row)).join('\n')
8164

82-
// Extract estimated and actual rows from the plan
8365
const estimatedRowsMatch = explainPlan.match(/rows=(\d+)/)
8466
const actualRowsMatch = explainPlan.match(/actual rows=(\d+)/)
8567

8668
const estimatedRows = estimatedRowsMatch ? parseInt(estimatedRowsMatch[1], 10) : null
8769
const actualRows = actualRowsMatch ? parseInt(actualRowsMatch[1], 10) : null
8870

89-
// Also run the actual query to get the real row count
9071
const queryStart = Date.now()
9172
const actualResult = db.prepare(query).all(...params)
9273
const queryTimeMs = Date.now() - queryStart
9374

75+
logAdminAction(actor, 'db_explain', queryId, null, { rowCount: actualResult.length })
76+
9477
return ok(res, {
9578
queryId,
9679
query,
@@ -111,21 +94,33 @@ adminRouter.post('/db/explain', requireAdmin, async (req: Request, res: Response
11194
}
11295
})
11396

114-
/**
115-
* GET /api/v1/admin/db/queries
116-
*
117-
* Returns the list of available predefined query identifiers.
118-
* Restricted to admin only.
119-
*/
120-
adminRouter.get('/db/queries', requireAdmin, async (_req: Request, res: Response) => {
97+
adminRouter.get('/db/queries', requireAdmin, async (req: Request, res: Response) => {
12198
try {
12299
const queries = Object.keys(PREDEFINED_QUERIES).map(key => ({
123100
id: key,
124101
query: PREDEFINED_QUERIES[key]
125102
}))
103+
logAdminAction(req.adminPublicKey ?? 'unknown', 'list_queries', null)
126104
return ok(res, { queries })
127105
} catch (error) {
128106
logger.error('[ADMIN] Failed to list queries', { error: getErrorMessage(error) })
129107
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
130108
}
131109
})
110+
111+
adminRouter.get('/audit-log', requireAdmin, async (req: Request, res: Response) => {
112+
try {
113+
const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined
114+
const action = typeof req.query.action === 'string' ? req.query.action : undefined
115+
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined
116+
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined
117+
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50
118+
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : 0
119+
120+
const result = databaseService.queryAdminAuditLog({ actor, action, startDate, endDate, limit, offset })
121+
return ok(res, result)
122+
} catch (error) {
123+
logger.error('[ADMIN] Failed to query audit log', { error: getErrorMessage(error) })
124+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
125+
}
126+
})

backend/src/api/ops.routes.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { StellarService } from '../services/stellar.js'
33
import { ReflectorService } from '../services/reflector.js'
44
import {
55
riskManagementService,
6-
rebalanceHistoryService
6+
rebalanceHistoryService,
7+
buildDependencyHealthSummary
78
} from '../services/serviceContainer.js'
89
import { portfolioStorage } from '../services/portfolioStorage.js'
910
import { contractEventIndexerService } from '../services/contractEventIndexer.js'
@@ -102,6 +103,17 @@ opsRouter.get('/health', async (_req: Request, res: Response) => {
102103
})
103104
})
104105

106+
opsRouter.get('/health/summary', async (_req: Request, res: Response) => {
107+
try {
108+
const summary = await buildDependencyHealthSummary()
109+
const statusCode = summary.status === 'healthy' ? 200 : 503
110+
return res.status(statusCode).json(summary)
111+
} catch (error) {
112+
logger.error('[HEALTH] Failed to build dependency health summary', { error: getErrorObject(error) })
113+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
114+
}
115+
})
116+
105117
opsRouter.get('/strategies', (_req: Request, res: Response) => {
106118
return ok(res, { strategies: REBALANCE_STRATEGIES })
107119
})

backend/src/api/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ portfolioRouter.use(debugRouter)
2222
portfolioRouter.use(consentRouter)
2323
portfolioRouter.use(assetsRouter)
2424
portfolioRouter.use(analyticsRouter)
25+
portfolioRouter.use('/admin', adminRouter)
2526

backend/src/services/databaseService.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,20 @@ CREATE TABLE IF NOT EXISTS user_preferences (
315315
notification_digest_frequency TEXT,
316316
updated_at TEXT NOT NULL
317317
);
318+
319+
CREATE TABLE IF NOT EXISTS admin_audit_log (
320+
id TEXT PRIMARY KEY,
321+
actor TEXT NOT NULL,
322+
action TEXT NOT NULL,
323+
target TEXT,
324+
before_value TEXT,
325+
after_value TEXT,
326+
timestamp TEXT NOT NULL
327+
);
328+
329+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_actor ON admin_audit_log (actor);
330+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log (action);
331+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_timestamp ON admin_audit_log (timestamp);
318332
`;
319333

320334
// ─────────────────────────────────────────────
@@ -2492,6 +2506,99 @@ getConsent(userId: string): ConsentRecord | undefined {
24922506
};
24932507
}
24942508
}
2509+
2510+
recordAdminAuditEntry(
2511+
actor: string,
2512+
action: string,
2513+
target: string | null,
2514+
beforeValue: unknown,
2515+
afterValue: unknown,
2516+
): string {
2517+
const id = randomUUID();
2518+
const timestamp = new Date().toISOString();
2519+
this._withTiming("recordAdminAuditEntry", () => {
2520+
this.db
2521+
.prepare(
2522+
`INSERT INTO admin_audit_log (id, actor, action, target, before_value, after_value, timestamp)
2523+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
2524+
)
2525+
.run(
2526+
id,
2527+
actor,
2528+
action,
2529+
target,
2530+
beforeValue != null ? JSON.stringify(beforeValue) : null,
2531+
afterValue != null ? JSON.stringify(afterValue) : null,
2532+
timestamp,
2533+
);
2534+
});
2535+
return id;
2536+
}
2537+
2538+
queryAdminAuditLog(filters: {
2539+
actor?: string;
2540+
action?: string;
2541+
startDate?: string;
2542+
endDate?: string;
2543+
limit?: number;
2544+
offset?: number;
2545+
}): { entries: Array<{
2546+
id: string;
2547+
actor: string;
2548+
action: string;
2549+
target: string | null;
2550+
before_value: string | null;
2551+
after_value: string | null;
2552+
timestamp: string;
2553+
}>; total: number } {
2554+
const conditions: string[] = [];
2555+
const params: unknown[] = [];
2556+
2557+
if (filters.actor) {
2558+
conditions.push("actor = ?");
2559+
params.push(filters.actor);
2560+
}
2561+
if (filters.action) {
2562+
conditions.push("action = ?");
2563+
params.push(filters.action);
2564+
}
2565+
if (filters.startDate) {
2566+
conditions.push("timestamp >= ?");
2567+
params.push(filters.startDate);
2568+
}
2569+
if (filters.endDate) {
2570+
conditions.push("timestamp <= ?");
2571+
params.push(filters.endDate);
2572+
}
2573+
2574+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2575+
const limit = Math.min(filters.limit ?? 50, 200);
2576+
const offset = filters.offset ?? 0;
2577+
2578+
return this._withTiming("queryAdminAuditLog", () => {
2579+
const total = (
2580+
this.db
2581+
.prepare(`SELECT COUNT(*) as cnt FROM admin_audit_log ${whereClause}`)
2582+
.get(...params) as { cnt: number }
2583+
).cnt;
2584+
2585+
const entries = this.db
2586+
.prepare(
2587+
`SELECT * FROM admin_audit_log ${whereClause} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
2588+
)
2589+
.all(...params, limit, offset) as Array<{
2590+
id: string;
2591+
actor: string;
2592+
action: string;
2593+
target: string | null;
2594+
before_value: string | null;
2595+
after_value: string | null;
2596+
timestamp: string;
2597+
}>;
2598+
2599+
return { entries, total };
2600+
});
2601+
}
24952602
}
24962603

24972604
// Singleton export

0 commit comments

Comments
 (0)