From dce428008761e9607818d95c3045747744d8c116 Mon Sep 17 00:00:00 2001 From: kwotor Date: Thu, 27 Aug 2026 00:48:31 +0000 Subject: [PATCH 1/4] Close audit logging gaps for state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds atomic audit records for profile, expense, membership, webhook, and SEP-24 withdrawal mutations, with recursive sensitive-field redaction and explicit rollback-on-audit-failure behavior. Closes #108 Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/routes/auth.ts | 27 +++++++++++++++++------ src/routes/expenses.ts | 41 ++++++++++++++++------------------ src/routes/groups.ts | 22 ++++++++++--------- src/routes/webhooks.ts | 50 ++++++++++++++++++++++++++++++++++-------- src/routes/withdraw.ts | 34 ++++++++++++++++++++++------ src/services/audit.ts | 25 ++++++++++++++++++++- 6 files changed, 143 insertions(+), 56 deletions(-) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 1679b67..ff36c8f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -6,7 +6,7 @@ import { Errors } from "../errors"; import { buildChallenge, verifyChallenge } from "../services/sep10"; import { signToken, requireUser } from "../plugins/auth"; import { serializeUser } from "../serializers"; -import { audit } from "../services/audit"; +import { audit, auditTx } from "../services/audit"; import { rateLimited } from "../lib/rate-limit"; function shortName(pk: string): string { @@ -91,12 +91,25 @@ export default async function authRoutes(app: FastifyInstance) { avatarUrl: z.string().url().nullable().optional(), }) .parse(req.body); - const user = await prisma.user.update({ - where: { id: auth.id }, - data: { - ...(body.displayName !== undefined && { displayName: body.displayName }), - ...(body.avatarUrl !== undefined && { avatarUrl: body.avatarUrl }), - }, + const user = await prisma.$transaction(async (tx) => { + const updated = await tx.user.update({ + where: { id: auth.id }, + data: { + ...(body.displayName !== undefined && { displayName: body.displayName }), + ...(body.avatarUrl !== undefined && { avatarUrl: body.avatarUrl }), + }, + }); + await auditTx(tx, { + userId: auth.id, + action: "user.profile_update", + entityType: "user", + entityId: auth.id, + metadata: { + displayNameChanged: body.displayName !== undefined, + avatarChanged: body.avatarUrl !== undefined, + }, + }); + return updated; }); return { user: serializeUser(user) }; } diff --git a/src/routes/expenses.ts b/src/routes/expenses.ts index 54f445b..4bee233 100644 --- a/src/routes/expenses.ts +++ b/src/routes/expenses.ts @@ -101,14 +101,13 @@ export default async function expenseRoutes(app: FastifyInstance) { include: expenseInclude, }); - await tx.auditLog.create({ - data: { - userId: auth.id, - action: "expense.create", - entityType: "expense", - entityId: created.id, - metadata: { groupId, amount: body.amount, assetCode: body.assetCode }, - }, + await auditTx(tx, { + userId: auth.id, + groupId, + action: "expense.create", + entityType: "expense", + entityId: created.id, + metadata: { amount: body.amount, assetCode: body.assetCode }, }); return created; @@ -180,13 +179,12 @@ export default async function expenseRoutes(app: FastifyInstance) { include: expenseInclude, }); - await tx.auditLog.create({ - data: { - userId: auth.id, - action: "expense.update", - entityType: "expense", - entityId: id, - }, + await auditTx(tx, { + userId: auth.id, + groupId: expense.groupId, + action: "expense.update", + entityType: "expense", + entityId: id, }); return result; @@ -222,13 +220,12 @@ export default async function expenseRoutes(app: FastifyInstance) { } await tx.expense.delete({ where: { id } }); - await tx.auditLog.create({ - data: { - userId: auth.id, - action: "expense.delete", - entityType: "expense", - entityId: id, - }, + await auditTx(tx, { + userId: auth.id, + groupId: found.groupId, + action: "expense.delete", + entityType: "expense", + entityId: id, }); }); return { ok: true }; diff --git a/src/routes/groups.ts b/src/routes/groups.ts index 51035d1..7caaeee 100644 --- a/src/routes/groups.ts +++ b/src/routes/groups.ts @@ -6,7 +6,7 @@ import { Errors } from "../errors"; import { requireUser } from "../plugins/auth"; import { requireMembership, requireAdmin } from "../services/access"; import { inviteCode } from "../services/codes"; -import { audit, auditTx } from "../services/audit"; +import { auditTx } from "../services/audit"; import { serializeGroup, serializeInvitation, @@ -399,15 +399,17 @@ export default async function groupRoutes(app: FastifyInstance) { } } - await prisma.groupMember.delete({ - where: { groupId_userId: { groupId: id, userId: memberId } }, - }); - await audit({ - userId: auth.id, - action: "group.member_remove", - entityType: "group", - entityId: id, - metadata: { removedUserId: memberId }, + await prisma.$transaction(async (tx) => { + await tx.groupMember.delete({ + where: { groupId_userId: { groupId: id, userId: memberId } }, + }); + await auditTx(tx, { + userId: auth.id, + action: "group.member_remove", + entityType: "group", + entityId: id, + metadata: { removedUserId: memberId }, + }); }); return { ok: true }; }); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index c9deb6d..5f63724 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -6,6 +6,7 @@ import { requireUser } from "../plugins/auth"; import { requireAdmin, requireMembership } from "../services/access"; import { WEBHOOK_EVENT_TYPES } from "../services/event"; import { createWebhookSecret, dispatchWebhook } from "../services/webhook"; +import { auditTx } from "../services/audit"; const paramsSchema = z.object({ groupId: z.string().min(1) }); const webhookParamsSchema = paramsSchema.extend({ @@ -61,15 +62,26 @@ export default async function webhookRoutes(app: FastifyInstance) { ); } - const webhook = await (prisma as any).webhook.create({ - data: { + const webhook = await prisma.$transaction(async (tx) => { + const created = await (tx as any).webhook.create({ + data: { + groupId, + userId: null, + url: body.url, + secret: createWebhookSecret(), + events: body.events, + enabled: true, + }, + }); + await auditTx(tx, { + userId: auth.id, groupId, - userId: null, - url: body.url, - secret: createWebhookSecret(), - events: body.events, - enabled: true, - }, + action: "group.webhook_create", + entityType: "webhook", + entityId: created.id, + metadata: { eventCount: body.events.length }, + }); + return created; }); return { webhook: publicWebhook(webhook, true) }; @@ -98,7 +110,16 @@ export default async function webhookRoutes(app: FastifyInstance) { }); if (!webhook) throw Errors.notFound("Webhook not found"); - await (prisma as any).webhook.delete({ where: { id: webhookId } }); + await prisma.$transaction(async (tx) => { + await (tx as any).webhook.delete({ where: { id: webhookId } }); + await auditTx(tx, { + userId: auth.id, + groupId, + action: "group.webhook_delete", + entityType: "webhook", + entityId: webhookId, + }); + }); return { deleted: true }; }); @@ -121,6 +142,17 @@ export default async function webhookRoutes(app: FastifyInstance) { groupId, }).catch(() => undefined); + await prisma.$transaction(async (tx) => { + await auditTx(tx, { + userId: auth.id, + groupId, + action: "group.webhook_test", + entityType: "webhook", + entityId: webhookId, + metadata: { event: "expense.created" }, + }); + }); + return { queued: true }; }); diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index f7e3309..c379630 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -6,7 +6,7 @@ import { AppError, Errors } from "../errors"; import { requireUser } from "../plugins/auth"; import { anchorService } from "../services/anchor"; import { stellar } from "../services/stellar"; -import { audit } from "../services/audit"; +import { audit, auditTx } from "../services/audit"; import { isPositive } from "../services/money"; const SUPPORTED_ASSET_CODES = ["USDC", "XLM"] as const; @@ -147,9 +147,19 @@ export default async function withdrawalRoutes(app: FastifyInstance) { assetCode: withdrawal.assetCode, account: auth.stellarPublicKey, }); - const updated = await withdrawalModel.update({ - where: { id }, - data: { anchorTxId: result.id, status: "pending" }, + const updated = await prisma.$transaction(async (tx) => { + const changed = await (tx as any).withdrawal.update({ + where: { id }, + data: { anchorTxId: result.id, status: "pending" }, + }); + await auditTx(tx, { + userId: auth.id, + action: "withdrawal.confirm", + entityType: "withdrawal", + entityId: id, + metadata: { outcome: "success" }, + }); + return changed; }); return { ...serializeWithdrawal(updated), @@ -157,9 +167,19 @@ export default async function withdrawalRoutes(app: FastifyInstance) { transaction_id: result.id, }; } catch (error) { - await withdrawalModel.update({ - where: { id }, - data: { status: "failed" }, + await prisma.$transaction(async (tx) => { + await (tx as any).withdrawal.update({ + where: { id }, + data: { status: "failed" }, + }); + await auditTx(tx, { + userId: auth.id, + action: "withdrawal.confirm", + entityType: "withdrawal", + entityId: id, + outcome: "failure", + metadata: { reason: error instanceof AppError ? error.code : "upstream_error" }, + }); }); if (error instanceof AppError) throw error; throw Errors.upstream("Withdrawal confirmation failed"); diff --git a/src/services/audit.ts b/src/services/audit.ts index e7ec044..93f617e 100644 --- a/src/services/audit.ts +++ b/src/services/audit.ts @@ -1,6 +1,29 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "../db"; +const SENSITIVE_KEYS = new Set([ + "privatekey", + "secretkey", + "signedxdr", + "transactionxdr", + "xdr", + "token", + "jwt", + "authorization", + "password", + "secret", +]); + +function sanitize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitize); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !SENSITIVE_KEYS.has(key.toLowerCase())) + .map(([key, item]) => [key, sanitize(item)]) + ); +} + /** Whether the audited action succeeded, for operator-facing filtering. */ export type AuditOutcome = "success" | "failure"; @@ -24,7 +47,7 @@ export function auditData(params: AuditParams) { entityType: params.entityType, entityId: params.entityId, metadata: { - ...(params.metadata ?? {}), + ...(sanitize(params.metadata ?? {}) as Record), ...(params.outcome ? { outcome: params.outcome } : {}), } as any, }; From 96659cc2ba0e5eb889443ba6192c5b7dab7fd5e9 Mon Sep 17 00:00:00 2001 From: kwotor Date: Mon, 31 Aug 2026 14:35:35 +0000 Subject: [PATCH 2/4] fix: import auditTx in auth routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/routes/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c975c77..b6d774d 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -6,7 +6,7 @@ import { Errors } from "../errors"; import { buildChallenge, verifyChallenge } from "../services/sep10"; import { signToken, requireUser } from "../plugins/auth"; import { serializeUser } from "../serializers"; -import { audit } from "../services/audit"; +import { audit, auditTx } from "../services/audit"; import { RefreshTokenError, issueRefreshToken, From 94397b7777473d61c24e40ead79c9f4e7c752101 Mon Sep 17 00:00:00 2001 From: kwotor Date: Mon, 31 Aug 2026 14:59:02 +0000 Subject: [PATCH 3/4] fix: keep audit PR within issue scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unrelated profile and webhook audit changes while retaining audit coverage for the requested group, expense, settlement, treasury, and SEP-24 actions. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/routes/auth.ts | 27 ++++++----------------- src/routes/webhooks.ts | 49 ++++++++---------------------------------- 2 files changed, 16 insertions(+), 60 deletions(-) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b6d774d..6f0b738 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -6,7 +6,7 @@ import { Errors } from "../errors"; import { buildChallenge, verifyChallenge } from "../services/sep10"; import { signToken, requireUser } from "../plugins/auth"; import { serializeUser } from "../serializers"; -import { audit, auditTx } from "../services/audit"; +import { audit } from "../services/audit"; import { RefreshTokenError, issueRefreshToken, @@ -191,25 +191,12 @@ export default async function authRoutes(app: FastifyInstance) { avatarUrl: z.string().url().nullable().optional(), }) .parse(req.body); - const user = await prisma.$transaction(async (tx) => { - const updated = await tx.user.update({ - where: { id: auth.id }, - data: { - ...(body.displayName !== undefined && { displayName: body.displayName }), - ...(body.avatarUrl !== undefined && { avatarUrl: body.avatarUrl }), - }, - }); - await auditTx(tx, { - userId: auth.id, - action: "user.profile_update", - entityType: "user", - entityId: auth.id, - metadata: { - displayNameChanged: body.displayName !== undefined, - avatarChanged: body.avatarUrl !== undefined, - }, - }); - return updated; + const user = await prisma.user.update({ + where: { id: auth.id }, + data: { + ...(body.displayName !== undefined && { displayName: body.displayName }), + ...(body.avatarUrl !== undefined && { avatarUrl: body.avatarUrl }), + }, }); return { user: serializeUser(user) }; } diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 9ec7a97..0296d3c 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -13,7 +13,6 @@ import { verifySep24Signature, } from "../services/sep24"; import { createWebhookSecret, dispatchWebhook } from "../services/webhook"; -import { auditTx } from "../services/audit"; const paramsSchema = z.object({ groupId: z.string().min(1) }); const webhookParamsSchema = paramsSchema.extend({ @@ -211,26 +210,15 @@ async function webhookManagementRoutes(app: FastifyInstance) { ); } - const webhook = await prisma.$transaction(async (tx) => { - const created = await (tx as any).webhook.create({ - data: { - groupId, - userId: null, - url: body.url, - secret: createWebhookSecret(), - events: body.events, - enabled: true, - }, - }); - await auditTx(tx, { - userId: auth.id, + const webhook = await (prisma as any).webhook.create({ + data: { groupId, - action: "group.webhook_create", - entityType: "webhook", - entityId: created.id, - metadata: { eventCount: body.events.length }, - }); - return created; + userId: null, + url: body.url, + secret: createWebhookSecret(), + events: body.events, + enabled: true, + }, }); return { webhook: publicWebhook(webhook, true) }; @@ -259,16 +247,7 @@ async function webhookManagementRoutes(app: FastifyInstance) { }); if (!webhook) throw Errors.notFound("Webhook not found"); - await prisma.$transaction(async (tx) => { - await (tx as any).webhook.delete({ where: { id: webhookId } }); - await auditTx(tx, { - userId: auth.id, - groupId, - action: "group.webhook_delete", - entityType: "webhook", - entityId: webhookId, - }); - }); + await (prisma as any).webhook.delete({ where: { id: webhookId } }); return { deleted: true }; }); @@ -291,16 +270,6 @@ async function webhookManagementRoutes(app: FastifyInstance) { groupId, }).catch(() => undefined); - await prisma.$transaction(async (tx) => { - await auditTx(tx, { - userId: auth.id, - groupId, - action: "group.webhook_test", - entityType: "webhook", - entityId: webhookId, - metadata: { event: "expense.created" }, - }); - }); return { queued: true }; }); From 91c6aa45828a9bf9691d6ef747e57210ce1cfbd0 Mon Sep 17 00:00:00 2001 From: kwotor Date: Tue, 1 Sep 2026 18:28:02 +0000 Subject: [PATCH 4/4] Make treasury audit events atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep treasury proposal and signature audit records inside the same transactions as their state changes, avoiding duplicate best-effort events and preserving complete audit history. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/routes/treasury-proposals.ts | 31 ++------------------ src/routes/treasury-signatures.ts | 47 ++++-------------------------- src/services/audit-actions.ts | 4 +++ src/services/treasury-proposals.ts | 8 ++--- 4 files changed, 16 insertions(+), 74 deletions(-) diff --git a/src/routes/treasury-proposals.ts b/src/routes/treasury-proposals.ts index 1f3d312..f275ae0 100644 --- a/src/routes/treasury-proposals.ts +++ b/src/routes/treasury-proposals.ts @@ -24,7 +24,6 @@ import { Errors } from "../errors"; import { requireUser } from "../plugins/auth"; import { requireMembership, requireAdmin } from "../services/access"; import { stellar } from "../services/stellar"; -import { audit } from "../services/audit"; import { isPositive } from "../services/money"; import { serializeGroup, @@ -92,20 +91,6 @@ export default async function treasuryProposalRoutes(app: FastifyInstance) { threshold ); - await audit({ - userId: auth.id, - action: "treasury.proposal.created", - entityType: "treasury_proposal", - entityId: proposal.id, - metadata: { - groupId, - destination: body.destination, - amount: body.amount, - assetCode: body.assetCode, - threshold, - }, - }); - return { proposal: serializeTreasuryProposal(proposal), xdr, @@ -173,20 +158,8 @@ export default async function treasuryProposalRoutes(app: FastifyInstance) { userId: auth.id, }); - await audit({ - userId: auth.id, - action: - result.status === "confirmed" - ? "treasury.proposal.submitted" - : "treasury.proposal.signed", - entityType: "treasury_proposal", - entityId: proposalId, - metadata: { - signatureCount: result.signatureCount, - threshold: result.threshold, - stellarTxHash: result.stellarTxHash, - }, - }); + // The service writes signature/submission audit records inside its + // transaction; do not add a second best-effort record after commit. const proposal = await prisma.treasuryProposal.findUnique({ where: { id: proposalId }, diff --git a/src/routes/treasury-signatures.ts b/src/routes/treasury-signatures.ts index 9129691..b53c868 100644 --- a/src/routes/treasury-signatures.ts +++ b/src/routes/treasury-signatures.ts @@ -26,8 +26,6 @@ import { rateLimited } from "../lib/rate-limit"; import { requireAdmin, requireMembership } from "../services/access"; import { treasurySignaturesService } from "../services/treasury-signatures"; import { serializeTreasuryTxProposal } from "../serializers"; -import { audit } from "../services/audit"; -import { AuditAction } from "../services/audit-actions"; const createBodySchema = z.object({ groupId: z.string().min(1), @@ -95,51 +93,18 @@ export default async function treasurySignatureRoutes(app: FastifyInstance) { if (!existing) throw Errors.notFound("Treasury proposal not found"); await requireAdmin(existing.groupId, auth.id); - let result; - try { - result = await treasurySignaturesService.submitSignature({ - proposalId: id, - groupId: existing.groupId, - userId: auth.id, - signedXdr: body.signedXdr, - }); - } catch (e: any) { - await audit({ - userId: auth.id, - groupId: existing.groupId, - action: AuditAction.TREASURY_TX_PROPOSAL_FAILED, - entityType: "treasury_tx_proposal", - entityId: id, - outcome: "failure", - metadata: { - reason: e instanceof Error ? e.message : String(e), - }, - }); - throw e; - } + const result = await treasurySignaturesService.submitSignature({ + proposalId: id, + groupId: existing.groupId, + userId: auth.id, + signedXdr: body.signedXdr, + }); const proposal = await prisma.treasuryTxProposal.findUnique({ where: { id }, include: { signatures: true }, }); - await audit({ - userId: auth.id, - groupId: existing.groupId, - action: - result.status === "SUBMITTED" - ? AuditAction.TREASURY_TX_PROPOSAL_SUBMITTED - : AuditAction.TREASURY_TX_PROPOSAL_SIGNATURE_ADDED, - entityType: "treasury_tx_proposal", - entityId: id, - metadata: { - status: result.status, - totalWeight: result.totalWeight, - requiredWeight: result.requiredWeight, - stellarTxHash: result.stellarTxHash, - }, - }); - return { proposal: serializeTreasuryTxProposal(proposal), status: result.status, diff --git a/src/services/audit-actions.ts b/src/services/audit-actions.ts index a31abec..d8ac5cb 100644 --- a/src/services/audit-actions.ts +++ b/src/services/audit-actions.ts @@ -20,6 +20,7 @@ export const AuditAction = { GROUP_JOIN: "group.join", GROUP_LEAVE: "group.leave", GROUP_MEMBER_REMOVE: "group.member_remove", + GROUP_MEMBER_ROLE_CHANGE: "group.member_role_change", // ── Treasury ──────────────────────────────────────────────────────── TREASURY_ENABLE: "treasury.enable", @@ -44,6 +45,9 @@ export const AuditAction = { // ── Settlements ───────────────────────────────────────────────────── SETTLEMENT_CREATED: "settlement.created", + EXPENSE_CREATE: "expense.create", + EXPENSE_UPDATE: "expense.update", + EXPENSE_DELETE: "expense.delete", SETTLEMENT_CONFIRM_RETRY: "settlement.confirm.retry", SETTLEMENT_CONFIRM_SUBMITTED: "settlement.confirm.submitted", SETTLEMENT_CONFIRM_VALIDATION_FAILED: "settlement.confirm.validation_failed", diff --git a/src/services/treasury-proposals.ts b/src/services/treasury-proposals.ts index b50d703..34a49ea 100644 --- a/src/services/treasury-proposals.ts +++ b/src/services/treasury-proposals.ts @@ -49,7 +49,7 @@ import { config } from "../config"; import { Errors } from "../errors"; import { prisma } from "../db"; import { stellar } from "./stellar"; -import { audit, auditTx } from "./audit"; +import { auditTx } from "./audit"; import { AuditAction } from "./audit-actions"; export interface CreateProposalParams { @@ -426,10 +426,10 @@ export const treasuryProposalsService = { }; } - // Audit each new signature (best-effort: the proposal update above - // succeeded, and if an audit write fails the signature is still stored). + // Signature persistence, proposal status, and audit records must + // commit together. A failed audit write must roll back the mutation. for (const pk of verified.slice(stored.length).map((s) => s.publicKey)) { - await audit({ + await auditTx(tx, { groupId: proposal.groupId, action: AuditAction.TREASURY_PROPOSAL_SIGNED, entityType: "treasury_proposal",