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
- 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.
- Storage is Postgres via
@power-rent/prisma-client(newsecurity_logsmodel), following theaccess_logsprecedent. No Fauna writes (migration to Postgres ongoing). - The log is personal: a user sees only their own events, keyed by email (failed attempts may have no user id). Not tenant-scoped.
- IP + geo come from
lib/getClientLocationInfo.ts(readsx-forwarded-for,x-vercel-ip-country,x-vercel-ip-city). The ticket links@vercel/functionsipAddress/geolocation, but those take a webRequest; our auth endpoints are pages-router Node handlers, andgetClientLocationInfois the established convention (used by access_logs). Same data source (Vercel headers) either way. login-client.js(company selection after auth) is not a separate login event;login-user.jssuccess is the login. Magic-link request is not logged (the resulting login is, when the link is redeemed andlogin-userruns).- MFA challenge failures are out of scope (not in AC).
Event sources
| Event | Where it happens | How it’s logged |
|---|---|---|
SIGN_IN_SUCCESS | pages/api/login-user.js after token verification + user lookup succeed | server-side, in the handler |
SIGN_IN_FAILURE | pages/api/login-user.js failure branches (invalid/expired token, user without companies) | server-side, in the handler |
PASSWORD_RESET_REQUEST | pages/api/send-recovery-link handler | server-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/nextjsTryfor 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:
securityLogsRelay connection onViewer, filtered to the viewer’s email server-side (no client-supplied filter). Schema fileserver/graphql/schema/security-logs.graphql, resolver underserver/graphql/resolvers/query/, modeled on theaccessLogsquery.
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 viaprisma migrateconventions inpackages/prisma-client/prisma/migrations/
Project structure (touched)
packages/prisma-client/prisma/schema.prisma → security_logs model + enum + migrationlib/recordSecurityEvent.ts → recordSecurityEvent helper (server)server/graphql/schema/security-logs.graphql → Viewer.securityLogs connectionserver/graphql/schema/index.js → register schema fileserver/graphql/resolvers/query/ → securityLogs resolverpages/api/login-user.js → log success + failurespages/api/send-recovery-link.(js|ts) → log reset requestscomponents/pages/Settings/SecurityTab/ → SecurityLogTable + messagesTesting 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:
Tryfrom@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
- Password sign-in success →
SIGN_IN_SUCCESSrow with IP/country/city/device/browser. - Server-side login failure (bad token, no companies) →
SIGN_IN_FAILURErow. - Password reset request →
PASSWORD_RESET_REQUESTrow. - Settings → Security tab shows the viewer’s events, paginated, localized.
- Typecheck, lint, affected tests pass.
Open questions
- Retention/TTL not specified in the ticket → none implemented (append-only).