Skip to content

Admin Action Logs (Comprehensive Audit Trail) #48

Description

@codebestia

Background

This is the most cross-cutting issue in the admin set, and deliberately so — the request is for every state change in the system, on-chain or off-chain, to leave a record of who did it, when, and to what. That serves three real needs: support (a merchant disputes an invoice status, and someone needs to reconstruct exactly what happened, in order); security (spotting an admin account behaving unexpectedly); and compliance (a defensible record of every merchant-affecting action).

Two things make this genuinely complex, not just "add a log table":

  1. Two independent write origins. On-chain state changes arrive via the indexer (an event fires, a handler processes it. Off-chain-only changes (a merchant editing their profile, an admin blocking a merchant, an admin logging in) happen entirely inside a normal Express request with no corresponding chain event at all. Both origins must produce the same shape of log row, through the same code path — not two divergent logging mechanisms that drift apart over time.
  2. Three actor types with different identity shapes. An action might be performed by an authenticated Admin (has a name), an authenticated Merchant (has a business name, or just an address if profile isn't complete), or effectively ANONYMOUS (the public pay page, a wallet calling the contract directly without ever touching our backend — the indexer only ever sees an address). The log schema has to represent all three without forcing a fake Merchant/Admin row into existence just to attach a log entry to it.

Proposed Steps

  1. Add AdminLog model and ActorType enum (see Schema Changes); prisma migrate dev.
  2. src/services/audit-log.services.ts — a single recordAuditLog(input) function. Nothing else ever writes to AdminLog directly — every service and every indexer handler that needs to log calls this one function, so the schema and the logging contract can only drift in one place if they drift at all.
    recordAuditLog({
      action: string,        // dot-namespaced, e.g. "invoice.voided", "fee.set"
      actorType: ActorType,
      actorId?: string,      // Admin.id or Merchant.id, when known
      actorLabel: string,    // admin.name | merchant.businessName ?? merchant.address | raw address | "system"
      targetType?: string,   // e.g. "Invoice", "Merchant", "SubscriptionPlan"
      targetId?: string,
      metadata?: object,     // arbitrary before/after or context
    })
    
    A failure inside recordAuditLog (DB hiccup, etc.) must never fail the calling operation — wrap the write in try/catch, log the failure to console.error, and return. An audit-trail bug becoming a user-facing 500 on, say, invoice payment, would be a worse outcome than a missing log row.
  3. Wire recordAuditLog into every action listed in the table below. This is the bulk of the work — it touches nearly every service and handler in the codebase, one call each.
  4. GET /admin/logs (mounted in src/routes/admin/logs.routes.ts, authenticateAdmin, no superadmin requirement — read-only) — filterable by action, actorType, actorId, targetType, targetId, from/to date range; paginated; sorted by createdAt desc by default (no other sort needed — logs are inherently chronological).

Action Catalog

Every row below is a required call site. "Source" is either a Soroban event topic or a backend service function (a direct recordAuditLog call at the point of mutation).

Governance & Config (all actorType: ADMIN, resolved by the on-chain admin address against the Admin table — if the address isn't a known Admin, log with actorType: ANONYMOUS and the raw address rather than failing to log at all)

Action Source
governance.admin_transfer_proposed / _accepted AdminTransferProposedEvent / AdminTransferAcceptedEvent
governance.token_added / token_removed TokenAddedEvent / TokenRemovedEvent
governance.fee_proposed / fee_set FeeProposedEvent / FeeSetEvent
governance.platform_account_set PlatformAccountSetEvent
governance.token_oracle_set TokenOracleSetEvent
governance.account_wasm_hash_set AccountWasmHashSetEvent
governance.contract_paused / _unpaused ContractPausedEvent / ContractUnpausedEvent
governance.contract_upgraded ContractUpgradedEvent
governance.role_granted / role_revoked RoleGrantedEvent / RoleRevokedEvent

Merchant Lifecycle

Action Actor Source
merchant.registered (on-chain) SYSTEM MerchantRegisteredEvent
merchant.account_deployed SYSTEM MerchantAccountDeployedEvent
merchant.status_changed (activated/deactivated on-chain) ADMIN MerchantStatusChangedEvent
merchant.verified ADMIN MerchantVerifiedEvent
merchant.webhook_set MERCHANT MerchantWebhookSetEvent
merchant.key_set (on-chain upload) MERCHANT MerchantKeySetEvent
merchant.tokens_set / token_removed (whitelist) MERCHANT MerchantTokensSetEvent / MerchantTokenRemovedEvent
merchant.account_restricted ADMIN AccountRestrictedEvent
merchant.profile_registered (off-chain) MERCHANT registerMerchant
merchant.profile_updated MERCHANT updateMyProfile
merchant.email_verified MERCHANT verifyEmailOtp
merchant.otp_resent MERCHANT resendEmailOtp
merchant.signing_key_generated MERCHANT generateMerchantSigningKey
merchant.blocked (off-chain) ADMIN

Invoice Lifecycle

Action Actor Source
invoice.created (off-chain) MERCHANT createInvoice
invoice.created (on-chain) SYSTEM InvoiceCreatedEvent
invoice.paid ANONYMOUS (the payer address — almost never a known Merchant/Admin) InvoicePaidEvent
invoice.payment_split_routed SYSTEM PaymentSplitRoutedEvent
invoice.refunded / partially_refunded MERCHANT InvoiceRefundedEvent / InvoicePartiallyRefundedEvent
invoice.cancelled (on-chain) MERCHANT InvoiceCancelledEvent
invoice.amended (on-chain) MERCHANT InvoiceAmendedEvent
invoice.fiat_priced SYSTEM FiatInvoicePricedEvent
invoice.voided (off-chain) MERCHANT voidInvoice
invoice.amended (off-chain) MERCHANT amendInvoice
invoice.email_sent MERCHANT sendInvoiceEmail

Subscription Lifecycle

Action Actor Source
subscription_plan.created MERCHANT SubscriptionPlanCreatedEvent
subscription_plan.deactivated MERCHANT PlanDeactivatedEvent
subscription.created ANONYMOUS (customer address) SubscribedEvent
subscription.charged SYSTEM SubscriptionChargedEvent
subscription.cancelled MERCHANT or ANONYMOUS (either party can cancel — use whichever caller the event reports) SubscriptionCancelledEvent

Account Contract / Withdrawals

Action Actor Source
account.initialized / verified SYSTEM AccountInitializedEvent / AccountVerifiedEvent
account.restricted ADMIN AccountRestrictedEvent (account contract)
account.withdrawal MERCHANT WithdrawalToEvent
account.refund_processed SYSTEM RefundProcessedEvent

Admin & Auth (off-chain only, no corresponding contract event)

Action Actor Source
admin.created ADMIN (the creator) add-admin endpoint
admin.login_succeeded / login_failed ADMIN / ANONYMOUS authenticateAdminWallet
deposit_account.created / assigned / released SYSTEM DepositAccountService

Events intentionally not logged: NonceInvalidatedEvent, InitializedEvent, FeeDiscountAppliedEvent (a computed side-effect of invoice.paid/subscription.charged, not an independent action), and BridgePlaceholderEvent (placeholder, no real state change yet). These are low-value noise for an audit trail; revisit if that changes.

Schema Changes

AdminLog (new model)

id          String    (uuid, PK)
action      String    (dot-namespaced, e.g. "merchant.blocked")
actorType   ActorType
actorId     String?   (Admin.id or Merchant.id when known; null for ANONYMOUS/SYSTEM)
actorLabel  String    (display name — see recordAuditLog's contract above)
targetType  String?
targetId    String?
metadata    Json?
createdAt   DateTime  (default now)

@@index([actorType, actorId])
@@index([targetType, targetId])
@@index([action])
@@index([createdAt])

ActorType (new enum)

ADMIN
MERCHANT
ANONYMOUS
SYSTEM

Acceptance Criteria

  • AdminLog model and ActorType enum added; prisma migrate dev runs cleanly
  • recordAuditLog is the only function anywhere in the codebase that writes to prisma.adminLog
  • A failure inside recordAuditLog never propagates to or fails the calling operation (test by mocking a DB error inside the log write and asserting the primary operation still succeeds)
  • Every row in the Merchant Lifecycle, Invoice Lifecycle, Subscription Lifecycle, and Admin & Auth tables above has a working call site
  • Governance, Ticketing, and Account/Withdrawal categories are wired where the underlying functionality already exists in the codebase; where it doesn't yet (e.g. ticketing), the handler is stubbed with a clear "not yet implemented upstream" comment rather than silently absent
  • GET /admin/logs supports all listed filters plus pagination, and returns entries ordered newest-first
  • A log entry for an anonymous on-chain actor (e.g. invoice.paid) stores the raw payer address as actorLabel with actorId: null, not a failed lookup or a thrown error

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions