Audit Logging

import { AuditLogService } from "@95octane/common/audit"

Structured audit trail for account management actions on User and Group entities. Internal use only — logs are not exposed to end users.

Storage: Cloud Logging (95octane-audit log name) → BigQuery via log sink Retention: 30 days in Cloud Logging (hot queries); 365 days in BigQuery (compliance)


AuditLogService

An Effect Service in packages/common shared across projects/service and projects/worker. Wraps the @google-cloud/logging SDK.

AuditLogService.log(entry) — writes one structured audit entry. Returns Effect.Effect<void, never>. Never fails — logging errors are swallowed and written to stderr only. A logging failure must never block or fail the primary operation.

AuditLogEntry

Field Type Required Description
eventType string yes Namespaced camelCase event name (see catalogue below)
actorId string yes UID of the user who performed the action; "system" for automated actions
actorType "user" \| "system" yes
resourceType "user" \| "group" yes The type of the primary entity affected
resourceId string yes ID of the affected user or group
targetId string no UID of the user affected by a group action (e.g. the member who was removed)
outcome "success" \| "failure" yes
severity "INFO" \| "WARNING" \| "ERROR" yes INFO = success; WARNING = auth/permission failure; ERROR = unexpected failure on sensitive op
failureReason string no Error code when outcome is "failure" (e.g. "FORBIDDEN", "INVALID_INVITE_CODE")
platform string no Client platform from request headers ("iOS", "android", "unknown")
requestId string no OTel trace ID from the current span — enables correlation with Grafana traces
metadata Record<string, unknown> no Event-specific extra context (e.g. which fields changed, new role value)
// Example: admin removed from group by owner
{
  "eventType": "group.member.roleChanged",
  "actorId": "uid_owner",
  "actorType": "user",
  "resourceType": "group",
  "resourceId": "grp_abc123",
  "targetId": "uid_admin1",
  "outcome": "success",
  "severity": "INFO",
  "failureReason": null,
  "platform": "iOS",
  "requestId": "trace-xyz789",
  "metadata": { "previousRole": "admin", "newRole": "member" },
}

// Example: non-owner attempting to delete group
{
  "eventType": "group.deleted",
  "actorId": "uid_member",
  "actorType": "user",
  "resourceType": "group",
  "resourceId": "grp_abc123",
  "outcome": "failure",
  "severity": "WARNING",
  "failureReason": "FORBIDDEN",
  "platform": "android",
  "requestId": "trace-abc456",
  "metadata": {},
}

Call Pattern

Success path

Call AuditLogService.log at the end of the handler or activity, after the primary operation succeeds.

// In a handler (projects/service)
const result = yield * UserService.deleteUser(id);
yield * AuditLogService.log({
  eventType: "user.account.deleteInitiated",
  actorId: req.authUser.uid,
  actorType: "user",
  resourceType: "user",
  resourceId: id,
  outcome: "success",
  severity: "INFO",
  platform: req.headers.platformName,
  requestId: getCurrentTraceId(),
});
return result;

Failure path

Use Effect.tapError to intercept security-relevant errors, log them, then re-raise unchanged.

Effect
  .gen(function*() {/* handler logic */})
  .pipe(
    Effect.tapError(error =>
      isSecurityRelevantError(error)
        ? AuditLogService.log({
          eventType: "group.member.removed",
          actorId: req.authUser.uid,
          actorType: "user",
          resourceType: "group",
          resourceId: groupId,
          targetId: userId,
          outcome: "failure",
          severity: "WARNING",
          failureReason: error.code,
          platform: req.headers.platformName,
          requestId: getCurrentTraceId(),
        })
        : Effect.void
    ),
  );

isSecurityRelevantError(error) returns true for:

  • HTTP 401 (UNAUTHORIZED)
  • HTTP 403 (FORBIDDEN, NOT_GROUP_MEMBER, INVALID_INVITE_CODE, INVITE_DISABLED, EMAIL_ALREADY_SET, GROUP_LIMIT_REACHED)
  • Specific business-rule blocks: OWNS_GROUPS (account deletion blocked)

Does not return true for: 400 validation errors (MISSING_FIELD, INVALID_FIELD) or 404 not-found errors — these are client errors, not security events.


Event Catalogue

User events

Event type Failure logged? Notes
user.auth.signIn Yes Failure = banned account, deleted account in grace period
user.auth.providerLinked Yes Failure = provider belongs to another account
user.profile.updated No 400s are client errors
user.email.verificationSent No
user.email.verified No
user.account.deleteInitiated Yes Failure = user owns one or more groups
user.account.deleteCancelled No
user.account.deleted No System-triggered; actorType: "system"
user.account.banned No Staff action; actorId = staff UID
user.settings.updated No

Group events

Event type Failure logged? Notes
group.created No
group.deleted Yes Failure = not owner
group.settings.updated Yes Failure = admin changing owner-only setting
group.archived No Auto-archive uses actorType: "system"
group.unarchived No
group.member.joined Yes Failure = invalid invite code, invite disabled
group.member.left Yes Failure = owner attempting to leave
group.member.removed Yes Failure = admin attempting to remove another admin
group.request.submitted No
group.request.approved No
group.request.rejected No
group.member.roleChanged Yes Failure = non-owner attempt; metadata: {previousRole, newRole}
group.ownership.transferred Yes Failure = target not a current admin
group.inviteCode.regenerated Yes Failure = not owner or admin

GCP Setup (one-time, per environment)

Cloud Logging

No configuration required beyond deploying the service. The @google-cloud/logging SDK uses the existing Cloud Run service account credentials (GOOGLE_APPLICATION_CREDENTIALS). Log name: 95octane-audit.

Default Cloud Logging retention: 30 days (sufficient for recent incident investigation and operational queries via Log Explorer).

Log Sink → BigQuery

Create a log sink in the GCP Console (Logging → Log Router → Create Sink):

Setting Value
Sink name audit-logs-to-bigquery
Sink destination BigQuery dataset
Filter logName = "projects/{projectId}/logs/95octane-audit"
Dataset (dev) auditLogsDev
Dataset (prod) auditLogsProd
Use partitioned tables Yes

Schema is auto-generated from the first entry's jsonPayload.

BigQuery Retention

Set partition expiration on the events table after the sink creates it:

Environment Partition expiry
Dev 30 days
Prod 365 days

Partitions expire automatically — no manual purge process needed.

Access Control

Restrict the BigQuery dataset to internal staff roles only. No public access. Apply IAM at the dataset level in the GCP Console.


  • India DPDP Act 2023: 12-month retention satisfies the accountability principle for data processing records. No specific log retention period is mandated.
  • GDPR (if applicable): Security and audit logs are generally exempt from the right to erasure (Article 17(3)(b) — legal obligation). When a user account is permanently deleted, their audit log entries in BigQuery are retained for the remainder of the 365-day window.
  • Right to erasure boundary: Firestore user data is deleted by DeleteUserDataWorkflow. Audit logs in Cloud Logging and BigQuery are not deleted — they are anonymised by the fact that the actorId UID no longer resolves to an active account.