Skip to content

User Security Logs Implementation

Overview

Replaced the old /me/security/login-log endpoint (which only read from audit_logs table) with a new /me/security/logs endpoint that uses the Internal Logs Service to show users their security-related logs.

Update: The list is implemented via lib/services/me/logs (createUserLogsService(env) and listUserLogs(userId, options)). The route parses query params and calls the logs service; the service uses getService('internalLogger').getLogs(...) with per-user index (when userId is set) and cursor-based pagination (default limit 100, max 500). Delete/clear endpoints return a policy message (retention and who may delete to be defined by institution).

See also: Internal logger improvement plan — per-user index, cursor API, and service adoption.

What Changed

1. List implementation (service + route)

Service: apps/worker/src/lib/services/me/logs/index.tslistUserLogs(userId, { limit, cursor, level, category }); cursor-based; uses internal logger’s per-user index.

Route: apps/worker/src/routes/me/security.ts — parses query and calls logsService.list(userId, options).

  • GET /me/security/logs: List user's security logs (lockouts, login failures, session tampering, etc.)

    • Query params: limit (default 100, max 500), cursor (optional), level (optional), category (optional: auth | security)
    • Returns { data: InternalLogEntry[], pagination: { limit, nextCursor?, hasMore } }
  • DELETE /me/security/logs and DELETE /me/security/logs/{id}: Return 403 with a policy message (retention and who may delete to be defined by institution).

2. Enhanced Internal Logs Filtering

Updated Files:

  • apps/worker/src/types/cache/internal.ts: Added userId to InternalLogFilters
  • apps/worker/src/lib/services/cache/multi-shard-reader/index.ts: Added userId filter support
  • apps/worker/src/lib/services/cache/internal/index.ts: Updated getLogs() to filter by userId

3. Enhanced Logging Methods

Updated Files:

  • apps/worker/src/lib/services/cache/internal/index.ts:

    • Added logAuth() method for auth events (category: auth, supports userId)
    • Updated logWarning(), logError() to accept userId and ipAddress parameters
  • apps/worker/src/lib/services/internal/index.ts:

    • Updated warning(), error(), info() to accept userId and ipAddress parameters
    • info() now uses logAuth() for auth-related events (e.g., login_success)
  • apps/worker/src/lib/services/auth/login/index.ts:

    • Updated all logging calls to include userId and ipAddress where applicable
    • Changed invalid password attempts from warning() to suspicious() (includes userId)
    • Login success now includes userId via info() with userId parameter

What Logs Are Included

Users can now see all security-related logs that include their userId:

  1. Lockout events (category: 'security', level: 'suspicious')

    • Account locked due to too many failed attempts
    • Lockout reason and remaining time
  2. Login failures (category: 'security', level: 'suspicious')

    • Invalid password attempts
    • Attempt count
  3. Login success (category: 'auth', level: 'info')

    • Successful login events
    • Device info, location, risk level
  4. Session tampering (future)

    • Suspicious session activity
    • Session revocation events
  5. Other security events (category: 'security')

    • Any other security-related logs that include the user's userId

Filtering logic and consistency

  • Internal logger: All entries are stored with optional userId (and category, level, etc.). There is no index by userId in the cache; the index is per-shard by log id + timestamp (newest first).
  • Multi-shard reader (getLogs({ userId, category, level, limit })): For each shard it walks the full index (id + timestamp), fetches each entry from the DO cache, then applies:
    • userId: entry is kept only if entry.userId === filters.userId
    • customFilter: level and category (and optional service) on the entry So we do not read “only the user’s slice” from storage: we read a bounded window of the global stream (by limit), then filter by userId and category/level. That makes listing consistent and reliable (same data as written, correct filtering), but not “faster” than reading all logs: we still scan index entries and fetch until we have enough matches. For a single user with many logs, we may fetch more entries than limit to fill one page.
  • services/me/logs (listUserLogs): Calls internalLogger.getLogs({ userId, category, level, limit: limit * page }), then restricts to auth / security when category is not set, and slices for the requested page. So all user actions that are logged with userId set on the internal log entry are accessible via this service; behavior is consistent and reliable. Performance is dominated by the multi-shard read (no separate “user log” store).

Pagination

Currently offset-based:

  • Request asks for limit * page entries from the internal logger (with userId + optional category/level).
  • Service then slices to the requested page: skip = (page - 1) * limit.

Future improvement: Use cursor-based pagination from MultiShardReader (e.g. nextCursor) so we don’t over-fetch for high page numbers.


Who writes internal logs with userId (user-visible in GET /me/security/logs)

Only entries that have userId set on the log entry are returned when filtering by that user. Currently:

Service / areaMethodcategoryuserId set?Visible in /me/security/logs
Auth login (auth/login/index.ts)logger.info, logger.suspicious, logger.warningauth / securityYes (when user exists)Yes
Auth registerlogger.logAuthEvent('register_success', userId, …)authYesYes
Auth logoutlogger.logAuthEvent('logout_success', session.userId, …)authYesYes
Auth profilelogger.logSystemEvent(...)system (error)NoNo
Maintenance coreinternalLogger.log(service, message, details, context)No (signature has no userId; currently passes userId as message by mistake)No

So today login, register, and logout events that pass userId are visible. Maintenance actions are not tied to userId on the entry; to include them in user logs, maintenance should call a logger method that accepts userId (e.g. internalLogger.warning('maintenance', message, details, context, userId, ipAddress)) and ensure the stored entry has userId set.


Where else to use the internal logger

Consider using the internal logger (with userId where relevant) for:

  • Security / 2FA: backup code use, 2FA enable/disable, secure-action verification (so they appear in user security logs).
  • Sensitive account actions: email change, password change, session revoke (already have some logging; ensure userId is set).
  • Maintenance: as above, pass userId (and optionally ipAddress) so per-user maintenance actions show under /me/security/logs if desired.
  • User management (admin): admin actions on a user (suspend, reset, etc.) — can log with that user’s id for audit and optional “view as user” logs later.
  • API / rate limits: critical or suspicious API abuse with optional userId when authenticated.
  • System health: keep using without userId for system-wide issues (these stay out of user-scoped lists).

TODOs

  1. Log Deletion: Implement actual deletion via DO endpoint

    • Currently returns 501 Not Implemented
    • Requires DO storage API to delete entries and update index
  2. Log Clearing: Implement clearing all user logs via DO endpoint

    • Currently returns 501 Not Implemented
    • Requires iterating through all shards and deleting entries
  3. Cursor Pagination: Switch from offset-based to cursor-based pagination

    • Use MultiShardReader cursor support for better performance

Benefits

  1. Unified Logging: All security logs in one place (internal logs cache)
  2. Better Performance: DO cache is faster than DB queries for read-heavy operations
  3. More Comprehensive: Users see all security events, not just login attempts
  4. Real-time: Logs are available immediately (no DB write delay)
  5. Scalable: Multi-shard support handles large log volumes

Types

  • User logs list: UserLogsListOptions, UserLogsListResult live in src/types/me/logs.ts (domain: me). Re-exported from @/types/me and from lib/services/me/logs for convenience.
  • Internal log entry and filters: InternalLogEntry, InternalLogFilters in src/types/cache/internal.ts.

Migration Notes

  • Old endpoint /me/security/login-log is replaced by /me/security/logs
  • List is implemented by lib/services/me/logs (user logs service) and routes/me/security.ts
  • Schema: SecurityLogsQuerySchema in lib/services/zod/me/security.ts (optional level, category; limit default 100, max 500)