Skip to content

Spec: Security Log Service (TOP-2706)

Objective

Audit log of authentication events for the main power-rent app, visible to each user in Settings → Security tab.

Acceptance criteria (from the ticket):

  • every user’s login is registered in the log
  • every user’s failed login attempt is in the log
  • every password reset request is in the log
  • the log is visible in Settings → Security tab

Assumptions

  1. Target is the main app in this repo (Toprent-app/power-rent). PR #989 on power-rent-billion implemented the same ticket for the marketplace app and was closed; this spec covers the fleet-management app, which has its own Settings → Security tab.
  2. Storage is Postgres via @power-rent/prisma-client (new security_logs model), following the access_logs precedent. No Fauna writes (migration to Postgres ongoing).
  3. The log is personal: a user sees only their own events, keyed by email (failed attempts may have no user id). Not tenant-scoped.
  4. IP + geo come from lib/getClientLocationInfo.ts (reads x-forwarded-for, x-vercel-ip-country, x-vercel-ip-city). The ticket links @vercel/functions ipAddress/geolocation, but those take a web Request; our auth endpoints are pages-router Node handlers, and getClientLocationInfo is the established convention (used by access_logs). Same data source (Vercel headers) either way.
  5. login-client.js (company selection after auth) is not a separate login event; login-user.js success is the login. Magic-link request is not logged (the resulting login is, when the link is redeemed and login-user runs).
  6. MFA challenge failures are out of scope (not in AC).

Event sources

EventWhere it happensHow it’s logged
SIGN_IN_SUCCESSpages/api/login-user.js after token verification + user lookup succeedserver-side, in the handler
SIGN_IN_FAILUREpages/api/login-user.js failure branches (invalid/expired token, user without companies)server-side, in the handler
PASSWORD_RESET_REQUESTpages/api/send-recovery-link handlerserver-side, in the handler

Wrong-password attempts are not logged. Firebase validates passwords client-side, so the server never observes them. Client self-reporting was rejected: an attacker can block the request (devtools) or skip the SPA entirely (hitting Firebase’s auth API directly), so it would capture only honest-user typos while presenting as a security control. Only server-observable failures are recorded. This narrows the ticket’s “every failed login attempt” AC to server-side failures; brute-force/credential-stuffing visibility would need a server-side signal (e.g. GCP Identity Platform audit logs) and is out of scope here. MFA challenge failures are not failures for this log and are not recorded.

Tech stack

  • Next.js 14 pages router (API routes), graphql-yoga 5, Relay 20, MUI 6
  • Prisma 7 / Neon Postgres via @power-rent/prisma-client
  • @power-rent/try-catch/nextjs Try for error handling (no native try/catch)

Data model

model security_logs {
id String @id @default(dbgenerated("snowflake_id()"))
event SecurityEventType
email String
userId String?
ipAddress String?
country String?
city String?
userAgent String?
timestamp DateTime @default(now()) @db.Timestamptz(6)
@@index([email, timestamp])
}
enum SecurityEventType {
SIGN_IN_SUCCESS
SIGN_IN_FAILURE
PASSWORD_RESET_REQUEST
}

Append-only. Email normalized to lowercase. Logging is best-effort: a failed insert is reported to Sentry and never breaks the auth flow.

API surface

  • GraphQL: securityLogs Relay connection on Viewer, filtered to the viewer’s email server-side (no client-supplied filter). Schema file server/graphql/schema/security-logs.graphql, resolver under server/graphql/resolvers/query/, modeled on the accessLogs query.

UI

Extend the existing Security tab (components/pages/Settings/SecurityTab/index.js, gated by components.settings_security): add a “Security log” section below MFA with a paginated table (event, timestamp, IP, location) using usePaginationFragment, modeled on components/pages/Settings/AuditLogs/AuditLogsTable.js. Localized via react-intl messages files, matching the existing pattern.

Commands

  • Typecheck: pnpm tsc --noEmit (TS) / flow for .js (existing files are Flow)
  • Lint: pnpm eslint <files> --max-warnings 0
  • Tests: pnpm jest <path>
  • Relay codegen: pnpm relay (postinstall also regenerates __generated__)
  • Prisma: pnpm --filter @power-rent/prisma-client prisma:generate; migration SQL via prisma migrate conventions in packages/prisma-client/prisma/migrations/

Project structure (touched)

packages/prisma-client/prisma/schema.prisma → security_logs model + enum + migration
lib/recordSecurityEvent.ts → recordSecurityEvent helper (server)
server/graphql/schema/security-logs.graphql → Viewer.securityLogs connection
server/graphql/schema/index.js → register schema file
server/graphql/resolvers/query/ → securityLogs resolver
pages/api/login-user.js → log success + failures
pages/api/send-recovery-link.(js|ts) → log reset requests
components/pages/Settings/SecurityTab/ → SecurityLogTable + messages

Testing strategy

  • Unit (vitest): SecurityLogsService — email normalization, device/browser derivation, field truncation, empty-email rejection, pagination/cursor handling, Prisma errors propagate.
  • Resolver test: viewer-scoping (only own email’s rows returned).
  • Manual: sign in (success), trigger a server-side failure (e.g. user without companies), request password reset; verify rows and Security tab rendering.

Boundaries

  • Always: Try from @power-rent/try-catch/nextjs; best-effort logging (auth flow never blocked by audit failure); lowercase emails; viewer-scoped reads.
  • Ask first: running the migration against a live DB; adding dependencies.
  • Never: log passwords/tokens; expose other users’ events; Fauna writes.

Success criteria

  1. Password sign-in success → SIGN_IN_SUCCESS row with IP/country/city/device/browser.
  2. Server-side login failure (bad token, no companies) → SIGN_IN_FAILURE row.
  3. Password reset request → PASSWORD_RESET_REQUEST row.
  4. Settings → Security tab shows the viewer’s events, paginated, localized.
  5. Typecheck, lint, affected tests pass.

Open questions

  • Retention/TTL not specified in the ticket → none implemented (append-only).