import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; import { auditLog } from "./audit_log"; /** * Runtime schema + discriminated-union type for every audit action * services emit. Paired with `emitAudit` in `@openstatus/services`, * which calls `AuditEntrySchema.parse(...)` before writing the row. * * Every entity gets at most three verbs: `create`, `update`, `delete`. * Operation-specific semantics (acknowledge, resolve, accept, revoke, * rename, bulk-set) collapse into whichever of the three they * structurally are; readers recover the specific intent by inspecting * `changed_fields` on the row. * * `metadata` is reserved for side-channel context that is *not* * derivable from the entity snapshot — e.g. `clonedFromMonitorId`, * `statusReportId` on a status-report update. Fields that change on the * entity itself belong in `before`/`after`. * * Adding a new emit site requires adding a variant here — there is no * escape hatch. If a new operation isn't a create/update/delete, it * probably shouldn't be in the audit log. */ const metadataSchema = z.record(z.string(), z.unknown()); const snapshotSchema = z.record(z.string(), z.unknown()); // Small helper so every variant reads the same — keeps the discriminated // union legible when skimming. function action( action: A, entityType: E, entityIdSchema: I, opts: { optionalMetadata?: boolean } = {}, ) { return z.object({ action: z.literal(action), entityType: z.literal(entityType), entityId: entityIdSchema, ...(opts.optionalMetadata ? { metadata: metadataSchema.optional() } : {}), }); } const intId = z.number().int(); const monitorActions = [ action("monitor.create", "monitor", intId, { optionalMetadata: true }), action("monitor.update", "monitor", intId), action("monitor.delete", "monitor", intId), ] as const; const pageActions = [ action("page.create", "page", intId, { optionalMetadata: true }), action("page.update", "page", intId), action("page.delete", "page", intId), ] as const; const pageComponentActions = [ action("page_component.create", "page_component", intId, { optionalMetadata: true, }), action("page_component.update", "page_component", intId), action("page_component.delete", "page_component", intId), ] as const; const pageComponentGroupActions = [ action("page_component_group.create", "page_component_group", intId, { optionalMetadata: true, }), action("page_component_group.update", "page_component_group", intId), action("page_component_group.delete", "page_component_group", intId), ] as const; const pageSubscriberActions = [ action("page_subscriber.create", "page_subscriber", intId, { optionalMetadata: true, }), action("page_subscriber.update", "page_subscriber", intId, { optionalMetadata: true, }), action("page_subscriber.delete", "page_subscriber", intId, { optionalMetadata: true, }), ] as const; const apiKeyActions = [ action("api_key.create", "api_key", intId, { optionalMetadata: true }), action("api_key.delete", "api_key", intId), ] as const; // `delete` covers every revocation path: manual, re-consent, member removal // and account deletion. `metadata` carries client name and scope on create. const oauthGrantActions = [ action("oauth_grant.create", "oauth_grant", intId, { optionalMetadata: true, }), action("oauth_grant.delete", "oauth_grant", intId, { optionalMetadata: true, }), ] as const; const notificationActions = [ action("notification.create", "notification", intId), action("notification.update", "notification", intId), action("notification.delete", "notification", intId), ] as const; const userActions = [action("user.delete", "user", intId)] as const; const workspaceActions = [ action("workspace.update", "workspace", intId, { optionalMetadata: true }), ] as const; const maintenanceActions = [ action("maintenance.create", "maintenance", intId, { optionalMetadata: true, }), action("maintenance.update", "maintenance", intId), action("maintenance.delete", "maintenance", intId), ] as const; const monitorIncidentActions = [ action("monitor_incident.update", "monitor_incident", intId), action("monitor_incident.delete", "monitor_incident", intId), ] as const; const incidentActions = [ action("incident.create", "incident", intId, { optionalMetadata: true }), action("incident.update", "incident", intId, { optionalMetadata: true }), action("incident.delete", "incident", intId), ] as const; // Append-only timeline: events are never updated or deleted on their own. const incidentEventActions = [ action("incident_event.create", "incident_event", intId, { optionalMetadata: true, }), ] as const; const incidentPostmortemActions = [ action("incident_postmortem.create", "incident_postmortem", intId, { optionalMetadata: true, }), action("incident_postmortem.update", "incident_postmortem", intId, { optionalMetadata: true, }), ] as const; const slackUserActions = [ action("slack_user.create", "slack_user", intId, { optionalMetadata: true }), action("slack_user.delete", "slack_user", intId, { optionalMetadata: true }), ] as const; const statusReportActions = [ action("status_report.create", "status_report", intId, { optionalMetadata: true, }), action("status_report.update", "status_report", intId), action("status_report.delete", "status_report", intId), ] as const; const statusReportUpdateActions = [ action("status_report_update.create", "status_report_update", intId, { optionalMetadata: true, }), action("status_report_update.update", "status_report_update", intId, { optionalMetadata: true, }), action("status_report_update.delete", "status_report_update", intId, { optionalMetadata: true, }), ] as const; const invitationActions = [ action("invitation.create", "invitation", intId, { optionalMetadata: true }), action("invitation.update", "invitation", intId), action("invitation.delete", "invitation", intId), ] as const; // Several entities below carry only a subset of the create/update/delete // triplet. This is intentional, not an oversight: // - `member`: invitation-driven joins are recorded as `invitation.update` // (the acceptance stamp), so `member.create` covers only the paths that // mint a membership with no invitation behind it — today, SSO JIT // provisioning. // - `monitor_incident`: rows originate from the checker pipeline, not user // mutations. // When those write paths migrate to the service layer, add the missing // verbs alongside. const memberActions = [ // `entityId` is the affected user's id. Member rows have no surrogate key — // they're (userId, workspaceId) composite — so the user id is the most // useful identifier to fix on in the audit log. action("member.create", "member", intId, { optionalMetadata: true }), action("member.delete", "member", intId), ] as const; const integrationActions = [ action("integration.create", "integration", intId, { optionalMetadata: true, }), action("integration.update", "integration", intId), action("integration.delete", "integration", intId), ] as const; const monitorTagActions = [ action("monitor_tag.create", "monitor_tag", intId, { optionalMetadata: true, }), action("monitor_tag.update", "monitor_tag", intId), action("monitor_tag.delete", "monitor_tag", intId), ] as const; const privateLocationActions = [ action("private_location.create", "private_location", intId, { optionalMetadata: true, }), action("private_location.update", "private_location", intId), action("private_location.delete", "private_location", intId), ] as const; // `entityId` is the workspace id — SSO config has no surrogate key of its own. // Verified-domain rows fold into the same entity, distinguished by // `metadata.domain`; `delete` therefore means "a domain was dropped", never // "SSO was turned off" (that's an `update` on the `ssoEnabled` switch). const ssoActions = [ action("workspace_sso.create", "workspace_sso", intId, { optionalMetadata: true, }), action("workspace_sso.update", "workspace_sso", intId, { optionalMetadata: true, }), action("workspace_sso.delete", "workspace_sso", intId, { optionalMetadata: true, }), ] as const; export const auditActionSchema = z.discriminatedUnion("action", [ ...monitorActions, ...pageActions, ...pageComponentActions, ...pageComponentGroupActions, ...pageSubscriberActions, ...apiKeyActions, ...oauthGrantActions, ...notificationActions, ...userActions, ...workspaceActions, ...maintenanceActions, ...monitorIncidentActions, ...incidentActions, ...incidentEventActions, ...incidentPostmortemActions, ...slackUserActions, ...statusReportActions, ...statusReportUpdateActions, ...invitationActions, ...memberActions, ...integrationActions, ...monitorTagActions, ...privateLocationActions, ...ssoActions, ]); /** * Audit-entry input schema — the action/entityType/entityId+metadata * discriminant plus optional `before`/`after` snapshots. * * Derived from `auditActionSchema` via `.and()` so the variant list * stays single-sourced — adding a new action here would otherwise * require two parallel edits. * * The `before`/`after` snapshots are intentionally loose at runtime * (`Record`): their shapes mirror the underlying * entity tables and binding them to this schema would force a cycle * with every column definition. Shape *presence* is enforced in * TypeScript via the `AuditEntry` type below, keyed off the action * verb suffix. */ export const auditEntrySchema = auditActionSchema.and( z.object({ before: snapshotSchema.optional(), after: snapshotSchema.optional(), }), ); export const insertAuditLogSchema = createInsertSchema(auditLog); export const selectAuditLogSchema = createSelectSchema(auditLog); export type AuditAction = z.infer; type Snapshot = Record; /** * Snapshot presence is derived from the action's verb suffix: * * - `*.create` — `after` required, `before` forbidden. * - `*.delete` — `before` required, `after` forbidden. * - `*.update` — both `before` and `after` required. * * Enforced at the `emitAudit` call site via the parameter type; Zod * stays loose at runtime so the schema file doesn't need to know each * entity's column list. */ export type AuditEntry = AuditAction extends infer A ? A extends { action: `${string}.create` } ? A & { after: Snapshot; before?: never } : A extends { action: `${string}.delete` } ? A & { before: Snapshot; after?: never } : A extends { action: `${string}.update` } ? A & { before: Snapshot; after: Snapshot } : A & { before?: Snapshot; after?: Snapshot } : never; export type AuditActionName = AuditAction["action"]; export type AuditEntityType = AuditAction["entityType"];