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.
Legal Notes
- 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 theactorIdUID no longer resolves to an active account.