From 74a234dcf691d9e955e4e06997d8de4731ae953a Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 26 Nov 2023 17:32:17 +0100 Subject: [PATCH] feat: audit logs (#479) * feat: tb audit log * chore: add example * fix: pipe order by * feat: add generic metadata schema * chore: audit example in server * chore: more zod magic * wip: audit log * fix: import and descriptions * chore: improve audit log * chore: include types * fix: typo * chore: small improvements * fix: index --- apps/server/src/checker/alerting.ts | 9 ++ apps/server/src/checker/checker.ts | 22 +---- apps/server/src/checker/monitor-handler.ts | 48 +++++++++ apps/server/src/utils/audit-log.ts | 7 ++ .../tinybird/datasources/audit_log.datasource | 13 +++ packages/tinybird/package.json | 2 +- .../tinybird/pipes/endpoint_audit_log.pipe | 8 ++ packages/tinybird/src/audit-log/README.md | 99 +++++++++++++++++++ .../tinybird/src/audit-log/action-schema.ts | 47 +++++++++ .../src/audit-log/action-validation.ts | 67 +++++++++++++ .../tinybird/src/audit-log/base-validation.ts | 95 ++++++++++++++++++ packages/tinybird/src/audit-log/client.ts | 30 ++++++ packages/tinybird/src/audit-log/examples.ts | 46 +++++++++ packages/tinybird/src/audit-log/index.ts | 1 + packages/tinybird/src/index.ts | 1 + pnpm-lock.yaml | 8 +- 16 files changed, 480 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/checker/monitor-handler.ts create mode 100644 apps/server/src/utils/audit-log.ts create mode 100644 packages/tinybird/datasources/audit_log.datasource create mode 100644 packages/tinybird/pipes/endpoint_audit_log.pipe create mode 100644 packages/tinybird/src/audit-log/README.md create mode 100644 packages/tinybird/src/audit-log/action-schema.ts create mode 100644 packages/tinybird/src/audit-log/action-validation.ts create mode 100644 packages/tinybird/src/audit-log/base-validation.ts create mode 100644 packages/tinybird/src/audit-log/client.ts create mode 100644 packages/tinybird/src/audit-log/examples.ts create mode 100644 packages/tinybird/src/audit-log/index.ts diff --git a/apps/server/src/checker/alerting.ts b/apps/server/src/checker/alerting.ts index e1657baf5a..b103465f54 100644 --- a/apps/server/src/checker/alerting.ts +++ b/apps/server/src/checker/alerting.ts @@ -7,6 +7,7 @@ import { import { flyRegionsDict } from "@openstatus/utils"; import { env } from "../env"; +import { checkerAudit } from "../utils/audit-log"; import { providerToFunction } from "./utils"; export const triggerAlerting = async ({ @@ -43,6 +44,14 @@ export const triggerAlerting = async ({ statusCode, message, }); + // ALPHA + await checkerAudit.publishAuditLog({ + id: `monitor:${monitorId}`, + action: "notification.sent", + targets: [{ id: monitorId, type: "monitor" }], + metadata: { provider: notif.notification.provider }, + }); + // } }; diff --git a/apps/server/src/checker/checker.ts b/apps/server/src/checker/checker.ts index 37934188b6..92e6237222 100644 --- a/apps/server/src/checker/checker.ts +++ b/apps/server/src/checker/checker.ts @@ -1,5 +1,4 @@ -import { env } from "../env"; -import { triggerAlerting, upsertMonitorStatus } from "./alerting"; +import { handleMonitorFailed, handleMonitorRecovered } from "./monitor-handler"; import type { PublishPingType } from "./ping"; import { pingEndpoint, publishPing } from "./ping"; import type { Payload } from "./schema"; @@ -76,10 +75,7 @@ const run = async (data: Payload, retry: number) => { message: undefined, }); if (data?.status === "error") { - await upsertMonitorStatus({ - monitorId: data.monitorId, - status: "active", - }); + handleMonitorRecovered(data, res); } } else { if (retry < 2) { @@ -96,20 +92,10 @@ const run = async (data: Payload, retry: number) => { payload: data, latency, statusCode: res?.status, - message: message, + message, }); - if (data?.status === "active") { - await upsertMonitorStatus({ - monitorId: data.monitorId, - status: "error", - }); - await triggerAlerting({ - monitorId: data.monitorId, - region: env.FLY_REGION, - statusCode: res?.status, - message, - }); + handleMonitorFailed(data, res, message); } } } diff --git a/apps/server/src/checker/monitor-handler.ts b/apps/server/src/checker/monitor-handler.ts new file mode 100644 index 0000000000..fd3d0a9856 --- /dev/null +++ b/apps/server/src/checker/monitor-handler.ts @@ -0,0 +1,48 @@ +import { env } from "../env"; +import { checkerAudit } from "../utils/audit-log"; +import { triggerAlerting, upsertMonitorStatus } from "./alerting"; +import type { Payload } from "./schema"; + +export async function handleMonitorRecovered(data: Payload, res: Response) { + await upsertMonitorStatus({ + monitorId: data.monitorId, + status: "active", + }); + // ALPHA + await checkerAudit.publishAuditLog({ + id: `monitor:${data.monitorId}`, + action: "monitor.recovered", + targets: [{ id: data.monitorId, type: "monitor" }], + metadata: { region: env.FLY_REGION, statusCode: res.status }, + }); + // +} + +export async function handleMonitorFailed( + data: Payload, + res: Response | null, + message?: string, +) { + await upsertMonitorStatus({ + monitorId: data.monitorId, + status: "error", + }); + // ALPHA + await checkerAudit.publishAuditLog({ + id: `monitor:${data.monitorId}`, + action: "monitor.failed", + targets: [{ id: data.monitorId, type: "monitor" }], + metadata: { + region: env.FLY_REGION, + statusCode: res?.status, + message, + }, + }); + // + await triggerAlerting({ + monitorId: data.monitorId, + region: env.FLY_REGION, + statusCode: res?.status, + message, + }); +} diff --git a/apps/server/src/utils/audit-log.ts b/apps/server/src/utils/audit-log.ts new file mode 100644 index 0000000000..1c2f37830c --- /dev/null +++ b/apps/server/src/utils/audit-log.ts @@ -0,0 +1,7 @@ +import { AuditLog, Tinybird } from "@openstatus/tinybird"; + +import { env } from "../env"; + +const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); + +export const checkerAudit = new AuditLog({ tb }); diff --git a/packages/tinybird/datasources/audit_log.datasource b/packages/tinybird/datasources/audit_log.datasource new file mode 100644 index 0000000000..4fa11660f7 --- /dev/null +++ b/packages/tinybird/datasources/audit_log.datasource @@ -0,0 +1,13 @@ +VERSION 0 + +SCHEMA > + `action` String `json:$.action`, + `actor` String `json:$.actor`, + `id` String `json:$.id`, + `targets` Nullable(String) `json:$.targets`, + `metadata` Nullable(String) `json:$.metadata`, + `timestamp` Int64 `json:$.timestamp`, + `version` Int16 `json:$.version` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "id, timestamp, action" diff --git a/packages/tinybird/package.json b/packages/tinybird/package.json index da5385634e..1d969551ad 100644 --- a/packages/tinybird/package.json +++ b/packages/tinybird/package.json @@ -4,7 +4,7 @@ "main": "src/index.ts", "license": "MIT", "dependencies": { - "@chronark/zod-bird": "0.2.2", + "@chronark/zod-bird": "0.3.1", "zod": "3.22.2" }, "devDependencies": { diff --git a/packages/tinybird/pipes/endpoint_audit_log.pipe b/packages/tinybird/pipes/endpoint_audit_log.pipe new file mode 100644 index 0000000000..50d27b92fc --- /dev/null +++ b/packages/tinybird/pipes/endpoint_audit_log.pipe @@ -0,0 +1,8 @@ +VERSION 0 + +NODE endpoint_audit_pipe_0 +SQL > + + % SELECT * FROM audit_log__v0 WHERE id = {{ String(event_id, 1) }} ORDER BY timestamp DESC + + diff --git a/packages/tinybird/src/audit-log/README.md b/packages/tinybird/src/audit-log/README.md new file mode 100644 index 0000000000..38c3627ff7 --- /dev/null +++ b/packages/tinybird/src/audit-log/README.md @@ -0,0 +1,99 @@ +## Motivation + +We want to track every change made for the `incident` and `monitor`. Therefore, +it requires us to build some audit log / event sourcing foundation. + +The `Event` is what the data type stored within [Tinybird](https://tinybird.co). +It has basic props that every event includes, as well as a `metadata` prop that +can be used to store additional informations. + +```ts +export type Event = { + /** + * Unique identifier for the event. + */ + id: string; + + /** + * The actor that triggered the event. + * @default { id: "", name: "system" } + * @example { id: "1", name: "mxkaske" } + */ + actor?: { + id: string; + name: string; + }; + + /** + * The ressources affected by the action taken. + * @example [{ id: "1", name: "monitor" }] + */ + targets?: { + id: string; + name: string; + }[]; + + /** + * The action that was triggered. + * @example monitor.down | incident.create + */ + action: string; + + /** + * The timestamp of the event in milliseconds since epoch UTC. + * @default Date.now() + */ + timestamp?: number; + + /** + * The version of the event. Should be incremented on each update. + * @default 1 + */ + version?: number; + + /** + * Metadata for the event. Defined via zod schema. + */ + metadata?: unknown; +}; +``` + +The objects are parsed and stored as string via +`schema.transform(val => JSON.stringify(val))` and transformed back into an +object before parsing via `z.preprocess(val => JSON.parse(val), schema)`. + +## Example + +```ts +const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); + +const auditLog = new AuditLog({ tb }); + +await auditLog.publishAuditLog({ + id: "monitor:1", + action: "monitor.down", + targets: [{ id: "1", type: "monitor" }], // not mandatory, but could be useful later on + metadata: { region: "gru", statusCode: 400, message: "timeout" }, +}); + +await auditLog.getAuditLog({ event_id: "monitor:1" }); +``` + +## Inspiration + +- WorkOS [Audit Logs](https://workos.com/docs/audit-logs) + +## Tinybird + +Push the pipe and datasource to tinybird: + +``` +tb push datasources/audit_log.datasource +tb push pipes/endpoint_audit_log.pipe +``` + +--- + +### Possible extention + +> TODO: Remove `Nullable` from `targets` to better index and query it. diff --git a/packages/tinybird/src/audit-log/action-schema.ts b/packages/tinybird/src/audit-log/action-schema.ts new file mode 100644 index 0000000000..cf1c00c548 --- /dev/null +++ b/packages/tinybird/src/audit-log/action-schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +/** + * The schema for the monitor.recovered action. + * It represents the event when a monitor has recovered from a failure. + */ +export const monitorRecoveredSchema = z.object({ + action: z.literal("monitor.recovered"), + metadata: z.object({ region: z.string(), statusCode: z.number() }), +}); + +/** + * The schema for the monitor.failed action. + * It represents the event when a monitor has failed. + */ +export const monitorFailedSchema = z.object({ + action: z.literal("monitor.failed"), + metadata: z.object({ + region: z.string(), + statusCode: z.number().optional(), + message: z.string().optional(), + }), +}); + +/** + * The schema for the notification.send action. + * + */ +export const notificationSentSchema = z.object({ + action: z.literal("notification.sent"), + // we could use the notificationProviderSchema for more type safety + metadata: z.object({ provider: z.string() }), +}); + +// TODO: update schemas with correct metadata and description + +export const incidentCreatedSchema = z.object({ + action: z.literal("incident.created"), + metadata: z.object({}), // tbd +}); + +export const incidentResolvedSchema = z.object({ + action: z.literal("incident.resolved"), + metadata: z.object({}), // tbd +}); + +// ... diff --git a/packages/tinybird/src/audit-log/action-validation.ts b/packages/tinybird/src/audit-log/action-validation.ts new file mode 100644 index 0000000000..e478cbee93 --- /dev/null +++ b/packages/tinybird/src/audit-log/action-validation.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { + monitorFailedSchema, + monitorRecoveredSchema, + notificationSentSchema, +} from "./action-schema"; +import { ingestBaseEventSchema, pipeBaseResponseData } from "./base-validation"; + +/** + * The schema for the event object. + * It extends the base schema. It uses the `discriminatedUnion` method for faster + * evaluation to determine which schema to be used to parse the input. + * It also transforms the metadata object into a string. + * + * @todo: whenever a new action is added, it should be included to the discriminatedUnion + */ +export const ingestActionEventSchema = z + .intersection( + // Unfortunately, the array cannot be dynamic, otherwise could be added to the Client + // and made available to devs as library + z.discriminatedUnion("action", [ + monitorRecoveredSchema, + monitorFailedSchema, + notificationSentSchema, + ]), + ingestBaseEventSchema, + ) + .transform((val) => ({ + ...val, + metadata: JSON.stringify(val.metadata), + })); + +/** + * The schema for the response object. + * It extends the base schema. It uses the `discriminatedUnion` method for faster + * evaluation to determine which schema to be used to parse the input. + * It also preprocesses the metadata string into the correct schema object. + * + * @todo: whenever a new action is added, it should be included to the discriminatedUnion + */ +export const pipeActionResponseData = z.intersection( + z.discriminatedUnion("action", [ + monitorRecoveredSchema.extend({ + metadata: z.preprocess( + (val) => JSON.parse(String(val)), + monitorRecoveredSchema.shape.metadata, + ), + }), + monitorFailedSchema.extend({ + metadata: z.preprocess( + (val) => JSON.parse(String(val)), + monitorFailedSchema.shape.metadata, + ), + }), + notificationSentSchema.extend({ + metadata: z.preprocess( + (val) => JSON.parse(String(val)), + notificationSentSchema.shape.metadata, + ), + }), + ]), + pipeBaseResponseData, +); + +export type IngestActionEvent = z.infer; +export type PipeActionResponseData = z.infer; diff --git a/packages/tinybird/src/audit-log/base-validation.ts b/packages/tinybird/src/audit-log/base-validation.ts new file mode 100644 index 0000000000..06ad4210e2 --- /dev/null +++ b/packages/tinybird/src/audit-log/base-validation.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +/** + * The base schema for every event, used to validate it's structure + * on datasource ingestion and pipe retrieval. + */ +export const baseSchema = z.object({ + id: z.string().min(1), // DISCUSS: we could use the `${targets.type}:${targets.id}` format automatic generation + action: z.string(), + // REMINDER: do not use .default(Date.now()), it will be evaluated only once + timestamp: z + .number() + .int() + .optional() + .transform((val) => val || Date.now()), + version: z.number().int().default(1), +}); + +/** + * The schema for the actor type. + * It represents the type of the user that triggered the event. + */ +export const actorTypeSchema = z.enum(["user", "system"]); + +/** + * The schema for the actor object. + * It represents the user that triggered the event. + */ +export const actorSchema = z + .object({ + id: z.string(), + type: actorTypeSchema, + }) + .default({ + id: "server", + type: "system", + }); + +/** + * The schema for the target type. + * It represents the type of the target that is affected by the event. + */ +export const targetTypeSchema = z.enum([ + "monitor", + "page", + "incident", + "user", + "notification", + "organization", +]); + +/** + * The schema for the targets object. + * It represents the targets that are affected by the event. + */ +export const targetsSchema = z + .object({ + id: z.string(), + type: targetTypeSchema, + }) + .array() + .optional(); + +/** + * The schema for the event object. + * It extends the base schema and transforms the actor, targets + * objects into strings. + */ +export const ingestBaseEventSchema = baseSchema.extend({ + actor: actorSchema.transform((val) => JSON.stringify(val)), + targets: targetsSchema.transform((val) => JSON.stringify(val)), +}); + +/** + * The schema for the response object. + * It extends the base schema and transforms the actor, targets + * back into typed objects. + */ +export const pipeBaseResponseData = baseSchema.extend({ + actor: z.preprocess((val) => JSON.parse(String(val)), actorSchema), + targets: z.preprocess( + (val) => (val ? JSON.parse(String(val)) : undefined), + targetsSchema, + ), +}); + +/** + * The schema for the parameters object. + * It represents the parameters that are passed to the pipe. + */ +export const pipeParameterData = z.object({ event_id: z.string().min(1) }); + +export type PipeParameterData = z.infer; +export type PipeBaseResponseData = z.infer; +export type IngestBaseEvent = z.infer; diff --git a/packages/tinybird/src/audit-log/client.ts b/packages/tinybird/src/audit-log/client.ts new file mode 100644 index 0000000000..3d9e53b7ba --- /dev/null +++ b/packages/tinybird/src/audit-log/client.ts @@ -0,0 +1,30 @@ +import type { Tinybird } from "@chronark/zod-bird"; + +import { + ingestActionEventSchema, + pipeActionResponseData, +} from "./action-validation"; +import { pipeParameterData } from "./base-validation"; + +export class AuditLog { + private readonly tb: Tinybird; + + constructor(opts: { tb: Tinybird }) { + this.tb = opts.tb; + } + + get publishAuditLog() { + return this.tb.buildIngestEndpoint({ + datasource: "audit_log__v0", + event: ingestActionEventSchema, + }); + } + + get getAuditLog() { + return this.tb.buildPipe({ + pipe: "endpoint_audit_log__v0", + parameters: pipeParameterData, + data: pipeActionResponseData, + }); + } +} diff --git a/packages/tinybird/src/audit-log/examples.ts b/packages/tinybird/src/audit-log/examples.ts new file mode 100644 index 0000000000..e04af47b1a --- /dev/null +++ b/packages/tinybird/src/audit-log/examples.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Tinybird } from "@chronark/zod-bird"; + +import { AuditLog } from "./client"; + +const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); + +const auditLog = new AuditLog({ tb }); + +async function seed() { + await auditLog.publishAuditLog({ + id: "monitor:2", + action: "monitor.failed", + targets: [{ id: "2", type: "monitor" }], + metadata: { region: "gru", statusCode: 500, message: "timeout" }, + }); + await auditLog.publishAuditLog({ + id: "monitor:1", + action: "monitor.recovered", + targets: [{ id: "1", type: "monitor" }], + metadata: { region: "gru", statusCode: 200 }, + }); + await auditLog.publishAuditLog({ + id: "user:1", + actor: { + type: "user", + id: "1", + }, + targets: [{ id: "1", type: "user" }], + action: "notification.sent", + metadata: { provider: "email" }, + }); +} + +async function history() { + return await auditLog.getAuditLog({ event_id: "user:1" }); +} + +// seed(); +// const all = await history(); +// console.log(all); +// const first = all.data[0]; + +// if (first.action === "monitor.failed") { +// first.metadata.message; +// } diff --git a/packages/tinybird/src/audit-log/index.ts b/packages/tinybird/src/audit-log/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/packages/tinybird/src/audit-log/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/packages/tinybird/src/index.ts b/packages/tinybird/src/index.ts index 2c54b7aebb..bbc7625365 100644 --- a/packages/tinybird/src/index.ts +++ b/packages/tinybird/src/index.ts @@ -1,3 +1,4 @@ export * from "./client"; export * from "./validation"; +export * from "./audit-log"; export * from "@chronark/zod-bird"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db3890ec8..38f4e0914f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -745,8 +745,8 @@ importers: packages/tinybird: dependencies: '@chronark/zod-bird': - specifier: 0.2.2 - version: 0.2.2 + specifier: 0.3.1 + version: 0.3.1 zod: specifier: 3.22.2 version: 3.22.2 @@ -1180,8 +1180,8 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 - /@chronark/zod-bird@0.2.2: - resolution: {integrity: sha512-0mmiiw4dny1aiEOmawsIJUaYW16vhWRuDGsDa62GfFhdD3F7hHrTi3lWNWYCVq2KsCS+CYwMj8nkdFuOnejCTA==} + /@chronark/zod-bird@0.3.1: + resolution: {integrity: sha512-4PNJx41m37Psk/3XAxdXxMb9VGAep43/puiwxAyFWYzIoQ1kq3Cwh1sA51LkTFSVHm+B2peMmW+p3/+oCHH9zg==} dependencies: zod: 3.22.2 dev: false