From 08ead1da8eaf600a58f0fa75c6e86c5b474851ce Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 00:44:32 +0100 Subject: [PATCH 01/30] feat(security): audit_trail_events table, list endpoint and Maple's own events One warehouse table for audit events from every connected platform, normalized to who did what to which target with what outcome. This first layer adds the table, the read path and the first writer. - audit_trail_events (migration 0064, local schema v56): ReplacingMergeTree keyed on a hash of provider, account and the provider's event id, so a redelivery collapses. A version column makes the first write win, so a redelivered event keeps its first IngestedAt. 400-day TTL, minmax index on IngestedAt. Written through ingest only, pinned to the managed warehouse and hidden from raw SQL like audit_log. - AuditTrailService: write (what every provider adapter calls) and list. - GET /internal/audit-trail/events: session only, behind audit_log:read, keyset paginated on (OccurredAt, Id), filters for provider, account, category, outcome, action, actor and time. A Maple member's email also finds their events. - Maple's own changes and refusals are copied in by the audit events consumer and the direct write path. Allowed reads, tool calls and SQL runs stay in audit_log. A failed copy is logged and dropped so it never sends stored entries to the DLQ. --- apps/ai/src/worker/http.test.ts | 11 +- apps/api/src/audit-events-runtime.test.ts | 128 +- apps/api/src/audit-events-runtime.ts | 39 +- .../routes/internal/audit-trail.http.test.ts | 250 ++ .../src/routes/internal/audit-trail.http.ts | 95 + apps/api/src/routes/org-member-directory.ts | 24 + apps/api/src/routes/v2/audit-log.http.ts | 34 +- apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + apps/cli/test/local-store-migrations.test.ts | 2 + apps/ingest/src/clickhouse_insert_mappings.rs | 2 +- .../audit-trail/AuditTrailService.test.ts | 271 ++ .../services/audit-trail/AuditTrailService.ts | 149 + .../services/audit-trail/audit-trail-row.ts | 90 + .../audit-trail/providers/maple.test.ts | 113 + .../services/audit-trail/providers/maple.ts | 167 + .../services/audit/AuditLogService.test.ts | 57 +- .../src/services/audit/AuditLogService.ts | 9 +- .../services/warehouse/warehouse-catalog.ts | 5 +- packages/domain/package.json | 1 + packages/domain/src/audit-trail.ts | 63 + .../src/clickhouse/effect-orm-parity.test.ts | 1 + .../migrations/0064_audit_trail_events.ts | 12 + .../domain/src/clickhouse/migrations/index.ts | 2 + .../anticipated-error-identifiers.ts | 1 + .../domain/src/generated/clickhouse-schema.ts | 3 +- .../generated/tinybird-project-manifest.ts | 7 +- packages/domain/src/http/audit-trail.ts | 108 + packages/domain/src/http/index.ts | 1 + packages/domain/src/http/internal-api.ts | 2 + packages/domain/src/tinybird/datasources.ts | 48 + .../src/tinybird/retention-matrix.test.ts | 2 + .../local-server/src/local-schema-history.ts | 2 + .../local-server/src/local-schema-version.ts | 2 +- .../src/local-store-migrations/steps.ts | 21 + .../src/schema/history/local-schema-v56.sql | 3007 +++++++++++++++++ .../src/schema/local-inserts.json | 2 +- .../local-server/src/schema/local-schema.sql | 32 +- packages/local-server/src/scoped-delete.ts | 1 + .../src/__sql_baseline__/catalog.sql | 65 + .../query-engine/src/benchmark/builders.ts | 43 + .../src/benchmark/catalog.test.ts | 2 + packages/query-engine/src/ch/index.ts | 1 + .../src/ch/queries/audit-trail.ts | 84 + .../src/execution/datasource-routing.ts | 1 + 45 files changed, 4885 insertions(+), 79 deletions(-) create mode 100644 apps/api/src/routes/internal/audit-trail.http.test.ts create mode 100644 apps/api/src/routes/internal/audit-trail.http.ts create mode 100644 apps/api/src/routes/org-member-directory.ts create mode 100644 packages/backend/src/services/audit-trail/AuditTrailService.test.ts create mode 100644 packages/backend/src/services/audit-trail/AuditTrailService.ts create mode 100644 packages/backend/src/services/audit-trail/audit-trail-row.ts create mode 100644 packages/backend/src/services/audit-trail/providers/maple.test.ts create mode 100644 packages/backend/src/services/audit-trail/providers/maple.ts create mode 100644 packages/domain/src/audit-trail.ts create mode 100644 packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts create mode 100644 packages/domain/src/http/audit-trail.ts create mode 100644 packages/local-server/src/schema/history/local-schema-v56.sql create mode 100644 packages/query-engine/src/ch/queries/audit-trail.ts diff --git a/apps/ai/src/worker/http.test.ts b/apps/ai/src/worker/http.test.ts index 859e79ec82..8c2c8440db 100644 --- a/apps/ai/src/worker/http.test.ts +++ b/apps/ai/src/worker/http.test.ts @@ -22,6 +22,7 @@ import { HttpServerResponse } from "effect/http" import { TestClock } from "effect/testing" import { AuditEventsQueueProducer, MapleDbConnection } from "@maple/backend/platform/bindings" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { makeWarehouseServiceStub } from "@maple/backend/testing/warehouse-test-support" import { OrgId } from "@maple/primitives" @@ -216,9 +217,13 @@ describe("the maple-ai Worker through alchemy's bridge", () => { const handedOver = yield* Ref.make>>([]) const sent = yield* Ref.make(0) const ingested = yield* Ref.make(0) - const warehouse = Layer.succeed( - WarehouseQueryService, - makeWarehouseServiceStub({ ingest: () => Ref.update(ingested, (n) => n + 1) }), + const warehouse = Layer.effect(AuditTrailService, AuditTrailService.make).pipe( + Layer.provideMerge( + Layer.succeed( + WarehouseQueryService, + makeWarehouseServiceStub({ ingest: () => Ref.update(ingested, (n) => n + 1) }), + ), + ), ) const producer = Layer.succeed(AuditEventsQueueProducer, { sendBatch: (messages) => Ref.update(sent, (n) => n + messages.length), diff --git a/apps/api/src/audit-events-runtime.test.ts b/apps/api/src/audit-events-runtime.test.ts index 2e7a011f47..78d832d273 100644 --- a/apps/api/src/audit-events-runtime.test.ts +++ b/apps/api/src/audit-events-runtime.test.ts @@ -1,26 +1,32 @@ import { describe, expect, it } from "@effect/vitest" import { OrgId } from "@maple/primitives" -import type { AuditLogRow } from "@maple/domain/tinybird" import { WarehouseUpstreamError } from "@maple/domain/http" import { Effect, Layer, Schema, Array as Arr } from "effect" import { processAuditEventsBatch } from "./audit-events-runtime" import { makeWarehouseServiceStub } from "@/routes/v2/v2-test-support" import { AuditLogEvent, encodeAuditLogEventSync } from "@maple/backend/services/audit/audit-event" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" const asOrgId = Schema.decodeUnknownSync(OrgId) const ORG = asOrgId("org_audit_consumer_test") const OTHER_ORG = asOrgId("org_audit_consumer_other") -const event = (id: string, orgId: OrgId = ORG) => +const event = ( + id: string, + orgId: OrgId = ORG, + what: { readonly action: string; readonly outcome: "allowed" | "denied" } = { + action: "dashboard.created", + outcome: "allowed", + }, +) => encodeAuditLogEventSync( new AuditLogEvent({ orgId, id: Schema.decodeSync(AuditLogEvent.fields.id)(id), actorType: "user", source: "dashboard", - action: "dashboard.created", - outcome: "allowed", + ...what, occurredAtMs: 1_700_000_000_000, }), ) @@ -45,42 +51,51 @@ const message = (body: unknown, attempts: number) => { const batchOf = (...messages: ReadonlyArray<{ readonly message: unknown }>) => ({ messages: Arr.map(messages, (entry) => entry.message) }) as never +/** The consumer's services over one warehouse stub: the trail writes through the same `ingest`. */ +const withTrail = (stub: ReturnType) => + Layer.effect(AuditTrailService, AuditTrailService.make).pipe( + Layer.provideMerge(Layer.succeed(WarehouseQueryService, stub)), + ) + /** A warehouse whose `ingest` is interrupted — a deploy tearing the isolate down. */ -const interruptedWarehouse = Layer.succeed( - WarehouseQueryService, - makeWarehouseServiceStub({ ingest: () => Effect.interrupt }), -) +const interruptedWarehouse = withTrail(makeWarehouseServiceStub({ ingest: () => Effect.interrupt })) /** A warehouse whose `ingest` dies rather than failing — an unexpected defect. */ -const dyingWarehouse = Layer.succeed( - WarehouseQueryService, +const dyingWarehouse = withTrail( makeWarehouseServiceStub({ ingest: () => Effect.die(new Error("ingest exploded")), }), ) -/** A warehouse whose `ingest` records each call, or fails every call. */ -const warehouse = (fail = false) => { - const written: Array<{ orgId: string; rows: ReadonlyArray }> = [] - const layer = Layer.succeed( - WarehouseQueryService, +const AUDIT_LOG = "audit_log" +const AUDIT_TRAIL = "audit_trail_events" + +/** A warehouse whose `ingest` records each call, failing the ones to the `failing` datasources. */ +const warehouse = (failing: ReadonlyArray = []) => { + const written: Array<{ orgId: string; datasource: string; rows: ReadonlyArray }> = [] + const layer = withTrail( makeWarehouseServiceStub({ - ingest: (tenant, _datasource, rows) => - fail + ingest: (tenant, datasource, rows) => + failing.includes(datasource) ? Effect.fail( new WarehouseUpstreamError({ message: "tinybird down", - pipeName: "audit_log", + pipeName: datasource, cause: new Error("down"), }), ) : Effect.sync(() => { - // SAFETY: this stub only ever receives the audit datasource's rows. - written.push({ orgId: tenant.orgId, rows: rows as ReadonlyArray }) + written.push({ orgId: tenant.orgId, datasource, rows }) }), }), ) - return { written, layer } + /** `[orgId, row count]` per write to one datasource, in a stable order. */ + const writesTo = (datasource: string) => + Arr.map( + Arr.filter(written, (write) => write.datasource === datasource), + (write) => [write.orgId, write.rows.length] as const, + ).sort() + return { written, writesTo, layer } } describe("processAuditEventsBatch", () => { @@ -97,16 +112,67 @@ describe("processAuditEventsBatch", () => { expect(first.calls).toEqual(["ack"]) expect(second.calls).toEqual(["ack"]) expect(other.calls).toEqual(["ack"]) - expect(Arr.map(store.written, (write) => [write.orgId, write.rows.length]).sort()).toEqual([ + expect(store.writesTo(AUDIT_LOG)).toEqual([ [OTHER_ORG, 1], [ORG, 2], ]) - expect( - Arr.every( - Arr.flatMap(store.written, (write) => write.rows), - (row) => row.Action === "dashboard.created", - ), - ).toBe(true) + }), + ) + + it.effect("copies changes and refusals to the audit trail, and leaves allowed reads out", () => + Effect.gen(function* () { + const store = warehouse() + const change = message(event("11111111-1111-4111-8111-111111111111"), 1) + const read = message( + event("22222222-2222-4222-8222-222222222222", ORG, { + action: "telemetry.read", + outcome: "allowed", + }), + 1, + ) + const refusedRead = message( + event("33333333-3333-4333-8333-333333333333", ORG, { + action: "telemetry.read", + outcome: "denied", + }), + 1, + ) + yield* processAuditEventsBatch(batchOf(change, read, refusedRead), MAX_RETRIES).pipe( + Effect.provide(store.layer), + ) + + expect(store.writesTo(AUDIT_LOG)).toEqual([[ORG, 3]]) + expect(store.writesTo(AUDIT_TRAIL)).toEqual([[ORG, 2]]) + }), + ) + + it.effect("writes nothing to the audit trail for a batch of allowed reads", () => + Effect.gen(function* () { + const store = warehouse() + const read = message( + event("11111111-1111-4111-8111-111111111111", ORG, { + action: "telemetry.read", + outcome: "allowed", + }), + 1, + ) + yield* processAuditEventsBatch(batchOf(read), MAX_RETRIES).pipe(Effect.provide(store.layer)) + + expect(read.calls).toEqual(["ack"]) + expect(store.writesTo(AUDIT_TRAIL)).toEqual([]) + }), + ) + + // `audit_log` is the record. A message retried because only its copy failed would be + // rewritten there on every attempt and end in the DLQ for an entry that was stored. + it.effect("acks an entry whose audit trail copy failed", () => + Effect.gen(function* () { + const store = warehouse([AUDIT_TRAIL]) + const change = message(event("11111111-1111-4111-8111-111111111111"), 1) + yield* processAuditEventsBatch(batchOf(change), MAX_RETRIES).pipe(Effect.provide(store.layer)) + + expect(change.calls).toEqual(["ack"]) + expect(store.writesTo(AUDIT_LOG)).toEqual([[ORG, 1]]) }), ) @@ -117,7 +183,7 @@ describe("processAuditEventsBatch", () => { Effect.gen(function* () { const exhausted = message(event("44444444-4444-4444-8444-444444444444"), MAX_RETRIES + 1) yield* processAuditEventsBatch(batchOf(exhausted), MAX_RETRIES).pipe( - Effect.provide(warehouse(true).layer), + Effect.provide(warehouse([AUDIT_LOG]).layer), ) expect(exhausted.calls).toEqual(["retry"]) }), @@ -128,7 +194,7 @@ describe("processAuditEventsBatch", () => { const a = message(event("55555555-5555-4555-8555-555555555555"), 2) const b = message(event("66666666-6666-4666-8666-666666666666"), 2) yield* processAuditEventsBatch(batchOf(a, b), MAX_RETRIES).pipe( - Effect.provide(warehouse(true).layer), + Effect.provide(warehouse([AUDIT_LOG]).layer), ) expect(a.calls).toEqual(["retry"]) expect(b.calls).toEqual(["retry"]) @@ -175,7 +241,7 @@ describe("processAuditEventsBatch", () => { expect(malformed.calls).toEqual(["ack"]) expect(fine.calls).toEqual(["ack"]) - expect(store.written).toHaveLength(1) + expect(store.writesTo(AUDIT_LOG)).toEqual([[ORG, 1]]) }), ) }) diff --git a/apps/api/src/audit-events-runtime.ts b/apps/api/src/audit-events-runtime.ts index 276b3a3dff..ef5216f46f 100644 --- a/apps/api/src/audit-events-runtime.ts +++ b/apps/api/src/audit-events-runtime.ts @@ -8,6 +8,11 @@ import type { QueueBatch } from "@maple/backend/platform/queue-batch" import { ALERTING_SYSTEM_USER_ID, systemTenant } from "@maple/backend/services/auth/system-tenant" import { AUDIT_LOG_DATASOURCE } from "@maple/backend/services/audit/AuditLogService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { + AuditTrailService, + type AuditTrailServiceApi, +} from "@maple/backend/services/audit-trail/AuditTrailService" +import { mapleAuditTrailEvents } from "@maple/backend/services/audit-trail/providers/maple" import { type AuditLogEvent, auditEventToRow, @@ -21,7 +26,7 @@ import { * layer requires them, not because a write ever consults them. Its spans are * `maple-api`'s, through the telemetry the bridge builds into the event. */ -export const AuditEventsLive = WarehouseQueryService.layer.pipe( +export const AuditEventsLive = Layer.mergeAll(WarehouseQueryService.layer, AuditTrailService.layer).pipe( Layer.provide(Layer.mergeAll(EventBaseLive, EdgeCacheServiceLive)), ) @@ -72,8 +77,24 @@ const retryOrExhaust = (message: Message, cause: unknown, maxRetries: number) => } /** - * Audit events queue consumer: lowers each event to its `audit_log` row and - * writes one batch per org through the managed ingest pipeline. The table is a + * Changes and refusals also go to the unified audit trail. `audit_log` is the record, so a failed + * copy is logged and dropped: retrying the message for it would send stored entries to the DLQ. + */ +const copyToTrail = (trail: AuditTrailServiceApi, orgId: OrgId, events: ReadonlyArray) => + Effect.suspend(() => trail.write(orgId, mapleAuditTrailEvents(events))).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("Audit trail copy failed; audit_log kept the entries").pipe( + Effect.annotateLogs({ orgId, entries: events.length, error: summarizeCause(cause) }), + ), + ), + ) + +/** + * Audit events queue consumer: lowers each event to its `audit_log` row (and, for a change or + * a refusal, its `audit_trail_events` row) and writes one batch per org through the managed + * ingest pipeline. The table is a * ReplacingMergeTree on the entry id, so queue redelivery collapses at merge * time; write failures retry through the queue's policy and, once exhausted, * land in `audit-events-dlq` rather than disappearing. @@ -83,6 +104,7 @@ export const processAuditEventsBatch = Effect.fn("AuditEvents.processBatch")(fun maxRetries: number, ) { const warehouse = yield* WarehouseQueryService + const trail = yield* AuditTrailService const now = yield* Clock.currentTimeMillis const decodedPerMessage = yield* Effect.forEach(batch.messages, (message) => @@ -113,14 +135,16 @@ export const processAuditEventsBatch = Effect.fn("AuditEvents.processBatch")(fun yield* Effect.forEach( byOrg, - ([orgId, group]) => - warehouse + ([orgId, group]) => { + const events = Arr.map(group, ({ event }) => event) + return warehouse .ingest( systemTenant(orgId, ALERTING_SYSTEM_USER_ID), AUDIT_LOG_DATASOURCE, - Arr.map(group, ({ event }) => auditEventToRow(event, now)), + Arr.map(events, (event) => auditEventToRow(event, now)), ) .pipe( + Effect.andThen(copyToTrail(trail, orgId, events)), Effect.flatMap(() => Effect.sync(() => { for (const { message } of group) message.ack() @@ -146,7 +170,8 @@ export const processAuditEventsBatch = Effect.fn("AuditEvents.processBatch")(fun }, ), ), - ), + ) + }, { concurrency: 3, discard: true }, ) }) diff --git a/apps/api/src/routes/internal/audit-trail.http.test.ts b/apps/api/src/routes/internal/audit-trail.http.test.ts new file mode 100644 index 0000000000..988915290c --- /dev/null +++ b/apps/api/src/routes/internal/audit-trail.http.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest" +import { + AlertMemberDirectoryUnavailableError, + ApiSchemaErrors, + ApiUnexpectedErrors, + AuditTrailApiGroup, + type AuditTrailEntry, + AuditTrailListResponse, + CurrentTenant, + OrgId, + RoleName, + UserId, +} from "@maple/domain/http" +import { Context, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +import { HttpRouter } from "effect/http" +import { HttpApi, HttpApiBuilder } from "effect/http-api" +import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary" +import { + type AuditTrailListFilters, + type AuditTrailPage, + AuditTrailService, +} from "@maple/backend/services/audit-trail/AuditTrailService" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { HttpAuditTrailLive } from "./audit-trail.http" + +class AuditTrailOnlyApi extends HttpApi.make("MapleInternalApi") + .add(AuditTrailApiGroup) + .middleware(ApiSchemaErrors) + .middleware(ApiUnexpectedErrors) {} + +const ORG_ID = Schema.decodeSync(OrgId)("org_trail_route") +const USER_ID = Schema.decodeSync(UserId)("user_trail_route") + +const unused = Effect.die("not used by the audit trail route") +const asRole = Schema.decodeSync(RoleName) +const asPage = Schema.decodeUnknownSync(AuditTrailListResponse) + +const event = (overrides: Partial = {}): AuditTrailEntry => ({ + id: Str.repeat(32)("a"), + provider: "github", + account: "acme", + providerEventId: "evt_1", + occurredAt: 1_700_000_000_000, + ingestedAt: 1_700_000_060_000, + actor: { type: "user", id: "octocat", name: null, email: null }, + action: "org.add_member", + category: "membership", + outcome: "success", + target: { type: "user", id: "hubot", name: null }, + originIp: null, + originCountry: null, + userAgent: null, + raw: null, + ...overrides, +}) + +/** The route over a trail that answers with `page` and records the filters it was asked for. */ +const makeHarness = (roles: ReadonlyArray, page: AuditTrailPage, directoryDown = false) => { + const asked: Array = [] + let directoryReads = 0 + const services = Layer.mergeAll( + Layer.succeed(AuditTrailService, { + write: () => unused, + list: (_orgId, filters) => Effect.sync(() => (asked.push(filters), page)), + }), + Layer.succeed(OrgMembersService, { + listMembers: () => + directoryDown + ? Effect.fail( + new AlertMemberDirectoryUnavailableError({ + message: "clerk down", + cause: new Error("down"), + }), + ) + : Effect.sync(() => { + directoryReads += 1 + return [{ userId: USER_ID, email: "ada@acme.test", name: "Ada", imageUrl: null }] + }), + listOrganizationsOf: () => unused, + resolveMembers: () => unused, + listMemberActivity: () => unused, + }), + Layer.succeed( + CurrentTenant.SessionAuthorization, + CurrentTenant.SessionAuthorization.of({ + bearer: (httpEffect) => + Effect.provideService( + httpEffect, + CurrentTenant.Context, + new CurrentTenant.TenantSchema({ + orgId: ORG_ID, + userId: USER_ID, + roles: Arr.map(roles, (role) => asRole(role)), + authMode: "clerk", + }), + ), + }), + ), + ) + const routes = HttpApiBuilder.layer(AuditTrailOnlyApi).pipe( + Layer.provide(HttpAuditTrailLive), + Layer.provide(ApiErrorBoundaryLive), + Layer.provide(services), + ) + const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + + const list = async (search = "") => { + const response = await handler( + new Request(`http://maple.test/internal/audit-trail/events${search}`, { + headers: { authorization: "Bearer test-token" }, + }), + Context.empty() as never, + ) + return { status: response.status, body: await response.json() } + } + + return { list, asked, directoryReads: () => directoryReads, dispose } +} + +const ADMIN = ["org:admin"] + +describe("GET /internal/audit-trail/events", () => { + it("refuses an engineer: the trail sits behind the audit log permission, not the Security hub's", async () => { + const harness = makeHarness(["org:engineer"], { events: [event()], next: null }) + try { + const response = await harness.list() + + expect(response.status).toBe(403) + expect(harness.asked).toEqual([]) + } finally { + await harness.dispose() + } + }) + + it("hands an admin a page whose cursor asks for the events after it", async () => { + const last = event({ id: Str.repeat(32)("b"), occurredAt: 1_700_000_000_412 }) + const harness = makeHarness(ADMIN, { + events: [event(), last], + next: { occurredAtMs: last.occurredAt, id: last.id }, + }) + try { + const first = await harness.list("?provider=github&limit=2") + expect(first.status).toBe(200) + const page = asPage(first.body) + expect(page.events).toHaveLength(2) + + await harness.list(`?provider=github&limit=2&cursor=${page.nextCursor}`) + expect(harness.asked).toEqual([ + { provider: "github", limit: 2 }, + { provider: "github", limit: 2, before: { occurredAtMs: last.occurredAt, id: last.id } }, + ]) + } finally { + await harness.dispose() + } + }) + + it("answers 400 for a cursor it did not issue, before anything reaches the warehouse", async () => { + const harness = makeHarness(ADMIN, { events: [], next: null }) + try { + const response = await harness.list("?cursor=1700000000000:not-an-id") + + expect(response.status).toBe(400) + expect(harness.asked).toEqual([]) + } finally { + await harness.dispose() + } + }) + + it("answers 400 for a time bound no event could have", async () => { + const harness = makeHarness(ADMIN, { events: [], next: null }) + try { + const response = await harness.list("?since=9000000000000000") + + expect(response.status).toBe(400) + expect(harness.asked).toEqual([]) + } finally { + await harness.dispose() + } + }) + + const mapleUser = (overrides: Partial = {}) => + event({ + id: Str.repeat(32)("c"), + provider: "maple", + actor: { type: "user", id: USER_ID, name: null, email: null, ...overrides }, + }) + + it("names Maple's own users from the directory and leaves other platforms' actors alone", async () => { + const harness = makeHarness(ADMIN, { + events: [ + mapleUser(), + // Another platform's actor whose id happens to equal a Maple user id. + event({ actor: { type: "user", id: USER_ID, name: null, email: null } }), + // A label frozen when the event was written is what the actor was called then. + mapleUser({ name: "Device credential" }), + ], + next: null, + }) + try { + const { events } = asPage((await harness.list()).body) + + expect(Arr.map(events, (listed) => [listed.actor.name, listed.actor.email])).toEqual([ + ["Ada", "ada@acme.test"], + [null, null], + ["Device credential", null], + ]) + } finally { + await harness.dispose() + } + }) + + it("still serves the page, with ids, when the member directory is down", async () => { + const harness = makeHarness(ADMIN, { events: [mapleUser()], next: null }, true) + try { + const response = await harness.list() + + expect(response.status).toBe(200) + expect(asPage(response.body).events[0]?.actor).toMatchObject({ id: USER_ID, name: null }) + } finally { + await harness.dispose() + } + }) + + // Maple's own events store the user id, and the page shows the email next to it. + it("finds a member's Maple events when filtering by the email the page showed", async () => { + const harness = makeHarness(ADMIN, { events: [], next: null }) + try { + await harness.list("?actor=Ada@acme.test") + await harness.list("?actor=octocat") + + expect(harness.asked).toEqual([ + { actor: "Ada@acme.test", actorAlias: USER_ID, limit: 50 }, + { actor: "octocat", limit: 50 }, + ]) + } finally { + await harness.dispose() + } + }) + + it("does not read the member directory for a page with no Maple users on it", async () => { + const harness = makeHarness(ADMIN, { events: [event()], next: null }) + try { + await harness.list() + + expect(harness.directoryReads()).toBe(0) + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/internal/audit-trail.http.ts b/apps/api/src/routes/internal/audit-trail.http.ts new file mode 100644 index 0000000000..ac83db0a66 --- /dev/null +++ b/apps/api/src/routes/internal/audit-trail.http.ts @@ -0,0 +1,95 @@ +import { HttpApiBuilder } from "effect/http-api" +import { AuditTrailForbiddenError, CurrentTenant, MapleInternalApi } from "@maple/domain/http" +import type { AuditTrailEntry } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" +import type { OrgMember } from "@maple/backend/services/org/OrgMembersService" +import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" +import { Effect, Array as Arr, Option, String as Str } from "effect" +import { orgMemberDirectory } from "@/routes/org-member-directory" + +const DEFAULT_PAGE_LIMIT = 50 + +/** `:`; the query schema has already checked the shape. */ +const parseCursor = (cursor: string) => { + const [occurredAtMs, id = ""] = Str.split(":")(cursor) + return { occurredAtMs: Number(occurredAtMs), id } +} + +/** + * The user id of a Maple dashboard event stored without a name, else null. An event that froze + * a label when it was written (a device credential) keeps it. + */ +const unnamedMapleUserId = (event: AuditTrailEntry): string | null => + event.provider === "maple" && event.actor.type === "user" && event.actor.name === null + ? event.actor.id + : null + +/** Put the member's current name and email on Maple's own user events; a departed id stays an id. */ +const nameMapleUsers = ( + events: ReadonlyArray, + directory: ReadonlyMap, +): ReadonlyArray => + Arr.map(events, (event) => { + const userId = unnamedMapleUserId(event) + const member = userId === null ? undefined : directory.get(userId) + return member === undefined + ? event + : { ...event, actor: { ...event.actor, name: member.name, email: member.email } } + }) + +/** The member an email belongs to. Maple's own events store the user id, never the email. */ +const memberByEmail = (directory: ReadonlyMap, email: string) => + Arr.findFirst( + [...directory.values()], + (member) => Str.toLowerCase(member.email) === Str.toLowerCase(email), + ) + +export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditTrail", (handlers) => + Effect.gen(function* () { + const trail = yield* AuditTrailService + const members = yield* OrgMembersService + + return handlers.handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // Every platform's actor emails and origin IPs: the same gate as Maple's own audit log. + yield* Policy.check( + tenant.roles, + "audit_log:read", + (denial) => new AuditTrailForbiddenError({ message: denial.message }), + ) + // Read at most once a request: to resolve an email filter, or to name the page. + const readDirectory = yield* Effect.cached(orgMemberDirectory(members, tenant.orgId)) + const emailActor = + query.actor !== undefined && Str.includes("@")(query.actor) ? query.actor : undefined + const actorAlias = + emailActor === undefined + ? Option.none() + : yield* Effect.map(readDirectory, (directory) => + memberByEmail(directory, emailActor), + ) + const page = yield* trail.list(tenant.orgId, { + ...(query.provider !== undefined ? { provider: query.provider } : undefined), + ...(query.account !== undefined ? { account: query.account } : undefined), + ...(query.category !== undefined ? { category: query.category } : undefined), + ...(query.outcome !== undefined ? { outcome: query.outcome } : undefined), + ...(query.action !== undefined ? { action: query.action } : undefined), + ...(query.actor !== undefined ? { actor: query.actor } : undefined), + ...(Option.isSome(actorAlias) ? { actorAlias: actorAlias.value.userId } : undefined), + ...(query.since !== undefined ? { sinceMs: query.since } : undefined), + ...(query.until !== undefined ? { untilMs: query.until } : undefined), + ...(query.cursor !== undefined ? { before: parseCursor(query.cursor) } : undefined), + limit: query.limit ?? DEFAULT_PAGE_LIMIT, + }) + const directory = Arr.some(page.events, (event) => unnamedMapleUserId(event) !== null) + ? yield* readDirectory + : new Map() + return { + events: nameMapleUsers(page.events, directory), + nextCursor: page.next === null ? null : `${page.next.occurredAtMs}:${page.next.id}`, + } + }), + ) + }), +) diff --git a/apps/api/src/routes/org-member-directory.ts b/apps/api/src/routes/org-member-directory.ts new file mode 100644 index 0000000000..d7a02b515e --- /dev/null +++ b/apps/api/src/routes/org-member-directory.ts @@ -0,0 +1,24 @@ +import type { OrgMember, OrgMembersServiceApi } from "@maple/backend/services/org/OrgMembersService" +import { summarizeCause } from "@maple/backend/platform/describe-cause" +import type { OrgId } from "@maple/primitives" +import { Cause, Effect, Array as Arr } from "effect" + +/** + * The org's current members by user id, for putting names on audit records. Never fails: an + * unconfigured or unavailable directory answers with nobody and the records keep their ids. + */ +export const orgMemberDirectory = ( + members: Pick, + orgId: OrgId, +): Effect.Effect> => + members.listMembers(orgId).pipe( + Effect.map((all) => new Map(Arr.map(all, (member) => [member.userId, member] as const))), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("Member directory unavailable; audit records keep their ids").pipe( + Effect.annotateLogs({ error: summarizeCause(cause) }), + Effect.as(new Map()), + ), + ), + ) diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts index e0d568278b..ba323c9bb7 100644 --- a/apps/api/src/routes/v2/audit-log.http.ts +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -13,21 +13,14 @@ import { } from "@maple/domain/http/v2" import type { V2AuditLogEntry } from "@maple/domain/http/v2" import type { AuditLogEntry } from "@maple/backend/services/audit/audit-event" -import { Cause, Effect, Option, Schema, Array as Arr } from "effect" -import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { Effect, Option, Schema, Array as Arr } from "effect" +import { orgMemberDirectory } from "@/routes/org-member-directory" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import * as Policy from "@maple/domain/policy" import type { AuditLogListFilters } from "@maple/backend/services/audit/AuditLogService" import { timestampMs } from "@maple/backend/platform/time" -/** No directory, no names — the entries still carry every id they were written with. */ -const unnamed = (cause: Cause.Cause) => - Effect.logWarning("Audit log: member directory unavailable; entries keep their ids").pipe( - Effect.annotateLogs({ error: summarizeCause(cause) }), - Effect.as(new Map()), - ) - const decodeApiKeyIdOption = Schema.decodeUnknownOption(ApiKeyId) const decodeActorIdOption = Schema.decodeUnknownOption(ActorId) const decodeUserIdOption = Schema.decodeUnknownOption(UserId) @@ -152,25 +145,17 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", ( const members = yield* OrgMembersService /** - * Names and avatars for the acting users, or nothing at all: a directory - * that is unconfigured (self-hosted without Clerk) or briefly unavailable - * must never turn reading the audit log into an error. Ids still render. - * - * A member with no name set falls back to their email, which is what the - * rest of the product shows and what an admin reading an audit trail - * actually recognises — an opaque `user_…` is the last resort, not the - * second one. - * - * Failures and defects both fall back; interruption is re-raised, because - * a request being torn down has no page left to label. + * Names and avatars for the acting users. A member with no name set falls back to their + * email, which is what the rest of the product shows and what an admin reading an audit + * trail recognises; an opaque `user_…` is the last resort, not the second one. */ const directory = (orgId: OrgId) => - members.listMembers(orgId).pipe( + orgMemberDirectory(members, orgId).pipe( Effect.map( - (all) => + (known) => new Map( Arr.map( - all, + [...known.values()], (member) => [ member.userId, @@ -179,9 +164,6 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", ( ), ), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Effect.interrupt : unnamed(cause), - ), ) return handlers.handle("list", ({ query }) => diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index ca4e41c481..679d6b8042 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -24,6 +24,7 @@ import { AwsOidcRouter } from "@/routes/aws-oidc.http" import { DiscoveryRouter, instanceApiV2, NotFoundRouter } from "@/routes/discovery.http" import { HttpDigestLive } from "@/routes/internal/digest.http" import { HttpCodeReviewLive } from "@/routes/internal/code-review.http" +import { HttpAuditTrailLive } from "@/routes/internal/audit-trail.http" import { HttpDependencyScanLive } from "@/routes/internal/dependency-scan.http" import { HttpErrorsLive } from "@/routes/internal/errors.http" import { HttpIntegrationsLive } from "@/routes/internal/integrations.http" @@ -162,6 +163,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( HttpAiTriageLive, HttpBillingLive, HttpDemoLive, + HttpAuditTrailLive, HttpDependencyScanLive, HttpDigestLive, HttpOpenRouterKeyLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 0c6124879e..cf06ef1b67 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -106,6 +106,7 @@ import { AiSessionReadService } from "@maple/backend/services/ai-sessions/AiSess import { ProductEventsService } from "@maple/backend/services/product-events/ProductEventsService" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" import { TelemetryReadService } from "@maple/backend/services/warehouse/TelemetryReadService" @@ -188,6 +189,7 @@ export const HttpServicesLive = Layer.mergeAll( OrgOpenRouterKeyService.layer, ServiceMapWorldService.layer, AuditLogService.layer, + AuditTrailService.layer, WarehouseQueryService.layer, QueryEngineService.layer, RuntimeReleaseCatalog.layer, diff --git a/apps/cli/test/local-store-migrations.test.ts b/apps/cli/test/local-store-migrations.test.ts index 7bdc12a962..90f05aabf1 100644 --- a/apps/cli/test/local-store-migrations.test.ts +++ b/apps/cli/test/local-store-migrations.test.ts @@ -206,6 +206,7 @@ describe("current local schema identity", () => { "ai_trace_index", "ai_trace_index_mv", "audit_log", + "audit_trail_events", "ci_jobs", "ci_jobs_mv", "ci_runs", @@ -362,6 +363,7 @@ describe("current local schema identity", () => { "ai_trace_index", "ai_trace_index_mv", "audit_log", + "audit_trail_events", "ci_jobs", "ci_jobs_mv", "ci_runs", diff --git a/apps/ingest/src/clickhouse_insert_mappings.rs b/apps/ingest/src/clickhouse_insert_mappings.rs index c34f03e1ae..3a7d59cdc2 100644 --- a/apps/ingest/src/clickhouse_insert_mappings.rs +++ b/apps/ingest/src/clickhouse_insert_mappings.rs @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-insert-mappings.ts // Do not edit manually. -pub const PROJECT_REVISION: &str = "266cb624c1996b27cb3d435f5feb3e57ec70c6fdf5f9ecfd1f291b6425b3a692"; +pub const PROJECT_REVISION: &str = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138"; // Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the // Tinybird-coupled PROJECT_REVISION. Compared against // org_clickhouse_settings.schema_version. See @maple/domain/clickhouse diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts new file mode 100644 index 0000000000..20459df1c3 --- /dev/null +++ b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "@effect/vitest" +import type { AuditTrailEvent } from "@maple/domain/audit-trail" +import { WarehouseUpstreamError } from "@maple/domain/http" +import { OrgId } from "@maple/primitives" +import type { CompiledQuery } from "@maple/query-engine/ch" +import { Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +import { TestClock } from "effect/testing" +import { makeWarehouseServiceStub } from "@maple/backend/testing/warehouse-test-support" +import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { auditTrailEventId } from "./audit-trail-row" +import { AuditTrailService } from "./AuditTrailService" + +const ORG = Schema.decodeSync(OrgId)("org_trail_service") + +const down = new WarehouseUpstreamError({ + message: "tinybird down", + pipeName: "audit_trail_events", + cause: new Error("down"), +}) + +/** + * A warehouse that answers the listing with canned rows, keeps the SQL it was handed and the + * rows `ingest` receives; `failing` makes both fail. + */ +const fakeWarehouse = (rows: ReadonlyArray> = [], failing = false) => { + const sql: Array = [] + const written: Array> = [] + const layer = Layer.succeed( + WarehouseQueryService, + makeWarehouseServiceStub({ + ingest: (_tenant, _datasource, batch) => + failing + ? Effect.fail(down) + : Effect.sync(() => { + // SAFETY: every warehouse row is a record of column values. + written.push(...(batch as ReadonlyArray>)) + }), + compiledQuery: ((_tenant: unknown, compiled: unknown) => + Effect.gen(function* () { + if (failing) return yield* down + const query = Effect.isEffect(compiled) ? yield* compiled : compiled + // SAFETY: the listing hands the stub a compiled query. + const compiledQuery = query as CompiledQuery + sql.push(compiledQuery.sql) + // Decode as the real executor does, so timestamps arrive as `DateTime.Utc`. + return yield* compiledQuery.decodeRows(rows) + })) as never, + }), + ) + return { sql, written, layer } +} + +const withTrail = ( + warehouse: ReturnType, + use: (trail: AuditTrailService["Service"]) => Effect.Effect, +) => + Effect.flatMap(AuditTrailService, use).pipe( + Effect.provide( + Layer.effect(AuditTrailService, AuditTrailService.make).pipe(Layer.provide(warehouse.layer)), + ), + ) + +const stored = (id: string, overrides: Partial> = {}) => ({ + id, + occurredAt: "2026-08-29 09:12:00.412", + ingestedAt: "2026-08-29 09:13:30.000", + provider: "github", + account: "acme", + providerEventId: `evt_${id}`, + actorType: "user", + actorId: "octocat", + actorName: "", + actorEmail: "", + action: "org.add_member", + category: "membership", + outcome: "success", + targetType: "user", + targetId: "hubot", + targetName: "", + originIp: "", + originCountry: "", + userAgent: "", + raw: JSON.stringify({ action: "org.add_member" }), + ...overrides, +}) + +const ids = [Str.repeat(32)("c"), Str.repeat(32)("b"), Str.repeat(32)("a")] as const +const OCCURRED_AT_MS = Date.UTC(2026, 7, 29, 9, 12, 0, 412) + +describe("AuditTrailService.list", () => { + it.effect("returns a page and where the next one starts when more events exist", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse(Arr.map(ids, (id) => stored(id))) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 2 })) + + expect(Arr.map(page.events, (event) => event.id)).toEqual([ids[0], ids[1]]) + expect(page.next).toEqual({ occurredAtMs: OCCURRED_AT_MS, id: ids[1] }) + }), + ) + + it.effect("has no next page when the events fit", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([stored(ids[0]), stored(ids[1])]) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 2 })) + + expect(page.events).toHaveLength(2) + expect(page.next).toBeNull() + }), + ) + + it.effect("shows a redelivered event the table has not merged yet once", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([stored(ids[0]), stored(ids[0]), stored(ids[1])]) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 10 })) + + expect(Arr.map(page.events, (event) => event.id)).toEqual([ids[0], ids[1]]) + }), + ) + + // Ending the list because the row past the page is a copy would hide whatever follows it. + it.effect("still offers a next page when the row past the page is an unmerged copy", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([stored(ids[0]), stored(ids[1]), stored(ids[1])]) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 2 })) + + expect(page.next).toEqual({ occurredAtMs: OCCURRED_AT_MS, id: ids[1] }) + }), + ) + + it.effect("reads '' as absent and a payload that is not a JSON object as no payload", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([ + stored(ids[0]), + stored(ids[1], { raw: "not json" }), + stored(ids[2], { raw: "" }), + ]) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 10 })) + + expect(page.events[0]).toMatchObject({ + providerEventId: `evt_${ids[0]}`, + actor: { type: "user", id: "octocat", name: null, email: null }, + target: { type: "user", id: "hubot", name: null }, + originIp: null, + raw: { action: "org.add_member" }, + }) + expect(Arr.map(page.events, (event) => event.raw === null)).toEqual([false, true, true]) + }), + ) + + // The cursor is an equality on the sorting key: a bound that dropped the milliseconds would + // skip every event sharing the boundary second. + it.effect("continues from the cursor's exact millisecond", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse() + yield* withTrail(warehouse, (trail) => + trail.list(ORG, { before: { occurredAtMs: OCCURRED_AT_MS, id: ids[1] }, limit: 2 }), + ) + + expect(warehouse.sql[0]).toContain("OccurredAt = '2026-08-29 09:12:00.412'") + expect(warehouse.sql[0]).toContain(`Id < '${ids[1]}'`) + }), + ) + + it.effect("matches an actor by id, by email, or by the alias the caller resolved", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse() + yield* withTrail(warehouse, (trail) => + trail.list(ORG, { actor: "ada@acme.test", actorAlias: "user_ada", limit: 2 }), + ) + + expect(warehouse.sql[0]).toContain( + "((audit_trail_events.ActorId = 'ada@acme.test' OR audit_trail_events.ActorEmail = 'ada@acme.test') OR audit_trail_events.ActorId = 'user_ada')", + ) + }), + ) + + it.effect("fails with a persistence error when the warehouse is down", () => + Effect.gen(function* () { + const error = yield* withTrail(fakeWarehouse([], true), (trail) => + trail.list(ORG, { limit: 2 }), + ).pipe(Effect.flip) + + expect(error._tag).toBe("@maple/http/audit-trail/AuditTrailPersistenceError") + }), + ) +}) + +describe("AuditTrailService.write", () => { + const event: AuditTrailEvent = { + provider: "maple", + providerEventId: "evt_1", + occurredAtMs: 1_700_000_000_000, + actor: { type: "user", id: "usr_1" }, + action: "member.added", + category: "membership", + outcome: "success", + } + + it.effect("stores absent values as '' and the payload as JSON text, under the writing org", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse() + yield* withTrail(warehouse, (trail) => + trail.write(ORG, [event, { ...event, providerEventId: "evt_2", raw: { id: "evt_2" } }]), + ) + + expect(warehouse.written).toMatchObject([ + { OrgId: ORG, ProviderEventId: "evt_1", Account: "", ActorName: "", TargetType: "", Raw: "" }, + { OrgId: ORG, ProviderEventId: "evt_2", Raw: '{"id":"evt_2"}' }, + ]) + }), + ) + + // The table keeps the highest `Version`: the first write has to win, or a redelivery would + // carry its later `IngestedAt` into a detection window the event already fired in. + it.effect("writes a redelivered event to the same key with a lower version", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse() + yield* withTrail(warehouse, (trail) => + Effect.gen(function* () { + yield* trail.write(ORG, [event]) + yield* TestClock.adjust("5 minutes") + yield* trail.write(ORG, [event]) + }), + ) + const [first, redelivered] = warehouse.written + + expect(redelivered?.["Id"]).toBe(first?.["Id"]) + expect(Number(redelivered?.["Version"])).toBeLessThan(Number(first?.["Version"])) + }), + ) + + it.effect("writes nothing for an empty batch", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([], true) + // A failing warehouse proves the point: it is never reached. + yield* withTrail(warehouse, (trail) => trail.write(ORG, [])) + + expect(warehouse.written).toEqual([]) + }), + ) + + it.effect("fails with a persistence error when the warehouse is down", () => + Effect.gen(function* () { + const error = yield* withTrail(fakeWarehouse([], true), (trail) => + trail.write(ORG, [event]), + ).pipe(Effect.flip) + + expect(error._tag).toBe("@maple/http/audit-trail/AuditTrailPersistenceError") + }), + ) +}) + +describe("auditTrailEventId", () => { + // The ingest gateway derives the same key in Rust for the providers it writes; these + // vectors are the contract between the two. + it("is the first 32 hex characters of sha256 over provider, account and event id", () => { + expect(auditTrailEventId({ provider: "maple", account: "acme", providerEventId: "evt_1" })).toBe( + "1d19ea3d78217e97b45a7a9d28300146", + ) + expect(auditTrailEventId({ provider: "maple", providerEventId: "evt_1" })).toBe( + "851ca0eff076aad987a0192777b176cd", + ) + }) + + it("keys another account's event, and another event, to a different row", () => { + const base = { provider: "maple", account: "team_1", providerEventId: "evt_1" } as const + + expect(auditTrailEventId({ ...base, account: "team_2" })).not.toBe(auditTrailEventId(base)) + expect(auditTrailEventId({ ...base, providerEventId: "evt_2" })).not.toBe(auditTrailEventId(base)) + }) +}) diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.ts b/packages/backend/src/services/audit-trail/AuditTrailService.ts new file mode 100644 index 0000000000..9968b4fb4d --- /dev/null +++ b/packages/backend/src/services/audit-trail/AuditTrailService.ts @@ -0,0 +1,149 @@ +import type { AuditTrailEvent } from "@maple/domain/audit-trail" +import { AuditTrailPersistenceError } from "@maple/domain/http" +import type { AuditTrailEntry } from "@maple/domain/http" +import type { OrgId } from "@maple/primitives" +import * as CH from "@maple/query-engine/ch" +import { Clock, Context, DateTime, Effect, Layer, Option, Array as Arr } from "effect" +import { ALERTING_SYSTEM_USER_ID, systemTenant } from "@maple/backend/services/auth/system-tenant" +import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { auditTrailEventToRow, storedRowToEntry } from "./audit-trail-row" + +const AUDIT_TRAIL_DATASOURCE = "audit_trail_events" + +export interface AuditTrailListFilters { + readonly provider?: string + readonly account?: string + readonly category?: string + readonly outcome?: string + readonly action?: string + /** An actor's provider id or email. */ + readonly actor?: string + /** With `actor`: a second id the same person acts under. */ + readonly actorAlias?: string + readonly sinceMs?: number + readonly untilMs?: number + /** Only events that sort after this one, newest first. */ + readonly before?: { readonly occurredAtMs: number; readonly id: string } + readonly limit: number +} + +/** One page, and where the next one starts; `next` is null on the last page. */ +export interface AuditTrailPage { + readonly events: ReadonlyArray + readonly next: { readonly occurredAtMs: number; readonly id: string } | null +} + +export interface AuditTrailServiceApi { + /** Store one org's events. Safe to repeat: the same event writes the same row. */ + readonly write: ( + orgId: OrgId, + events: ReadonlyArray, + ) => Effect.Effect + /** One org's audit events, newest first, `filters.limit` to a page. */ + readonly list: ( + orgId: OrgId, + filters: AuditTrailListFilters, + ) => Effect.Effect +} + +const persistenceError = (message: string) => (error: { readonly _tag: string }) => + new AuditTrailPersistenceError({ message, cause: error._tag }) + +/** Which optional filters bind, and the parameter values behind them. */ +const listQueryInputs = (orgId: OrgId, filters: AuditTrailListFilters) => { + const opts: CH.AuditTrailEventsOpts = { + provider: filters.provider !== undefined, + account: filters.account !== undefined, + category: filters.category !== undefined, + outcome: filters.outcome !== undefined, + action: filters.action !== undefined, + actor: filters.actor !== undefined, + actorAlias: filters.actorAlias !== undefined, + since: filters.sinceMs !== undefined, + until: filters.untilMs !== undefined, + before: filters.before !== undefined, + // One row past the page says whether another page exists. + limit: filters.limit + 1, + } + const values = { + orgId, + ...(filters.provider !== undefined ? { provider: filters.provider } : undefined), + ...(filters.account !== undefined ? { account: filters.account } : undefined), + ...(filters.category !== undefined ? { category: filters.category } : undefined), + ...(filters.outcome !== undefined ? { outcome: filters.outcome } : undefined), + ...(filters.action !== undefined ? { action: filters.action } : undefined), + ...(filters.actor !== undefined ? { actor: filters.actor } : undefined), + ...(filters.actorAlias !== undefined ? { actorAlias: filters.actorAlias } : undefined), + ...(filters.sinceMs !== undefined ? { since: DateTime.makeUnsafe(filters.sinceMs) } : undefined), + ...(filters.untilMs !== undefined ? { until: DateTime.makeUnsafe(filters.untilMs) } : undefined), + ...(filters.before !== undefined + ? { + beforeOccurredAt: DateTime.makeUnsafe(filters.before.occurredAtMs), + beforeId: filters.before.id, + } + : undefined), + } + return { opts, values } +} + +/** The unified audit trail: provider adapters write their events here and the Security hub lists them. */ +export class AuditTrailService extends Context.Service()( + "@maple/backend/services/AuditTrailService", + { + make: Effect.gen(function* () { + const warehouse = yield* WarehouseQueryService + + // A transient 5xx is retried: a duplicate row collapses on the event's key. + const writeRows = Effect.fn("AuditTrailService.write")(function* ( + orgId: OrgId, + events: ReadonlyArray, + ) { + const now = yield* Clock.currentTimeMillis + yield* warehouse + .ingest( + systemTenant(orgId, ALERTING_SYSTEM_USER_ID), + AUDIT_TRAIL_DATASOURCE, + Arr.map(events, (event) => auditTrailEventToRow(orgId, event, now)), + { retryTransient: true }, + ) + .pipe(Effect.mapError(persistenceError("The audit trail could not be written"))) + }) + + const write: AuditTrailServiceApi["write"] = (orgId, events) => + events.length === 0 ? Effect.void : writeRows(orgId, events) + + const list: AuditTrailServiceApi["list"] = Effect.fn("AuditTrailService.list")( + function* (orgId, filters) { + const { opts, values } = listQueryInputs(orgId, filters) + const rows = yield* warehouse + .compiledQuery( + systemTenant(orgId, ALERTING_SYSTEM_USER_ID), + CH.compile(CH.auditTrailEventsQuery(opts), values), + { profile: "list", context: "auditTrailEvents" }, + ) + .pipe(Effect.mapError(persistenceError("The audit trail could not be read"))) + const entries = Arr.map(Arr.take(rows, filters.limit), storedRowToEntry) + const last = Arr.last(entries) + // A redelivered event the table has not merged yet is the same entry twice. When + // the copy lands in the lookahead slot the next page can come back empty, which + // is cheaper than ending the list early. + const seen = new Set() + return { + events: Arr.filter( + entries, + (entry) => !seen.has(entry.id) && seen.add(entry.id) !== undefined, + ), + next: + rows.length > filters.limit && Option.isSome(last) + ? { occurredAtMs: last.value.occurredAt, id: last.value.id } + : null, + } + }, + ) + + return { write, list } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(WarehouseQueryService.layer)) +} diff --git a/packages/backend/src/services/audit-trail/audit-trail-row.ts b/packages/backend/src/services/audit-trail/audit-trail-row.ts new file mode 100644 index 0000000000..923f5921b8 --- /dev/null +++ b/packages/backend/src/services/audit-trail/audit-trail-row.ts @@ -0,0 +1,90 @@ +import { createHash } from "node:crypto" +import type { InferQueryOutput } from "@maple-dev/effect-orm/clickhouse" +import type { AuditTrailEvent } from "@maple/domain/audit-trail" +import type { AuditTrailEntry } from "@maple/domain/http" +import type { AuditTrailEventsRow } from "@maple/domain/tinybird" +import type { OrgId } from "@maple/primitives" +import type { auditTrailEventsQuery } from "@maple/query-engine/ch" +import { warehouseDateTime64 } from "@maple/query-engine/datetime" +import { DateTime, Option, Schema, String as Str } from "effect" + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) +const decodeRawJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +) + +const sha256Hex = (value: string) => createHash("sha256").update(value, "utf8").digest("hex") + +/** + * The stored row's key, the first 32 hex characters of sha256 over `provider\naccount\neventId`. + * The ingest gateway derives the same key for the providers it writes. + */ +export const auditTrailEventId = ( + event: Pick, +): string => + Str.slice(0, 32)(sha256Hex(`${event.provider}\n${event.account ?? ""}\n${event.providerEventId}`)) + +/** Above any ingest time in ms, so `Version` stays positive and falls as time rises. */ +const VERSION_CEILING_MS = 9_999_999_999_999 + +/** Lower an adapter's event to its `audit_trail_events` row; `ingestedAtMs` is the write time. */ +export const auditTrailEventToRow = ( + orgId: OrgId, + event: AuditTrailEvent, + ingestedAtMs: number, +): AuditTrailEventsRow => ({ + OrgId: orgId, + Id: auditTrailEventId(event), + OccurredAt: warehouseDateTime64(event.occurredAtMs), + IngestedAt: warehouseDateTime64(ingestedAtMs), + Provider: event.provider, + Account: event.account ?? "", + ProviderEventId: event.providerEventId, + ActorType: event.actor.type, + ActorId: event.actor.id ?? "", + ActorName: event.actor.name ?? "", + ActorEmail: event.actor.email ?? "", + Action: event.action, + Category: event.category, + Outcome: event.outcome, + TargetType: event.target?.type ?? "", + TargetId: event.target?.id ?? "", + TargetName: event.target?.name ?? "", + OriginIp: event.originIp ?? "", + OriginCountry: event.originCountry ?? "", + UserAgent: event.userAgent ?? "", + Raw: event.raw === undefined ? "" : encodeJson(event.raw), + Version: VERSION_CEILING_MS - ingestedAtMs, +}) + +/** `''` in the warehouse row is "absent". */ +const absent = (value: string): string | null => (value === "" ? null : value) + +/** + * A listed row as the reader sees it. `raw` is whatever the provider sent, written by adapters + * in two languages: one that is not a JSON object reads as absent and keeps the page. + */ +export const storedRowToEntry = ( + row: InferQueryOutput>, +): AuditTrailEntry => ({ + id: row.id, + provider: row.provider, + account: absent(row.account), + providerEventId: row.providerEventId, + occurredAt: DateTime.toEpochMillis(row.occurredAt), + ingestedAt: DateTime.toEpochMillis(row.ingestedAt), + actor: { + type: row.actorType, + id: absent(row.actorId), + name: absent(row.actorName), + email: absent(row.actorEmail), + }, + action: row.action, + category: row.category, + outcome: row.outcome, + target: { type: absent(row.targetType), id: absent(row.targetId), name: absent(row.targetName) }, + originIp: absent(row.originIp), + originCountry: absent(row.originCountry), + userAgent: absent(row.userAgent), + raw: Option.getOrNull(decodeRawJson(row.raw)), +}) diff --git a/packages/backend/src/services/audit-trail/providers/maple.test.ts b/packages/backend/src/services/audit-trail/providers/maple.test.ts new file mode 100644 index 0000000000..03a445304a --- /dev/null +++ b/packages/backend/src/services/audit-trail/providers/maple.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { OrgId, UserId } from "@maple/primitives" +import { Schema, Array as Arr } from "effect" +import { AuditLogEvent } from "@maple/backend/services/audit/audit-event" +import { mapleAuditTrailEvents } from "./maple" + +const ORG = Schema.decodeSync(OrgId)("org_trail_maple") +const USER = Schema.decodeSync(UserId)("user_trail_maple") +const KEY_ID = "5b8f2c1e-7a55-4c0e-9a3b-0d2c7e5b9a11" + +const entry = (overrides: Partial[0]> = {}) => + new AuditLogEvent({ + orgId: ORG, + id: Schema.decodeSync(AuditLogEvent.fields.id)("9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f"), + actorType: "user", + userId: USER, + source: "dashboard", + action: "alert_rule.updated", + outcome: "allowed", + occurredAtMs: 1_700_000_000_000, + ...overrides, + }) + +const ALERT_RULE = { resourceType: "alert_rule", resourceId: "alrt_1" } + +/** The trail event for one audit entry, or undefined when the trail leaves it out. */ +const trailEventOf = (overrides: Parameters[0] = {}) => + mapleAuditTrailEvents([entry(overrides)])[0] + +describe("mapleAuditTrailEvents", () => { + it("carries a change with its actor, target and diff", () => { + const changes = { fields: ["name"], before: { name: "a" }, after: { name: "b" } } + const event = trailEventOf({ ...ALERT_RULE, changes, originIp: "203.0.113.7", originCountry: "DE" }) + + expect(event).toMatchObject({ + provider: "maple", + actor: { type: "user", id: USER }, + action: "alert_rule.updated", + category: "configuration", + outcome: "success", + target: { type: "alert_rule", id: "alrt_1" }, + originIp: "203.0.113.7", + originCountry: "DE", + raw: { source: "dashboard", changes }, + }) + }) + + it("leaves an allowed read out and keeps a refused one", () => { + const read = { action: "telemetry.read", resourceType: "telemetry" } + + expect(trailEventOf({ ...read, outcome: "allowed" })).toBeUndefined() + expect(trailEventOf({ ...read, outcome: "denied", denialReason: "missing scope" })).toMatchObject({ + category: "data_access", + outcome: "denied", + raw: { denialReason: "missing scope" }, + }) + }) + + it("names an API key by its public id and the name it had, not by the person who minted it", () => { + const event = trailEventOf({ + actorType: "api_key", + apiKeyId: Schema.decodeSync(AuditLogEvent.fields.apiKeyId)(KEY_ID), + actorLabel: "CI deploy key", + }) + + expect(event?.actor).toEqual({ + type: "service", + id: encodePublicId(PublicIdPrefixes.apiKey, KEY_ID), + name: "CI deploy key", + }) + }) + + it("points a membership change at the member it happened to", () => { + const event = trailEventOf({ + actorType: "system", + source: "system", + action: "member.removed", + resourceType: "member", + affectedUserId: USER, + }) + + expect(event).toMatchObject({ + category: "membership", + actor: { type: "system" }, + target: { type: "member", id: USER }, + }) + }) + + it("files a secret rotated on a non-credential resource under credential", () => { + expect( + trailEventOf({ action: "status_page.break_glass_rotated", resourceType: "status_page" }) + ?.category, + ).toBe("credential") + expect(trailEventOf({ action: "status_page.updated", resourceType: "status_page" })?.category).toBe( + "resource", + ) + }) + + it("files a resource this build does not know under other", () => { + expect(trailEventOf({ action: "hologram.created", resourceType: "hologram" })?.category).toBe("other") + }) + + it("keeps the kept entries in order and drops the rest", () => { + const events = mapleAuditTrailEvents([ + entry(ALERT_RULE), + entry({ action: "telemetry.read", resourceType: "telemetry" }), + entry({ action: "dashboard.created", resourceType: "dashboard" }), + ]) + + expect(Arr.map(events, (event) => event.action)).toEqual(["alert_rule.updated", "dashboard.created"]) + }) +}) diff --git a/packages/backend/src/services/audit-trail/providers/maple.ts b/packages/backend/src/services/audit-trail/providers/maple.ts new file mode 100644 index 0000000000..5fdd7580e6 --- /dev/null +++ b/packages/backend/src/services/audit-trail/providers/maple.ts @@ -0,0 +1,167 @@ +import { isAuditAccessAction } from "@maple/domain/audit-actions" +import type { AuditTrailCategory, AuditTrailEvent } from "@maple/domain/audit-trail" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { Array as Arr, Option, Record as Rec } from "effect" +import type { AuditAction, AuditResourceType } from "@maple/domain/audit-actions" +import type { AuditLogEvent } from "@maple/backend/services/audit/audit-event" + +/** + * What each Maple resource's actions are about. Complete on purpose: a new audited resource + * does not compile until someone decides where it belongs in the trail. + */ +const CATEGORY_BY_RESOURCE = { + agent: "resource", + agent_feedback: "other", + ai_provider_key: "credential", + alert_destination: "configuration", + alert_rule: "configuration", + alert_silence: "configuration", + alert_maintenance_window: "configuration", + anomaly_incident: "resource", + anomaly_settings: "configuration", + // The only `api` action is a refused request: a credential that did not get in. + api: "authentication", + api_key: "credential", + attribute_mapping: "configuration", + ingest_rule: "configuration", + data_source: "configuration", + dashboard: "resource", + chat_integration: "configuration", + // A share link is a grant: it lets someone without an account read the dashboard. + dashboard_share: "permission", + device_credential: "credential", + error_issue: "resource", + escalation_chain: "configuration", + oncall_schedule: "configuration", + oncall_override: "configuration", + gcp_connector: "configuration", + aws_connection: "configuration", + source: "configuration", + ingest_key: "credential", + mcp_connection: "configuration", + mcp_tool: "data_access", + investigation: "resource", + onboarding_reward: "billing", + planetscale_integration: "configuration", + cloudflare_integration: "configuration", + member: "membership", + organization: "configuration", + scrape_target: "configuration", + uptime_monitor: "configuration", + slo: "configuration", + status_page: "resource", + status_page_component: "resource", + status_page_incident: "resource", + security_audit_finding: "resource", + security_audit_run: "resource", + security_audit_settings: "configuration", + session_replay: "data_access", + support_channel: "configuration", + telemetry: "data_access", + vercel_integration: "configuration", + warehouse_settings: "configuration", + widget_credential: "credential", +} as const satisfies Record + +/** Actions about a secret or a grant on a resource whose other actions are not. */ +const CATEGORY_BY_ACTION = { + "source.rolled": "credential", + "status_page.break_glass_rotated": "credential", + "status_page.password_set": "credential", + "planetscale_integration.metrics_token_set": "credential", + "mcp_connection.revoked": "credential", + // The link is standing authority to act as the member from the chat platform. + "chat_integration.identity_link_started": "permission", +} as const satisfies Partial> + +const isAuditResourceType = (type: string): type is AuditResourceType => + Rec.has(CATEGORY_BY_RESOURCE, type) + +/** An entry from a build that knew a resource this one does not lands in `other`. */ +const categoryOf = (event: AuditLogEvent): AuditTrailCategory => + Rec.get(CATEGORY_BY_ACTION, event.action).pipe( + Option.getOrElse(() => + event.resourceType !== undefined && isAuditResourceType(event.resourceType) + ? CATEGORY_BY_RESOURCE[event.resourceType] + : "other", + ), + ) + +/** The actor's id in the public form the rest of the product shows. */ +const actorOf = (event: AuditLogEvent): AuditTrailEvent["actor"] => { + const name = event.actorLabel !== undefined ? { name: event.actorLabel } : undefined + switch (event.actorType) { + case "user": + return { + type: "user", + ...(event.userId !== undefined ? { id: event.userId } : undefined), + ...name, + } + case "api_key": + return { + type: "service", + ...(event.apiKeyId !== undefined + ? { id: encodePublicId(PublicIdPrefixes.apiKey, event.apiKeyId) } + : undefined), + ...name, + } + case "agent": + return { + type: "service", + ...(event.actorId !== undefined + ? { id: encodePublicId(PublicIdPrefixes.actor, event.actorId) } + : undefined), + ...name, + } + case "system": + return { type: "system", ...name } + } +} + +/** What the trail's detail view shows of a Maple entry beyond the shared columns. */ +const rawOf = (event: AuditLogEvent): Record => ({ + source: event.source, + ...(event.denialReason !== undefined ? { denialReason: event.denialReason } : undefined), + ...(event.affectedUserId !== undefined ? { affectedUserId: event.affectedUserId } : undefined), + ...(event.changes !== undefined ? { changes: event.changes } : undefined), + ...(event.metadata !== undefined ? { metadata: event.metadata } : undefined), + ...(event.requestId !== undefined ? { requestId: event.requestId } : undefined), +}) + +/** + * Maple's own audit entry as a trail event, or null for one the trail leaves out: an allowed + * read, tool call or SQL run. A heavy agent user writes hundreds a day, and they would bury + * every other platform's events. They stay in `audit_log`. + */ +const mapleAuditTrailEvent = (event: AuditLogEvent): AuditTrailEvent | null => { + if (isAuditAccessAction(event.action) && event.outcome !== "denied") return null + // A membership change names the member it happened to, not a resource id. + const targetId = event.resourceId ?? event.affectedUserId + const target = + event.resourceType !== undefined || targetId !== undefined + ? { + ...(event.resourceType !== undefined ? { type: event.resourceType } : undefined), + ...(targetId !== undefined ? { id: targetId } : undefined), + } + : undefined + return { + provider: "maple", + providerEventId: event.id, + occurredAtMs: event.occurredAtMs, + actor: actorOf(event), + action: event.action, + category: categoryOf(event), + outcome: event.outcome === "denied" ? "denied" : "success", + ...(target !== undefined ? { target } : undefined), + ...(event.originIp !== undefined ? { originIp: event.originIp } : undefined), + ...(event.originCountry !== undefined ? { originCountry: event.originCountry } : undefined), + raw: rawOf(event), + } +} + +/** The trail events for a batch of Maple's own audit entries. */ +export const mapleAuditTrailEvents = (events: ReadonlyArray): ReadonlyArray => + Arr.flatMap(events, (event) => { + const trailEvent = mapleAuditTrailEvent(event) + return trailEvent === null ? [] : [trailEvent] + }) diff --git a/packages/backend/src/services/audit/AuditLogService.test.ts b/packages/backend/src/services/audit/AuditLogService.test.ts index 4ada068e9b..645c2e0290 100644 --- a/packages/backend/src/services/audit/AuditLogService.test.ts +++ b/packages/backend/src/services/audit/AuditLogService.test.ts @@ -11,6 +11,7 @@ import { TestClock } from "effect/testing" import { makeWarehouseServiceStub } from "@maple/backend/testing/warehouse-test-support" import { type AuditActorInfo, CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { AUDIT_ACCESS_ACTION_SUFFIXES, AUDIT_LOG_DATASOURCE, @@ -38,15 +39,21 @@ const recordingWarehouse = (rows: ReadonlyArray> = []) = bestEffort: boolean retryTransient: boolean }> = [] + /** Rows written to the unified audit trail, the only other table the service writes. */ + const trail: Array = [] const sql: Array = [] - const layer = Layer.succeed( + const stub = Layer.succeed( WarehouseQueryService, makeWarehouseServiceStub({ ingest: (_tenant, datasource, batch, options) => Effect.sync(() => { + if (datasource !== AUDIT_LOG_DATASOURCE) { + trail.push(...batch) + return + } ingested.push({ datasource, - // SAFETY: this stub only ever receives the audit datasource's rows. + // SAFETY: only `audit_log` writes reach this branch. rows: batch as ReadonlyArray, bestEffort: options?.bestEffort === true, retryTransient: options?.retryTransient === true, @@ -63,7 +70,9 @@ const recordingWarehouse = (rows: ReadonlyArray> = []) = })) as never, }), ) - return { ingested, sql, layer } + // The audit log writes its copy through the real trail service, over the same stub. + const layer = Layer.effect(AuditTrailService, AuditTrailService.make).pipe(Layer.provideMerge(stub)) + return { ingested, trail, sql, layer } } const storedRow = (overrides: Partial> = {}) => ({ @@ -131,6 +140,48 @@ describe("AuditLogService (warehouse-backed)", () => { }), ) + it.effect("copies a change to the audit trail under the same time, and keeps reads out of it", () => + Effect.gen(function* () { + const warehouse = recordingWarehouse() + yield* Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + resourceId: DASHBOARD_ID, + }) + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "telemetry.read", + }) + }).pipe( + Effect.provide( + Layer.effect(AuditLogService, AuditLogService.make).pipe(Layer.provide(warehouse.layer)), + ), + ) + + expect(warehouse.ingested).toHaveLength(2) + expect(warehouse.trail).toEqual([ + expect.objectContaining({ + OrgId: ORG, + Provider: "maple", + Action: "dashboard.created", + Category: "resource", + Outcome: "success", + ActorType: "user", + ActorId: USER, + TargetType: "dashboard", + TargetId: encodePublicId(PublicIdPrefixes.dashboard, DASHBOARD_ID), + OccurredAt: warehouse.ingested[0].rows[0].OccurredAt, + }), + ]) + }), + ) + it.effect("publishes to the audit queue instead of writing when the binding is present", () => Effect.gen(function* () { const warehouse = recordingWarehouse() diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index b399726b63..d4b98db6bf 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -12,6 +12,8 @@ import { ALERTING_SYSTEM_USER_ID, systemTenant } from "@maple/backend/services/a import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import { AUDIT_ACCESS_ACTION_SUFFIXES, isAuditAccessAction } from "@maple/domain/audit-actions" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" +import { mapleAuditTrailEvents } from "@maple/backend/services/audit-trail/providers/maple" import { type AuditAction, auditResourceFields, type AuditResourceIdOption } from "./audit-actions" import { AuditLogEvent, @@ -239,6 +241,7 @@ export class AuditLogService extends Context.Service = new Set(["audit_log"]) +const RAW_SQL_HIDDEN_DATASOURCES: ReadonlySet = new Set(["audit_log", "audit_trail_events"]) function collectDatasources() { // `Datasources` exports a mix of datasource definitions, type aliases, helper diff --git a/packages/domain/package.json b/packages/domain/package.json index 1d3b012015..1694f75bb6 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -9,6 +9,7 @@ "./ai-traffic": "./src/ai-traffic.ts", "./anticipated-errors": "./src/anticipated-errors.ts", "./audit-actions": "./src/audit-actions.ts", + "./audit-trail": "./src/audit-trail.ts", "./billing": "./src/billing.ts", "./billing-tax-ids": "./src/billing-tax-ids.ts", "./clerk-js-url": "./src/clerk-js-url.ts", diff --git a/packages/domain/src/audit-trail.ts b/packages/domain/src/audit-trail.ts new file mode 100644 index 0000000000..2f0fdadfd4 --- /dev/null +++ b/packages/domain/src/audit-trail.ts @@ -0,0 +1,63 @@ +import { Schema } from "effect" + +/** Platforms whose audit events the trail holds. One literal per built adapter; `maple` is Maple's own audit log. */ +export const AuditTrailProvider = Schema.Literals(["maple"]) +export type AuditTrailProvider = typeof AuditTrailProvider.Type + +/** + * What an event is about, the same across platforms so one rule covers all of them. + * `configuration` is how the platform behaves (settings, rules, monitors, integrations); + * `resource` is something people make and own in it (a repository, a dashboard, a deployment). + */ +export const AuditTrailCategory = Schema.Literals([ + "authentication", + "membership", + "permission", + "credential", + "configuration", + "resource", + "data_access", + "billing", + "other", +]) +export type AuditTrailCategory = typeof AuditTrailCategory.Type + +/** `service`: a token, key, app, service account or agent. `system`: the platform acting on its own. */ +export const AuditTrailActorType = Schema.Literals(["user", "service", "system", "unknown"]) +export type AuditTrailActorType = typeof AuditTrailActorType.Type + +/** `failure`: attempted and it did not work. `denied`: refused for lack of access. */ +export const AuditTrailOutcome = Schema.Literals(["success", "failure", "denied", "unknown"]) +export type AuditTrailOutcome = typeof AuditTrailOutcome.Type + +/** + * One audit event as a provider adapter hands it over. `provider`, `account` and + * `providerEventId` make the stored row's key: a redelivery collapses only when it also + * carries the same `occurredAtMs`. `raw` is the platform's payload, kept for the detail view. + */ +export interface AuditTrailEvent { + readonly provider: AuditTrailProvider + readonly providerEventId: string + /** The platform account, organization, team or project the event belongs to. */ + readonly account?: string + readonly occurredAtMs: number + readonly actor: { + readonly type: AuditTrailActorType + readonly id?: string + readonly name?: string + readonly email?: string + } + /** The platform's own action name, unchanged (`org.add_member`, `alert_rule.deleted`). */ + readonly action: string + readonly category: AuditTrailCategory + readonly outcome: AuditTrailOutcome + readonly target?: { + readonly type?: string + readonly id?: string + readonly name?: string + } + readonly originIp?: string + readonly originCountry?: string + readonly userAgent?: string + readonly raw?: Record +} diff --git a/packages/domain/src/clickhouse/effect-orm-parity.test.ts b/packages/domain/src/clickhouse/effect-orm-parity.test.ts index 4761f94f56..c2a5f1f015 100644 --- a/packages/domain/src/clickhouse/effect-orm-parity.test.ts +++ b/packages/domain/src/clickhouse/effect-orm-parity.test.ts @@ -90,6 +90,7 @@ const KNOWN_DIFFERENCES = { session_replays: "ENGINE = ReplacingMergeTree(Version)", // Ingest-pinned to managed Tinybird, which keeps the version; self-managed copies stay empty. search_console_daily: "ENGINE = ReplacingMergeTree(IngestedAt)", + audit_trail_events: "ENGINE = ReplacingMergeTree(Version)", cost_daily: "ENGINE = ReplacingMergeTree(IngestedAt)", } satisfies Record diff --git a/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts b/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts new file mode 100644 index 0000000000..8334994041 --- /dev/null +++ b/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts @@ -0,0 +1,12 @@ +// Migration 0064: `audit_trail_events`, audit events from connected platforms and Maple itself. +// Written through the managed ingest pipeline (pinned like `audit_log`), so a BYO cluster only +// mirrors the empty table and it never gates ingest. Frozen v64 DDL. +export const migration_0064_audit_trail_events = { + version: 64, + description: "Create audit_trail_events, normalized audit events from connected platforms", + requiredForIngest: false, + statements: [ + "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", + "ALTER TABLE audit_trail_events MODIFY SETTING ttl_only_drop_parts = 1", + ], +} as const diff --git a/packages/domain/src/clickhouse/migrations/index.ts b/packages/domain/src/clickhouse/migrations/index.ts index 8d428e5a5f..517f81f813 100644 --- a/packages/domain/src/clickhouse/migrations/index.ts +++ b/packages/domain/src/clickhouse/migrations/index.ts @@ -62,6 +62,7 @@ import { migration_0060_search_console_daily } from "./0060_search_console_daily import { migration_0061_error_events_blank_event_fingerprint } from "./0061_error_events_blank_event_fingerprint" import { migration_0062_scanner_autonomous_agents } from "./0062_scanner_autonomous_agents" import { migration_0063_cost_daily } from "./0063_cost_daily" +import { migration_0064_audit_trail_events } from "./0064_audit_trail_events" import { Array as Arr } from "effect" /** @@ -157,6 +158,7 @@ export const migrations: ReadonlyArray = [ migration_0061_error_events_blank_event_fingerprint, migration_0062_scanner_autonomous_agents, migration_0063_cost_daily, + migration_0064_audit_trail_events, ] as const /** Highest migration `version` bundled — i.e. the schema level a fully-applied diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 9099172dd0..56be12756c 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -11,6 +11,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/anomalies/AnomalyForbiddenError", "@maple/http/anomalies/AnomalyIncidentNotFoundError", "@maple/http/anomalies/AnomalyLinkedIssueNotFoundError", + "@maple/http/audit-trail/AuditTrailForbiddenError", "@maple/http/data-sources/CatalogEntryNotFoundError", "@maple/http/data-sources/DataSourceInvalidError", "@maple/http/data-sources/DataSourceNotFoundError", diff --git a/packages/domain/src/generated/clickhouse-schema.ts b/packages/domain/src/generated/clickhouse-schema.ts index 71db8944bc..daac431c7a 100644 --- a/packages/domain/src/generated/clickhouse-schema.ts +++ b/packages/domain/src/generated/clickhouse-schema.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-schema.ts // Do not edit manually. -export const projectRevision = "266cb624c1996b27cb3d435f5feb3e57ec70c6fdf5f9ecfd1f291b6425b3a692" as const +export const projectRevision = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138" as const export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS ai_crawler_requests (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n ServiceName LowCardinality(String),\n Crawler LowCardinality(String),\n Host LowCardinality(String),\n Path String,\n HttpStatus UInt16\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 30 DAY", @@ -10,6 +10,7 @@ export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS attribute_keys_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, Hour, AttributeKey)\nTTL toDate(Hour) + INTERVAL 90 DAY", "CREATE TABLE IF NOT EXISTS attribute_values_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeValue String,\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue)\nTTL toDate(Hour) + INTERVAL 90 DAY", "CREATE TABLE IF NOT EXISTS audit_log (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n RecordedAt DateTime64(3),\n ActorType LowCardinality(String),\n UserId String,\n ApiKeyId String,\n ActorId String,\n ActorLabel String,\n AffectedUserId String,\n Source LowCardinality(String),\n Action LowCardinality(String),\n Outcome LowCardinality(String),\n DenialReason String,\n ResourceType LowCardinality(String),\n ResourceId String,\n ChangedFields Array(String),\n Changes String,\n Metadata String,\n RequestId String,\n OriginIp String,\n OriginCountry LowCardinality(String)\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 2190 DAY", + "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", "CREATE TABLE IF NOT EXISTS ci_jobs (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n Owner LowCardinality(String),\n Repository LowCardinality(String),\n Pipeline LowCardinality(String),\n RunId String,\n Attempt UInt16,\n Job LowCardinality(String),\n Result LowCardinality(String),\n StatusCode LowCardinality(String),\n Duration UInt64,\n QueueMs UInt32,\n Url String,\n FailureReason String,\n RunnerName LowCardinality(String),\n RunnerLabels LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toYYYYMM(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 365 DAY", "CREATE TABLE IF NOT EXISTS ci_runs (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n Owner LowCardinality(String),\n Repository LowCardinality(String),\n Pipeline LowCardinality(String),\n RunId String,\n RunNumber UInt64,\n Attempt UInt16,\n Result LowCardinality(String),\n Duration UInt64,\n Url String,\n Branch String,\n Revision String,\n TriggerEvent LowCardinality(String),\n Actor LowCardinality(String),\n PullRequest UInt32,\n WorkflowPath LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toYYYYMM(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 365 DAY", "CREATE TABLE IF NOT EXISTS cost_daily (\n OrgId LowCardinality(String),\n SourceId LowCardinality(String),\n Provider LowCardinality(String),\n AccountId LowCardinality(String),\n Date DateTime,\n CloudService LowCardinality(String),\n ServiceCategory LowCardinality(String),\n UsageType LowCardinality(String),\n Region LowCardinality(String),\n ResourceId String,\n ResourceType LowCardinality(String),\n ServiceName LowCardinality(String),\n ChargeCategory LowCardinality(String),\n Basis LowCardinality(String),\n Currency LowCardinality(String),\n BilledCost Float64,\n EffectiveCost Float64,\n ListCost Float64,\n ConsumedQuantity Float64,\n ConsumedUnit LowCardinality(String),\n Tags Map(LowCardinality(String), String),\n Estimated UInt8,\n IngestedAt DateTime64(3)\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(Date)\nORDER BY (OrgId, SourceId, Date, CloudService, UsageType, Region, ResourceId, ChargeCategory, AccountId, ServiceName, Currency)\nTTL toDate(Date) + INTERVAL 760 DAY", diff --git a/packages/domain/src/generated/tinybird-project-manifest.ts b/packages/domain/src/generated/tinybird-project-manifest.ts index 4fa2926ddd..517e302e6e 100644 --- a/packages/domain/src/generated/tinybird-project-manifest.ts +++ b/packages/domain/src/generated/tinybird-project-manifest.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-tinybird-project-manifest.ts // Do not edit manually. -export const projectRevision = "266cb624c1996b27cb3d435f5feb3e57ec70c6fdf5f9ecfd1f291b6425b3a692" as const +export const projectRevision = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138" as const export const datasources = [ { @@ -34,6 +34,11 @@ export const datasources = [ content: 'DESCRIPTION >\n Org-wide audit trail: allowed and denied actions plus telemetry/session-replay reads, attributed to the user, API key, or agent that performed them. Admin-only; read through GET /v2/audit_log.\n\nSCHEMA >\n OrgId LowCardinality(String) `json:$.OrgId`,\n Id String `json:$.Id`,\n OccurredAt DateTime64(3) `json:$.OccurredAt`,\n RecordedAt DateTime64(3) `json:$.RecordedAt`,\n ActorType LowCardinality(String) `json:$.ActorType`,\n UserId String `json:$.UserId`,\n ApiKeyId String `json:$.ApiKeyId`,\n ActorId String `json:$.ActorId`,\n ActorLabel String `json:$.ActorLabel`,\n AffectedUserId String `json:$.AffectedUserId`,\n Source LowCardinality(String) `json:$.Source`,\n Action LowCardinality(String) `json:$.Action`,\n Outcome LowCardinality(String) `json:$.Outcome`,\n DenialReason String `json:$.DenialReason`,\n ResourceType LowCardinality(String) `json:$.ResourceType`,\n ResourceId String `json:$.ResourceId`,\n ChangedFields Array(String) `json:$.ChangedFields[:]`,\n Changes String `json:$.Changes`,\n Metadata String `json:$.Metadata`,\n RequestId String `json:$.RequestId`,\n OriginIp String `json:$.OriginIp`,\n OriginCountry LowCardinality(String) `json:$.OriginCountry`\n\nENGINE "ReplacingMergeTree"\nENGINE_PARTITION_KEY "toYYYYMM(OccurredAt)"\nENGINE_SORTING_KEY "OrgId, OccurredAt, Id"\nENGINE_TTL "toDate(OccurredAt) + INTERVAL 2190 DAY"', }, + { + name: "audit_trail_events", + content: + 'DESCRIPTION >\n Audit events from connected platforms and Maple itself, normalized to one shape: who did what to which target, with what outcome. Admin-only; read through the Security hub.\n\nSCHEMA >\n OrgId LowCardinality(String) `json:$.OrgId`,\n Id String `json:$.Id`,\n OccurredAt DateTime64(3) `json:$.OccurredAt`,\n IngestedAt DateTime64(3) `json:$.IngestedAt`,\n Provider LowCardinality(String) `json:$.Provider`,\n Account String `json:$.Account`,\n ProviderEventId String `json:$.ProviderEventId`,\n ActorType LowCardinality(String) `json:$.ActorType`,\n ActorId String `json:$.ActorId`,\n ActorName String `json:$.ActorName`,\n ActorEmail String `json:$.ActorEmail`,\n Action LowCardinality(String) `json:$.Action`,\n Category LowCardinality(String) `json:$.Category`,\n Outcome LowCardinality(String) `json:$.Outcome`,\n TargetType LowCardinality(String) `json:$.TargetType`,\n TargetId String `json:$.TargetId`,\n TargetName String `json:$.TargetName`,\n OriginIp String `json:$.OriginIp`,\n OriginCountry LowCardinality(String) `json:$.OriginCountry`,\n UserAgent String `json:$.UserAgent`,\n Raw String `json:$.Raw`,\n Version UInt64 `json:$.Version`\n\nENGINE "ReplacingMergeTree"\nENGINE_PARTITION_KEY "toYYYYMM(OccurredAt)"\nENGINE_SORTING_KEY "OrgId, OccurredAt, Id"\nENGINE_TTL "toDate(OccurredAt) + INTERVAL 400 DAY"\nENGINE_VER "Version"\n\nINDEXES >\n idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1', + }, { name: "ci_jobs", content: diff --git a/packages/domain/src/http/audit-trail.ts b/packages/domain/src/http/audit-trail.ts new file mode 100644 index 0000000000..29dbd96982 --- /dev/null +++ b/packages/domain/src/http/audit-trail.ts @@ -0,0 +1,108 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" +import { Schema } from "effect" +import { SessionAuthorization } from "./current-tenant" + +// The Security hub's audit log: audit events from connected platforms and Maple itself in one +// list. Dashboard only (the session tier refuses API keys) and behind `audit_log:read`. + +const PAGE_LIMIT_MAX = 100 + +/** 2100-01-01: past it a bound is a typo, and far past it the warehouse cannot parse the date. */ +const EPOCH_MS_MAX = 4_102_444_800_000 + +/** + * One stored event. `provider`, `category`, `actor.type` and `outcome` are plain strings on the + * wire: the ingest gateway writes rows too and deploys on its own schedule, so a value this + * build does not know must show as itself and not fail the page. + */ +export const AuditTrailEntry = Schema.Struct({ + id: Schema.String, + provider: Schema.String, + account: Schema.NullOr(Schema.String), + /** The platform's own id for the event. */ + providerEventId: Schema.String, + /** Epoch ms. `ingestedAt` trails `occurredAt` by the provider's delivery delay. */ + occurredAt: Schema.Finite, + ingestedAt: Schema.Finite, + actor: Schema.Struct({ + type: Schema.String, + id: Schema.NullOr(Schema.String), + name: Schema.NullOr(Schema.String), + email: Schema.NullOr(Schema.String), + }), + action: Schema.String, + category: Schema.String, + outcome: Schema.String, + target: Schema.Struct({ + type: Schema.NullOr(Schema.String), + id: Schema.NullOr(Schema.String), + name: Schema.NullOr(Schema.String), + }), + originIp: Schema.NullOr(Schema.String), + originCountry: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + /** The provider's payload, or null when none was kept. */ + raw: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), +}) +export type AuditTrailEntry = typeof AuditTrailEntry.Type + +const EpochMs = Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: EPOCH_MS_MAX }), +) + +/** `:` of the last event on the previous page. */ +const AuditTrailCursor = Schema.String.check(Schema.isPattern(/^\d{1,13}:[0-9a-f]{32}$/)) + +const Filter = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)) + +export const AuditTrailListQuery = Schema.Struct({ + provider: Schema.optional(Filter), + account: Schema.optional(Filter), + category: Schema.optional(Filter), + outcome: Schema.optional(Filter), + action: Schema.optional(Filter), + /** An actor's provider id or email. A Maple member's email finds their events too. */ + actor: Schema.optional(Filter), + since: Schema.optional(EpochMs), + until: Schema.optional(EpochMs), + cursor: Schema.optional(AuditTrailCursor), + limit: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: PAGE_LIMIT_MAX }), + ), + ), +}) +export type AuditTrailListQuery = typeof AuditTrailListQuery.Type + +/** Newest first. `nextCursor` is null on the last page. */ +export const AuditTrailListResponse = Schema.Struct({ + events: Schema.Array(AuditTrailEntry), + nextCursor: Schema.NullOr(Schema.String), +}) +export type AuditTrailListResponse = typeof AuditTrailListResponse.Type + +export class AuditTrailPersistenceError extends Schema.TaggedError()( + "@maple/http/audit-trail/AuditTrailPersistenceError", + /** `cause` names the failure behind it (its tag), never its details. */ + { message: Schema.String, cause: Schema.optionalKey(Schema.String) }, + { httpApiStatus: 503 }, +) {} + +export class AuditTrailForbiddenError extends Schema.TaggedError()( + "@maple/http/audit-trail/AuditTrailForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class AuditTrailApiGroup extends HttpApiGroup.make("auditTrail") + .add( + HttpApiEndpoint.get("list", "/events", { + query: AuditTrailListQuery, + success: AuditTrailListResponse, + error: [AuditTrailPersistenceError, AuditTrailForbiddenError], + }), + ) + .prefix("/internal/audit-trail") + .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 8092e04dfd..d6de6990ad 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -20,6 +20,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" +export * from "./audit-trail" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 84c47f032a..0794c20ebe 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -6,6 +6,7 @@ import { AiTriageApiGroup } from "./ai-triage" import { BillingApiGroup } from "./billing" import { CodeReviewApiGroup } from "./code-review" import { DemoApiGroup } from "./demo" +import { AuditTrailApiGroup } from "./audit-trail" import { DependencyScanApiGroup } from "./dependency-scan" import { DigestApiGroup } from "./digest" import { ErrorsApiGroup } from "./errors" @@ -50,6 +51,7 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(AiModelsInternalApiGroup) .add(AiSessionsInternalApiGroup) .add(AiTriageApiGroup) + .add(AuditTrailApiGroup) .add(BillingApiGroup) .add(CodeReviewApiGroup) .add(DemoApiGroup) diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index 400e39ac21..3fffbbfebf 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -2102,6 +2102,54 @@ export const auditLog = defineDatasource("audit_log", { export type AuditLogRow = InferRow +/** + * Audit events from every connected platform, and Maple's own changes and refusals, in one shape. + * Written only by provider adapters, never from OTLP. `''` is absent; `Raw` is the payload as JSON. + */ +export const auditTrailEvents = defineDatasource("audit_trail_events", { + tenantColumn: "OrgId", + description: + "Audit events from connected platforms and Maple itself, normalized to one shape: who did what to which target, with what outcome. Admin-only; read through the Security hub.", + schema: { + OrgId: t.string().lowCardinality().brand(OrgId), + // A hash of Provider, Account and ProviderEventId: a redelivery writes the same key. + Id: t.string(), + OccurredAt: utcDateTime64(3), + // When Maple stored the row. Detections window on it: a polled event arrives late. + IngestedAt: utcDateTime64(3), + Provider: t.string().lowCardinality(), + Account: t.string(), + ProviderEventId: t.string(), + ActorType: t.string().lowCardinality(), + ActorId: t.string(), + ActorName: t.string(), + ActorEmail: t.string(), + Action: t.string().lowCardinality(), + Category: t.string().lowCardinality(), + Outcome: t.string().lowCardinality(), + TargetType: t.string().lowCardinality(), + TargetId: t.string(), + TargetName: t.string(), + OriginIp: t.string(), + OriginCountry: t.string().lowCardinality(), + UserAgent: t.string(), + Raw: t.string(), + // Falls as IngestedAt rises, so a merge keeps the first write: a redelivered event keeps + // its first IngestedAt and never moves into a later detection window. + Version: t.uint64(), + }, + indexes: [{ name: "idx_ingested_at", expr: "IngestedAt", type: "minmax", granularity: 1 }], + engine: engine.replacingMergeTree({ + partitionKey: "toYYYYMM(OccurredAt)", + sortingKey: ["OrgId", "OccurredAt", "Id"], + ver: "Version", + // Thirteen months: a year of history plus a month to compare against. + ttl: "toDate(OccurredAt) + INTERVAL 400 DAY", + }), +}) + +export type AuditTrailEventsRow = InferRow + /** * Minute-grain operation metrics used by the service-detail Operations panel. * The operation name is normalized once by the write-side MV, while exact and diff --git a/packages/domain/src/tinybird/retention-matrix.test.ts b/packages/domain/src/tinybird/retention-matrix.test.ts index d860f635ed..2bae908891 100644 --- a/packages/domain/src/tinybird/retention-matrix.test.ts +++ b/packages/domain/src/tinybird/retention-matrix.test.ts @@ -17,6 +17,8 @@ const RETENTION_DAYS = { ai_crawler_requests: 30, // Six years — HIPAA's documentation retention floor. Never rebuildable. audit_log: 2190, + // Thirteen months: a year of audit history plus a month to compare against. + audit_trail_events: 400, attribute_keys_hourly: 90, attribute_values_hourly: 90, error_events: 90, diff --git a/packages/local-server/src/local-schema-history.ts b/packages/local-server/src/local-schema-history.ts index 6b30d3d6d2..7dcff75604 100644 --- a/packages/local-server/src/local-schema-history.ts +++ b/packages/local-server/src/local-schema-history.ts @@ -53,6 +53,7 @@ import schemaV52Sql from "./schema/history/local-schema-v52.sql" with { type: "t import schemaV53Sql from "./schema/history/local-schema-v53.sql" with { type: "text" } import schemaV54Sql from "./schema/history/local-schema-v54.sql" with { type: "text" } import schemaV55Sql from "./schema/history/local-schema-v55.sql" with { type: "text" } +import schemaV56Sql from "./schema/history/local-schema-v56.sql" with { type: "text" } import { Array as Arr } from "effect" import { buildLocalSchemaManifest, type LocalSchemaManifest } from "./schema-manifest" import { LEGACY_LOCAL_SCHEMA, LEGACY_LOCAL_SCHEMA_VERSION, type LocalSchemaIdentity } from "./schema-identity" @@ -122,6 +123,7 @@ const SNAPSHOT_SQL: ReadonlyArray = [ schemaV53Sql, schemaV54Sql, schemaV55Sql, + schemaV56Sql, ] export interface LocalSchemaSnapshot { diff --git a/packages/local-server/src/local-schema-version.ts b/packages/local-server/src/local-schema-version.ts index 37a51d33a6..060bb59834 100644 --- a/packages/local-server/src/local-schema-version.ts +++ b/packages/local-server/src/local-schema-version.ts @@ -1,7 +1,7 @@ // Increment this value for every structural change to the generated local // schema. The compatibility manifest and migration registry must be updated in // the same change before a new value can ship. -export const LOCAL_SCHEMA_VERSION = 55 as const +export const LOCAL_SCHEMA_VERSION = 56 as const /** SQLite eventing state has its own independent version sequence. */ export const LOCAL_CONTROL_SCHEMA_VERSION = 1 as const diff --git a/packages/local-server/src/local-store-migrations/steps.ts b/packages/local-server/src/local-store-migrations/steps.ts index 53da1ffc7e..ba2a5ea63e 100644 --- a/packages/local-server/src/local-store-migrations/steps.ts +++ b/packages/local-server/src/local-store-migrations/steps.ts @@ -1939,6 +1939,27 @@ const STEP_ROWS: ReadonlyArray = [ }, ], }, + { + // Purely additive: a new empty table, created by the bootstrap. Audit events come from the + // hosted provider adapters, so a local store has nothing to backfill. + slug: "audit-trail-events", + description: "Create audit_trail_events for audit events from connected platforms", + clonedBefore: "additive DDL runs", + plan: [ + [ + "install-audit-trail-events", + "Install the empty audit_trail_events table via the v56 bootstrap", + ], + ], + dispositions: [ + { + name: "audit_trail_events", + classification: "authoritative", + disposition: "preserve-exact", + guarantee: "Created empty; no existing table is read or rewritten.", + }, + ], + }, // local-schema:bump appends the next step above this line. ] diff --git a/packages/local-server/src/schema/history/local-schema-v56.sql b/packages/local-server/src/schema/history/local-schema-v56.sql new file mode 100644 index 0000000000..834972c572 --- /dev/null +++ b/packages/local-server/src/schema/history/local-schema-v56.sql @@ -0,0 +1,3007 @@ +-- This file is generated by scripts/generate-clickhouse-schema-sql.ts +-- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. +-- projectRevision: 91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138 + +CREATE TABLE IF NOT EXISTS ai_crawler_requests ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + ServiceName LowCardinality(String), + Crawler LowCardinality(String), + Host LowCardinality(String), + Path String, + HttpStatus UInt16 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS ai_trace_index ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SessionId String, + VendorId LowCardinality(String), + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + Model LowCardinality(String), + AgentName LowCardinality(String), + ToolName LowCardinality(String), + SpanId String, + ParentSpanId String, + Duration UInt64, + IsError UInt8, + IsLlmCall UInt8, + IsToolCall UInt8, + Tokens Float64, + Cost Float64, + ResponseId String, + VendorVersion LowCardinality(String), + InputTokens Float64, + CacheReadTokens Float64, + CacheWriteTokens Float64, + OutputTokens Float64, + ReasoningTokens Float64, + ErrorType LowCardinality(String), + StatusMessage String, + ToolDescription String, + FailedToolCallResult String, + ErrorFingerprint UInt64 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS alert_checks ( + OrgId LowCardinality(String), + RuleId String, + GroupKey String, + Timestamp DateTime64(3), + Status LowCardinality(String), + SignalType LowCardinality(String), + Comparator LowCardinality(String), + Threshold Float64, + ObservedValue Nullable(Float64), + SampleCount UInt32, + WindowMinutes UInt16, + WindowStart DateTime64(3), + WindowEnd DateTime64(3), + ConsecutiveBreaches UInt16, + ConsecutiveHealthy UInt16, + IncidentId Nullable(String), + IncidentTransition LowCardinality(String), + EvaluationDurationMs UInt32, + ErrorMessage Nullable(String), + ErrorCategory LowCardinality(String), + SkipReason LowCardinality(String) DEFAULT '', + Level LowCardinality(String) DEFAULT '' +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, RuleId, GroupKey, Timestamp) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS attribute_keys_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, Hour, AttributeKey) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS attribute_values_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + AttributeKey LowCardinality(String), + AttributeValue String, + AttributeScope LowCardinality(String), + UsageCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS audit_log ( + OrgId LowCardinality(String), + Id String, + OccurredAt DateTime64(3), + RecordedAt DateTime64(3), + ActorType LowCardinality(String), + UserId String, + ApiKeyId String, + ActorId String, + ActorLabel String, + AffectedUserId String, + Source LowCardinality(String), + Action LowCardinality(String), + Outcome LowCardinality(String), + DenialReason String, + ResourceType LowCardinality(String), + ResourceId String, + ChangedFields Array(String), + Changes String, + Metadata String, + RequestId String, + OriginIp String, + OriginCountry LowCardinality(String) +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(OccurredAt) +ORDER BY (OrgId, OccurredAt, Id) +TTL toDate(OccurredAt) + INTERVAL 2190 DAY; + +CREATE TABLE IF NOT EXISTS audit_trail_events ( + OrgId LowCardinality(String), + Id String, + OccurredAt DateTime64(3), + IngestedAt DateTime64(3), + Provider LowCardinality(String), + Account String, + ProviderEventId String, + ActorType LowCardinality(String), + ActorId String, + ActorName String, + ActorEmail String, + Action LowCardinality(String), + Category LowCardinality(String), + Outcome LowCardinality(String), + TargetType LowCardinality(String), + TargetId String, + TargetName String, + OriginIp String, + OriginCountry LowCardinality(String), + UserAgent String, + Raw String, + Version UInt64, + INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1 +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(OccurredAt) +ORDER BY (OrgId, OccurredAt, Id) +TTL toDate(OccurredAt) + INTERVAL 400 DAY; + +CREATE TABLE IF NOT EXISTS ci_jobs ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + Owner LowCardinality(String), + Repository LowCardinality(String), + Pipeline LowCardinality(String), + RunId String, + Attempt UInt16, + Job LowCardinality(String), + Result LowCardinality(String), + StatusCode LowCardinality(String), + Duration UInt64, + QueueMs UInt32, + Url String, + FailureReason String, + RunnerName LowCardinality(String), + RunnerLabels LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS ci_runs ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + Owner LowCardinality(String), + Repository LowCardinality(String), + Pipeline LowCardinality(String), + RunId String, + RunNumber UInt64, + Attempt UInt16, + Result LowCardinality(String), + Duration UInt64, + Url String, + Branch String, + Revision String, + TriggerEvent LowCardinality(String), + Actor LowCardinality(String), + PullRequest UInt32, + WorkflowPath LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS cost_daily ( + OrgId LowCardinality(String), + SourceId LowCardinality(String), + Provider LowCardinality(String), + AccountId LowCardinality(String), + Date DateTime, + CloudService LowCardinality(String), + ServiceCategory LowCardinality(String), + UsageType LowCardinality(String), + Region LowCardinality(String), + ResourceId String, + ResourceType LowCardinality(String), + ServiceName LowCardinality(String), + ChargeCategory LowCardinality(String), + Basis LowCardinality(String), + Currency LowCardinality(String), + BilledCost Float64, + EffectiveCost Float64, + ListCost Float64, + ConsumedQuantity Float64, + ConsumedUnit LowCardinality(String), + Tags Map(LowCardinality(String), String), + Estimated UInt8, + IngestedAt DateTime64(3) +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(Date) +ORDER BY (OrgId, SourceId, Date, CloudService, UsageType, Region, ResourceId, ChargeCategory, AccountId, ServiceName, Currency) +TTL toDate(Date) + INTERVAL 760 DAY; + +CREATE TABLE IF NOT EXISTS error_events ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String), + UserId String DEFAULT '' +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, FingerprintHash, Timestamp) +TTL toDate(Timestamp) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_events_by_time ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String DEFAULT '__unset__', + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ExceptionType LowCardinality(String), + ExceptionMessage String, + ExceptionStacktrace String, + TopFrame String, + FingerprintHash UInt64, + StatusMessage String, + Duration UInt64, + ErrorLabel String, + ServiceVersion LowCardinality(String), + UserId String DEFAULT '', + INDEX idx_user_id UserId TYPE bloom_filter GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, FingerprintHash) +TTL toDate(Timestamp) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS error_fingerprint_lineage ( + OrgId LowCardinality(String), + Minute DateTime, + FingerprintHash UInt64, + PreviousFingerprintHash UInt64 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, Minute, FingerprintHash, PreviousFingerprintHash) +TTL toDate(Minute) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS error_fingerprints_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + FingerprintHash UInt64, + ServiceName SimpleAggregateFunction(anyLast, String), + ExceptionType SimpleAggregateFunction(anyLast, String), + ExceptionMessage SimpleAggregateFunction(anyLast, String), + ErrorLabel SimpleAggregateFunction(anyLast, String), + TopFrame SimpleAggregateFunction(anyLast, String), + OccurrenceCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime), + ServiceVersions SimpleAggregateFunction(groupUniqArrayArray, Array(String)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Minute) +ORDER BY (OrgId, Minute, FingerprintHash) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS identity_links ( + OrgId LowCardinality(String), + VisitorId String, + UserId String, + FirstSeen SimpleAggregateFunction(min, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY tuple() +ORDER BY (OrgId, VisitorId, UserId) +TTL toDate(FirstSeen) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS logs ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TimestampTime DateTime, + TraceId String, + SpanId String, + TraceFlags UInt8, + SeverityText LowCardinality(String), + SeverityNumber UInt8, + ServiceName LowCardinality(String), + Body String, + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + LogAttributes Map(LowCardinality(String), String), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + LogAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(LogAttributes), mapValues(LogAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_keys mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_vals mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_lower_body lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8 +) +ENGINE = MergeTree +PARTITION BY toDate(TimestampTime) +ORDER BY (OrgId, toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp) +TTL toDate(TimestampTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS logs_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SeverityText LowCardinality(String), + DeploymentEnv LowCardinality(String), + Count SimpleAggregateFunction(sum, UInt64), + SizeBytes SimpleAggregateFunction(sum, UInt64), + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS metric_catalog ( + OrgId LowCardinality(String), + Hour DateTime, + MetricType LowCardinality(String), + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription SimpleAggregateFunction(anyLast, String), + MetricUnit SimpleAggregateFunction(anyLast, String), + IsMonotonic SimpleAggregateFunction(anyLast, UInt8), + DataPointCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, MetricType, ServiceName, MetricName, Hour) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_exponential_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + Scale Int32, + ZeroCount UInt64, + PositiveOffset Int32, + PositiveBucketCounts Array(UInt64), + NegativeOffset Int32, + NegativeBucketCounts Array(UInt64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_gauge ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)) +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_histogram ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Count UInt64, + Sum Float64, + BucketCounts Array(UInt64), + ExplicitBounds Array(Float64), + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + Flags UInt32, + Min Nullable(Float64), + Max Nullable(Float64), + AggregationTemporality Int32 +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS metrics_sum ( + OrgId LowCardinality(String), + ResourceAttributes Map(LowCardinality(String), String), + ResourceSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + MetricDescription LowCardinality(String), + MetricUnit LowCardinality(String), + Attributes Map(LowCardinality(String), String), + StartTimeUnix DateTime64(9), + TimeUnix DateTime64(9), + Value Float64, + Flags UInt32, + ExemplarsTraceId Array(String), + ExemplarsSpanId Array(String), + ExemplarsTimestamp Array(DateTime64(9)), + ExemplarsValue Array(Float64), + ExemplarsFilteredAttributes Array(Map(LowCardinality(String), String)), + AggregationTemporality Int32, + IsMonotonic Bool +) +ENGINE = MergeTree +PARTITION BY toDate(TimeUnix) +ORDER BY (OrgId, ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix)) +TTL toDate(TimeUnix) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS product_events ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + Source LowCardinality(String) DEFAULT 'browser', + SessionId String DEFAULT '', + Seq UInt32 DEFAULT 0, + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + Kind LowCardinality(String), + EventName String, + Host LowCardinality(String) DEFAULT '', + PagePath String DEFAULT '', + Url String DEFAULT '', + ServiceName LowCardinality(String) DEFAULT '', + Attributes Map(String, String) DEFAULT map(), + TraceId String DEFAULT '', + SpanId String DEFAULT '', + INDEX idx_event_name EventName TYPE set(64) GRANULARITY 4, + INDEX idx_user_id UserId TYPE bloom_filter GRANULARITY 4, + INDEX idx_trace_id TraceId TYPE bloom_filter GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, VisitorId, SessionId, Seq) +TTL toDate(Timestamp) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS scanner_requests ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + ServiceName LowCardinality(String), + Signature LowCardinality(String), + Scanner LowCardinality(String), + Host LowCardinality(String), + Path String, + HttpStatus UInt16, + ClientAddress String, + UserAgent String +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS search_console_daily ( + OrgId LowCardinality(String), + SiteUrl LowCardinality(String), + Grain LowCardinality(String), + SearchType LowCardinality(String), + Date DateTime, + Page String, + Query String, + Country LowCardinality(String), + Device LowCardinality(String), + Clicks UInt64, + Impressions UInt64, + PositionSum Float64, + IngestedAt DateTime64(3) +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(Date) +ORDER BY (OrgId, SiteUrl, Grain, SearchType, Date, Page, Query, Country, Device) +TTL toDate(Date) + INTERVAL 520 DAY; + +CREATE TABLE IF NOT EXISTS service_address_resolutions_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + ParentServerAddress String, + ResolvedTargetService LowCardinality(String), + DeploymentEnv LowCardinality(String) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, ParentServerAddress, ResolvedTargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_endpoint_details_hourly ( + OrgId LowCardinality(String), + ServiceName LowCardinality(String), + Hour DateTime, + DeploymentEnv LowCardinality(String), + SpanName String, + HttpStatusCode LowCardinality(String), + ErrorType LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + BodySizeQuantiles AggregateFunction(quantilesTDigestIf(0.5, 0.95, 0.99), UInt64, UInt8), + BodySizeMax SimpleAggregateFunction(max, UInt64), + BodyCount SimpleAggregateFunction(sum, UInt64), + UserAgents AggregateFunction(approx_top_k(10), String), + SlowestDuration SimpleAggregateFunction(max, UInt64), + SlowestTraceId AggregateFunction(argMax, String, UInt64), + SlowestTimestamp AggregateFunction(argMax, DateTime64(9), UInt64), + SlowestBodySize AggregateFunction(argMax, Int64, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, Hour, DeploymentEnv, SpanName, HttpStatusCode, ErrorType) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_external_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + TargetType LowCardinality(String), + TargetSystem LowCardinality(String), + TargetName String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, TargetType, TargetSystem, TargetName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_children ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, toStartOfFiveMinutes(Timestamp), TraceId, ParentSpanId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DbNamespace LowCardinality(String), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_edges_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DbNamespace LowCardinality(String), + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampleRateSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32), + FirstInsertedAt SimpleAggregateFunction(min, DateTime) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, Minute, DeploymentEnv, ServiceName, DbSystem, DbNamespace) +TTL toDate(Minute) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_map_db_query_shapes_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DbSystem LowCardinality(String), + DeploymentEnv LowCardinality(String), + QueryKey String, + QueryLabel SimpleAggregateFunction(any, String), + SampleStatement SimpleAggregateFunction(any, String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedCount SimpleAggregateFunction(sum, Float64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSumMs SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95), UInt64, UInt32), + DbNamespace LowCardinality(String) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, ServiceName, DbSystem, DbNamespace, QueryKey) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount SimpleAggregateFunction(sum, UInt64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + DurationSumMs SimpleAggregateFunction(sum, Float64), + MaxDurationMs SimpleAggregateFunction(max, Float64), + SampledSpanCount SimpleAggregateFunction(sum, UInt64), + UnsampledSpanCount SimpleAggregateFunction(sum, UInt64), + SampleRateSum SimpleAggregateFunction(sum, Float64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, DeploymentEnv, SourceService, TargetService) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_map_edges_hourly_ingest ( + OrgId LowCardinality(String), + Hour DateTime, + SourceService LowCardinality(String), + TargetService String, + DeploymentEnv LowCardinality(String), + CallCount UInt64, + ErrorCount UInt64, + DurationSumMs Float64, + MaxDurationMs Float64, + SampledSpanCount UInt64, + UnsampledSpanCount UInt64, + SampleRateSum Float64 +) +ENGINE = Null; + +CREATE TABLE IF NOT EXISTS service_map_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + TraceId String, + SpanId String, + ParentSpanId String, + ServiceName LowCardinality(String), + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, toStartOfFiveMinutes(Timestamp), TraceId, SpanId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64), + HttpClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + HttpStatusCount SimpleAggregateFunction(sum, UInt64), + Http4xxCount SimpleAggregateFunction(sum, UInt64), + Http5xxCount SimpleAggregateFunction(sum, UInt64), + RequestBodyBytes SimpleAggregateFunction(sum, UInt64), + RequestBodyCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Hour, SpanName) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_operations_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + SpanName String, + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64), + ClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + ServerSpanCount SimpleAggregateFunction(sum, UInt64), + RoutedSpanCount SimpleAggregateFunction(sum, UInt64), + HttpClassifiedSpanCount SimpleAggregateFunction(sum, UInt64), + HttpStatusCount SimpleAggregateFunction(sum, UInt64), + Http4xxCount SimpleAggregateFunction(sum, UInt64), + Http5xxCount SimpleAggregateFunction(sum, UInt64), + RequestBodyBytes SimpleAggregateFunction(sum, UInt64), + RequestBodyCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, DeploymentEnv, Minute, SpanName) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64), + EstimatedLadderSpanCount SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder50Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder100Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder250Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder500Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder1000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder2500Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder5000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder10000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0 +) +ENGINE = AggregatingMergeTree +PARTITION BY toYYYYMM(Hour) +ORDER BY (OrgId, ServiceName, Hour, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_minutely ( + OrgId LowCardinality(String), + Minute DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + CommitSha LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + EstimatedSpanCount SimpleAggregateFunction(sum, Float64), + ErrorCount SimpleAggregateFunction(sum, UInt64), + EstimatedErrorCount SimpleAggregateFunction(sum, Float64), + DurationSum SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95, 0.99), UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + ApdexSatisfiedCount SimpleAggregateFunction(sum, UInt64), + ApdexToleratingCount SimpleAggregateFunction(sum, UInt64), + EstimatedLadderSpanCount SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder50Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder100Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder250Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder500Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder1000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder2500Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder5000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0, + EstimatedUnder10000Ms SimpleAggregateFunction(sum, Float64) DEFAULT 0 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Minute) +ORDER BY (OrgId, ServiceName, Minute, DeploymentEnv, ServiceNamespace, CommitSha) +TTL toDate(Minute) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_overview_spans ( + OrgId LowCardinality(String), + Timestamp DateTime, + ServiceName LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + TraceState String, + DeploymentEnv LowCardinality(String), + CommitSha LowCardinality(String), + SampleRate Float64 DEFAULT 1, + ServiceNamespace LowCardinality(String), + SpanKind LowCardinality(String) DEFAULT '', + IsRoot UInt8 DEFAULT 0, + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, Timestamp) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS service_platforms_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + K8sCluster SimpleAggregateFunction(max, String), + K8sPodName SimpleAggregateFunction(max, String), + K8sDeploymentName SimpleAggregateFunction(max, String), + K8sStatefulSetName SimpleAggregateFunction(max, String), + K8sDaemonSetName SimpleAggregateFunction(max, String), + K8sNamespaceName SimpleAggregateFunction(max, String), + CloudPlatform SimpleAggregateFunction(max, String), + CloudProvider SimpleAggregateFunction(max, String), + FaasName SimpleAggregateFunction(max, String), + MapleSdkType SimpleAggregateFunction(max, String), + ProcessRuntimeName SimpleAggregateFunction(max, String), + SpanCount SimpleAggregateFunction(sum, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS service_runtimes_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + DeploymentEnv LowCardinality(String), + RuntimeName LowCardinality(String), + RuntimeVersion LowCardinality(String), + ExecutableName LowCardinality(String), + SdkLanguage LowCardinality(String), + SdkName LowCardinality(String), + SdkVersion LowCardinality(String), + SpanCount UInt64 +) +ENGINE = SummingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, DeploymentEnv, RuntimeName, RuntimeVersion, ExecutableName, SdkLanguage, SdkName, SdkVersion) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS service_usage ( + OrgId LowCardinality(String), + ServiceName LowCardinality(String), + Hour DateTime, + LogCount UInt64, + LogSizeBytes UInt64, + TraceCount UInt64, + TraceSizeBytes UInt64, + SumMetricCount UInt64, + SumMetricSizeBytes UInt64, + GaugeMetricCount UInt64, + GaugeMetricSizeBytes UInt64, + HistogramMetricCount UInt64, + HistogramMetricSizeBytes UInt64, + ExpHistogramMetricCount UInt64, + ExpHistogramMetricSizeBytes UInt64 +) +ENGINE = SummingMergeTree +ORDER BY (OrgId, ServiceName, Hour) +TTL Hour + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS session_events ( + OrgId LowCardinality(String), + SessionId String, + Timestamp DateTime64(9), + Seq UInt32 DEFAULT 0, + Type LowCardinality(String), + Url String DEFAULT '', + TraceId String DEFAULT '', + Level LowCardinality(String) DEFAULT '', + Message String DEFAULT '', + TargetSelector String DEFAULT '', + TargetText String DEFAULT '', + NetMethod LowCardinality(String) DEFAULT '', + NetUrl String DEFAULT '', + NetStatus UInt16 DEFAULT 0, + NetDurationMs UInt32 DEFAULT 0, + ErrorStack String DEFAULT '', + Attributes Map(String, String), + VisitorId String DEFAULT '', + UserId String DEFAULT '', + GroupId String DEFAULT '', + INDEX idx_type Type TYPE set(16) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, Timestamp, Seq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replay_events ( + OrgId LowCardinality(String), + SessionId String, + ChunkSeq UInt32, + Timestamp DateTime64(9), + DurationMs UInt32 DEFAULT 0, + EventCount UInt32 DEFAULT 0, + ByteSize UInt32 DEFAULT 0, + Events String, + IsCheckpoint UInt8 DEFAULT 0 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, SessionId, ChunkSeq) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS session_replays ( + OrgId LowCardinality(String), + SessionId String, + StartTime DateTime64(9), + EndTime Nullable(DateTime64(9)), + DurationMs Nullable(UInt32), + Status LowCardinality(String), + UserId String, + UrlInitial String, + UserAgent String, + BrowserName LowCardinality(String), + OsName LowCardinality(String), + DeviceType LowCardinality(String), + Country LowCardinality(String) DEFAULT '', + ServiceName LowCardinality(String), + PageViews UInt32 DEFAULT 0, + ClickCount UInt32 DEFAULT 0, + ErrorCount UInt32 DEFAULT 0, + TraceIds Array(String) DEFAULT [], + ResourceAttributes Map(LowCardinality(String), String), + Version UInt32, + VisitorId String DEFAULT '', + VisitorIsNew UInt8 DEFAULT 0, + UserEmail String DEFAULT '', + UserName String DEFAULT '', + GroupId String DEFAULT '', + GroupName String DEFAULT '', + UserTraits Map(String, String) DEFAULT map(), + Referrer String DEFAULT '', + ReferrerHost LowCardinality(String) DEFAULT '', + UtmSource LowCardinality(String) DEFAULT '', + UtmMedium LowCardinality(String) DEFAULT '', + UtmCampaign LowCardinality(String) DEFAULT '', + UtmTerm String DEFAULT '', + UtmContent String DEFAULT '', + Host LowCardinality(String) DEFAULT '', + EntryPath String DEFAULT '', + ExitPath String DEFAULT '', + Language LowCardinality(String) DEFAULT '', + LastActivityAt Nullable(DateTime64(9)) +) +ENGINE = ReplacingMergeTree +PARTITION BY toDate(StartTime) +ORDER BY (OrgId, SessionId) +TTL toDate(StartTime) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS source_usage_hourly ( + OrgId LowCardinality(String), + SourceId String, + Hour DateTime, + SpanCount UInt64, + SpanSizeBytes UInt64, + LogCount UInt64, + LogSizeBytes UInt64, + MetricCount UInt64, + MetricSizeBytes UInt64 +) +ENGINE = SummingMergeTree +ORDER BY (OrgId, SourceId, Hour) +TTL Hour + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS span_metrics_calls_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + MetricName LowCardinality(String), + SpanKind LowCardinality(String), + AttrFingerprint UInt64, + ResourceFingerprint UInt64, + StartTimeUnix DateTime64(9), + LastValue AggregateFunction(argMax, Float64, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE TABLE IF NOT EXISTS trace_facets_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SpanName String, + HttpMethod LowCardinality(String), + HttpStatusCode LowCardinality(String), + DeploymentEnv LowCardinality(String), + ServiceNamespace LowCardinality(String), + HasError UInt8, + TraceCount SimpleAggregateFunction(sum, UInt64), + DurationMin SimpleAggregateFunction(min, UInt64), + DurationMax SimpleAggregateFunction(max, UInt64), + DurationQuantiles AggregateFunction(quantilesTDigest(0.5, 0.95), UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SpanName, HttpMethod, HttpStatusCode, DeploymentEnv, ServiceNamespace, HasError) +TTL toDate(Hour) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS trace_index ( + OrgId LowCardinality(String), + TraceId String, + ServiceName LowCardinality(String), + Bucket DateTime, + MinTimestamp SimpleAggregateFunction(min, DateTime64(9)), + MaxTimestamp SimpleAggregateFunction(max, DateTime64(9)) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Bucket) +ORDER BY (OrgId, TraceId, ServiceName, Bucket) +TTL toDate(Bucket) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS trace_list_mv ( + OrgId LowCardinality(String), + TraceId String, + Timestamp DateTime, + ServiceName LowCardinality(String), + SpanName String, + SpanKind LowCardinality(String), + Duration UInt64, + StatusCode LowCardinality(String), + HttpMethod LowCardinality(String), + HttpRoute String, + HttpStatusCode LowCardinality(String), + DeploymentEnv LowCardinality(String), + HasError UInt8, + TraceState String, + ServiceNamespace LowCardinality(String), + INDEX idx_service_namespace ServiceNamespace TYPE set(1000) GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, Timestamp, TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces ( + OrgId LowCardinality(String), + Timestamp DateTime64(9), + TraceId String, + SpanId String, + ParentSpanId String, + TraceState String, + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + ServiceName LowCardinality(String), + ResourceSchemaUrl String, + ResourceAttributes Map(LowCardinality(String), String), + ScopeSchemaUrl String, + ScopeName String, + ScopeVersion String, + ScopeAttributes Map(LowCardinality(String), String), + Duration UInt64 DEFAULT 0, + StatusCode LowCardinality(String), + StatusMessage String, + SpanAttributes Map(LowCardinality(String), String), + EventsTimestamp Array(DateTime64(9)), + EventsName Array(LowCardinality(String)), + EventsAttributes Array(Map(LowCardinality(String), String)), + LinksTraceId Array(String), + LinksSpanId Array(String), + LinksTraceState Array(String), + LinksAttributes Array(Map(LowCardinality(String), String)), + SampleRate Float64 DEFAULT multiIf(SpanAttributes['SampleRate'] != '' AND toFloat64OrZero(SpanAttributes['SampleRate']) >= 1.0, toFloat64OrZero(SpanAttributes['SampleRate']), match(TraceState, 'th:[0-9a-f]+'), 1.0 / greatest(1.0 - reinterpretAsUInt64(reverse(unhex(rightPad(extract(TraceState, 'th:([0-9a-f]+)'), 16, '0')))) / pow(2.0, 64), 0.0001), 1.0), + IsEntryPoint UInt8 DEFAULT if(SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '', 1, 0), + ResourceAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)), + ScopeAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(ScopeAttributes), mapValues(ScopeAttributes)), + SpanAttributeItems Array(String) DEFAULT arrayMap((k, v) -> concat(k, char(31), v), mapKeys(SpanAttributes), mapValues(SpanAttributes)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_name SpanName TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_keys mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_scope_attr_vals mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (OrgId, ServiceName, toStartOfFiveMinutes(Timestamp), TraceId) +TTL toDate(Timestamp) + INTERVAL 30 DAY; + +CREATE TABLE IF NOT EXISTS traces_aggregates_hourly ( + OrgId LowCardinality(String), + Hour DateTime, + ServiceName LowCardinality(String), + SpanName LowCardinality(String), + SpanKind LowCardinality(String), + StatusCode LowCardinality(String), + IsEntryPoint UInt8, + DeploymentEnv LowCardinality(String), + WeightedCount SimpleAggregateFunction(sum, Float64), + WeightedDurationSum SimpleAggregateFunction(sum, Float64), + WeightedErrorCount SimpleAggregateFunction(sum, Float64), + DurationQuantiles AggregateFunction(quantilesTDigestWeighted(0.5, 0.95, 0.99), UInt64, UInt32), + DurationMin SimpleAggregateFunction(min, UInt64), + DurationMax SimpleAggregateFunction(max, UInt64) +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv) +TTL toDate(Hour) + INTERVAL 365 DAY; + +CREATE TABLE IF NOT EXISTS user_activity_hourly ( + OrgId LowCardinality(String), + ServiceNamespace LowCardinality(String), + UserId String, + Hour DateTime, + ServiceName LowCardinality(String), + SpanCount SimpleAggregateFunction(sum, UInt64), + ErrorSpanCount SimpleAggregateFunction(sum, UInt64), + RequestCount SimpleAggregateFunction(sum, UInt64), + FailedRequestCount SimpleAggregateFunction(sum, UInt64), + TraceIds AggregateFunction(uniqIf, String, UInt8), + SessionIds AggregateFunction(uniqIf, String, UInt8), + RequestDurationQuantiles AggregateFunction(quantilesTDigestIf(0.5, 0.95), UInt64, UInt8), + TopRoutes AggregateFunction(approx_top_k(10), String), + LogCount SimpleAggregateFunction(sum, UInt64), + WarnLogCount SimpleAggregateFunction(sum, UInt64), + ErrorLogCount SimpleAggregateFunction(sum, UInt64), + FirstSeen SimpleAggregateFunction(min, DateTime), + LastSeen SimpleAggregateFunction(max, DateTime), + ClassifiedRequestCount SimpleAggregateFunction(sum, UInt64) DEFAULT 0, + WebRequestCount SimpleAggregateFunction(sum, UInt64) DEFAULT 0, + MobileRequestCount SimpleAggregateFunction(sum, UInt64) DEFAULT 0 +) +ENGINE = AggregatingMergeTree +PARTITION BY toDate(Hour) +ORDER BY (OrgId, ServiceNamespace, UserId, Hour, ServiceName) +TTL toDate(Hour) + INTERVAL 90 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ai_crawler_requests_mv TO ai_crawler_requests AS +SELECT + OrgId, + Timestamp, + TraceId, + ServiceName, + arrayElement(['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'Meta-ExternalAgent', 'Meta-WebIndexer', 'Meta-ExternalFetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'], multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai'])) AS Crawler, + lower(replaceRegexpOne(coalesce(nullIf(SpanAttributes['server.address'], ''), nullIf(SpanAttributes['http.host'], ''), nullIf(SpanAttributes['net.host.name'], ''), nullIf(domain(SpanAttributes['url.full']), ''), nullIf(domain(SpanAttributes['http.url']), ''), ''), ':[0-9]+$', '')) AS Host, + leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) AS Path, + toUInt16OrZero(coalesce(nullIf(SpanAttributes['http.response.status_code'], ''), nullIf(SpanAttributes['http.status_code'], ''), '')) AS HttpStatus + FROM traces + WHERE SpanKind = 'Server' + AND multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'Claude-SearchBot', 'Claude-User', 'PerplexityBot', 'Perplexity-User', 'meta-externalagent', 'meta-webindexer', 'meta-externalfetcher', 'Bytespider', 'MistralAI-User', 'CCBot', 'Amazonbot', 'DuckAssistBot', 'cohere-training-data-crawler', 'cohere-ai']) > 0 + AND leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ai_trace_index_mv TO ai_trace_index AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanAttributes['maple_ai.session.id'] AS SessionId, + SpanAttributes['maple_ai.vendor.id'] AS VendorId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + SpanAttributes['maple_ai.model'] AS Model, + SpanAttributes['maple_ai.agent.name'] AS AgentName, + SpanAttributes['maple_ai.tool.name'] AS ToolName, + SpanId, + ParentSpanId, + Duration, + toUInt8(SpanAttributes['maple_ai.error'] = '1') AS IsError, + toUInt8(SpanAttributes['maple_ai.llm_call'] = '1') AS IsLlmCall, + toUInt8(SpanAttributes['maple_ai.tool_call'] = '1') AS IsToolCall, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS Tokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cost']) AS Cost, + SpanAttributes['maple_ai.response.id'] AS ResponseId, + SpanAttributes['maple_ai.vendor.version'] AS VendorVersion, + toFloat64OrZero(SpanAttributes['maple_ai.usage.input_tokens']) AS InputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_read_tokens']) AS CacheReadTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.cache_write_tokens']) AS CacheWriteTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.output_tokens']) AS OutputTokens, + toFloat64OrZero(SpanAttributes['maple_ai.usage.reasoning_tokens']) AS ReasoningTokens, + SpanAttributes['error.type'] AS ErrorType, + leftUTF8(StatusMessage, 400) AS StatusMessage, + SpanAttributes['maple_ai.tool.description'] AS ToolDescription, + SpanAttributes['maple_ai.tool.error_result'] AS FailedToolCallResult, + if(SpanAttributes['maple_ai.error'] = '1', cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(leftUTF8(coalesce(nullIf(SpanAttributes['maple_ai.tool.error_result'], ''), leftUTF8(StatusMessage, 400)), 400), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '[0-9a-fA-F-]{6,}|[0-9]+', '#')), 0) AS ErrorFingerprint + FROM traces + WHERE SpanAttributes['maple_ai.vendor.id'] != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ci_jobs_mv TO ci_jobs AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanId, + ResourceAttributes['vcs.owner.name'] AS Owner, + ResourceAttributes['vcs.repository.name'] AS Repository, + SpanAttributes['cicd.pipeline.name'] AS Pipeline, + SpanAttributes['cicd.pipeline.run.id'] AS RunId, + toUInt16OrZero(SpanAttributes['maple.cicd.run_attempt']) AS Attempt, + SpanAttributes['cicd.pipeline.task.name'] AS Job, + SpanAttributes['cicd.pipeline.task.run.result'] AS Result, + StatusCode, + Duration, + toUInt32OrZero(SpanAttributes['maple.cicd.queue_duration_ms']) AS QueueMs, + SpanAttributes['cicd.pipeline.task.run.url.full'] AS Url, + SpanAttributes['maple.cicd.failure.reason'] AS FailureReason, + SpanAttributes['cicd.worker.name'] AS RunnerName, + SpanAttributes['maple.cicd.runner_labels'] AS RunnerLabels + FROM traces + WHERE ServiceName = 'github-actions' + AND ParentSpanId != '' + AND SpanAttributes['cicd.pipeline.task.name'] != '' + AND SpanAttributes['maple.cicd.step.number'] = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS ci_runs_mv TO ci_runs AS +SELECT + OrgId, + Timestamp, + TraceId, + SpanId, + ResourceAttributes['vcs.owner.name'] AS Owner, + ResourceAttributes['vcs.repository.name'] AS Repository, + SpanAttributes['cicd.pipeline.name'] AS Pipeline, + SpanAttributes['cicd.pipeline.run.id'] AS RunId, + toUInt64OrZero(SpanAttributes['maple.cicd.run_number']) AS RunNumber, + toUInt16OrZero(SpanAttributes['maple.cicd.run_attempt']) AS Attempt, + SpanAttributes['cicd.pipeline.result'] AS Result, + Duration, + SpanAttributes['cicd.pipeline.run.url.full'] AS Url, + SpanAttributes['vcs.ref.head.name'] AS Branch, + SpanAttributes['vcs.ref.head.revision'] AS Revision, + SpanAttributes['maple.cicd.trigger_event'] AS TriggerEvent, + SpanAttributes['maple.cicd.actor'] AS Actor, + toUInt32OrZero(SpanAttributes['vcs.change.id']) AS PullRequest, + SpanAttributes['maple.cicd.workflow_path'] AS WorkflowPath + FROM traces + WHERE ServiceName = 'github-actions' + AND ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_by_time_mv TO error_events_by_time AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + -- Only fill the old Unknown Error bucket. Event values (including + -- empty fields) and spans with StatusMessage keep every hash input. + _ei = 0 AND StatusMessage = '' AS _useAttrs, + if( + _ei > 0, EventsAttributes[_ei]['exception.type'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '') + ) AS _exType, + if( + _ei > 0, EventsAttributes[_ei]['exception.message'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage) + ) AS _exMsg, + if( + _ei > 0, EventsAttributes[_ei]['exception.stacktrace'], + if(_useAttrs, SpanAttributes['exception.stacktrace'], '') + ) AS _exStack, + if(_useAttrs, _exMsg, StatusMessage) AS _msgText, + -- A user key replaces type, frames, message and salt in the hash: the + -- exception event's value unless blank after trimming, then the span's. + -- Trimmed and capped. + substringUTF8( + replaceRegexpAll(coalesce(nullIf(replaceRegexpAll(if(_ei > 0, EventsAttributes[_ei]['maple.fingerprint'], ''), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), ''), SpanAttributes['maple.fingerprint']), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), + 1, 200 + ) AS _customFp, + -- JSON detection for the message signature below. + isValidJSON(_msgText) AS _isJson, + _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', if( + kv.1 = 'type', + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '"[^" ]*/[^" ]*"', '"#"'), '[0-9a-fA-F-]{6,}|[0-9]+', '#') + )), + JSONExtractKeysAndValuesRaw(_msgText) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See ERROR_MSG_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 1000), '\\x1b\\[[0-9;]*m', ''), '^((\\[Error\\]|[\\s\\S]*?DB::Exception:)[\\s\\S]*?)( [Ii]n scope | [Ww]hile processing )[\\s\\S]*', '\\1'), 'RouteNotFound \\(([A-Z]+) [^ \\t\\r\\n]*\\)?', 'RouteNotFound (\\1 PATH)'), '^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]* not found([\\r\\n]|$)', '\\1 PATH not found\\2'), '\\bCannot (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]*', 'Cannot \\1 PATH'), '\\bRoute (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS):/[^ \\t\\r\\n]* not found', 'Route \\1:PATH not found'), 'No route found for "([A-Z]+) [^"\\r\\n]*"', 'No route found for "\\1 PATH"'), '(^|[ \\t\\r\\n\'"(`=])~/[^ \'"`:,)]*', '\\1PATH'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '\\b[a-z]{2,8}_[a-z]*[A-Z0-9][A-Za-z0-9]{8,}\\b', 'ID'), '\\b[0-9A-HJKMNP-TV-Z]{26}\\b', 'ID'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)([1-4][0-9][0-9]|500)\\b', '\\1\\2§\\3'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)5(0[1-9]|[1-9][0-9])\\b', '\\1\\2§5xx'), '(^|[^§0-9A-Za-z_.])([1-4][0-9][0-9]|500) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§\\2 \\3 '), '(^|[^§0-9A-Za-z_.])5(0[1-9]|[1-9][0-9]) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§5xx \\3 '), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/[^/? \\t\\r\\n\'"`:,)]*[~@%$][^/? \\t\\r\\n\'"`:,)]*', '/#'), '\'[^\' ]*([.][.][.]|[*]{3,})[^\' ]*\'', '\'#\''), '"[^" ]*([.][.][.]|[*]{3,})[^" ]*"', '"#"'), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{20,})', '\\1⟦\\2⟧'), '⟦([^⟧0-9]*)⟧', '\\1'), '⟦([^⟧A-Z]*)⟧', '\\1'), '⟦([^⟧a-z]*)⟧', '\\1'), '⟦[^⟧]*⟧', '#'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{10,})', '\\1⟦\\2⟧'), '⟦([^⟧]*[_-][^⟧]*)⟧', '\\1'), '⟦(0[xX][0-9a-fA-F]*)⟧', '\\1'), '⟦([^⟧]*[a-z][^⟧]*[A-Z][^⟧]*)⟧', '\\1'), '⟦([^⟧]*[A-Z][^⟧]*[a-z][^⟧]*)⟧', '\\1'), '⟦[^⟧]*[0-9][A-Za-z]+[0-9]+[A-Za-z]+[0-9][^⟧]*⟧', '#'), '⟦([^⟧]*)⟧', '\\1'), '(§[1-4][0-9][0-9]|§500|§5xx)|[0-9a-fA-F-]{6,}|[0-9]+', '\\1#'), '§([1-4][0-9][0-9]|500|5xx)#', '\\1'), + 1, 120 + ) + ) AS _msgSig, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@{"\\[]+@[^ \\t]*:[0-9]+|^[ \\t]*[^ \\t@{"\\[]*@([a-z][a-z0-9+.-]*:)?/[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+') + AND position(line, '(definition)') = 0 + AND NOT match(line, '^[ \\t]*at \\[') + AND NOT match(line, '^[ \\t]*at ([^(:]|:[^0-9])*:?$') + AND NOT match(line, '^[ \\t]*at ([^(]* \\()?(\\)?$|native(:|\\)|$)|node:|ext:)'), + splitByChar('\n', _exStack) + ) AS _frameLines, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, '@v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*', '@#'), '\\(index [0-9]+\\)', '(index #)'), 'file:///bundle/', ''), 'https?://[^/ )]+', ''), '(^|[ \\t("@])(file://)?([/.][^ \\t()"]*)?/(node_modules|site-packages|dist-packages|gems|pkg/mod)/', '\\1\\4/'), '([/-])v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*/', '\\1#/'), '(/python)[0-9]+\\.[0-9]+/', '\\1#/'), '(/\\$bunfs|[A-Z]:/~BUN)/root/', ''), '(\\$\\$Lambda|\\$Proxy|jdk\\.proxy|Generated[A-Za-z]*Accessor)\\$?[0-9]+', '\\1'), '(\\$(HibernateProxy|ByteBuddy|MockitoMock))\\$[A-Za-z0-9]+', '\\1'), '(-[A-Za-z0-9_-]{8})+\\.js', '.js'), '(-[A-Za-z0-9_-]{8})+\\.css', '.css'), '(/_astro/[^/ ).]+)\\.[A-Za-z0-9_-]{8}\\.js', '\\1.js'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + arraySlice( + -- Python prints the outermost frame first: read it from the raise site. + if(match(_frameLines[1], '^[ \\t]*File "'), arrayReverse(_frameLines), _frameLines), + 1, 10 + ) + ) AS _normFrames, + -- A repeat (two anonymous closures in one bundle, or recursion) collapses + -- so it does not use up the frames the hash keeps. + arrayFilter((frame, i) -> i = 1 OR frame != _normFrames[i - 1], _normFrames, arrayEnumerate(_normFrames)) AS _distinctFrames, + -- The application's own frames: a dependency's or a runtime's frames vary with + -- its internals and version. A stack with none keeps every frame. + arrayFilter(frame -> NOT match(frame, '(^|[ \\t("@/])(node_modules|site-packages|dist-packages|gems|pkg/mod)/|/python#/|/ruby/#/|/usr/local/go/src/|/rustc/|\\.cargo/registry/|^[ \\t]*at (java|javax|jdk|sun|kotlin|kotlinx|scala)\\.'), _distinctFrames) AS _appFrames, + -- A namespaced type plus its message names one failure: keep only the + -- throw site. With no message the frames are all that is left; keep 3. + if(position(_exType, '/') > 0 AND _msgSig != '', 1, 3) AS _maxFrames, + arraySlice(if(length(_appFrames) > 0, _appFrames, _distinctFrames), 1, _maxFrames) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- The previous version's frames, for the issue evaluator's re-key while a bump settles. + arrayStringConcat(arraySlice(_distinctFrames, 1, _maxFrames), '\n') AS _prevFpFrames, + if( + _customFp != '', + cityHash64(OrgId, ServiceName, 'maple.fingerprint', _customFp), + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig, 'fp4') + ) AS _hash, + if( + _customFp != '', + _hash, + cityHash64(OrgId, ServiceName, _exType, _prevFpFrames, _msgSig, 'fp3') + ) AS _prevHash, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + -- Only string values: JSONExtractString renders a number or an object + -- as its JSON text, which titled `{"error":{"code":5}}` with the object. + multiIf( + JSONType(_msgText, 'title') = 'String' AND JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'), + JSONType(_msgText, 'message') = 'String' AND JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'), + JSONType(_msgText, 'error') = 'String' AND JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'), + JSONType(_msgText, '_tag') = 'String' AND JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'), + JSONType(_msgText, 'reason') = 'String' AND JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'), + JSONType(_msgText, 'name') = 'String' AND JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'), + JSONType(_msgText, 'type') = 'String' AND JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + _msgText = '', 'Unknown Error', + position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0, + if( + extract(_msgText, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(_msgText, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel, + -- UTF8 variants throughout: a byte cut split a character and + -- left U+FFFD at the end of a non-ASCII title. + leftUTF8(_msgText, multiIf( + positionUTF8(_msgText, ': ') > 3, toInt64(positionUTF8(_msgText, ': ')) - 1, + positionUTF8(_msgText, ' (') > 3, toInt64(positionUTF8(_msgText, ' (')) - 1, + positionUTF8(_msgText, '\n') > 3, toInt64(positionUTF8(_msgText, '\n')) - 1, + 150 + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + _hash AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion, + if(SpanAttributes['user.id'] != '', SpanAttributes['user.id'], SpanAttributes['enduser.id']) AS UserId + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all hold: 4xx, no exception event, no exception.type + -- attribute, and no error.type beyond the status code itself (HTTP + -- semconv sets error.type to the bare status on a non-2xx response, + -- which carries no exception). 5xx and anything carrying a real + -- exception still count, and SpanKind is deliberately not consulted — + -- these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND SpanAttributes['exception.type'] = '' + AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus)) + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_events_mv TO error_events AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + -- Only fill the old Unknown Error bucket. Event values (including + -- empty fields) and spans with StatusMessage keep every hash input. + _ei = 0 AND StatusMessage = '' AS _useAttrs, + if( + _ei > 0, EventsAttributes[_ei]['exception.type'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '') + ) AS _exType, + if( + _ei > 0, EventsAttributes[_ei]['exception.message'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage) + ) AS _exMsg, + if( + _ei > 0, EventsAttributes[_ei]['exception.stacktrace'], + if(_useAttrs, SpanAttributes['exception.stacktrace'], '') + ) AS _exStack, + if(_useAttrs, _exMsg, StatusMessage) AS _msgText, + -- A user key replaces type, frames, message and salt in the hash: the + -- exception event's value unless blank after trimming, then the span's. + -- Trimmed and capped. + substringUTF8( + replaceRegexpAll(coalesce(nullIf(replaceRegexpAll(if(_ei > 0, EventsAttributes[_ei]['maple.fingerprint'], ''), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), ''), SpanAttributes['maple.fingerprint']), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), + 1, 200 + ) AS _customFp, + -- JSON detection for the message signature below. + isValidJSON(_msgText) AS _isJson, + _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', if( + kv.1 = 'type', + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '"[^" ]*/[^" ]*"', '"#"'), '[0-9a-fA-F-]{6,}|[0-9]+', '#') + )), + JSONExtractKeysAndValuesRaw(_msgText) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See ERROR_MSG_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 1000), '\\x1b\\[[0-9;]*m', ''), '^((\\[Error\\]|[\\s\\S]*?DB::Exception:)[\\s\\S]*?)( [Ii]n scope | [Ww]hile processing )[\\s\\S]*', '\\1'), 'RouteNotFound \\(([A-Z]+) [^ \\t\\r\\n]*\\)?', 'RouteNotFound (\\1 PATH)'), '^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]* not found([\\r\\n]|$)', '\\1 PATH not found\\2'), '\\bCannot (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]*', 'Cannot \\1 PATH'), '\\bRoute (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS):/[^ \\t\\r\\n]* not found', 'Route \\1:PATH not found'), 'No route found for "([A-Z]+) [^"\\r\\n]*"', 'No route found for "\\1 PATH"'), '(^|[ \\t\\r\\n\'"(`=])~/[^ \'"`:,)]*', '\\1PATH'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '\\b[a-z]{2,8}_[a-z]*[A-Z0-9][A-Za-z0-9]{8,}\\b', 'ID'), '\\b[0-9A-HJKMNP-TV-Z]{26}\\b', 'ID'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)([1-4][0-9][0-9]|500)\\b', '\\1\\2§\\3'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)5(0[1-9]|[1-9][0-9])\\b', '\\1\\2§5xx'), '(^|[^§0-9A-Za-z_.])([1-4][0-9][0-9]|500) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§\\2 \\3 '), '(^|[^§0-9A-Za-z_.])5(0[1-9]|[1-9][0-9]) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§5xx \\3 '), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/[^/? \\t\\r\\n\'"`:,)]*[~@%$][^/? \\t\\r\\n\'"`:,)]*', '/#'), '\'[^\' ]*([.][.][.]|[*]{3,})[^\' ]*\'', '\'#\''), '"[^" ]*([.][.][.]|[*]{3,})[^" ]*"', '"#"'), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{20,})', '\\1⟦\\2⟧'), '⟦([^⟧0-9]*)⟧', '\\1'), '⟦([^⟧A-Z]*)⟧', '\\1'), '⟦([^⟧a-z]*)⟧', '\\1'), '⟦[^⟧]*⟧', '#'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{10,})', '\\1⟦\\2⟧'), '⟦([^⟧]*[_-][^⟧]*)⟧', '\\1'), '⟦(0[xX][0-9a-fA-F]*)⟧', '\\1'), '⟦([^⟧]*[a-z][^⟧]*[A-Z][^⟧]*)⟧', '\\1'), '⟦([^⟧]*[A-Z][^⟧]*[a-z][^⟧]*)⟧', '\\1'), '⟦[^⟧]*[0-9][A-Za-z]+[0-9]+[A-Za-z]+[0-9][^⟧]*⟧', '#'), '⟦([^⟧]*)⟧', '\\1'), '(§[1-4][0-9][0-9]|§500|§5xx)|[0-9a-fA-F-]{6,}|[0-9]+', '\\1#'), '§([1-4][0-9][0-9]|500|5xx)#', '\\1'), + 1, 120 + ) + ) AS _msgSig, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@{"\\[]+@[^ \\t]*:[0-9]+|^[ \\t]*[^ \\t@{"\\[]*@([a-z][a-z0-9+.-]*:)?/[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+') + AND position(line, '(definition)') = 0 + AND NOT match(line, '^[ \\t]*at \\[') + AND NOT match(line, '^[ \\t]*at ([^(:]|:[^0-9])*:?$') + AND NOT match(line, '^[ \\t]*at ([^(]* \\()?(\\)?$|native(:|\\)|$)|node:|ext:)'), + splitByChar('\n', _exStack) + ) AS _frameLines, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, '@v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*', '@#'), '\\(index [0-9]+\\)', '(index #)'), 'file:///bundle/', ''), 'https?://[^/ )]+', ''), '(^|[ \\t("@])(file://)?([/.][^ \\t()"]*)?/(node_modules|site-packages|dist-packages|gems|pkg/mod)/', '\\1\\4/'), '([/-])v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*/', '\\1#/'), '(/python)[0-9]+\\.[0-9]+/', '\\1#/'), '(/\\$bunfs|[A-Z]:/~BUN)/root/', ''), '(\\$\\$Lambda|\\$Proxy|jdk\\.proxy|Generated[A-Za-z]*Accessor)\\$?[0-9]+', '\\1'), '(\\$(HibernateProxy|ByteBuddy|MockitoMock))\\$[A-Za-z0-9]+', '\\1'), '(-[A-Za-z0-9_-]{8})+\\.js', '.js'), '(-[A-Za-z0-9_-]{8})+\\.css', '.css'), '(/_astro/[^/ ).]+)\\.[A-Za-z0-9_-]{8}\\.js', '\\1.js'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + arraySlice( + -- Python prints the outermost frame first: read it from the raise site. + if(match(_frameLines[1], '^[ \\t]*File "'), arrayReverse(_frameLines), _frameLines), + 1, 10 + ) + ) AS _normFrames, + -- A repeat (two anonymous closures in one bundle, or recursion) collapses + -- so it does not use up the frames the hash keeps. + arrayFilter((frame, i) -> i = 1 OR frame != _normFrames[i - 1], _normFrames, arrayEnumerate(_normFrames)) AS _distinctFrames, + -- The application's own frames: a dependency's or a runtime's frames vary with + -- its internals and version. A stack with none keeps every frame. + arrayFilter(frame -> NOT match(frame, '(^|[ \\t("@/])(node_modules|site-packages|dist-packages|gems|pkg/mod)/|/python#/|/ruby/#/|/usr/local/go/src/|/rustc/|\\.cargo/registry/|^[ \\t]*at (java|javax|jdk|sun|kotlin|kotlinx|scala)\\.'), _distinctFrames) AS _appFrames, + -- A namespaced type plus its message names one failure: keep only the + -- throw site. With no message the frames are all that is left; keep 3. + if(position(_exType, '/') > 0 AND _msgSig != '', 1, 3) AS _maxFrames, + arraySlice(if(length(_appFrames) > 0, _appFrames, _distinctFrames), 1, _maxFrames) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- The previous version's frames, for the issue evaluator's re-key while a bump settles. + arrayStringConcat(arraySlice(_distinctFrames, 1, _maxFrames), '\n') AS _prevFpFrames, + if( + _customFp != '', + cityHash64(OrgId, ServiceName, 'maple.fingerprint', _customFp), + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig, 'fp4') + ) AS _hash, + if( + _customFp != '', + _hash, + cityHash64(OrgId, ServiceName, _exType, _prevFpFrames, _msgSig, 'fp3') + ) AS _prevHash, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + -- Only string values: JSONExtractString renders a number or an object + -- as its JSON text, which titled `{"error":{"code":5}}` with the object. + multiIf( + JSONType(_msgText, 'title') = 'String' AND JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'), + JSONType(_msgText, 'message') = 'String' AND JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'), + JSONType(_msgText, 'error') = 'String' AND JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'), + JSONType(_msgText, '_tag') = 'String' AND JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'), + JSONType(_msgText, 'reason') = 'String' AND JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'), + JSONType(_msgText, 'name') = 'String' AND JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'), + JSONType(_msgText, 'type') = 'String' AND JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + _msgText = '', 'Unknown Error', + position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0, + if( + extract(_msgText, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(_msgText, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel, + -- UTF8 variants throughout: a byte cut split a character and + -- left U+FFFD at the end of a non-ASCII title. + leftUTF8(_msgText, multiIf( + positionUTF8(_msgText, ': ') > 3, toInt64(positionUTF8(_msgText, ': ')) - 1, + positionUTF8(_msgText, ' (') > 3, toInt64(positionUTF8(_msgText, ' (')) - 1, + positionUTF8(_msgText, '\n') > 3, toInt64(positionUTF8(_msgText, '\n')) - 1, + 150 + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + _exType AS ExceptionType, + _exMsg AS ExceptionMessage, + _exStack AS ExceptionStacktrace, + _topFrame AS TopFrame, + _hash AS FingerprintHash, + StatusMessage, + Duration, + _errorLabel AS ErrorLabel, + ResourceAttributes['service.version'] AS ServiceVersion, + if(SpanAttributes['user.id'] != '', SpanAttributes['user.id'], SpanAttributes['enduser.id']) AS UserId + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all hold: 4xx, no exception event, no exception.type + -- attribute, and no error.type beyond the status code itself (HTTP + -- semconv sets error.type to the bare status on a non-2xx response, + -- which carries no exception). 5xx and anything carrying a real + -- exception still count, and SpanKind is deliberately not consulted — + -- these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND SpanAttributes['exception.type'] = '' + AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus)) + ); + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprint_lineage_mv TO error_fingerprint_lineage AS +WITH + arrayFirstIndex(n -> n = 'exception', EventsName) AS _ei, + -- Only fill the old Unknown Error bucket. Event values (including + -- empty fields) and spans with StatusMessage keep every hash input. + _ei = 0 AND StatusMessage = '' AS _useAttrs, + if( + _ei > 0, EventsAttributes[_ei]['exception.type'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.type'], ''), SpanAttributes['error.type']), '') + ) AS _exType, + if( + _ei > 0, EventsAttributes[_ei]['exception.message'], + if(_useAttrs, coalesce(nullIf(SpanAttributes['exception.message'], ''), SpanAttributes['error.message']), StatusMessage) + ) AS _exMsg, + if( + _ei > 0, EventsAttributes[_ei]['exception.stacktrace'], + if(_useAttrs, SpanAttributes['exception.stacktrace'], '') + ) AS _exStack, + if(_useAttrs, _exMsg, StatusMessage) AS _msgText, + -- A user key replaces type, frames, message and salt in the hash: the + -- exception event's value unless blank after trimming, then the span's. + -- Trimmed and capped. + substringUTF8( + replaceRegexpAll(coalesce(nullIf(replaceRegexpAll(if(_ei > 0, EventsAttributes[_ei]['maple.fingerprint'], ''), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), ''), SpanAttributes['maple.fingerprint']), '^[ \\t\\n\\r]+|[ \\t\\n\\r]+$', ''), + 1, 200 + ) AS _customFp, + -- JSON detection for the message signature below. + isValidJSON(_msgText) AS _isJson, + _isJson AND JSONType(_msgText) = 'Object' AS _isJsonObj, + -- General, KEY-NAME-AGNOSTIC canonical signature: iterate ALL top-level + -- keys, redact volatile tokens (long hex / numbers) in each raw value, then + -- sort by "key=value" so key order & whitespace don't matter. No assumption + -- about which keys exist — works for any producer's JSON shape. (Nested + -- objects are hashed as their raw substring; only top-level is canonicalized.) + arrayStringConcat( + arraySort( + arrayMap( + kv -> concat(kv.1, '=', if( + kv.1 = 'type', + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '[0-9a-fA-F-]{6,}|[0-9]+', '#'), + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(kv.2, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '"[^" ]*/[^" ]*"', '"#"'), '[0-9a-fA-F-]{6,}|[0-9]+', '#') + )), + JSONExtractKeysAndValuesRaw(_msgText) + ) + ), + '|' + ) AS _jsonSig, + -- The message signature is folded in ALWAYS, not only when there are no + -- frames. Bundled runtimes minify every module into one file, so the top + -- three frames of a Worker error are `toDatabaseError (worker.js)` for + -- every failing query alike: on frames alone, 25 distinct DatabaseError + -- bugs (316k occurrences) collapse into a single issue. The signature + -- restores that discrimination, and it cannot reinflate cardinality the + -- way a raw prefix would because everything variable is redacted first: + -- emails, URL origins, home directories, query strings, quoted values, + -- then ids and every digit run. See ERROR_MSG_REDACTIONS for the order, + -- what is deliberately kept, and the one residual it cannot reach. + multiIf( + _isJsonObj, _jsonSig, + substringUTF8( + replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(substringUTF8(_msgText, 1, 1000), '\\x1b\\[[0-9;]*m', ''), '^((\\[Error\\]|[\\s\\S]*?DB::Exception:)[\\s\\S]*?)( [Ii]n scope | [Ww]hile processing )[\\s\\S]*', '\\1'), 'RouteNotFound \\(([A-Z]+) [^ \\t\\r\\n]*\\)?', 'RouteNotFound (\\1 PATH)'), '^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]* not found([\\r\\n]|$)', '\\1 PATH not found\\2'), '\\bCannot (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /[^ \\t\\r\\n]*', 'Cannot \\1 PATH'), '\\bRoute (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS):/[^ \\t\\r\\n]* not found', 'Route \\1:PATH not found'), 'No route found for "([A-Z]+) [^"\\r\\n]*"', 'No route found for "\\1 PATH"'), '(^|[ \\t\\r\\n\'"(`=])~/[^ \'"`:,)]*', '\\1PATH'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '(^|[ \\t\\r\\n\'"(`=])/(tmp|var|srv|private|opt|mnt|data|Volumes|System|app|work|run|Users/[^/ \\t\\r\\n\'"`:,)]+|home/[^/ \\t\\r\\n\'"`:,)]+)([/.][^ \\t\\r\\n\'"`:,)]*)?([ \\t\\r\\n\'"`:,)]|$)', '\\1PATH\\4'), '\\b[a-z]{2,8}_[a-z]*[A-Z0-9][A-Za-z0-9]{8,}\\b', 'ID'), '\\b[0-9A-HJKMNP-TV-Z]{26}\\b', 'ID'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)([1-4][0-9][0-9]|500)\\b', '\\1\\2§\\3'), '\\b(HTTP|[Ss]tatus_?[Cc]ode|[Ss]tatus|returned|code|failed|failed with)(:? ?|=)5(0[1-9]|[1-9][0-9])\\b', '\\1\\2§5xx'), '(^|[^§0-9A-Za-z_.])([1-4][0-9][0-9]|500) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§\\2 \\3 '), '(^|[^§0-9A-Za-z_.])5(0[1-9]|[1-9][0-9]) (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ', '\\1§5xx \\3 '), '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 'EMAIL'), 'https?://[^/ )"]+', ''), '/[^/? \\t\\r\\n\'"`:,)]*[~@%$][^/? \\t\\r\\n\'"`:,)]*', '/#'), '\'[^\' ]*([.][.][.]|[*]{3,})[^\' ]*\'', '\'#\''), '"[^" ]*([.][.][.]|[*]{3,})[^" ]*"', '"#"'), '/(Users|home)/[^/ ]+', '/~'), '[?][A-Za-z0-9_]+=[^ )"]*', '?#'), '\'[^\' ]*/[^\' ]*\'|\'[^\' ]{25,}\'', '\'#\''), '"[^" ]*/[^" ]*"|"[^" ]{25,}"', '"#"'), '`[^` ]*/[^` ]*`|`[^` ]{25,}`', '`#`'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{20,})', '\\1⟦\\2⟧'), '⟦([^⟧0-9]*)⟧', '\\1'), '⟦([^⟧A-Z]*)⟧', '\\1'), '⟦([^⟧a-z]*)⟧', '\\1'), '⟦[^⟧]*⟧', '#'), '(^|[^A-Za-z0-9_-])([A-Za-z0-9_-]{10,})', '\\1⟦\\2⟧'), '⟦([^⟧]*[_-][^⟧]*)⟧', '\\1'), '⟦(0[xX][0-9a-fA-F]*)⟧', '\\1'), '⟦([^⟧]*[a-z][^⟧]*[A-Z][^⟧]*)⟧', '\\1'), '⟦([^⟧]*[A-Z][^⟧]*[a-z][^⟧]*)⟧', '\\1'), '⟦[^⟧]*[0-9][A-Za-z]+[0-9]+[A-Za-z]+[0-9][^⟧]*⟧', '#'), '⟦([^⟧]*)⟧', '\\1'), '(§[1-4][0-9][0-9]|§500|§5xx)|[0-9a-fA-F-]{6,}|[0-9]+', '\\1#'), '§([1-4][0-9][0-9]|500|5xx)#', '\\1'), + 1, 120 + ) + ) AS _msgSig, + -- Frame lines are matched by SHAPE, not by "contains :NUMBER". The old + -- rule accepted any line with a colon-digit, which let non-frame lines + -- in: Drizzle's `params: ` line, and the `Type: message` + -- header (`Code: 62`, `position 1628`, embedded timestamps). Row values + -- and message text then entered the hash and split one bug into + -- thousands of issues — 23,035 fingerprints for six real + -- AnomalyPersistenceError call sites, 15,051 for thirteen DatabaseError + -- ones. + -- + -- The pattern is rendered from FRAME_LINE_PATTERN in fingerprint.ts, + -- as is every redaction below. They used to be hand-copied here, which + -- let the reference implementation the tests exercise drift away from + -- the SQL that actually runs, silently. + arrayFilter( + line -> match(line, '^[ \\t]*at |^[ \\t]*File "|^[ \\t]+from [^ ]+:[0-9]+|^[^ \\t@{"\\[]+@[^ \\t]*:[0-9]+|^[ \\t]*[^ \\t@{"\\[]*@([a-z][a-z0-9+.-]*:)?/[^ \\t]*:[0-9]+|^[ \\t]+[^ \\t]+\\.(go|rs):[0-9]+|^[0-9]+ +\\S.* +0x[0-9a-fA-F]+') + AND position(line, '(definition)') = 0 + AND NOT match(line, '^[ \\t]*at \\[') + AND NOT match(line, '^[ \\t]*at ([^(:]|:[^0-9])*:?$') + AND NOT match(line, '^[ \\t]*at ([^(]* \\()?(\\)?$|native(:|\\)|$)|node:|ext:)'), + splitByChar('\n', _exStack) + ) AS _frameLines, + -- Redact every volatile token a frame line can carry: the URL origin + -- (so preview hosts share one fingerprint), Vite's 8-char bundle + -- content hash (so a deploy does not re-split every triaged browser and + -- Worker issue), then line numbers, hex pointers and long id runs. See + -- FRAME_REDACTIONS for the order and the reasoning. + arrayMap( + line -> replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(line, '@v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*', '@#'), '\\(index [0-9]+\\)', '(index #)'), 'file:///bundle/', ''), 'https?://[^/ )]+', ''), '(^|[ \\t("@])(file://)?([/.][^ \\t()"]*)?/(node_modules|site-packages|dist-packages|gems|pkg/mod)/', '\\1\\4/'), '([/-])v?[0-9]+\\.[0-9]+\\.[0-9]+[^/ )]*/', '\\1#/'), '(/python)[0-9]+\\.[0-9]+/', '\\1#/'), '(/\\$bunfs|[A-Z]:/~BUN)/root/', ''), '(\\$\\$Lambda|\\$Proxy|jdk\\.proxy|Generated[A-Za-z]*Accessor)\\$?[0-9]+', '\\1'), '(\\$(HibernateProxy|ByteBuddy|MockitoMock))\\$[A-Za-z0-9]+', '\\1'), '(-[A-Za-z0-9_-]{8})+\\.js', '.js'), '(-[A-Za-z0-9_-]{8})+\\.css', '.css'), '(/_astro/[^/ ).]+)\\.[A-Za-z0-9_-]{8}\\.js', '\\1.js'), ':[0-9]+|line [0-9]+|0x[0-9a-fA-F]+|[0-9a-fA-F]{8,}|[0-9]{6,}', ''), + arraySlice( + -- Python prints the outermost frame first: read it from the raise site. + if(match(_frameLines[1], '^[ \\t]*File "'), arrayReverse(_frameLines), _frameLines), + 1, 10 + ) + ) AS _normFrames, + -- A repeat (two anonymous closures in one bundle, or recursion) collapses + -- so it does not use up the frames the hash keeps. + arrayFilter((frame, i) -> i = 1 OR frame != _normFrames[i - 1], _normFrames, arrayEnumerate(_normFrames)) AS _distinctFrames, + -- The application's own frames: a dependency's or a runtime's frames vary with + -- its internals and version. A stack with none keeps every frame. + arrayFilter(frame -> NOT match(frame, '(^|[ \\t("@/])(node_modules|site-packages|dist-packages|gems|pkg/mod)/|/python#/|/ruby/#/|/usr/local/go/src/|/rustc/|\\.cargo/registry/|^[ \\t]*at (java|javax|jdk|sun|kotlin|kotlinx|scala)\\.'), _distinctFrames) AS _appFrames, + -- A namespaced type plus its message names one failure: keep only the + -- throw site. With no message the frames are all that is left; keep 3. + if(position(_exType, '/') > 0 AND _msgSig != '', 1, 3) AS _maxFrames, + arraySlice(if(length(_appFrames) > 0, _appFrames, _distinctFrames), 1, _maxFrames) AS _topFrames, + if(length(_topFrames) > 0, _topFrames[1], '') AS _topFrame, + arrayStringConcat(_topFrames, '\n') AS _fpFrames, + -- The previous version's frames, for the issue evaluator's re-key while a bump settles. + arrayStringConcat(arraySlice(_distinctFrames, 1, _maxFrames), '\n') AS _prevFpFrames, + if( + _customFp != '', + cityHash64(OrgId, ServiceName, 'maple.fingerprint', _customFp), + cityHash64(OrgId, ServiceName, _exType, _fpFrames, _msgSig, 'fp4') + ) AS _hash, + if( + _customFp != '', + _hash, + cityHash64(OrgId, ServiceName, _exType, _prevFpFrames, _msgSig, 'fp3') + ) AS _prevHash, + -- Display-only, best-effort human label (decoupled from the fingerprint: + -- many labels may map to one hash). The broad key list here is a DISPLAY + -- heuristic only; the fingerprint above makes no key-name assumption. + -- Only string values: JSONExtractString renders a number or an object + -- as its JSON text, which titled `{"error":{"code":5}}` with the object. + multiIf( + JSONType(_msgText, 'title') = 'String' AND JSONExtractString(_msgText, 'title') != '', JSONExtractString(_msgText, 'title'), + JSONType(_msgText, 'message') = 'String' AND JSONExtractString(_msgText, 'message') != '', JSONExtractString(_msgText, 'message'), + JSONType(_msgText, 'error') = 'String' AND JSONExtractString(_msgText, 'error') != '', JSONExtractString(_msgText, 'error'), + JSONType(_msgText, '_tag') = 'String' AND JSONExtractString(_msgText, '_tag') != '', JSONExtractString(_msgText, '_tag'), + JSONType(_msgText, 'reason') = 'String' AND JSONExtractString(_msgText, 'reason') != '', JSONExtractString(_msgText, 'reason'), + JSONType(_msgText, 'name') = 'String' AND JSONExtractString(_msgText, 'name') != '', JSONExtractString(_msgText, 'name'), + JSONType(_msgText, 'type') = 'String' AND JSONExtractString(_msgText, 'type') != '', extract(JSONExtractString(_msgText, 'type'), '([^/]+)$'), + 'JSON error' + ) AS _jsonLabel, + multiIf( + _msgText = '', 'Unknown Error', + position(_msgText, '{ readonly') = 1 OR position(_msgText, '└─') > 0, + if( + extract(_msgText, 'readonly (\\w+)') != '', + concat('Schema parse error: ', extract(_msgText, 'readonly (\\w+)')), + 'Schema parse error' + ), + _isJsonObj OR position(_msgText, '[') = 1, _jsonLabel, + -- UTF8 variants throughout: a byte cut split a character and + -- left U+FFFD at the end of a non-ASCII title. + leftUTF8(_msgText, multiIf( + positionUTF8(_msgText, ': ') > 3, toInt64(positionUTF8(_msgText, ': ')) - 1, + positionUTF8(_msgText, ' (') > 3, toInt64(positionUTF8(_msgText, ' (')) - 1, + positionUTF8(_msgText, '\n') > 3, toInt64(positionUTF8(_msgText, '\n')) - 1, + 150 + )) + ) AS _statusLabel, + if(_exType != '', _exType, _statusLabel) AS _errorLabel, + -- Both semconv spellings; the current key wins when both are present. + toUInt16OrZero( + if( + SpanAttributes['http.response.status_code'] != '', + SpanAttributes['http.response.status_code'], + SpanAttributes['http.status_code'] + ) + ) AS _httpStatus + SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + _hash AS FingerprintHash, + _prevHash AS PreviousFingerprintHash + FROM traces + WHERE StatusCode = 'Error' + -- Client-side runtimes (notably the native Cloudflare Workers + -- observability) mark ANY non-2xx fetch span as Error, so 404s from bot + -- traffic arrived here as unlabelled "Unknown Error" issues. Drop a + -- span only when all hold: 4xx, no exception event, no exception.type + -- attribute, and no error.type beyond the status code itself (HTTP + -- semconv sets error.type to the bare status on a non-2xx response, + -- which carries no exception). 5xx and anything carrying a real + -- exception still count, and SpanKind is deliberately not consulted — + -- these are Client spans. + AND NOT ( + _httpStatus >= 400 AND _httpStatus < 500 + AND _ei = 0 + AND SpanAttributes['exception.type'] = '' + AND (SpanAttributes['error.type'] = '' OR SpanAttributes['error.type'] = toString(_httpStatus)) + ) + AND _hash != _prevHash; + +CREATE MATERIALIZED VIEW IF NOT EXISTS error_fingerprints_minutely_mv TO error_fingerprints_minutely AS +SELECT + OrgId, + toStartOfMinute(Timestamp) AS Minute, + FingerprintHash, + anyLast(ServiceName) AS ServiceName, + anyLast(ExceptionType) AS ExceptionType, + anyLast(ExceptionMessage) AS ExceptionMessage, + anyLast(ErrorLabel) AS ErrorLabel, + anyLast(TopFrame) AS TopFrame, + count() AS OccurrenceCount, + min(Timestamp) AS FirstSeen, + max(Timestamp) AS LastSeen, + -- Distinct builds, not a sample: see ServiceVersions on the datasource. + groupUniqArray(ServiceVersion) AS ServiceVersions + FROM error_events + GROUP BY OrgId, Minute, FingerprintHash; + +CREATE MATERIALIZED VIEW IF NOT EXISTS identity_links_mv TO identity_links AS +SELECT + OrgId, + VisitorId, + UserId, + StartTime AS FirstSeen + FROM session_replays + WHERE VisitorId != '' AND UserId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(LogAttributes)) AS AttributeKey, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + WHERE LogAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS log_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'log' AS AttributeScope, + count() AS UsageCount + FROM logs + ARRAY JOIN + mapKeys(LogAttributes) AS AttributeKey, + mapValues(LogAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS logs_aggregates_hourly_mv TO logs_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(TimestampTime) AS Hour, + ServiceName, + SeverityText, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS Count, + sum(length(Body) + 200) AS SizeBytes, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM logs + GROUP BY OrgId, Hour, ServiceName, SeverityText, DeploymentEnv, ServiceNamespace; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + arrayJoin(mapKeys(Attributes)) AS AttributeKey, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + WHERE Attributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + AttributeKey, + AttributeValue, + 'metric' AS AttributeScope, + count() AS UsageCount + FROM metrics_sum + ARRAY JOIN + mapKeys(Attributes) AS AttributeKey, + mapValues(Attributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_exp_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'exponential_histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_exponential_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_gauge_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'gauge' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_gauge + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_histogram_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'histogram' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + toUInt8(0) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_histogram + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS metric_catalog_sum_mv TO metric_catalog AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 'sum' AS MetricType, + ServiceName, + MetricName, + anyLast(MetricDescription) AS MetricDescription, + anyLast(MetricUnit) AS MetricUnit, + anyLast(toUInt8(IsMonotonic)) AS IsMonotonic, + count() AS DataPointCount, + min(toDateTime(TimeUnix)) AS FirstSeen, + max(toDateTime(TimeUnix)) AS LastSeen + FROM metrics_sum + GROUP BY OrgId, Hour, MetricType, ServiceName, MetricName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS product_events_mv TO product_events AS +SELECT + OrgId, + Timestamp, + 'browser' AS Source, + SessionId, + Seq, + VisitorId, + UserId, + GroupId, + Type AS Kind, + if(Type = 'navigation', '$pageview', Message) AS EventName, + domain(Url) AS Host, + path(Url) AS PagePath, + Url, + '' AS ServiceName, + Attributes, + '' AS TraceId, + '' AS SpanId + FROM session_events + WHERE Type IN ('navigation', 'custom'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS product_events_traces_mv TO product_events AS +SELECT + OrgId, + Timestamp, + 'trace' AS Source, + SpanAttributes['session.id'] AS SessionId, + 0 AS Seq, + SpanAttributes['maple.product_event.visitor_id'] AS VisitorId, + SpanAttributes['maple.product_event.user_id'] AS UserId, + SpanAttributes['maple.product_event.group_id'] AS GroupId, + 'custom' AS Kind, + SpanAttributes['maple.product_event.name'] AS EventName, + domain(SpanAttributes['maple.product_event.url']) AS Host, + path(SpanAttributes['maple.product_event.url']) AS PagePath, + SpanAttributes['maple.product_event.url'] AS Url, + ServiceName, + mapUpdate( + CAST( + mapFilter( + (k, v) -> NOT startsWith(k, 'maple.product_event.') + AND ( + NOT has(mapKeys(SpanAttributes), 'maple.product_event.include') + OR has( + arrayMap( + key -> trimBoth(key), + splitByChar(',', SpanAttributes['maple.product_event.include']) + ), + k + ) + ), + SpanAttributes + ), + 'Map(String, String)' + ), + mapApply( + (k, v) -> (substring(k, 26), v), + mapFilter((k, v) -> startsWith(k, 'maple.product_event.prop.'), SpanAttributes) + ) + ) AS Attributes, + TraceId, + SpanId + FROM traces + WHERE SpanAttributes['maple.product_event.name'] != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS scanner_requests_mv TO scanner_requests AS +SELECT + OrgId, + Timestamp, + TraceId, + ServiceName, + if(multiSearchFirstIndexCaseInsensitive(concat(' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512), ' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.query'], ''), nullIf(extract(SpanAttributes['http.target'], '\\?(.*)'), ''), ''), 1024)), ['/.env', '/.aws/', '/.ssh/', '/id_rsa', '/.npmrc', '/.htpasswd', '/.docker/config.json', '/.vscode/sftp.json', '/.s3cfg', '/wp-config.php', '/.git/', '/.svn/', '/.hg/', '/.ds_store', ' /actuator/env', ' /actuator/heapdump', ' /actuator/configprops', ' /actuator/gateway', ' /server-status', ' /server-info', 'phpinfo', '/info.php', ' /_profiler', ' /debug/pprof', ' /telescope/requests', '../', '..%2f', '%2e%2e', '..%5c', '%252e%252e', '/etc/passwd', '/proc/self/environ', 'win.ini', '/phpmyadmin', '/pma/', '/adminer', '/vendor/phpunit', 'eval-stdin.php', '/boaform', '/hnap1', '/goform/', '/cgi-bin/luci']) > 0, arrayElement(['/.env', '/.aws/', '/.ssh/', '/id_rsa', '/.npmrc', '/.htpasswd', '/.docker/config.json', '/.vscode/sftp.json', '/.s3cfg', '/wp-config.php', '/.git/', '/.svn/', '/.hg/', '/.ds_store', '/actuator/env', '/actuator/heapdump', '/actuator/configprops', '/actuator/gateway', '/server-status', '/server-info', 'phpinfo', '/info.php', '/_profiler', '/debug/pprof', '/telescope/requests', '../', '..%2f', '%2e%2e', '..%5c', '%252e%252e', '/etc/passwd', '/proc/self/environ', 'win.ini', '/phpmyadmin', '/pma/', '/adminer', '/vendor/phpunit', 'eval-stdin.php', '/boaform', '/hnap1', '/goform/', '/cgi-bin/luci'], multiSearchFirstIndexCaseInsensitive(concat(' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512), ' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.query'], ''), nullIf(extract(SpanAttributes['http.target'], '\\?(.*)'), ''), ''), 1024)), ['/.env', '/.aws/', '/.ssh/', '/id_rsa', '/.npmrc', '/.htpasswd', '/.docker/config.json', '/.vscode/sftp.json', '/.s3cfg', '/wp-config.php', '/.git/', '/.svn/', '/.hg/', '/.ds_store', ' /actuator/env', ' /actuator/heapdump', ' /actuator/configprops', ' /actuator/gateway', ' /server-status', ' /server-info', 'phpinfo', '/info.php', ' /_profiler', ' /debug/pprof', ' /telescope/requests', '../', '..%2f', '%2e%2e', '..%5c', '%252e%252e', '/etc/passwd', '/proc/self/environ', 'win.ini', '/phpmyadmin', '/pma/', '/adminer', '/vendor/phpunit', 'eval-stdin.php', '/boaform', '/hnap1', '/goform/', '/cgi-bin/luci'])), arrayElement(['/wp-admin', '/wp-login.php', '/xmlrpc.php', '/wp-includes/', '/wp-content/plugins/'], multiSearchFirstIndexCaseInsensitive(concat(' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512), ' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.query'], ''), nullIf(extract(SpanAttributes['http.target'], '\\?(.*)'), ''), ''), 1024)), ['/wp-admin', '/wp-login.php', '/xmlrpc.php', '/wp-includes/', '/wp-content/plugins/']))) AS Signature, + arrayElement(['ZGrab', 'Masscan', 'Nuclei', 'sqlmap', 'Nikto', 'Nmap', 'Gobuster', 'DirBuster', 'ffuf', 'WPScan', 'WhatWeb', 'Acunetix', 'Nessus', 'Censys', 'Palo Alto Expanse', 'LeakIX', 'LeakIX', 'Modat', 'Driftnet', 'ARTEX', 'norma agent SDK'], multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['zgrab', 'masscan', 'nuclei', 'sqlmap', 'nikto', 'nmap', 'gobuster', 'dirbuster', 'fuzz faster u fool', 'wpscan', 'whatweb', 'acunetix', 'nessus', 'censysinspect', 'palo alto networks', 'l9explore', 'l9tcpid', 'modatscanner', 'internetmeasurement', 'artex-enrich', 'norma/'])) AS Scanner, + leftUTF8(lower(replaceRegexpOne(coalesce(nullIf(SpanAttributes['server.address'], ''), nullIf(SpanAttributes['http.host'], ''), nullIf(SpanAttributes['net.host.name'], ''), nullIf(domain(SpanAttributes['url.full']), ''), nullIf(domain(SpanAttributes['http.url']), ''), ''), ':[0-9]+$', '')), 255) AS Host, + leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) AS Path, + toUInt16OrZero(coalesce(nullIf(SpanAttributes['http.response.status_code'], ''), nullIf(SpanAttributes['http.status_code'], ''), '')) AS HttpStatus, + leftUTF8(coalesce(nullIf(SpanAttributes['client.address'], ''), nullIf(SpanAttributes['http.client_ip'], ''), nullIf(SpanAttributes['net.peer.ip'], ''), ''), 64) AS ClientAddress, + leftUTF8(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), 256) AS UserAgent + FROM traces + WHERE SpanKind = 'Server' + AND (multiSearchFirstIndexCaseInsensitive(concat(' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512), ' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.query'], ''), nullIf(extract(SpanAttributes['http.target'], '\\?(.*)'), ''), ''), 1024)), ['/.env', '/.aws/', '/.ssh/', '/id_rsa', '/.npmrc', '/.htpasswd', '/.docker/config.json', '/.vscode/sftp.json', '/.s3cfg', '/wp-config.php', '/.git/', '/.svn/', '/.hg/', '/.ds_store', ' /actuator/env', ' /actuator/heapdump', ' /actuator/configprops', ' /actuator/gateway', ' /server-status', ' /server-info', 'phpinfo', '/info.php', ' /_profiler', ' /debug/pprof', ' /telescope/requests', '../', '..%2f', '%2e%2e', '..%5c', '%252e%252e', '/etc/passwd', '/proc/self/environ', 'win.ini', '/phpmyadmin', '/pma/', '/adminer', '/vendor/phpunit', 'eval-stdin.php', '/boaform', '/hnap1', '/goform/', '/cgi-bin/luci']) > 0 OR multiSearchFirstIndexCaseInsensitive(concat(' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512), ' ', leftUTF8(coalesce(nullIf(SpanAttributes['url.query'], ''), nullIf(extract(SpanAttributes['http.target'], '\\?(.*)'), ''), ''), 1024)), ['/wp-admin', '/wp-login.php', '/xmlrpc.php', '/wp-includes/', '/wp-content/plugins/']) > 0 OR multiSearchFirstIndexCaseInsensitive(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), ''), ['zgrab', 'masscan', 'nuclei', 'sqlmap', 'nikto', 'nmap', 'gobuster', 'dirbuster', 'fuzz faster u fool', 'wpscan', 'whatweb', 'acunetix', 'nessus', 'censysinspect', 'palo alto networks', 'l9explore', 'l9tcpid', 'modatscanner', 'internetmeasurement', 'artex-enrich', 'norma/']) > 0) + AND leftUTF8(coalesce(nullIf(SpanAttributes['url.path'], ''), nullIf(replaceRegexpOne(SpanAttributes['http.target'], '[?#].*$', ''), ''), nullIf(path(SpanAttributes['url.full']), ''), nullIf(path(SpanAttributes['http.url']), ''), ''), 512) != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_endpoint_details_hourly_mv TO service_endpoint_details_hourly AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + if(((SpanName LIKE 'http.server %' OR SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != '')), concat(if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), ' ', if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path'])), SpanName) AS SpanName, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS HttpStatusCode, + SpanAttributes['error.type'] AS ErrorType, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + quantilesTDigestIfState(0.5, 0.95, 0.99)(ifNull(toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])), 0), toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])) IS NOT NULL) AS BodySizeQuantiles, + maxIf(ifNull(toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])), 0), toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])) IS NOT NULL) AS BodySizeMax, + countIf(toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])) IS NOT NULL) AS BodyCount, + approx_top_kState(10)(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), nullIf(SpanAttributes['http.user_agent'], ''), '')) AS UserAgents, + max(Duration) AS SlowestDuration, + argMaxState(TraceId, Duration) AS SlowestTraceId, + argMaxState(Timestamp, Duration) AS SlowestTimestamp, + argMaxState(ifNull(toInt64(toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length']))), -1), Duration) AS SlowestBodySize + FROM traces + WHERE SpanKind = 'Server' + AND if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) != '' + GROUP BY OrgId, ServiceName, Hour, DeploymentEnv, SpanName, HttpStatusCode, ErrorType; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_external_edges_hourly_mv TO service_external_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', 'messaging', + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', 'rpc', + 'http' + ) AS TargetType, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', SpanAttributes['messaging.system'], + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', SpanAttributes['rpc.system'], + '' + ) AS TargetSystem, + multiIf( + coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' OR SpanAttributes['messaging.system'] != '', + if(coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '', coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']), SpanAttributes['messaging.system']), + SpanAttributes['rpc.service'] != '' OR SpanAttributes['rpc.system'] != '', + if(SpanAttributes['rpc.service'] != '', SpanAttributes['rpc.service'], SpanAttributes['rpc.system']), + if(SpanAttributes['server.address'] != '', + SpanAttributes['server.address'], + if(SpanAttributes['http.host'] != '', + SpanAttributes['http.host'], + SpanAttributes['url.authority'])) + ) AS TargetName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) = '' + AND ServiceName != '' + AND ( + SpanAttributes['server.address'] != '' + OR SpanAttributes['http.host'] != '' + OR SpanAttributes['url.authority'] != '' + OR coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanAttributes['messaging.destination']) != '' + OR SpanAttributes['messaging.system'] != '' + OR SpanAttributes['rpc.service'] != '' + OR SpanAttributes['rpc.system'] != '' + ) + GROUP BY OrgId, Hour, ServiceName, TargetType, TargetSystem, TargetName, DeploymentEnv + HAVING TargetName != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_children_mv TO service_map_children AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') + AND ParentSpanId != ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_edges_hourly_mv TO service_map_db_edges_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + countIf(TraceState LIKE '%th:%') AS SampledSpanCount, + countIf(TraceState = '' OR TraceState NOT LIKE '%th:%') AS UnsampledSpanCount, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_edges_minutely_mv TO service_map_db_edges_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(Duration / 1000000) AS DurationSumMs, + max(Duration / 1000000) AS MaxDurationMs, + sum(SampleRate) AS SampleRateSum, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles, + now() AS FirstInsertedAt + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Minute, ServiceName, DbSystem, DbNamespace, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_db_query_shapes_hourly_mv TO service_map_db_query_shapes_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) AS DbSystem, + if(match(coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name']), '^([0-9a-fA-F]{32}|.*[.]hyperdrive[.]local)$'), 'hyperdrive', coalesce(nullIf(SpanAttributes['db.namespace'], ''), nullIf(SpanAttributes['db.name'], ''), nullIf(SpanAttributes['server.address'], ''), SpanAttributes['net.peer.name'])) AS DbNamespace, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + coalesce( + nullIf(SpanAttributes['db.query.fingerprint'], ''), + nullIf(SpanAttributes['db.statement.fingerprint'], ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', toString(cityHash64(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(replaceRegexpAll(lower(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement'])), '\'[^\']*\'', '?'), '\\bin\\s*\\([^)]*\\)', 'in (?)'), '[0-9]+(\\.[0-9]+)?', '?'), '\\s+', ' '), '^\\s+|\\s+$', ''))), ''), ''), + toString(cityHash64(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +))) +) AS QueryKey, + any(substring(coalesce( + nullIf(SpanAttributes['db.query.summary'], ''), + nullIf(if(SpanAttributes['db.operation.name'] != '', trimBoth(concat(SpanAttributes['db.operation.name'], if(coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace']) != '', concat(' ', coalesce(nullIf(SpanAttributes['db.collection.name'], ''), SpanAttributes['db.namespace'])), ''))), ''), ''), + nullIf(if(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']) != '', trimBoth(concat(upper(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '^\\s*(\\w+)')), if(extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)') != '', concat(' ', extract(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), '(?i)(?:from|into|update|join|table)\\s+\\W?([\\w.]+)')), ''))), ''), ''), + nullIf(SpanAttributes['query.context'], ''), + nullIf(SpanAttributes['db.operation.name'], ''), + nullIf(SpanAttributes['db.operation'], ''), + SpanName +), 1, 220)) AS QueryLabel, + any(substring(coalesce(nullIf(SpanAttributes['db.query.text'], ''), SpanAttributes['db.statement']), 1, 1000)) AS SampleStatement, + count() AS CallCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sum(SampleRate) AS EstimatedCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration) * SampleRate / 1000000) AS WeightedDurationSumMs, + quantilesTDigestWeightedState(0.5, 0.95)(Duration, toUInt32(greatest(SampleRate, 1.0))) AS DurationQuantiles + FROM traces + WHERE SpanKind IN ('Client', 'Producer') + AND coalesce(nullIf(SpanAttributes['db.system.name'], ''), SpanAttributes['db.system']) != '' + AND ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DbSystem, DbNamespace, DeploymentEnv, QueryKey; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_edges_hourly_ingest_mv TO service_map_edges_hourly AS +SELECT + OrgId, + Hour, + SourceService, + TargetService, + DeploymentEnv, + CallCount, + ErrorCount, + DurationSumMs, + MaxDurationMs, + SampledSpanCount, + UnsampledSpanCount, + SampleRateSum + FROM service_map_edges_hourly_ingest; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_map_spans_mv TO service_map_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + TraceId, + SpanId, + ParentSpanId, + ServiceName, + SpanKind, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv + FROM traces + WHERE SpanKind IN ('Client', 'Producer', 'Server', 'Consumer'); + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_hourly_mv TO service_operations_hourly AS +SELECT + OrgId, + toStartOfHour(Minute) AS Hour, + ServiceName, + DeploymentEnv, + SpanName, + sum(SpanCount) AS SpanCount, + sum(EstimatedSpanCount) AS EstimatedSpanCount, + sum(ErrorCount) AS ErrorCount, + sum(EstimatedErrorCount) AS EstimatedErrorCount, + sum(DurationSum) AS DurationSum, + quantilesTDigestMergeState(0.5, 0.95)(DurationQuantiles) AS DurationQuantiles, + sum(ClassifiedSpanCount) AS ClassifiedSpanCount, + sum(ServerSpanCount) AS ServerSpanCount, + sum(RoutedSpanCount) AS RoutedSpanCount, + sum(HttpClassifiedSpanCount) AS HttpClassifiedSpanCount, + sum(HttpStatusCount) AS HttpStatusCount, + sum(Http4xxCount) AS Http4xxCount, + sum(Http5xxCount) AS Http5xxCount, + sum(RequestBodyBytes) AS RequestBodyBytes, + sum(RequestBodyCount) AS RequestBodyCount + FROM service_operations_minutely + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_operations_minutely_mv TO service_operations_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + if(((SpanName LIKE 'http.server %' OR SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != '')), concat(if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), ' ', if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path'])), SpanName) AS SpanName, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95)(Duration) AS DurationQuantiles, + count() AS ClassifiedSpanCount, + countIf(SpanKind IN ('Server', 'Consumer')) AS ServerSpanCount, + countIf(SpanAttributes['http.route'] != '') AS RoutedSpanCount, + count() AS HttpClassifiedSpanCount, + countIf(SpanKind = 'Server' AND if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) != '') AS HttpStatusCount, + countIf(SpanKind = 'Server' AND toUInt16OrZero(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code'])) BETWEEN 400 AND 499) AS Http4xxCount, + countIf(SpanKind = 'Server' AND toUInt16OrZero(if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code'])) BETWEEN 500 AND 599) AS Http5xxCount, + sumIf(ifNull(toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])), 0), SpanKind = 'Server' AND toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])) IS NOT NULL) AS RequestBodyBytes, + countIf(SpanKind = 'Server' AND toUInt64OrNull(if(SpanAttributes['http.request.body.size'] != '', SpanAttributes['http.request.body.size'], SpanAttributes['http.request_content_length'])) IS NOT NULL) AS RequestBodyCount + FROM traces + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, SpanName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_hourly_mv TO service_overview_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount, + sum(SampleRate) AS EstimatedLadderSpanCount, + sumIf(SampleRate, Duration < 50000000) AS EstimatedUnder50Ms, + sumIf(SampleRate, Duration < 100000000) AS EstimatedUnder100Ms, + sumIf(SampleRate, Duration < 250000000) AS EstimatedUnder250Ms, + sumIf(SampleRate, Duration < 500000000) AS EstimatedUnder500Ms, + sumIf(SampleRate, Duration < 1000000000) AS EstimatedUnder1000Ms, + sumIf(SampleRate, Duration < 2500000000) AS EstimatedUnder2500Ms, + sumIf(SampleRate, Duration < 5000000000) AS EstimatedUnder5000Ms, + sumIf(SampleRate, Duration < 10000000000) AS EstimatedUnder10000Ms + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_minutely_mv TO service_overview_minutely AS +SELECT + OrgId, + toStartOfMinute(toDateTime(Timestamp)) AS Minute, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + count() AS SpanCount, + sum(SampleRate) AS EstimatedSpanCount, + countIf(StatusCode = 'Error') AS ErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS EstimatedErrorCount, + sum(toFloat64(Duration)) AS DurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS DurationQuantiles, + min(toDateTime(Timestamp)) AS FirstSeen, + countIf(StatusCode != 'Error' AND Duration < 500000000) AS ApdexSatisfiedCount, + countIf(StatusCode != 'Error' AND Duration >= 500000000 AND Duration < 2000000000) AS ApdexToleratingCount, + sum(SampleRate) AS EstimatedLadderSpanCount, + sumIf(SampleRate, Duration < 50000000) AS EstimatedUnder50Ms, + sumIf(SampleRate, Duration < 100000000) AS EstimatedUnder100Ms, + sumIf(SampleRate, Duration < 250000000) AS EstimatedUnder250Ms, + sumIf(SampleRate, Duration < 500000000) AS EstimatedUnder500Ms, + sumIf(SampleRate, Duration < 1000000000) AS EstimatedUnder1000Ms, + sumIf(SampleRate, Duration < 2500000000) AS EstimatedUnder2500Ms, + sumIf(SampleRate, Duration < 5000000000) AS EstimatedUnder5000Ms, + sumIf(SampleRate, Duration < 10000000000) AS EstimatedUnder10000Ms + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = '' + GROUP BY OrgId, Minute, ServiceName, DeploymentEnv, ServiceNamespace, CommitSha; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_overview_spans_mv TO service_overview_spans AS +SELECT + OrgId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + Duration, + StatusCode, + TraceState, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + ResourceAttributes['vcs.ref.head.revision'] AS CommitSha, + SampleRate, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + SpanKind, + toUInt8(ParentSpanId = '') AS IsRoot + FROM traces + WHERE SpanKind IN ('Server', 'Consumer') OR ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_platforms_hourly_mv TO service_platforms_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + max(ResourceAttributes['k8s.cluster.name']) AS K8sCluster, + max(ResourceAttributes['k8s.pod.name']) AS K8sPodName, + max(ResourceAttributes['k8s.deployment.name']) AS K8sDeploymentName, + max(ResourceAttributes['k8s.statefulset.name']) AS K8sStatefulSetName, + max(ResourceAttributes['k8s.daemonset.name']) AS K8sDaemonSetName, + max(ResourceAttributes['k8s.namespace.name']) AS K8sNamespaceName, + max(ResourceAttributes['cloud.platform']) AS CloudPlatform, + max(ResourceAttributes['cloud.provider']) AS CloudProvider, + max(ResourceAttributes['faas.name']) AS FaasName, + max(ResourceAttributes['maple.sdk.type']) AS MapleSdkType, + max(ResourceAttributes['process.runtime.name']) AS ProcessRuntimeName, + count() AS SpanCount + FROM traces + WHERE ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_runtimes_hourly_mv TO service_runtimes_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + leftUTF8(ResourceAttributes['process.runtime.name'], 64) AS RuntimeName, + leftUTF8(ResourceAttributes['process.runtime.version'], 64) AS RuntimeVersion, + leftUTF8(ResourceAttributes['process.executable.name'], 64) AS ExecutableName, + leftUTF8(ResourceAttributes['telemetry.sdk.language'], 64) AS SdkLanguage, + leftUTF8(ResourceAttributes['telemetry.sdk.name'], 64) AS SdkName, + leftUTF8(ResourceAttributes['telemetry.sdk.version'], 64) AS SdkVersion, + count() AS SpanCount + FROM traces + WHERE ServiceName != '' + GROUP BY OrgId, Hour, ServiceName, DeploymentEnv, RuntimeName, RuntimeVersion, ExecutableName, SdkLanguage, SdkName, SdkVersion; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_logs_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(TimestampTime) AS Hour, + count() AS LogCount, + sum(length(Body) + 200) AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM logs + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_exp_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + count() AS ExpHistogramMetricCount, + count() * 300 AS ExpHistogramMetricSizeBytes + FROM metrics_exponential_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_gauge_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + count() AS GaugeMetricCount, + count() * 150 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_gauge + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_histogram_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + count() AS HistogramMetricCount, + count() * 250 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_histogram + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_metrics_sum_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS TraceCount, + 0 AS TraceSizeBytes, + count() AS SumMetricCount, + count() * 150 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM metrics_sum + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS service_usage_traces_mv TO service_usage AS +SELECT + OrgId, + ServiceName, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS TraceCount, + sum(length(SpanName) + 300) AS TraceSizeBytes, + 0 AS SumMetricCount, + 0 AS SumMetricSizeBytes, + 0 AS GaugeMetricCount, + 0 AS GaugeMetricSizeBytes, + 0 AS HistogramMetricCount, + 0 AS HistogramMetricSizeBytes, + 0 AS ExpHistogramMetricCount, + 0 AS ExpHistogramMetricSizeBytes + FROM traces + GROUP BY OrgId, ServiceName, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_logs_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(TimestampTime) AS Hour, + 0 AS SpanCount, + 0 AS SpanSizeBytes, + count() AS LogCount, + sum(length(Body) + 200) AS LogSizeBytes, + 0 AS MetricCount, + 0 AS MetricSizeBytes + FROM logs + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_metrics_exp_histogram_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS SpanCount, + 0 AS SpanSizeBytes, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS MetricCount, + count() * 300 AS MetricSizeBytes + FROM metrics_exponential_histogram + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_metrics_gauge_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS SpanCount, + 0 AS SpanSizeBytes, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS MetricCount, + count() * 150 AS MetricSizeBytes + FROM metrics_gauge + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_metrics_histogram_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS SpanCount, + 0 AS SpanSizeBytes, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS MetricCount, + count() * 250 AS MetricSizeBytes + FROM metrics_histogram + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_metrics_sum_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + 0 AS SpanCount, + 0 AS SpanSizeBytes, + 0 AS LogCount, + 0 AS LogSizeBytes, + count() AS MetricCount, + count() * 150 AS MetricSizeBytes + FROM metrics_sum + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS source_usage_traces_mv TO source_usage_hourly AS +SELECT + OrgId, + ResourceAttributes['maple_source_id'] AS SourceId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + count() AS SpanCount, + sum(length(SpanName) + 300) AS SpanSizeBytes, + 0 AS LogCount, + 0 AS LogSizeBytes, + 0 AS MetricCount, + 0 AS MetricSizeBytes + FROM traces + WHERE ResourceAttributes['maple_source_id'] != '' + GROUP BY OrgId, SourceId, Hour; + +CREATE MATERIALIZED VIEW IF NOT EXISTS span_metrics_calls_hourly_mv TO span_metrics_calls_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(TimeUnix)) AS Hour, + ServiceName, + MetricName, + Attributes['span.kind'] AS SpanKind, + cityHash64(mapKeys(Attributes), mapValues(Attributes)) AS AttrFingerprint, + cityHash64(mapKeys(ResourceAttributes), mapValues(ResourceAttributes)) AS ResourceFingerprint, + StartTimeUnix, + argMaxState(Value, TimeUnix) AS LastValue + FROM metrics_sum + -- 'traces.span.metrics.calls' is the name the collector actually emits: + -- spanmetricsconnector output is namespaced by the pipeline it is attached + -- to. Without it this MV matched nothing and the target sat at 0 rows since + -- it was created, while ~880k rows / 2 days of the real counter flowed past + -- into metrics_sum and every read fell back to the raw window-function scan + -- (~7s p95 -- see queries/metrics.ts). Keep this list in sync with + -- SPAN_METRICS_CALLS_NAMES on the read side. + WHERE MetricName IN ('span.metrics.calls', 'calls', 'traces.span.metrics.calls') AND IsMonotonic + GROUP BY OrgId, Hour, ServiceName, MetricName, SpanKind, AttrFingerprint, ResourceFingerprint, StartTimeUnix; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_facets_hourly_mv TO trace_facets_hourly AS +SELECT + OrgId, + toStartOfHour(Timestamp) AS Hour, + ServiceName, + SpanName, + HttpMethod, + HttpStatusCode, + DeploymentEnv, + ServiceNamespace, + HasError, + count() AS TraceCount, + min(Duration) AS DurationMin, + max(Duration) AS DurationMax, + quantilesTDigestState(0.5, 0.95)(Duration) AS DurationQuantiles + FROM trace_list_mv + GROUP BY OrgId, Hour, ServiceName, SpanName, HttpMethod, HttpStatusCode, DeploymentEnv, ServiceNamespace, HasError; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_index_mv TO trace_index AS +SELECT + OrgId, + TraceId, + ServiceName, + toStartOfFiveMinutes(Timestamp) AS Bucket, + min(Timestamp) AS MinTimestamp, + max(Timestamp) AS MaxTimestamp + FROM traces + GROUP BY OrgId, TraceId, ServiceName, Bucket; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_list_mv_mv TO trace_list_mv AS +SELECT + OrgId, + TraceId, + toDateTime(Timestamp) AS Timestamp, + ServiceName, + if( + (SpanName LIKE 'http.server %' OR SpanName IN ('GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS')) + AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != ''), + concat( + if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), + ' ', + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path']) + ), + SpanName + ) AS SpanName, + SpanKind, + Duration, + StatusCode, + if(SpanAttributes['http.method'] != '', SpanAttributes['http.method'], SpanAttributes['http.request.method']) AS HttpMethod, + if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], if(SpanAttributes['url.path'] != '', SpanAttributes['url.path'], SpanAttributes['http.target'])) AS HttpRoute, + if(SpanAttributes['http.status_code'] != '', SpanAttributes['http.status_code'], SpanAttributes['http.response.status_code']) AS HttpStatusCode, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + toUInt8( + StatusCode = 'Error' + OR (SpanAttributes['http.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.status_code']) >= 500) + OR (SpanAttributes['http.response.status_code'] != '' AND toUInt16OrZero(SpanAttributes['http.response.status_code']) >= 500) + ) AS HasError, + TraceState, + ResourceAttributes['service.namespace'] AS ServiceNamespace + FROM traces + WHERE ParentSpanId = ''; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(ResourceAttributes)) AS AttributeKey, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE ResourceAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_resource_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'resource' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(ResourceAttributes) AS AttributeKey, + mapValues(ResourceAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_keys_mv TO attribute_keys_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + arrayJoin(mapKeys(SpanAttributes)) AS AttributeKey, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + WHERE SpanAttributes != map() + GROUP BY OrgId, Hour, AttributeKey, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS trace_span_attribute_values_mv TO attribute_values_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + AttributeKey, + AttributeValue, + 'span' AS AttributeScope, + count() AS UsageCount + FROM traces + ARRAY JOIN + mapKeys(SpanAttributes) AS AttributeKey, + mapValues(SpanAttributes) AS AttributeValue + WHERE AttributeValue != '' + AND length(AttributeValue) <= 128 + AND NOT (length(AttributeValue) > 4 AND match(AttributeValue, '^[0-9]+([.][0-9]+)?$')) + AND NOT match(AttributeKey, '(_id|[.]id|Id|_ns)$') + AND AttributeKey NOT LIKE 'http.request.header.%' + AND AttributeKey NOT LIKE 'http.response.header.%' + GROUP BY OrgId, Hour, AttributeKey, AttributeValue, AttributeScope; + +CREATE MATERIALIZED VIEW IF NOT EXISTS traces_aggregates_hourly_mv TO traces_aggregates_hourly AS +SELECT + OrgId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + SpanName, + SpanKind, + StatusCode, + IsEntryPoint, + coalesce(nullIf(ResourceAttributes['deployment.environment.name'], ''), ResourceAttributes['deployment.environment']) AS DeploymentEnv, + sum(SampleRate) AS WeightedCount, + sum(toFloat64(Duration) * SampleRate) AS WeightedDurationSum, + sumIf(SampleRate, StatusCode = 'Error') AS WeightedErrorCount, + quantilesTDigestWeightedState(0.5, 0.95, 0.99)(Duration, toUInt32(SampleRate)) AS DurationQuantiles, + min(Duration) AS DurationMin, + max(Duration) AS DurationMax + FROM traces + GROUP BY OrgId, Hour, ServiceName, SpanName, SpanKind, StatusCode, IsEntryPoint, DeploymentEnv; + +CREATE MATERIALIZED VIEW IF NOT EXISTS user_activity_hourly_logs_mv TO user_activity_hourly AS +SELECT + OrgId, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + if(LogAttributes['user.id'] != '', LogAttributes['user.id'], LogAttributes['enduser.id']) AS UserId, + toStartOfHour(TimestampTime) AS Hour, + ServiceName, + toUInt64(0) AS SpanCount, + toUInt64(0) AS ErrorSpanCount, + toUInt64(0) AS RequestCount, + toUInt64(0) AS FailedRequestCount, + uniqIfState(TraceId, toUInt8(0)) AS TraceIds, + uniqIfState(LogAttributes['session.id'], LogAttributes['session.id'] != '') AS SessionIds, + quantilesTDigestIfState(0.5, 0.95)(toUInt64(0), toUInt8(0)) AS RequestDurationQuantiles, + approx_top_kState(10)('') AS TopRoutes, + count() AS LogCount, + countIf(SeverityNumber >= 13 AND SeverityNumber < 17) AS WarnLogCount, + countIf(SeverityNumber >= 17) AS ErrorLogCount, + min(TimestampTime) AS FirstSeen, + max(TimestampTime) AS LastSeen, + toUInt64(0) AS ClassifiedRequestCount, + toUInt64(0) AS WebRequestCount, + toUInt64(0) AS MobileRequestCount + FROM logs + WHERE if(LogAttributes['user.id'] != '', LogAttributes['user.id'], LogAttributes['enduser.id']) != '' + GROUP BY OrgId, ServiceNamespace, UserId, Hour, ServiceName; + +CREATE MATERIALIZED VIEW IF NOT EXISTS user_activity_hourly_traces_mv TO user_activity_hourly AS +SELECT + OrgId, + ResourceAttributes['service.namespace'] AS ServiceNamespace, + if(SpanAttributes['user.id'] != '', SpanAttributes['user.id'], SpanAttributes['enduser.id']) AS UserId, + toStartOfHour(toDateTime(Timestamp)) AS Hour, + ServiceName, + count() AS SpanCount, + countIf(StatusCode = 'Error') AS ErrorSpanCount, + countIf(IsEntryPoint = 1) AS RequestCount, + countIf(IsEntryPoint = 1 AND StatusCode = 'Error') AS FailedRequestCount, + uniqIfState(TraceId, TraceId != '') AS TraceIds, + uniqIfState(SpanAttributes['session.id'], SpanAttributes['session.id'] != '') AS SessionIds, + quantilesTDigestIfState(0.5, 0.95)(Duration, IsEntryPoint = 1) AS RequestDurationQuantiles, + approx_top_kState(10)(if(IsEntryPoint = 1, if(((SpanName LIKE 'http.server %' OR SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (SpanAttributes['http.route'] != '' OR SpanAttributes['url.path'] != '')), concat(if(SpanName LIKE 'http.server %', replaceOne(SpanName, 'http.server ', ''), SpanName), ' ', if(SpanAttributes['http.route'] != '', SpanAttributes['http.route'], SpanAttributes['url.path'])), SpanName), '')) AS TopRoutes, + toUInt64(0) AS LogCount, + toUInt64(0) AS WarnLogCount, + toUInt64(0) AS ErrorLogCount, + min(toDateTime(Timestamp)) AS FirstSeen, + max(toDateTime(Timestamp)) AS LastSeen, + countIf(IsEntryPoint = 1) AS ClassifiedRequestCount, + countIf(IsEntryPoint = 1 AND (ResourceAttributes['maple.sdk.type'] IN ('browser', 'client') OR if(SpanAttributes['user_agent.original'] != '', SpanAttributes['user_agent.original'], SpanAttributes['http.user_agent']) LIKE 'Mozilla/%')) AS WebRequestCount, + countIf(IsEntryPoint = 1 AND (NOT (ResourceAttributes['maple.sdk.type'] IN ('browser', 'client') OR if(SpanAttributes['user_agent.original'] != '', SpanAttributes['user_agent.original'], SpanAttributes['http.user_agent']) LIKE 'Mozilla/%') AND (ResourceAttributes['telemetry.sdk.language'] IN ('swift', 'kotlin') OR ResourceAttributes['os.name'] IN ('iOS', 'iPadOS', 'Android') OR match(if(SpanAttributes['user_agent.original'] != '', SpanAttributes['user_agent.original'], SpanAttributes['http.user_agent']), '^(Dalvik|okhttp)/| CFNetwork/')))) AS MobileRequestCount + FROM traces + WHERE if(SpanAttributes['user.id'] != '', SpanAttributes['user.id'], SpanAttributes['enduser.id']) != '' + GROUP BY OrgId, ServiceNamespace, UserId, Hour, ServiceName; diff --git a/packages/local-server/src/schema/local-inserts.json b/packages/local-server/src/schema/local-inserts.json index 6913adef52..3414a9f913 100644 --- a/packages/local-server/src/schema/local-inserts.json +++ b/packages/local-server/src/schema/local-inserts.json @@ -1,5 +1,5 @@ { - "projectRevision": "266cb624c1996b27cb3d435f5feb3e57ec70c6fdf5f9ecfd1f291b6425b3a692", + "projectRevision": "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138", "orgPlaceholder": "__ORG__", "datasources": { "traces": { diff --git a/packages/local-server/src/schema/local-schema.sql b/packages/local-server/src/schema/local-schema.sql index 2003e3a4af..834972c572 100644 --- a/packages/local-server/src/schema/local-schema.sql +++ b/packages/local-server/src/schema/local-schema.sql @@ -1,6 +1,6 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: 266cb624c1996b27cb3d435f5feb3e57ec70c6fdf5f9ecfd1f291b6425b3a692 +-- projectRevision: 91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138 CREATE TABLE IF NOT EXISTS ai_crawler_requests ( OrgId LowCardinality(String), @@ -137,6 +137,36 @@ PARTITION BY toYYYYMM(OccurredAt) ORDER BY (OrgId, OccurredAt, Id) TTL toDate(OccurredAt) + INTERVAL 2190 DAY; +CREATE TABLE IF NOT EXISTS audit_trail_events ( + OrgId LowCardinality(String), + Id String, + OccurredAt DateTime64(3), + IngestedAt DateTime64(3), + Provider LowCardinality(String), + Account String, + ProviderEventId String, + ActorType LowCardinality(String), + ActorId String, + ActorName String, + ActorEmail String, + Action LowCardinality(String), + Category LowCardinality(String), + Outcome LowCardinality(String), + TargetType LowCardinality(String), + TargetId String, + TargetName String, + OriginIp String, + OriginCountry LowCardinality(String), + UserAgent String, + Raw String, + Version UInt64, + INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1 +) +ENGINE = ReplacingMergeTree +PARTITION BY toYYYYMM(OccurredAt) +ORDER BY (OrgId, OccurredAt, Id) +TTL toDate(OccurredAt) + INTERVAL 400 DAY; + CREATE TABLE IF NOT EXISTS ci_jobs ( OrgId LowCardinality(String), Timestamp DateTime64(9), diff --git a/packages/local-server/src/scoped-delete.ts b/packages/local-server/src/scoped-delete.ts index 8c524ebbc3..7b035fcc2d 100644 --- a/packages/local-server/src/scoped-delete.ts +++ b/packages/local-server/src/scoped-delete.ts @@ -103,6 +103,7 @@ export const TABLE_DELETE_PLAN = { attribute_keys_hourly: unkeyed("Hour"), attribute_values_hourly: unkeyed("Hour"), audit_log: { strategy: "excluded", reason: NOT_TELEMETRY }, + audit_trail_events: { strategy: "excluded", reason: NOT_TELEMETRY }, ci_jobs: unkeyed("Timestamp"), ci_runs: unkeyed("Timestamp"), error_events: derived("Timestamp"), diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index db5b93dab7..31ce1fe0e5 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -373,6 +373,71 @@ SELECT OFFSET 50 FORMAT JSON +-- builder:audit-trail:auditTrailEventsQuery:default [066a2e72] +SELECT + audit_trail_events.Id AS id, + audit_trail_events.OccurredAt AS occurredAt, + audit_trail_events.IngestedAt AS ingestedAt, + audit_trail_events.Provider AS provider, + audit_trail_events.Account AS account, + audit_trail_events.ProviderEventId AS providerEventId, + audit_trail_events.ActorType AS actorType, + audit_trail_events.ActorId AS actorId, + audit_trail_events.ActorName AS actorName, + audit_trail_events.ActorEmail AS actorEmail, + audit_trail_events.Action AS action, + audit_trail_events.Category AS category, + audit_trail_events.Outcome AS outcome, + audit_trail_events.TargetType AS targetType, + audit_trail_events.TargetId AS targetId, + audit_trail_events.TargetName AS targetName, + audit_trail_events.OriginIp AS originIp, + audit_trail_events.OriginCountry AS originCountry, + audit_trail_events.UserAgent AS userAgent, + audit_trail_events.Raw AS raw + FROM audit_trail_events + WHERE audit_trail_events.OrgId = 'org_sql_catalog' + ORDER BY occurredAt DESC, id DESC + LIMIT 51 + FORMAT JSON + +-- builder:audit-trail:auditTrailEventsQuery:filtered [526381eb] +SELECT + audit_trail_events.Id AS id, + audit_trail_events.OccurredAt AS occurredAt, + audit_trail_events.IngestedAt AS ingestedAt, + audit_trail_events.Provider AS provider, + audit_trail_events.Account AS account, + audit_trail_events.ProviderEventId AS providerEventId, + audit_trail_events.ActorType AS actorType, + audit_trail_events.ActorId AS actorId, + audit_trail_events.ActorName AS actorName, + audit_trail_events.ActorEmail AS actorEmail, + audit_trail_events.Action AS action, + audit_trail_events.Category AS category, + audit_trail_events.Outcome AS outcome, + audit_trail_events.TargetType AS targetType, + audit_trail_events.TargetId AS targetId, + audit_trail_events.TargetName AS targetName, + audit_trail_events.OriginIp AS originIp, + audit_trail_events.OriginCountry AS originCountry, + audit_trail_events.UserAgent AS userAgent, + audit_trail_events.Raw AS raw + FROM audit_trail_events + WHERE audit_trail_events.OrgId = 'org_sql_catalog' + AND audit_trail_events.Provider = 'github' + AND audit_trail_events.Account = 'acme' + AND audit_trail_events.Category = 'membership' + AND audit_trail_events.Outcome = 'success' + AND audit_trail_events.Action = 'org.add_member' + AND ((audit_trail_events.ActorId = 'octocat@acme.test' OR audit_trail_events.ActorEmail = 'octocat@acme.test') OR audit_trail_events.ActorId = 'user_1') + AND audit_trail_events.OccurredAt >= '2026-01-01 10:30:00' + AND audit_trail_events.OccurredAt <= '2026-01-03 14:15:00' + AND (audit_trail_events.OccurredAt < '2026-01-03 14:15:00' OR (audit_trail_events.OccurredAt = '2026-01-03 14:15:00' AND audit_trail_events.Id < '0f6f2c1e7a554c0e9a3b0d2c7e5b9a11')) + ORDER BY occurredAt DESC, id DESC + LIMIT 51 + FORMAT JSON + -- builder:aws-ingest:awsIngestQuery:default [c5891e34] SELECT metrics_sum.Attributes['aws.namespace'] AS namespace, diff --git a/packages/query-engine/src/benchmark/builders.ts b/packages/query-engine/src/benchmark/builders.ts index 38fc5f4ab7..0122c2aee8 100644 --- a/packages/query-engine/src/benchmark/builders.ts +++ b/packages/query-engine/src/benchmark/builders.ts @@ -1459,6 +1459,49 @@ export const builderFixtures: ReadonlyArray = [ }, ), }, + // Audit trail listing (packages/backend/src/services/audit-trail/AuditTrailService.ts `list`). + { + module: "audit-trail", + name: "auditTrailEventsQuery", + label: "default", + compile: () => CH.compileUnsafe(CH.auditTrailEventsQuery({ limit: 51 }), { orgId: ORG_ID }), + }, + { + // Every optional filter bound at once, with the keyset cursor. + module: "audit-trail", + name: "auditTrailEventsQuery", + label: "filtered", + compile: () => + CH.compileUnsafe( + CH.auditTrailEventsQuery({ + provider: true, + account: true, + category: true, + outcome: true, + action: true, + actor: true, + actorAlias: true, + since: true, + until: true, + before: true, + limit: 51, + }), + { + orgId: ORG_ID, + provider: "github", + account: "acme", + category: "membership", + outcome: "success", + action: "org.add_member", + actor: "octocat@acme.test", + actorAlias: "user_1", + since: START_TIME, + until: END_TIME, + beforeOccurredAt: END_TIME, + beforeId: "0f6f2c1e7a554c0e9a3b0d2c7e5b9a11", + }, + ), + }, // Cancellation evidence (CancellationTelemetryEvidence.read). { module: "cancellation-evidence", diff --git a/packages/query-engine/src/benchmark/catalog.test.ts b/packages/query-engine/src/benchmark/catalog.test.ts index b828d8ffaa..9401f07d68 100644 --- a/packages/query-engine/src/benchmark/catalog.test.ts +++ b/packages/query-engine/src/benchmark/catalog.test.ts @@ -25,6 +25,7 @@ import * as activityQueries from "../ch/queries/activity" import * as alertCheckQueries from "../ch/queries/alert-checks" import * as ciRunsQueries from "../ch/queries/ci-runs" import * as auditLogQueries from "../ch/queries/audit-log" +import * as auditTrailQueries from "../ch/queries/audit-trail" import * as cancellationEvidenceQueries from "../ch/queries/cancellation-evidence" import * as anomalyQueries from "../ch/queries/anomaly" import * as attributeKeyQueries from "../ch/queries/attribute-keys" @@ -279,6 +280,7 @@ const QUERY_MODULES: Record> = { activity: activityQueries, "alert-checks": alertCheckQueries, "audit-log": auditLogQueries, + "audit-trail": auditTrailQueries, "cancellation-evidence": cancellationEvidenceQueries, "ci-runs": ciRunsQueries, anomaly: anomalyQueries, diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 78c4d0cc11..cfc52e8f95 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -60,6 +60,7 @@ export * from "./queries/service-api" export * from "./queries/service-endpoint-details" export * from "./queries/alert-checks" export * from "./queries/audit-log" +export * from "./queries/audit-trail" export * from "./queries/cancellation-evidence" export * from "./queries/liveness" export * from "./queries/signal-presence" diff --git a/packages/query-engine/src/ch/queries/audit-trail.ts b/packages/query-engine/src/ch/queries/audit-trail.ts new file mode 100644 index 0000000000..cc703134e4 --- /dev/null +++ b/packages/query-engine/src/ch/queries/audit-trail.ts @@ -0,0 +1,84 @@ +// The unified audit trail: audit events from connected platforms and Maple itself, in one shape. +// Every read is pinned to the managed route, like `audit_log`: the table is written through +// `ingest` and does not exist in a BYO ClickHouse. + +import * as CH from "@maple-dev/effect-orm/expr" +import { from, param } from "@maple-dev/effect-orm/clickhouse" +import * as Datasources from "@maple/domain/tinybird/datasources" +import { orgIdParam } from "../tables" + +/** + * Which optional filters a listing applies. Every set flag binds a parameter of the same name at + * compile time; the values themselves never enter the SQL. + */ +export interface AuditTrailEventsOpts { + readonly provider?: boolean + readonly account?: boolean + readonly category?: boolean + readonly outcome?: boolean + readonly action?: boolean + /** Matches the actor's provider id or their email, whichever the caller has. */ + readonly actor?: boolean + /** With `actor`: a second id the same person acts under (a Maple member's user id). */ + readonly actorAlias?: boolean + readonly since?: boolean + readonly until?: boolean + /** Keyset cursor: only events that sort after `beforeOccurredAt` / `beforeId`, newest first. */ + readonly before?: boolean + readonly limit: number +} + +/** + * One org's audit events, newest first, keyset-paginated on the sorting key. A redelivered event + * ReplacingMergeTree has not merged yet can appear twice; the service collapses it by id. + */ +export function auditTrailEventsQuery(opts: AuditTrailEventsOpts) { + return from(Datasources.auditTrailEvents) + .select(($) => ({ + id: $.Id, + occurredAt: $.OccurredAt, + ingestedAt: $.IngestedAt, + provider: $.Provider, + account: $.Account, + providerEventId: $.ProviderEventId, + actorType: $.ActorType, + actorId: $.ActorId, + actorName: $.ActorName, + actorEmail: $.ActorEmail, + action: $.Action, + category: $.Category, + outcome: $.Outcome, + targetType: $.TargetType, + targetId: $.TargetId, + targetName: $.TargetName, + originIp: $.OriginIp, + originCountry: $.OriginCountry, + userAgent: $.UserAgent, + raw: $.Raw, + })) + .where(($) => [ + $.OrgId.eq(orgIdParam), + CH.whenTrue(!!opts.provider, () => $.Provider.eq(param.string("provider"))), + CH.whenTrue(!!opts.account, () => $.Account.eq(param.string("account"))), + CH.whenTrue(!!opts.category, () => $.Category.eq(param.string("category"))), + CH.whenTrue(!!opts.outcome, () => $.Outcome.eq(param.string("outcome"))), + CH.whenTrue(!!opts.action, () => $.Action.eq(param.string("action"))), + CH.whenTrue(!!opts.actor, () => { + const byActor = $.ActorId.eq(param.string("actor")).or($.ActorEmail.eq(param.string("actor"))) + return opts.actorAlias ? byActor.or($.ActorId.eq(param.string("actorAlias"))) : byActor + }), + CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), + CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), + CH.whenTrue(!!opts.before, () => + $.OccurredAt.lt(param.dateTime("beforeOccurredAt")).or( + $.OccurredAt.eq(param.dateTime("beforeOccurredAt")).and( + $.Id.lt(param.string("beforeId")), + ), + ), + ), + ]) + .orderBy(["occurredAt", "desc"], ["id", "desc"]) + .limit(opts.limit) + .format("JSON") + .route("ingest") +} diff --git a/packages/query-engine/src/execution/datasource-routing.ts b/packages/query-engine/src/execution/datasource-routing.ts index f98d589c1a..c7f7159ce2 100644 --- a/packages/query-engine/src/execution/datasource-routing.ts +++ b/packages/query-engine/src/execution/datasource-routing.ts @@ -12,6 +12,7 @@ import { String as Str, Array as Arr, Option } from "effect" export const INGEST_PINNED_TABLES: ReadonlyArray = [ "alert_checks", "audit_log", + "audit_trail_events", "cost_daily", "search_console_daily", ] From ded24238e92f1abe4500fae6df60131e88cbbf22 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 01:31:28 +0100 Subject: [PATCH 02/30] fix(security): best-effort trail copy on the direct write, sibling credential actions, review tests - The direct write path drops a failed audit_log insert at Warn; its trail copy now does the same instead of leaving an Error span. - Removing a status page password, revoking a break-glass token and unlinking a chat identity are filed like the actions that set them. - An email actor filter is resolved against Maple's members only when Maple's events can be in the result. - Tests: a trail copy that dies still acks, one that is interrupted stays unacked, the directory is read once per request, an empty batch never reaches the warehouse. --- apps/api/src/audit-events-runtime.test.ts | 59 +++++++++++++++---- .../routes/internal/audit-trail.http.test.ts | 24 ++++++++ .../src/routes/internal/audit-trail.http.ts | 7 ++- .../audit-trail/AuditTrailService.test.ts | 25 ++++++-- .../services/audit-trail/AuditTrailService.ts | 13 ++-- .../services/audit-trail/audit-trail-row.ts | 2 +- .../audit-trail/providers/maple.test.ts | 34 ++++++++--- .../services/audit-trail/providers/maple.ts | 3 + .../src/services/audit/AuditLogService.ts | 4 +- 9 files changed, 137 insertions(+), 34 deletions(-) diff --git a/apps/api/src/audit-events-runtime.test.ts b/apps/api/src/audit-events-runtime.test.ts index 78d832d273..cd3966f0da 100644 --- a/apps/api/src/audit-events-runtime.test.ts +++ b/apps/api/src/audit-events-runtime.test.ts @@ -70,23 +70,30 @@ const dyingWarehouse = withTrail( const AUDIT_LOG = "audit_log" const AUDIT_TRAIL = "audit_trail_events" -/** A warehouse whose `ingest` records each call, failing the ones to the `failing` datasources. */ -const warehouse = (failing: ReadonlyArray = []) => { +/** + * A warehouse whose `ingest` records each call, failing the ones to the `failing` datasources. + * `trailCopy` makes the write to the audit trail die or be interrupted instead. + */ +const warehouse = (failing: ReadonlyArray = [], trailCopy?: "dies" | "interrupted") => { const written: Array<{ orgId: string; datasource: string; rows: ReadonlyArray }> = [] const layer = withTrail( makeWarehouseServiceStub({ ingest: (tenant, datasource, rows) => - failing.includes(datasource) - ? Effect.fail( - new WarehouseUpstreamError({ - message: "tinybird down", - pipeName: datasource, - cause: new Error("down"), - }), - ) - : Effect.sync(() => { - written.push({ orgId: tenant.orgId, datasource, rows }) - }), + datasource === AUDIT_TRAIL && trailCopy === "dies" + ? Effect.die(new Error("trail exploded")) + : datasource === AUDIT_TRAIL && trailCopy === "interrupted" + ? Effect.interrupt + : failing.includes(datasource) + ? Effect.fail( + new WarehouseUpstreamError({ + message: "tinybird down", + pipeName: datasource, + cause: new Error("down"), + }), + ) + : Effect.sync(() => { + written.push({ orgId: tenant.orgId, datasource, rows }) + }), }), ) /** `[orgId, row count]` per write to one datasource, in a stable order. */ @@ -165,6 +172,32 @@ describe("processAuditEventsBatch", () => { // `audit_log` is the record. A message retried because only its copy failed would be // rewritten there on every attempt and end in the DLQ for an entry that was stored. + it.effect("acks an entry whose audit trail copy died", () => + Effect.gen(function* () { + const store = warehouse([], "dies") + const change = message(event("11111111-1111-4111-8111-111111111111"), 1) + yield* processAuditEventsBatch(batchOf(change), MAX_RETRIES).pipe(Effect.provide(store.layer)) + + expect(change.calls).toEqual(["ack"]) + expect(store.writesTo(AUDIT_LOG)).toEqual([[ORG, 1]]) + }), + ) + + // A deploy landing between the two writes: unacked, so the platform redelivers the entry. + it.effect("leaves an entry unacked when its audit trail copy is interrupted", () => + Effect.gen(function* () { + const store = warehouse([], "interrupted") + const change = message(event("11111111-1111-4111-8111-111111111111"), 1) + const exit = yield* processAuditEventsBatch(batchOf(change), MAX_RETRIES).pipe( + Effect.provide(store.layer), + Effect.exit, + ) + + expect(exit._tag).toBe("Failure") + expect(change.calls).toEqual([]) + }), + ) + it.effect("acks an entry whose audit trail copy failed", () => Effect.gen(function* () { const store = warehouse([AUDIT_TRAIL]) diff --git a/apps/api/src/routes/internal/audit-trail.http.test.ts b/apps/api/src/routes/internal/audit-trail.http.test.ts index 988915290c..dd8aa83e98 100644 --- a/apps/api/src/routes/internal/audit-trail.http.test.ts +++ b/apps/api/src/routes/internal/audit-trail.http.test.ts @@ -221,6 +221,30 @@ describe("GET /internal/audit-trail/events", () => { } }) + it("reads the directory once to resolve an email filter and name the page it returns", async () => { + const harness = makeHarness(ADMIN, { events: [mapleUser()], next: null }) + try { + const { events } = asPage((await harness.list("?actor=ada@acme.test")).body) + + expect(events[0]?.actor.name).toBe("Ada") + expect(harness.directoryReads()).toBe(1) + } finally { + await harness.dispose() + } + }) + + it("does not resolve an email against Maple's members when another platform is asked for", async () => { + const harness = makeHarness(ADMIN, { events: [], next: null }) + try { + await harness.list("?provider=github&actor=ada@acme.test") + + expect(harness.asked).toEqual([{ provider: "github", actor: "ada@acme.test", limit: 50 }]) + expect(harness.directoryReads()).toBe(0) + } finally { + await harness.dispose() + } + }) + // Maple's own events store the user id, and the page shows the email next to it. it("finds a member's Maple events when filtering by the email the page showed", async () => { const harness = makeHarness(ADMIN, { events: [], next: null }) diff --git a/apps/api/src/routes/internal/audit-trail.http.ts b/apps/api/src/routes/internal/audit-trail.http.ts index ac83db0a66..bad9847349 100644 --- a/apps/api/src/routes/internal/audit-trail.http.ts +++ b/apps/api/src/routes/internal/audit-trail.http.ts @@ -61,8 +61,13 @@ export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditT ) // Read at most once a request: to resolve an email filter, or to name the page. const readDirectory = yield* Effect.cached(orgMemberDirectory(members, tenant.orgId)) + // Only Maple's own events store a user id where the page shows an email. const emailActor = - query.actor !== undefined && Str.includes("@")(query.actor) ? query.actor : undefined + query.actor !== undefined && + Str.includes("@")(query.actor) && + (query.provider === undefined || query.provider === "maple") + ? query.actor + : undefined const actorAlias = emailActor === undefined ? Option.none() diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts index 20459df1c3..f452d8c349 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts @@ -25,13 +25,15 @@ const down = new WarehouseUpstreamError({ const fakeWarehouse = (rows: ReadonlyArray> = [], failing = false) => { const sql: Array = [] const written: Array> = [] + const bestEffort: Array = [] const layer = Layer.succeed( WarehouseQueryService, makeWarehouseServiceStub({ - ingest: (_tenant, _datasource, batch) => + ingest: (_tenant, _datasource, batch, options) => failing ? Effect.fail(down) : Effect.sync(() => { + bestEffort.push(options?.bestEffort === true) // SAFETY: every warehouse row is a record of column values. written.push(...(batch as ReadonlyArray>)) }), @@ -47,7 +49,7 @@ const fakeWarehouse = (rows: ReadonlyArray> = [], failin })) as never, }), ) - return { sql, written, layer } + return { sql, written, bestEffort, ingests: () => bestEffort.length, layer } } const withTrail = ( @@ -229,13 +231,24 @@ describe("AuditTrailService.write", () => { }), ) - it.effect("writes nothing for an empty batch", () => + it.effect("does not reach the warehouse for an empty batch", () => Effect.gen(function* () { - const warehouse = fakeWarehouse([], true) - // A failing warehouse proves the point: it is never reached. + const warehouse = fakeWarehouse() yield* withTrail(warehouse, (trail) => trail.write(ORG, [])) + yield* withTrail(warehouse, (trail) => trail.write(ORG, [event])) + + expect(warehouse.ingests()).toBe(1) + }), + ) + + // A caller that drops the events on failure asks for a Warn, not an Error span. + it.effect("passes best effort through to the insert", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse() + yield* withTrail(warehouse, (trail) => trail.write(ORG, [event], { bestEffort: true })) + yield* withTrail(warehouse, (trail) => trail.write(ORG, [event])) - expect(warehouse.written).toEqual([]) + expect(warehouse.bestEffort).toEqual([true, false]) }), ) diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.ts b/packages/backend/src/services/audit-trail/AuditTrailService.ts index 9968b4fb4d..a27854a288 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.ts @@ -34,10 +34,14 @@ export interface AuditTrailPage { } export interface AuditTrailServiceApi { - /** Store one org's events. Safe to repeat: the same event writes the same row. */ + /** + * Store one org's events. Safe to repeat: the same event writes the same row. `bestEffort` + * is for a caller that drops the events on failure: the failed insert is a Warn, not an Error span. + */ readonly write: ( orgId: OrgId, events: ReadonlyArray, + options?: { readonly bestEffort?: boolean }, ) => Effect.Effect /** One org's audit events, newest first, `filters.limit` to a page. */ readonly list: ( @@ -97,6 +101,7 @@ export class AuditTrailService extends Context.Service, + bestEffort: boolean, ) { const now = yield* Clock.currentTimeMillis yield* warehouse @@ -104,13 +109,13 @@ export class AuditTrailService extends Context.Service auditTrailEventToRow(orgId, event, now)), - { retryTransient: true }, + { bestEffort, retryTransient: true }, ) .pipe(Effect.mapError(persistenceError("The audit trail could not be written"))) }) - const write: AuditTrailServiceApi["write"] = (orgId, events) => - events.length === 0 ? Effect.void : writeRows(orgId, events) + const write: AuditTrailServiceApi["write"] = (orgId, events, options) => + events.length === 0 ? Effect.void : writeRows(orgId, events, options?.bestEffort === true) const list: AuditTrailServiceApi["list"] = Effect.fn("AuditTrailService.list")( function* (orgId, filters) { diff --git a/packages/backend/src/services/audit-trail/audit-trail-row.ts b/packages/backend/src/services/audit-trail/audit-trail-row.ts index 923f5921b8..682e38768b 100644 --- a/packages/backend/src/services/audit-trail/audit-trail-row.ts +++ b/packages/backend/src/services/audit-trail/audit-trail-row.ts @@ -17,7 +17,7 @@ const sha256Hex = (value: string) => createHash("sha256").update(value, "utf8"). /** * The stored row's key, the first 32 hex characters of sha256 over `provider\naccount\neventId`. - * The ingest gateway derives the same key for the providers it writes. + * A writer in another language has to derive the same key, or its redeliveries stop collapsing. */ export const auditTrailEventId = ( event: Pick, diff --git a/packages/backend/src/services/audit-trail/providers/maple.test.ts b/packages/backend/src/services/audit-trail/providers/maple.test.ts index 03a445304a..06fc4d13d7 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.test.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.test.ts @@ -87,14 +87,34 @@ describe("mapleAuditTrailEvents", () => { }) }) - it("files a secret rotated on a non-credential resource under credential", () => { + it("files a secret set or removed on a non-credential resource under credential", () => { + const categoryOf = (action: string) => trailEventOf({ action, resourceType: "status_page" })?.category + + expect( + Arr.map( + [ + "status_page.break_glass_rotated", + "status_page.break_glass_revoked", + "status_page.password_set", + "status_page.password_removed", + ], + categoryOf, + ), + ).toEqual(["credential", "credential", "credential", "credential"]) + expect(categoryOf("status_page.updated")).toBe("resource") + }) + + it("files linking and unlinking a chat identity under permission", () => { + const categoryOf = (action: string) => + trailEventOf({ action, resourceType: "chat_integration" })?.category + expect( - trailEventOf({ action: "status_page.break_glass_rotated", resourceType: "status_page" }) - ?.category, - ).toBe("credential") - expect(trailEventOf({ action: "status_page.updated", resourceType: "status_page" })?.category).toBe( - "resource", - ) + Arr.map( + ["chat_integration.identity_link_started", "chat_integration.identity_unlinked"], + categoryOf, + ), + ).toEqual(["permission", "permission"]) + expect(categoryOf("chat_integration.settings_updated")).toBe("configuration") }) it("files a resource this build does not know under other", () => { diff --git a/packages/backend/src/services/audit-trail/providers/maple.ts b/packages/backend/src/services/audit-trail/providers/maple.ts index 5fdd7580e6..a4f2c712e0 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.ts @@ -67,11 +67,14 @@ const CATEGORY_BY_RESOURCE = { const CATEGORY_BY_ACTION = { "source.rolled": "credential", "status_page.break_glass_rotated": "credential", + "status_page.break_glass_revoked": "credential", "status_page.password_set": "credential", + "status_page.password_removed": "credential", "planetscale_integration.metrics_token_set": "credential", "mcp_connection.revoked": "credential", // The link is standing authority to act as the member from the chat platform. "chat_integration.identity_link_started": "permission", + "chat_integration.identity_unlinked": "permission", } as const satisfies Partial> const isAuditResourceType = (type: string): type is AuditResourceType => diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index d4b98db6bf..6f65a09d02 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -261,8 +261,8 @@ export class AuditLogService extends Context.Service Date: Thu, 12 Nov 2026 01:33:04 +0100 Subject: [PATCH 03/30] fix(security): fail an email filter when the member directory is down, show the first write - Maple's events are stored under the user id, so an email filter needs the member directory. A directory outage now answers 503 instead of a page that silently leaves the member's Maple events out. A deployment with no directory is unchanged. - Unmerged copies of one event are read oldest write first, so the list shows the IngestedAt the table will keep. --- .../routes/internal/audit-trail.http.test.ts | 14 +++ .../src/routes/internal/audit-trail.http.ts | 41 +++++--- apps/api/src/routes/org-member-directory.ts | 17 +++- .../audit-trail/AuditTrailService.test.ts | 17 ++++ .../src/__sql_baseline__/catalog.sql | 8 +- .../src/ch/queries/audit-trail.ts | 97 ++++++++++--------- 6 files changed, 132 insertions(+), 62 deletions(-) diff --git a/apps/api/src/routes/internal/audit-trail.http.test.ts b/apps/api/src/routes/internal/audit-trail.http.test.ts index dd8aa83e98..23ee0b1daf 100644 --- a/apps/api/src/routes/internal/audit-trail.http.test.ts +++ b/apps/api/src/routes/internal/audit-trail.http.test.ts @@ -221,6 +221,20 @@ describe("GET /internal/audit-trail/events", () => { } }) + // Maple's events are stored under the user id: without the directory an email matches none + // of them, and a page that left them out would look complete. + it("answers 503 for an email filter while the member directory is down", async () => { + const harness = makeHarness(ADMIN, { events: [], next: null }, true) + try { + const response = await harness.list("?actor=ada@acme.test") + + expect(response.status).toBe(503) + expect(harness.asked).toEqual([]) + } finally { + await harness.dispose() + } + }) + it("reads the directory once to resolve an email filter and name the page it returns", async () => { const harness = makeHarness(ADMIN, { events: [mapleUser()], next: null }) try { diff --git a/apps/api/src/routes/internal/audit-trail.http.ts b/apps/api/src/routes/internal/audit-trail.http.ts index bad9847349..a29c5acd75 100644 --- a/apps/api/src/routes/internal/audit-trail.http.ts +++ b/apps/api/src/routes/internal/audit-trail.http.ts @@ -1,12 +1,17 @@ import { HttpApiBuilder } from "effect/http-api" -import { AuditTrailForbiddenError, CurrentTenant, MapleInternalApi } from "@maple/domain/http" +import { + AuditTrailForbiddenError, + AuditTrailPersistenceError, + CurrentTenant, + MapleInternalApi, +} from "@maple/domain/http" import type { AuditTrailEntry } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import type { OrgMember } from "@maple/backend/services/org/OrgMembersService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { Effect, Array as Arr, Option, String as Str } from "effect" -import { orgMemberDirectory } from "@/routes/org-member-directory" +import { orgMemberDirectory, orgMemberDirectoryOrFail } from "@/routes/org-member-directory" const DEFAULT_PAGE_LIMIT = 50 @@ -59,8 +64,6 @@ export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditT "audit_log:read", (denial) => new AuditTrailForbiddenError({ message: denial.message }), ) - // Read at most once a request: to resolve an email filter, or to name the page. - const readDirectory = yield* Effect.cached(orgMemberDirectory(members, tenant.orgId)) // Only Maple's own events store a user id where the page shows an email. const emailActor = query.actor !== undefined && @@ -68,12 +71,25 @@ export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditT (query.provider === undefined || query.provider === "maple") ? query.actor : undefined - const actorAlias = + // The filter's answer depends on this lookup, so an outage fails the request: a + // page that silently left the member's Maple events out would read as complete. + const filterDirectory = emailActor === undefined - ? Option.none() - : yield* Effect.map(readDirectory, (directory) => - memberByEmail(directory, emailActor), + ? undefined + : yield* orgMemberDirectoryOrFail(members, tenant.orgId).pipe( + Effect.mapError( + (error) => + new AuditTrailPersistenceError({ + message: + "The member directory is unavailable, so an email cannot be matched to a member", + cause: error._tag, + }), + ), ) + const actorAlias = + emailActor === undefined || filterDirectory === undefined + ? Option.none() + : memberByEmail(filterDirectory, emailActor) const page = yield* trail.list(tenant.orgId, { ...(query.provider !== undefined ? { provider: query.provider } : undefined), ...(query.account !== undefined ? { account: query.account } : undefined), @@ -87,9 +103,12 @@ export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditT ...(query.cursor !== undefined ? { before: parseCursor(query.cursor) } : undefined), limit: query.limit ?? DEFAULT_PAGE_LIMIT, }) - const directory = Arr.some(page.events, (event) => unnamedMapleUserId(event) !== null) - ? yield* readDirectory - : new Map() + // Names are decoration: without a directory the page still shows every id. + const directory = + filterDirectory ?? + (Arr.some(page.events, (event) => unnamedMapleUserId(event) !== null) + ? yield* orgMemberDirectory(members, tenant.orgId) + : new Map()) return { events: nameMapleUsers(page.events, directory), nextCursor: page.next === null ? null : `${page.next.occurredAtMs}:${page.next.id}`, diff --git a/apps/api/src/routes/org-member-directory.ts b/apps/api/src/routes/org-member-directory.ts index d7a02b515e..1a4b88fbd5 100644 --- a/apps/api/src/routes/org-member-directory.ts +++ b/apps/api/src/routes/org-member-directory.ts @@ -3,6 +3,21 @@ import { summarizeCause } from "@maple/backend/platform/describe-cause" import type { OrgId } from "@maple/primitives" import { Cause, Effect, Array as Arr } from "effect" +const byUserId = (members: ReadonlyArray): ReadonlyMap => + new Map(Arr.map(members, (member) => [member.userId, member] as const)) + +/** + * The directory for a caller whose answer depends on it: an outage fails. A deployment with no + * directory at all (self-hosted without Clerk) has no members to look up, which is not a failure. + */ +export const orgMemberDirectoryOrFail = (members: Pick, orgId: OrgId) => + members.listMembers(orgId).pipe( + Effect.map(byUserId), + Effect.catchTag("@maple/http/errors/AlertMemberDirectoryNotConfiguredError", () => + Effect.succeed>(new Map()), + ), + ) + /** * The org's current members by user id, for putting names on audit records. Never fails: an * unconfigured or unavailable directory answers with nobody and the records keep their ids. @@ -12,7 +27,7 @@ export const orgMemberDirectory = ( orgId: OrgId, ): Effect.Effect> => members.listMembers(orgId).pipe( - Effect.map((all) => new Map(Arr.map(all, (member) => [member.userId, member] as const))), + Effect.map(byUserId), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.interrupt diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts index f452d8c349..f8b6a07e04 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts @@ -110,6 +110,23 @@ describe("AuditTrailService.list", () => { }), ) + // Two unmerged copies differ only in when they were stored; the first write is the one the + // table will keep, so it is the one to show. + it.effect("reads an event's copies oldest write first, so the kept one is the first write", () => + Effect.gen(function* () { + const warehouse = fakeWarehouse([ + stored(ids[0], { ingestedAt: "2026-08-29 09:13:30.000" }), + stored(ids[0], { ingestedAt: "2026-08-29 09:20:00.000" }), + ]) + const page = yield* withTrail(warehouse, (trail) => trail.list(ORG, { limit: 10 })) + + expect(warehouse.sql[0]).toContain("ORDER BY occurredAt DESC, id DESC, ingestedAt ASC") + expect(Arr.map(page.events, (event) => event.ingestedAt)).toEqual([ + Date.UTC(2026, 7, 29, 9, 13, 30), + ]) + }), + ) + it.effect("shows a redelivered event the table has not merged yet once", () => Effect.gen(function* () { const warehouse = fakeWarehouse([stored(ids[0]), stored(ids[0]), stored(ids[1])]) diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 31ce1fe0e5..f9fac39834 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -373,7 +373,7 @@ SELECT OFFSET 50 FORMAT JSON --- builder:audit-trail:auditTrailEventsQuery:default [066a2e72] +-- builder:audit-trail:auditTrailEventsQuery:default [ac8596a7] SELECT audit_trail_events.Id AS id, audit_trail_events.OccurredAt AS occurredAt, @@ -397,11 +397,11 @@ SELECT audit_trail_events.Raw AS raw FROM audit_trail_events WHERE audit_trail_events.OrgId = 'org_sql_catalog' - ORDER BY occurredAt DESC, id DESC + ORDER BY occurredAt DESC, id DESC, ingestedAt ASC LIMIT 51 FORMAT JSON --- builder:audit-trail:auditTrailEventsQuery:filtered [526381eb] +-- builder:audit-trail:auditTrailEventsQuery:filtered [a97c670a] SELECT audit_trail_events.Id AS id, audit_trail_events.OccurredAt AS occurredAt, @@ -434,7 +434,7 @@ SELECT AND audit_trail_events.OccurredAt >= '2026-01-01 10:30:00' AND audit_trail_events.OccurredAt <= '2026-01-03 14:15:00' AND (audit_trail_events.OccurredAt < '2026-01-03 14:15:00' OR (audit_trail_events.OccurredAt = '2026-01-03 14:15:00' AND audit_trail_events.Id < '0f6f2c1e7a554c0e9a3b0d2c7e5b9a11')) - ORDER BY occurredAt DESC, id DESC + ORDER BY occurredAt DESC, id DESC, ingestedAt ASC LIMIT 51 FORMAT JSON diff --git a/packages/query-engine/src/ch/queries/audit-trail.ts b/packages/query-engine/src/ch/queries/audit-trail.ts index cc703134e4..138a531151 100644 --- a/packages/query-engine/src/ch/queries/audit-trail.ts +++ b/packages/query-engine/src/ch/queries/audit-trail.ts @@ -33,52 +33,57 @@ export interface AuditTrailEventsOpts { * ReplacingMergeTree has not merged yet can appear twice; the service collapses it by id. */ export function auditTrailEventsQuery(opts: AuditTrailEventsOpts) { - return from(Datasources.auditTrailEvents) - .select(($) => ({ - id: $.Id, - occurredAt: $.OccurredAt, - ingestedAt: $.IngestedAt, - provider: $.Provider, - account: $.Account, - providerEventId: $.ProviderEventId, - actorType: $.ActorType, - actorId: $.ActorId, - actorName: $.ActorName, - actorEmail: $.ActorEmail, - action: $.Action, - category: $.Category, - outcome: $.Outcome, - targetType: $.TargetType, - targetId: $.TargetId, - targetName: $.TargetName, - originIp: $.OriginIp, - originCountry: $.OriginCountry, - userAgent: $.UserAgent, - raw: $.Raw, - })) - .where(($) => [ - $.OrgId.eq(orgIdParam), - CH.whenTrue(!!opts.provider, () => $.Provider.eq(param.string("provider"))), - CH.whenTrue(!!opts.account, () => $.Account.eq(param.string("account"))), - CH.whenTrue(!!opts.category, () => $.Category.eq(param.string("category"))), - CH.whenTrue(!!opts.outcome, () => $.Outcome.eq(param.string("outcome"))), - CH.whenTrue(!!opts.action, () => $.Action.eq(param.string("action"))), - CH.whenTrue(!!opts.actor, () => { - const byActor = $.ActorId.eq(param.string("actor")).or($.ActorEmail.eq(param.string("actor"))) - return opts.actorAlias ? byActor.or($.ActorId.eq(param.string("actorAlias"))) : byActor - }), - CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), - CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), - CH.whenTrue(!!opts.before, () => - $.OccurredAt.lt(param.dateTime("beforeOccurredAt")).or( - $.OccurredAt.eq(param.dateTime("beforeOccurredAt")).and( - $.Id.lt(param.string("beforeId")), + return ( + from(Datasources.auditTrailEvents) + .select(($) => ({ + id: $.Id, + occurredAt: $.OccurredAt, + ingestedAt: $.IngestedAt, + provider: $.Provider, + account: $.Account, + providerEventId: $.ProviderEventId, + actorType: $.ActorType, + actorId: $.ActorId, + actorName: $.ActorName, + actorEmail: $.ActorEmail, + action: $.Action, + category: $.Category, + outcome: $.Outcome, + targetType: $.TargetType, + targetId: $.TargetId, + targetName: $.TargetName, + originIp: $.OriginIp, + originCountry: $.OriginCountry, + userAgent: $.UserAgent, + raw: $.Raw, + })) + .where(($) => [ + $.OrgId.eq(orgIdParam), + CH.whenTrue(!!opts.provider, () => $.Provider.eq(param.string("provider"))), + CH.whenTrue(!!opts.account, () => $.Account.eq(param.string("account"))), + CH.whenTrue(!!opts.category, () => $.Category.eq(param.string("category"))), + CH.whenTrue(!!opts.outcome, () => $.Outcome.eq(param.string("outcome"))), + CH.whenTrue(!!opts.action, () => $.Action.eq(param.string("action"))), + CH.whenTrue(!!opts.actor, () => { + const byActor = $.ActorId.eq(param.string("actor")).or( + $.ActorEmail.eq(param.string("actor")), + ) + return opts.actorAlias ? byActor.or($.ActorId.eq(param.string("actorAlias"))) : byActor + }), + CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), + CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), + CH.whenTrue(!!opts.before, () => + $.OccurredAt.lt(param.dateTime("beforeOccurredAt")).or( + $.OccurredAt.eq(param.dateTime("beforeOccurredAt")).and( + $.Id.lt(param.string("beforeId")), + ), ), ), - ), - ]) - .orderBy(["occurredAt", "desc"], ["id", "desc"]) - .limit(opts.limit) - .format("JSON") - .route("ingest") + ]) + // Unmerged copies of one event: the first write first, the one the service keeps. + .orderBy(["occurredAt", "desc"], ["id", "desc"], ["ingestedAt", "asc"]) + .limit(opts.limit) + .format("JSON") + .route("ingest") + ) } From 918b68c4be04f0e227152d1419fa4566f158cf13 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 01:40:14 +0100 Subject: [PATCH 04/30] feat(security): the audit log page in the Security hub /security/audit-log lists the unified audit trail, newest first, for org admins. - A new Activity section in the Security rail links to it. The page reads the session's grants first: without audit_log:read it says who can read the log and asks the API for nothing. - Events are grouped by day. A row shows when, the platform, who acted, the platform's action name with its category, the target and a mark for a refusal. Opening a row shows everything recorded about the event and its payload. - Category, outcome and actor filters live in the URL. The platform filter appears once a second platform writes events. - Load more continues from the last event shown. A failed page keeps the list and offers a retry. - jsonBlock moves from the settings audit log into the page kit, and both pages use it. - Six lab scenarios under /lab/security?state=audit-log* and a scene test. --- apps/web/src/lab/entries/security.ts | 37 +- .../web/src/lab/security-audit-log-fixture.ts | 156 +++++++ apps/web/src/lab/security-lab/scenarios.ts | 98 +++- apps/web/src/page/kit/json-block.ts | 16 + apps/web/src/page/registry.ts | 2 + .../audit-log/audit-log.scene.test.ts | 216 +++++++++ .../src/page/security/audit-log/command.ts | 57 +++ .../web/src/page/security/audit-log/derive.ts | 82 ++++ apps/web/src/page/security/audit-log/index.ts | 20 + apps/web/src/page/security/audit-log/model.ts | 66 +++ .../web/src/page/security/audit-log/update.ts | 158 +++++++ apps/web/src/page/security/audit-log/view.ts | 442 ++++++++++++++++++ apps/web/src/page/security/kit/layout.ts | 17 +- .../page/settings/audit-log/view-detail.ts | 10 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/redirect.ts | 1 + apps/web/src/route/route.test.ts | 4 + apps/web/src/route/routers.ts | 5 + apps/web/src/route/search.ts | 10 + packages/views/src/ui/INDEX.md | 1 + 20 files changed, 1387 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/lab/security-audit-log-fixture.ts create mode 100644 apps/web/src/page/kit/json-block.ts create mode 100644 apps/web/src/page/security/audit-log/audit-log.scene.test.ts create mode 100644 apps/web/src/page/security/audit-log/command.ts create mode 100644 apps/web/src/page/security/audit-log/derive.ts create mode 100644 apps/web/src/page/security/audit-log/index.ts create mode 100644 apps/web/src/page/security/audit-log/model.ts create mode 100644 apps/web/src/page/security/audit-log/update.ts create mode 100644 apps/web/src/page/security/audit-log/view.ts diff --git a/apps/web/src/lab/entries/security.ts b/apps/web/src/lab/entries/security.ts index 6b5b333178..c9522b655f 100644 --- a/apps/web/src/lab/entries/security.ts +++ b/apps/web/src/lab/entries/security.ts @@ -2,6 +2,9 @@ import { Clock, Effect, Option, Schema, Array as Arr } from "effect" import { Command } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import * as AuditLogPage from "../../page/security/audit-log/model" +import * as AuditLogUpdate from "../../page/security/audit-log/update" +import { view as auditLogView } from "../../page/security/audit-log/view" import * as DependenciesPage from "../../page/security/dependencies/model" import * as DependenciesUpdate from "../../page/security/dependencies/update" import { view as dependenciesView } from "../../page/security/dependencies/view" @@ -30,6 +33,7 @@ const Model = Schema.Struct({ run: Schema.NullOr(RunPage.Model), finding: Schema.NullOr(FindingPage.Model), telemetry: Schema.NullOr(TelemetryPage.Model), + auditLog: Schema.NullOr(AuditLogPage.Model), dependencies: Schema.NullOr(DependenciesPage.Model), }) type Model = typeof Model.Type @@ -39,6 +43,7 @@ const Message = defineMessageUnion({ GotRunMessage: { message: RunPage.Message }, GotFindingMessage: { message: FindingPage.Message }, GotTelemetryMessage: { message: TelemetryPage.Message }, + GotAuditLogMessage: { message: AuditLogPage.Message }, GotDependenciesMessage: { message: DependenciesPage.Message }, }) type Message = typeof Message.Type @@ -65,6 +70,7 @@ const init = (input: typeof Flags.Type) => { run: null, finding: null, telemetry: null, + auditLog: null, dependencies: null, } switch (built._tag) { @@ -96,6 +102,13 @@ const init = (input: typeof Flags.Type) => { Message.GotTelemetryMessage({ message }), ), } + case "AuditLog": + return { + model: { ...empty, auditLog: built.step.model }, + commands: Command.mapMessages(built.step.commands, (message) => + Message.GotAuditLogMessage({ message }), + ), + } case "Dependencies": return { model: { ...empty, dependencies: built.step.model }, @@ -148,6 +161,16 @@ const update = (model: Model, message: Message) => ), } }, + GotAuditLogMessage: ({ message: inner }) => { + if (model.auditLog === null) return { model } + const next = AuditLogUpdate.update(model.auditLog, inner, context) + return { + model: { ...model, auditLog: next.model }, + commands: Command.mapMessages(Arr.filter(next.commands ?? [], isLocalCommand), (child) => + Message.GotAuditLogMessage({ message: child }), + ), + } + }, GotDependenciesMessage: ({ message: inner }) => { if (model.dependencies === null) return { model } const next = DependenciesUpdate.update(model.dependencies, inner, context) @@ -201,7 +224,15 @@ const view = (model: Model, h: HtmlBuilder): Html => viewInputs: { context }, toParentMessage: (message) => Message.GotDependenciesMessage({ message }), }) - : h.empty + : model.auditLog !== null + ? h.submodel({ + slotId: "security-lab-audit-log", + model: model.auditLog, + view: auditLogView, + viewInputs: { context }, + toParentMessage: (message) => Message.GotAuditLogMessage({ message }), + }) + : h.empty export const lab = defineShellLab("Security Center", { Model, @@ -223,5 +254,7 @@ export const lab = defineShellLab("Security Center", { ? tabsCrumbs(model.telemetry.tab) : model.dependencies !== null ? tabsCrumbs("dependencies") - : [], + : model.auditLog !== null + ? tabsCrumbs("auditLog") + : [], }) diff --git a/apps/web/src/lab/security-audit-log-fixture.ts b/apps/web/src/lab/security-audit-log-fixture.ts new file mode 100644 index 0000000000..6870e1a1c6 --- /dev/null +++ b/apps/web/src/lab/security-audit-log-fixture.ts @@ -0,0 +1,156 @@ +import type { AuditTrailEntry, AuditTrailListResponse } from "@maple/domain/http" +import { Array as Arr, String as Str } from "effect" + +// Audit events for the Security hub's audit log lab: Maple's own changes and refusals over the +// last two days, newest first, the shapes the page has to draw. + +const MINUTE = 60_000 + +type Seed = Readonly<{ + minutesAgo: number + action: string + category: string + outcome?: string + actor: AuditTrailEntry["actor"] + target?: Partial + originIp?: string + originCountry?: string + raw?: Readonly> +}> + +const ada: AuditTrailEntry["actor"] = { + type: "user", + id: "user_2fj3K9dLqWm8xYbT", + name: "Ada Lovelace", + email: "ada@acme.test", +} +const grace: AuditTrailEntry["actor"] = { + type: "user", + id: "user_9aB2cD4eF6gH8iJ0", + name: "Grace Hopper", + email: "grace@acme.test", +} +const deployKey: AuditTrailEntry["actor"] = { + type: "service", + id: "key_4CzLmR1pTxWvYbNhQd82Kf", + name: "CI deploy key", + email: null, +} +const system: AuditTrailEntry["actor"] = { type: "system", id: null, name: null, email: null } + +const SEEDS: ReadonlyArray = [ + { + minutesAgo: 4, + action: "alert_rule.updated", + category: "configuration", + actor: ada, + target: { type: "alert_rule", id: "alrt_YofPTrK9782DWwcnXhpcCw" }, + originIp: "203.0.113.7", + originCountry: "DE", + raw: { + source: "dashboard", + changes: { fields: ["threshold"], before: { threshold: 5 }, after: { threshold: 2 } }, + requestId: "8f2c1a9d4b7e3f60", + }, + }, + { + minutesAgo: 19, + action: "api.request", + category: "authentication", + outcome: "denied", + actor: deployKey, + originIp: "198.51.100.24", + originCountry: "US", + raw: { source: "api", denialReason: "The key lacks the dashboards:write scope." }, + }, + { + minutesAgo: 47, + action: "api_key.created", + category: "credential", + actor: grace, + target: { type: "api_key", id: "key_7HnQ2wErTyUiOpAsDfGhJk" }, + originIp: "203.0.113.90", + originCountry: "NL", + raw: { source: "dashboard", metadata: { name: "Terraform", scopes: ["alerts:write"] } }, + }, + { + minutesAgo: 130, + action: "member.role_changed", + category: "membership", + actor: system, + target: { type: "member", id: "user_9aB2cD4eF6gH8iJ0" }, + raw: { source: "system", affectedUserId: "user_9aB2cD4eF6gH8iJ0", metadata: { role: "admin" } }, + }, + { + minutesAgo: 260, + action: "dashboard_share.created", + category: "permission", + actor: ada, + target: { type: "dashboard_share", id: "dash_3KpLmN5oQrStUvWxYz12Ab" }, + originIp: "203.0.113.7", + originCountry: "DE", + raw: { source: "dashboard" }, + }, + { + minutesAgo: 24 * 60 + 12, + action: "status_page.break_glass_rotated", + category: "credential", + actor: grace, + target: { type: "status_page", id: "spg_5TgBnHy6UjMki8OlP0qWeR" }, + originIp: "203.0.113.90", + originCountry: "NL", + raw: { source: "dashboard" }, + }, + { + minutesAgo: 24 * 60 + 95, + action: "telemetry.sql_executed", + category: "data_access", + outcome: "denied", + actor: { type: "service", id: "actor_8UjMki9OlP0qWeRtY1uI2o", name: "Triage agent", email: null }, + target: { type: "telemetry" }, + raw: { source: "mcp", denialReason: "The query reads a table this agent may not." }, + }, + { + minutesAgo: 24 * 60 + 300, + action: "warehouse_settings.updated", + category: "configuration", + actor: ada, + target: { type: "warehouse_settings" }, + originIp: "203.0.113.7", + originCountry: "DE", + raw: { source: "dashboard", changes: { fields: ["host"], before: {}, after: {} } }, + }, +] + +const eventOf = (seed: Seed, index: number, nowMs: number): AuditTrailEntry => { + const occurredAt = nowMs - seed.minutesAgo * MINUTE + return { + id: Str.padStart(32, "0")(index.toString(16)), + provider: "maple", + account: null, + providerEventId: `9d2c1e3a-6a1b-4f0e-9c1d-${Str.padStart(12, "0")(index.toString(16))}`, + occurredAt, + ingestedAt: occurredAt + 900, + actor: seed.actor, + action: seed.action, + category: seed.category, + outcome: seed.outcome ?? "success", + target: { type: null, id: null, name: null, ...seed.target }, + originIp: seed.originIp ?? null, + originCountry: seed.originCountry ?? null, + userAgent: null, + raw: seed.raw ?? null, + } +} + +/** One page of events ending at `nowMs`; `hasMore` leaves a cursor for the footer. */ +export const auditEventsOf = (nowMs: number, hasMore = true): AuditTrailListResponse => { + const events = Arr.map(SEEDS, (seed, index) => eventOf(seed, index, nowMs)) + const last = Arr.last(events) + return { + events, + nextCursor: hasMore && last._tag === "Some" ? `${last.value.occurredAt}:${last.value.id}` : null, + } +} + +export const noAuditEvents: AuditTrailListResponse = { events: [], nextCursor: null } diff --git a/apps/web/src/lab/security-lab/scenarios.ts b/apps/web/src/lab/security-lab/scenarios.ts index fb0f9865a3..ae544f79c7 100644 --- a/apps/web/src/lab/security-lab/scenarios.ts +++ b/apps/web/src/lab/security-lab/scenarios.ts @@ -5,12 +5,13 @@ import { type DependencyFindingsResponse, type DependencyScanRepositoriesResponse, SecurityAuditDisabledError, + AuditTrailPersistenceError, SecurityAuditForbiddenError, SecurityAuditTargetExistsError, } from "@maple/domain/http" import { SecurityAuditRunId, SecurityAuditTargetId } from "@maple/domain/security-audit" import { Result, Schema, Array as Arr, Record as Rec, String as Str } from "effect" -import type { Command } from "foldkit" +import { AsyncData, type Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../page/contract" import * as AddTarget from "../../page/security/kit/add-target" @@ -22,6 +23,8 @@ import * as RunPage from "../../page/security/run/model" import * as RunUpdate from "../../page/security/run/update" import * as TabsPage from "../../page/security/tabs/model" import * as TabsUpdate from "../../page/security/tabs/update" +import * as AuditLogPage from "../../page/security/audit-log/model" +import * as AuditLogUpdate from "../../page/security/audit-log/update" import * as TelemetryPage from "../../page/security/telemetry/model" import * as TelemetryUpdate from "../../page/security/telemetry/update" import type { Resources } from "../../platform/resources" @@ -59,6 +62,7 @@ import { repositoriesOf, } from "../security-dependencies-fixture" import { emptyExposureOf, exposureOf, runtimesOf } from "../security-telemetry-fixture" +import { auditEventsOf, noAuditEvents } from "../security-audit-log-fixture" // Every Security Center state the lab renders, built by sending each page the fixture answers its // own requests would get. API Commands are dropped; dialog and select Commands still run. @@ -87,6 +91,12 @@ export const SCENARIOS = [ "finding-lead", "exposure", "exposure-empty", + "audit-log", + "audit-log-detail", + "audit-log-empty", + "audit-log-filtered-empty", + "audit-log-member", + "audit-log-error", "runtimes", "dependencies", "dependencies-finding", @@ -329,6 +339,38 @@ const finding = ( ) } +type AuditLogStep = Step +const A = AuditLogPage.Message + +/** The audit log entered under `search`, as an admin or a member, then sent `answers`. */ +const auditLog = ( + nowMs: number, + options: Readonly<{ + search?: AuditLogPage.Search + isMember?: boolean + answers?: ReadonlyArray<(model: AuditLogPage.Model) => AuditLogPage.Message> + }> = {}, +): AuditLogStep => { + const entered = AuditLogUpdate.init(AppRoute.SecurityAuditLog({ search: options.search ?? {} }), context) + return fold( + { model: entered.model, commands: entered.commands ?? [] }, + (model, message) => AuditLogUpdate.update(model, message, context), + [ + () => A.ReadClock({ nowMs }), + () => + A.CompletedReadGrants({ + orgId: context.orgId, + grants: + options.isMember === true ? Policy.grantsOf(["org:engineer"]) : Policy.allPermissions, + }), + ...(options.answers ?? []), + ], + ) +} + +const auditLogPage = (page: AuditLogPage.EventsPage) => (model: AuditLogPage.Model) => + A.CompletedFetchFirstPage({ key: model.firstPage.key ?? "", result: Result.succeed(page) }) + type TelemetryStep = Step const T = TelemetryPage.Message @@ -404,6 +446,7 @@ export type Built = | Readonly<{ _tag: "Run"; step: RunStep }> | Readonly<{ _tag: "Finding"; step: FindingStep }> | Readonly<{ _tag: "Telemetry"; step: TelemetryStep }> + | Readonly<{ _tag: "AuditLog"; step: AuditLogStep }> | Readonly<{ _tag: "Dependencies"; step: DependenciesStep }> const overviewRoute = AppRoute.SecurityCode() @@ -524,6 +567,59 @@ export const build = (scenario: Scenario, nowMs: number): Built => { return { _tag: "Telemetry", step: exposure(nowMs, exposureOf(nowMs)) } case "exposure-empty": return { _tag: "Telemetry", step: exposure(nowMs, emptyExposureOf()) } + case "audit-log": + return { + _tag: "AuditLog", + step: auditLog(nowMs, { answers: [auditLogPage(auditEventsOf(nowMs))] }), + } + case "audit-log-detail": + return { + _tag: "AuditLog", + step: auditLog(nowMs, { + answers: [ + auditLogPage(auditEventsOf(nowMs, false)), + (model) => + A.ToggledEvent({ + id: AsyncData.matchData(model.firstPage.data, { + onEmpty: () => "", + onFailure: () => "", + onData: (page) => page.events[0]?.id ?? "", + }), + }), + ], + }), + } + case "audit-log-empty": + return { _tag: "AuditLog", step: auditLog(nowMs, { answers: [auditLogPage(noAuditEvents)] }) } + case "audit-log-filtered-empty": + return { + _tag: "AuditLog", + step: auditLog(nowMs, { + search: { category: "billing", outcome: "denied" }, + answers: [auditLogPage(noAuditEvents)], + }), + } + case "audit-log-member": + return { _tag: "AuditLog", step: auditLog(nowMs, { isMember: true }) } + case "audit-log-error": + return { + _tag: "AuditLog", + step: auditLog(nowMs, { + answers: [ + (model) => + A.CompletedFetchFirstPage({ + key: model.firstPage.key ?? "", + result: Result.fail( + toLoadFailure( + new AuditTrailPersistenceError({ + message: "The audit trail could not be read", + }), + ), + ), + }), + ], + }), + } case "runtimes": return { _tag: "Telemetry", diff --git a/apps/web/src/page/kit/json-block.ts b/apps/web/src/page/kit/json-block.ts new file mode 100644 index 0000000000..6fa10e71b1 --- /dev/null +++ b/apps/web/src/page/kit/json-block.ts @@ -0,0 +1,16 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" + +/** A JSON document or other preformatted text in a scrolling well (an audit entry's payload). */ +export const jsonBlock = (h: HtmlBuilder, text: string, className?: string): Html => + h.pre( + [ + h.Class( + cn( + "bg-surface-well max-h-64 overflow-auto rounded-md border px-2.5 py-2 font-mono text-2xs leading-relaxed whitespace-pre-wrap wrap-anywhere", + className, + ), + ), + ], + [text], + ) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 71d0954ba4..091677ad4a 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -38,6 +38,7 @@ import * as DashboardTemplates from "./dashboards/templates" import * as DashboardView from "./dashboards/view" import * as DashboardWidget from "./dashboards/widget" import * as CodeReview from "./code-review" +import * as SecurityAuditLog from "./security/audit-log" import * as SecurityDependencies from "./security/dependencies" import * as SecurityFinding from "./security/finding" import * as SecurityRun from "./security/run" @@ -173,6 +174,7 @@ export const pages = [ SecurityTabs.page, SecurityTelemetry.page, SecurityDependencies.page, + SecurityAuditLog.page, SecurityRun.page, SecurityFinding.page, AgentSessionsList.page, diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts new file mode 100644 index 0000000000..38ee2cea60 --- /dev/null +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -0,0 +1,216 @@ +import { OrgId } from "@maple/domain" +import { AuditTrailPersistenceError } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr, String as Str } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { auditEventsOf, noAuditEvents } from "../../../lab/security-audit-log-fixture" +import { AppRoute, hrefOf } from "../../../route" +import type { PageContext } from "../../contract" +import { eventsOf } from "./derive" +import { type EventsPage, Message, type Model, type Search } from "./model" +import { init, routeChanged, update } from "./update" +import { view as pageView } from "./view" + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const NOW = Date.UTC(2026, 9, 31, 14, 32) +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const routeOf = (search: Search = {}) => AppRoute.SecurityAuditLog({ search }) +const ADMIN = Policy.allPermissions +const ENGINEER = Policy.grantsOf(["org:engineer"]) + +const fold = (model: Model, messages: ReadonlyArray<(model: Model) => Message>): Model => + Arr.reduce(messages, model, (current, toMessage) => pageUpdate(current, toMessage(current)).model) + +const grantsRead = (grants: ReadonlyArray) => () => + Message.CompletedReadGrants({ orgId: context.orgId, grants: [...grants] }) + +const firstPage = (page: EventsPage) => (model: Model) => + Message.CompletedFetchFirstPage({ key: model.firstPage.key ?? "", result: Result.succeed(page) }) + +/** The page as an admin sees it once the first page answered. */ +const loaded = (page: EventsPage = auditEventsOf(NOW), search: Search = {}): Model => + fold(init(routeOf(search), context).model, [ + () => Message.ReadClock({ nowMs: NOW }), + grantsRead(ADMIN), + firstPage(page), + ]) + +const nextPage = (model: Model, page: EventsPage, generation = model.laterPages.generation) => + Message.CompletedFetchNextPage({ + key: model.laterPages.key ?? "", + generation, + result: Result.succeed(page), + }) + +/** A second page: the same events a day older, under other ids. */ +const olderPage = (hasMore: boolean): EventsPage => { + const page = auditEventsOf(NOW - 3 * 24 * 3600 * 1000, hasMore) + return { + ...page, + events: Arr.map(page.events, (event) => ({ ...event, id: `f${Str.slice(1)(event.id)}` })), + } +} + +describe("security audit log", () => { + test("an engineer is told the log is for admins and nothing is asked of the API", () => { + const entered = init(routeOf(), context) + const answered = pageUpdate(entered.model, grantsRead(ENGINEER)()) + + expect(names(answered.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(answered.model), + expectView(text("Only org admins can read the audit log.")).toExist(), + expectView(role("table", { name: "Audit events" })).toBeAbsent(), + ) + }) + + test("an admin's first page is asked for once, whichever of the session and the page comes first", () => { + const entered = init(routeOf(), context) + const answered = pageUpdate(entered.model, grantsRead(ADMIN)()) + + expect( + Arr.filter( + [...names(entered.commands), ...names(answered.commands)], + (name) => name === "SecurityApiAuditLogFirstPage", + ), + ).toHaveLength(1) + }) + + test("lists who did what, marks a refusal and groups the events by day", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(role("table", { name: "Audit events" })).toExist(), + expectView(text("Ada Lovelace")).toExist(), + expectView(text("alert_rule.updated")).toExist(), + expectView(text("Denied")).toExist(), + expectView(text("Today")).toExist(), + expectView(role("button", { name: "Load more" })).toExist(), + ) + }) + + test("opening an event shows what the platform recorded about it", () => { + const model = loaded() + const first = eventsOf(model)?.[0] + const opened = pageUpdate(model, Message.ToggledEvent({ id: first?.id ?? "" })).model + + scene( + { update: pageUpdate, view }, + given(opened), + expectView(text("Payload")).toExist(), + expectView(text(first?.providerEventId ?? "")).toExist(), + expectView(text("203.0.113.7")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(pageUpdate(opened, Message.ToggledEvent({ id: first?.id ?? "" })).model), + expectView(text("Payload")).toBeAbsent(), + ) + }) + + test("a filter change goes to the URL, and choosing All drops it", () => { + const denied = pageUpdate(loaded(), Message.ChangedFilter({ filter: "outcome", value: "denied" })) + expect(denied.outMessage).toMatchObject({ url: hrefOf(routeOf({ outcome: "denied" })) }) + + const cleared = pageUpdate( + loaded(auditEventsOf(NOW), { outcome: "denied", category: "credential" }), + Message.ChangedFilter({ filter: "outcome", value: "" }), + ) + expect(cleared.outMessage).toMatchObject({ url: hrefOf(routeOf({ category: "credential" })) }) + }) + + test("the actor filter applies on Enter, trimmed, and not while typing", () => { + const typed = pageUpdate(loaded(), Message.ChangedActorText({ value: " ada@acme.test " })) + expect(typed.outMessage).toBeUndefined() + + const submitted = pageUpdate(typed.model, Message.SubmittedActor()) + expect(submitted.outMessage).toMatchObject({ url: hrefOf(routeOf({ actor: "ada@acme.test" })) }) + }) + + test("new filters in the URL read the list again and start the later pages over", () => { + const model = loaded() + const withMore = fold(pageUpdate(model, Message.ClickedLoadMore()).model, [ + (current) => nextPage(current, olderPage(true)), + ]) + const changed = routeChanged(withMore, routeOf({ outcome: "denied" }), context) + + expect(names(changed.commands)).toEqual(["SecurityApiAuditLogFirstPage"]) + expect(changed.model.laterPages.pages).toEqual([]) + }) + + test("load more continues from the last event shown and appends what comes back", () => { + const model = loaded() + const asked = pageUpdate(model, Message.ClickedLoadMore()) + const last = Arr.last(auditEventsOf(NOW).events) + + expect(asked.commands?.[0]).toMatchObject({ + name: "SecurityApiAuditLogNextPage", + args: { cursor: last._tag === "Some" ? `${last.value.occurredAt}:${last.value.id}` : "" }, + }) + const appended = pageUpdate(asked.model, nextPage(asked.model, olderPage(false))).model + expect(eventsOf(appended)).toHaveLength(16) + scene( + { update: pageUpdate, view }, + given(appended), + expectView(role("button", { name: "Load more" })).toBeAbsent(), + ) + }) + + test("a page that answers after the filters changed is dropped", () => { + const asked = pageUpdate(loaded(), Message.ClickedLoadMore()).model + const stale = pageUpdate( + asked, + nextPage(asked, olderPage(true), asked.laterPages.generation - 1), + ).model + + expect(eventsOf(stale)).toHaveLength(8) + }) + + test("a failed next page keeps the events shown and offers to try again", () => { + const asked = pageUpdate(loaded(), Message.ClickedLoadMore()).model + const failed = pageUpdate( + asked, + Message.CompletedFetchNextPage({ + key: asked.laterPages.key ?? "", + generation: asked.laterPages.generation, + result: Result.fail( + toLoadFailure( + new AuditTrailPersistenceError({ message: "The audit trail could not be read" }), + ), + ), + }), + ).model + + expect(eventsOf(failed)).toHaveLength(8) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(role("button", { name: "Try again" })).toExist(), + ) + }) + + test("an empty log says what will show up; an empty filter result offers to clear the filters", () => { + scene( + { update: pageUpdate, view }, + given(loaded(noAuditEvents)), + expectView(text("No audit events yet")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(noAuditEvents, { outcome: "denied" })), + expectView(text("No audit events yet")).toBeAbsent(), + expectView(role("button", { name: "Clear filters" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/security/audit-log/command.ts b/apps/web/src/page/security/audit-log/command.ts new file mode 100644 index 0000000000..bd24fa3f5d --- /dev/null +++ b/apps/web/src/page/security/audit-log/command.ts @@ -0,0 +1,57 @@ +import { readClock } from "@maple/views/data/timing" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { settleTo } from "@maple/views/data/load-failure" +import { MapleInternalApiClient } from "../../../platform/api-clients" +import { SecurityAuditLogSearch } from "../../../route/search" +import { fetchSessionGrants } from "../../kit/session-grants" +import { Message } from "./model" + +// The audit log's reads on the api Worker. Names start with `SecurityApi` (and the clock's and +// session's with `ReadSecurity`) so the lab drops them. + +export const PAGE_LIMIT = 50 + +export const ReadAuditLogClock = readClock("ReadSecurityAuditLogClock", Message.ReadClock) + +export const ReadAuditLogGrants = fetchSessionGrants( + "ReadSecurityAuditLogGrants", + Message.CompletedReadGrants, +) + +const listEvents = (search: SecurityAuditLogSearch, cursor: string | undefined) => + Effect.flatMap(MapleInternalApiClient, (api) => + api.auditTrail.list({ + query: { + ...(search.provider !== undefined ? { provider: search.provider } : undefined), + ...(search.category !== undefined ? { category: search.category } : undefined), + ...(search.outcome !== undefined ? { outcome: search.outcome } : undefined), + ...(search.actor !== undefined ? { actor: search.actor } : undefined), + ...(cursor !== undefined ? { cursor } : undefined), + limit: PAGE_LIMIT, + }, + }), + ) + +export const FetchFirstPage = Command.define("SecurityApiAuditLogFirstPage", { + args: { key: Schema.String, search: SecurityAuditLogSearch }, + messages: [Message.CompletedFetchFirstPage], + execute: ({ key, search }) => + listEvents(search, undefined).pipe( + settleTo((result) => Message.CompletedFetchFirstPage({ key, result })), + ), +}) + +export const FetchNextPage = Command.define("SecurityApiAuditLogNextPage", { + args: { + key: Schema.String, + generation: Schema.Number, + search: SecurityAuditLogSearch, + cursor: Schema.String, + }, + messages: [Message.CompletedFetchNextPage], + execute: ({ key, generation, search, cursor }) => + listEvents(search, cursor).pipe( + settleTo((result) => Message.CompletedFetchNextPage({ key, generation, result })), + ), +}) diff --git a/apps/web/src/page/security/audit-log/derive.ts b/apps/web/src/page/security/audit-log/derive.ts new file mode 100644 index 0000000000..5a58f6c86b --- /dev/null +++ b/apps/web/src/page/security/audit-log/derive.ts @@ -0,0 +1,82 @@ +import type { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import type { AuditTrailEntry } from "@maple/domain/http" +import { Array as Arr, Record as Rec } from "effect" +import { AsyncData } from "foldkit" +import type { Model, Search } from "./model" + +// What the audit log shows of an event: the names of platforms, categories and outcomes, who +// acted and on what. A value this build does not know shows as itself. + +export const PROVIDER_LABEL = { maple: "Maple" } satisfies Record + +export const CATEGORY_LABEL = { + authentication: "Authentication", + membership: "Membership", + permission: "Permissions", + credential: "Credentials", + configuration: "Configuration", + resource: "Resources", + data_access: "Data access", + billing: "Billing", + other: "Other", +} satisfies Record + +export const OUTCOME_LABEL = { + success: "Succeeded", + failure: "Failed", + denied: "Denied", + unknown: "Unknown", +} satisfies Record + +const labelOf = + (labels: Readonly>) => + (value: string): string => + Rec.get(labels, value).pipe((label) => (label._tag === "Some" ? label.value : value)) + +export const providerLabel = labelOf(PROVIDER_LABEL) +export const categoryLabel = labelOf(CATEGORY_LABEL) +export const outcomeLabel = labelOf(OUTCOME_LABEL) + +const ACTOR_TYPE_LABEL = { + service: "Service", + system: "System", + unknown: "Unknown actor", +} as const + +/** Who acted: their name, else email, else id, else what kind of actor it was. */ +export const actorText = (event: AuditTrailEntry): string => + event.actor.name ?? event.actor.email ?? event.actor.id ?? labelOf(ACTOR_TYPE_LABEL)(event.actor.type) + +/** A second line under the actor when the first is a name: the email or id that tells two apart. */ +export const actorDetail = (event: AuditTrailEntry): string | null => + event.actor.name === null ? null : (event.actor.email ?? event.actor.id) + +/** What was acted on: its name or id, null when the event names nothing. */ +export const targetText = (event: AuditTrailEntry): string | null => event.target.name ?? event.target.id + +/** An outcome worth a mark in the list: a success is the unmarked case. */ +export const isRefused = (event: AuditTrailEntry): boolean => + event.outcome === "denied" || event.outcome === "failure" + +export const isFiltered = (search: Search): boolean => + search.provider !== undefined || + search.category !== undefined || + search.outcome !== undefined || + search.actor !== undefined + +/** Every event loaded so far, newest first; null until the first page answers. */ +export const eventsOf = ( + model: Pick, +): ReadonlyArray | null => + AsyncData.matchData(model.firstPage.data, { + onEmpty: () => null, + onFailure: () => null, + onData: (first) => { + // A page boundary can repeat an event the table had not merged when the page was read. + const seen = new Set() + return Arr.filter( + [...first.events, ...Arr.flatMap(model.laterPages.pages, (page) => page.events)], + (event) => !seen.has(event.id) && seen.add(event.id) !== undefined, + ) + }, + }) diff --git a/apps/web/src/page/security/audit-log/index.ts b/apps/web/src/page/security/audit-log/index.ts new file mode 100644 index 0000000000..7ef02f7481 --- /dev/null +++ b/apps/web/src/page/security/audit-log/index.ts @@ -0,0 +1,20 @@ +import { definePage } from "../../contract" +import { breadcrumbsOf } from "../kit/layout" +import { Message, Model } from "./model" +import { init, routeChanged, update } from "./update" +import { view } from "./view" + +/** `/security/audit-log`, in the Security frame: every platform's audit events in one list, for org admins. */ +export const page = definePage( + "SecurityAuditLog", + { Model, Message }, + { + routes: ["SecurityAuditLog"], + key: () => "security-audit-log", + init, + update, + routeChanged, + view, + breadcrumbs: () => breadcrumbsOf("auditLog"), + }, +) diff --git a/apps/web/src/page/security/audit-log/model.ts b/apps/web/src/page/security/audit-log/model.ts new file mode 100644 index 0000000000..ea5f158cb1 --- /dev/null +++ b/apps/web/src/page/security/audit-log/model.ts @@ -0,0 +1,66 @@ +import { AuditTrailListResponse } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import { SecurityAuditLogSearch } from "../../../route/search" +import { laterPagesFields } from "../../kit/infinite-pages" +import { SessionGrants } from "../../kit/session-grants" + +// `/security/audit-log`: audit events from every connected platform and Maple itself, newest +// first. The filters live in the URL; only org admins (`audit_log:read`) read the list. + +export type Search = SecurityAuditLogSearch + +/** One page of events and the cursor of the next. */ +export const EventsPage = AuditTrailListResponse +export type EventsPage = typeof EventsPage.Type + +/** The pages after the first, for the current first-page key. */ +export const LaterPages = Schema.Struct({ + key: Schema.NullOr(Schema.String), + ...laterPagesFields(EventsPage), +}) +export type LaterPages = typeof LaterPages.Type + +/** The filters a select sets; the actor is typed and applied on Enter. */ +export const SelectFilter = Schema.Literals(["provider", "category", "outcome"]) +export type SelectFilter = typeof SelectFilter.Type + +export const Model = Schema.Struct({ + search: SecurityAuditLogSearch, + nowMs: Schema.Finite, + grants: Schema.Array(Policy.PermissionName), + /** False until the session answers: the page says nothing about access before it knows. */ + isGrantsRead: Schema.Boolean, + firstPage: KeyedSchema(EventsPage, LoadFailure), + laterPages: LaterPages, + /** The events whose detail is open, by id. */ + expanded: Schema.Array(Schema.String), + /** The actor field as typed; the URL holds what was applied. */ + actorText: Schema.String, +}) +export type Model = typeof Model.Type + +export const canRead = (model: Pick): boolean => Policy.has(model.grants, "audit_log:read") + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Finite }, + CompletedReadGrants: SessionGrants, + CompletedFetchFirstPage: { key: Schema.String, result: Schema.Result(EventsPage, LoadFailure) }, + CompletedFetchNextPage: { + key: Schema.String, + generation: Schema.Number, + result: Schema.Result(EventsPage, LoadFailure), + }, + ClickedRetry: {}, + ClickedLoadMore: {}, + ChangedFilter: { filter: SelectFilter, value: Schema.String }, + ChangedActorText: { value: Schema.String }, + SubmittedActor: {}, + ClearedActor: {}, + ClickedClearFilters: {}, + ToggledEvent: { id: Schema.String }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/security/audit-log/update.ts b/apps/web/src/page/security/audit-log/update.ts new file mode 100644 index 0000000000..a81e6c14ea --- /dev/null +++ b/apps/web/src/page/security/audit-log/update.ts @@ -0,0 +1,158 @@ +import * as Policy from "@maple/domain/policy" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import { Array as Arr, Option, String as Str } from "effect" +import { AsyncData } from "foldkit" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" +import { AppRoute, hrefOf } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { appendedPage, fetchingNext, freshLaterPages } from "../../kit/infinite-pages" +import { chain, slotStep } from "../../kit/keyed-step" +import { acceptGrants } from "../../kit/session-grants" +import { FetchFirstPage, FetchNextPage, ReadAuditLogClock, ReadAuditLogGrants } from "./command" +import { canRead, type LaterPages, Message, type Model, type Search } from "./model" + +type Return = PageReturn + +export type SecurityAuditLogRoute = Extract + +const keyOf = (context: PageContext, search: Search) => requestKey(context.orgId, "auditTrail.list", search) + +const freshPages = (key: string | null, generation: number): LaterPages => ({ + key, + ...freshLaterPages(generation), +}) + +/** + * The first page under the URL's filters, once the viewer is known to hold `audit_log:read`. + * New filters, or a refresh, start the later pages over. + */ +const readFirstPage = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { + if (!canRead(model)) return { model } + const key = keyOf(context, model.search) + const laterPages = + model.laterPages.key === key && mode === "Load" + ? model.laterPages + : freshPages(key, model.laterPages.generation + 1) + return slotStep( + { ...model, laterPages }, + model.firstPage, + (firstPage) => ({ firstPage }), + key, + mode, + (next) => FetchFirstPage({ key: next, search: model.search }), + ) +} + +/** The cursor after everything loaded so far, or null at the end of the list. */ +export const nextCursorOf = (model: Pick): string | null => + Option.match(Arr.last(model.laterPages.pages), { + onNone: () => + AsyncData.matchData(model.firstPage.data, { + onEmpty: () => null, + onFailure: () => null, + onData: (page) => page.nextCursor, + }), + onSome: (page) => page.nextCursor, + }) + +const fetchNext = (model: Model): Return => { + const cursor = nextCursorOf(model) + const { laterPages } = model + if (cursor === null || laterPages.key === null || laterPages.isFetching) return { model } + return { + model: { ...model, laterPages: fetchingNext(laterPages) }, + commands: [ + FetchNextPage({ + key: laterPages.key, + generation: laterPages.generation, + search: model.search, + cursor, + }), + ], + } +} + +const navigate = (model: Model, search: Search): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: hrefOf(AppRoute.SecurityAuditLog({ search })), + isReplace: false, + }), +}) + +/** `search` with one filter set, or dropped when the value is empty. */ +const withFilter = (search: Search, filter: keyof Search, value: string): Search => { + const { [filter]: _dropped, ...rest } = search + return value === "" ? rest : { ...rest, [filter]: value } +} + +export const init = (route: SecurityAuditLogRoute, context: PageContext): Return => { + const model: Model = { + search: route.search, + nowMs: 0, + grants: isClerkAuthEnabled ? [] : Policy.allPermissions, + isGrantsRead: !isClerkAuthEnabled, + firstPage: Keyed.idle(), + laterPages: freshPages(null, 0), + expanded: [], + actorText: route.search.actor ?? "", + } + const entered: Return = { + model, + commands: [ + ReadAuditLogClock(), + ...(isClerkAuthEnabled ? [ReadAuditLogGrants({ orgId: context.orgId })] : []), + ], + } + return chain(entered, (next) => readFirstPage(next, context, "Load")) +} + +/** Same instance, other filters: the URL is the filter state, so the list follows it. */ +export const routeChanged = (model: Model, route: SecurityAuditLogRoute, context: PageContext): Return => + readFirstPage( + { ...model, search: route.search, actorText: route.search.actor ?? "", expanded: [] }, + context, + "Load", + ) + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedReadGrants: (answer) => + readFirstPage( + acceptGrants(model, context.orgId, answer, { isGrantsRead: true }), + context, + "Load", + ), + CompletedFetchFirstPage: ({ key, result }) => ({ + model: { ...model, firstPage: Keyed.accept(model.firstPage, key, result) }, + }), + CompletedFetchNextPage: ({ key, generation, result }) => + key !== model.laterPages.key || + generation !== model.laterPages.generation || + !model.laterPages.isFetching + ? { model } + : { model: { ...model, laterPages: appendedPage(model.laterPages, result) } }, + ClickedRetry: () => + chain({ model, commands: [ReadAuditLogClock()] }, (next) => + readFirstPage(next, context, "Refresh"), + ), + ClickedLoadMore: () => fetchNext(model), + ChangedFilter: ({ filter, value }) => navigate(model, withFilter(model.search, filter, value)), + ChangedActorText: ({ value }) => ({ model: { ...model, actorText: value } }), + SubmittedActor: () => navigate(model, withFilter(model.search, "actor", Str.trim(model.actorText))), + ClearedActor: () => + model.search.actor === undefined + ? { model: { ...model, actorText: "" } } + : navigate({ ...model, actorText: "" }, withFilter(model.search, "actor", "")), + ClickedClearFilters: () => navigate({ ...model, actorText: "" }, {}), + ToggledEvent: ({ id }) => ({ + model: { + ...model, + expanded: Arr.contains(model.expanded, id) + ? Arr.filter(model.expanded, (open) => open !== id) + : [...model.expanded, id], + }, + }), + }) diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts new file mode 100644 index 0000000000..b28298064e --- /dev/null +++ b/apps/web/src/page/security/audit-log/view.ts @@ -0,0 +1,442 @@ +import { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import type { AuditTrailEntry } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { cn } from "@maple/ui/lib/utils" +import { Array as Arr, DateTime, Schema } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { compactSearchInput } from "@maple/views/ui/compact-search-input" +import { badge } from "@maple/views/ui/display" +import { + empty, + emptyDescription, + emptyFrameClass, + emptyHeader, + emptyMedia, + emptyTitle, +} from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { icon } from "@maple/views/ui/icon" +import { keyValue, keyValueList } from "@maple/views/ui/key-value" +import { listFooter } from "@maple/views/ui/list-footer" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { panel } from "@maple/views/ui/panel" +import { pressable } from "@maple/views/ui/pressable" +import { filteredEmpty } from "@maple/views/ui/signal-empty-state" +import { hstack, vstack } from "@maple/views/ui/stack" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" +import { timestamp } from "@maple/views/ui/timestamp" +import { typography } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dayKey, dayLabel } from "../../kit/day-label" +import { jsonBlock } from "../../kit/json-block" +import { relativeTime } from "../../kit/relative-time" +import { securityLayout, WIDE_COLUMN } from "../kit/layout" +import { placeholder } from "../kit/parts" +import { + actorDetail, + actorText, + CATEGORY_LABEL, + categoryLabel, + eventsOf, + isFiltered, + isRefused, + OUTCOME_LABEL, + outcomeLabel, + PROVIDER_LABEL, + providerLabel, + targetText, +} from "./derive" +import { canRead, Message, type Model, type SelectFilter } from "./model" +import { nextCursorOf } from "./update" + +type H = HtmlBuilder +type Clock = Readonly<{ nowMs: number; timeZone: string }> + +const COLUMN_COUNT = 6 + +// On a phone the two text columns share the width left of the time, so neither scrolls away. +const NARROW_ACTOR = "max-w-32 sm:max-w-56" +const NARROW_ACTION = "max-w-36 sm:max-w-72" + +const encodePrettyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })) + +/** One filter as a native select: "All …" clears it. Keyed on its value so a URL change shows. */ +const filterSelect = ( + h: H, + filter: SelectFilter, + value: string | undefined, + all: string, + labels: Readonly>, + values: ReadonlyArray, +): Html => + nativeSelect( + h, + { + size: "sm", + defaultValue: value ?? "", + attributes: [ + h.Key(`${filter}:${value ?? ""}`), + h.AriaLabel(all), + h.OnChange((next) => Message.ChangedFilter({ filter, value: next })), + ], + }, + [ + nativeOption("", all), + ...Arr.map(values, (option) => nativeOption(option, labels[option] ?? option)), + ], + ) + +const toolbar = (h: H, model: Model): Html => + hstack(h, { gap: "2", align: "center", wrap: true, className: "border-b px-4 py-2.5" }, [ + // One platform has nothing to choose between; the select appears with the second. + ...(AuditTrailProvider.literals.length > 1 + ? [ + filterSelect( + h, + "provider", + model.search.provider, + "All platforms", + PROVIDER_LABEL, + AuditTrailProvider.literals, + ), + ] + : []), + filterSelect( + h, + "category", + model.search.category, + "All categories", + CATEGORY_LABEL, + AuditTrailCategory.literals, + ), + filterSelect( + h, + "outcome", + model.search.outcome, + "All outcomes", + OUTCOME_LABEL, + AuditTrailOutcome.literals, + ), + compactSearchInput(h, { + value: model.actorText, + placeholder: "Actor id or email", + ariaLabel: "Filter by actor", + onInput: (value) => Message.ChangedActorText({ value }), + onClear: Message.ClearedActor(), + onEnter: Message.SubmittedActor(), + className: "w-56", + }), + ...(isFiltered(model.search) + ? [ + button( + h, + { + variant: "ghost", + size: "sm", + attributes: [h.OnClick(Message.ClickedClearFilters())], + }, + ["Clear filters"], + ), + ] + : []), + ]) + +const outcomeBadge = (h: H, event: AuditTrailEntry): ReadonlyArray => + isRefused(event) + ? [ + badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, [ + icon(h, "CircleXmarkIcon"), + outcomeLabel(event.outcome), + ]), + ] + : [] + +const detailField = (h: H, label: string, value: string | null, mono = false): ReadonlyArray => + value === null ? [] : [keyValue(h, { label, wrap: true, mono }, [value])] + +/** Everything the platform said about one event, under its row. */ +const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => + vstack(h, { gap: "3", className: "px-4 py-3" }, [ + keyValueList(h, { layout: "grid" }, [ + keyValue(h, { label: "Happened", wrap: true }, [ + timestamp(h, { + value: isoOf(event.occurredAt), + ...clock, + withYear: true, + className: "tabular-nums", + }), + ]), + keyValue(h, { label: "Recorded", wrap: true }, [ + timestamp(h, { + value: isoOf(event.ingestedAt), + ...clock, + withYear: true, + className: "tabular-nums", + }), + ]), + keyValue(h, { label: "Platform", wrap: true }, [providerLabel(event.provider)]), + ...detailField(h, "Account", event.account), + keyValue(h, { label: "Category", wrap: true }, [categoryLabel(event.category)]), + keyValue(h, { label: "Outcome", wrap: true }, [outcomeLabel(event.outcome)]), + ...detailField(h, "Actor id", event.actor.id, true), + ...detailField(h, "Actor email", event.actor.email), + ...detailField(h, "Target type", event.target.type, true), + ...detailField(h, "Target id", event.target.id, true), + ...detailField(h, "Origin IP", event.originIp, true), + ...detailField(h, "Origin country", event.originCountry), + ...detailField(h, "User agent", event.userAgent, true), + keyValue(h, { label: "Event id", wrap: true, mono: true }, [event.providerEventId]), + ]), + ...(event.raw === null + ? [] + : [ + vstack(h, { gap: "1.5" }, [ + eyebrow(h, { variant: "mono" }, ["Payload"]), + jsonBlock(h, encodePrettyJson(event.raw)), + ]), + ]), + ]) + +const isoOf = (epochMs: number): string => DateTime.formatIso(DateTime.makeUnsafe(epochMs)) + +const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): ReadonlyArray => { + const isExpanded = Arr.contains(model.expanded, event.id) + const detailId = `audit-event-${event.id}` + const actor = actorDetail(event) + const target = targetText(event) + return [ + tableRow( + h, + [ + tableCell(h, [ + pressable( + h, + { + onClick: Message.ToggledEvent({ id: event.id }), + label: `${isExpanded ? "Hide" : "Show"} details: ${event.action}`, + className: + "focus-visible:ring-ring rounded-sm focus-visible:ring-1 focus-visible:outline-none", + attributes: [h.AriaExpanded(isExpanded), h.AriaControls(detailId)], + }, + [ + hstack(h, { gap: "1.5", align: "center", as: "span" }, [ + icon(h, "ChevronRightIcon", { + size: 12, + className: cn( + "text-foreground-subtle shrink-0 transition-transform", + isExpanded && "rotate-90", + ), + }), + relativeTime(h, { value: isoOf(event.occurredAt), ...clock }), + ]), + ], + ), + ]), + tableCell(h, [providerLabel(event.provider)], WIDE_COLUMN), + tableCell( + h, + [ + vstack(h, { shrink: true }, [ + typography(h, { truncate: true }, [actorText(event)]), + ...(actor === null + ? [] + : [typography(h, { size: "2xs", tone: "muted", truncate: true }, [actor])]), + ]), + ], + NARROW_ACTOR, + ), + tableCell( + h, + [ + vstack(h, { shrink: true }, [ + typography(h, { mono: true, size: "xs", truncate: true }, [event.action]), + hstack(h, { gap: "1.5", align: "center", wrap: true }, [ + typography(h, { size: "2xs", tone: "muted" }, [ + categoryLabel(event.category), + ]), + // A phone has no Outcome column: a refusal still shows, under its action. + ...Arr.map(outcomeBadge(h, event), (mark) => + h.span([h.Class("sm:hidden")], [mark]), + ), + ]), + ]), + ], + NARROW_ACTION, + ), + tableCell( + h, + target === null + ? [] + : [ + typography(h, { mono: true, size: "xs", tone: "muted", truncate: true }, [ + target, + ]), + ], + WIDE_COLUMN, + ), + tableCell(h, outcomeBadge(h, event), WIDE_COLUMN), + ], + isExpanded ? "bg-surface-faint" : undefined, + [h.Key(event.id)], + ), + ...(isExpanded + ? [ + tableRow( + h, + [ + tableCell(h, [eventDetail(h, event, clock)], "p-0 whitespace-normal", [ + h.Attribute("colspan", String(COLUMN_COUNT)), + ]), + ], + "bg-surface-faint hover:bg-surface-faint active:bg-surface-faint", + [h.Key(`${event.id}:detail`), h.Id(detailId)], + ), + ] + : []), + ] +} + +/** The rows with a heading wherever the day changes ("Today", "Yesterday", "Mon 2 Nov"). */ +const rowsByDay = ( + h: H, + model: Model, + events: ReadonlyArray, + clock: Clock, +): ReadonlyArray => + Arr.flatMap(events, (event, index) => { + const day = dayKey(event.occurredAt, clock.timeZone) + const previous = events[index - 1] + const isNewDay = previous === undefined || dayKey(previous.occurredAt, clock.timeZone) !== day + return [ + ...(isNewDay + ? [ + tableRow( + h, + [ + tableCell( + h, + [ + eyebrow( + h, + { variant: "mono", icon: "CalendarIcon", className: "gap-2" }, + [dayLabel(event.occurredAt, clock)], + ), + ], + "bg-surface-faint py-1.5", + [h.Attribute("colspan", String(COLUMN_COUNT))], + ), + ], + "hover:bg-transparent active:bg-transparent", + [h.Key(`day-${day}`)], + ), + ] + : []), + ...eventRows(h, model, event, clock), + ] + }) + +const eventsTable = (h: H, model: Model, events: ReadonlyArray, clock: Clock): Html => + table(h, { ariaLabel: "Audit events" }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Time"], "w-28 sm:w-36"), + tableHead(h, ["Platform"], WIDE_COLUMN), + tableHead(h, ["Actor"]), + tableHead(h, ["Action"]), + tableHead(h, ["Target"], WIDE_COLUMN), + tableHead(h, ["Outcome"], cn("w-28", WIDE_COLUMN)), + ]), + ]), + tableBody(h, rowsByDay(h, model, events, clock)), + ]) + +const loading = (h: H): ReadonlyArray => [ + vstack(h, { gap: "4", attributes: [h.AriaBusy(true), h.AriaLabel("Loading")] }, [ + placeholder(h, "h-9 w-full"), + placeholder(h, "h-80 w-full"), + ]), +] + +/** Not an org admin: the page says who can read the log, and loads nothing. */ +const adminsOnly = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "LockIcon")]), + emptyTitle(h, [Policy.denialFor("audit_log:read").message]), + emptyDescription(h, ["It records who did what, with their email and IP address."]), + ]), + ], + emptyFrameClass("dashed"), + ) + +/** Nothing recorded yet: what will show up, so the first change is not a surprise. */ +const noEvents = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "HistoryIcon")]), + emptyTitle(h, ["No audit events yet"]), + emptyDescription(h, [ + "Changes made in Maple, and requests it refused, appear here as they happen.", + ]), + ]), + ], + "py-14", + ) + +const listBody = (h: H, model: Model, clock: Clock): Html => + AsyncData.matchData(model.firstPage.data, { + onEmpty: () => vstack(h, { className: "p-4" }, loading(h)), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load the audit log", + onRetry: Message.ClickedRetry(), + className: "border-0", + }), + onData: () => { + const events = eventsOf(model) ?? [] + if (events.length === 0 && isFiltered(model.search)) + return filteredEmpty(h, { + noun: "events", + description: "Try a wider category or outcome, or another actor.", + onClear: Message.ClickedClearFilters(), + className: "py-8", + }) + if (events.length === 0) return noEvents(h) + const footer = listFooter(h, { + shown: events.length, + noun: "events", + hasMore: nextCursorOf(model) !== null, + isLoading: model.laterPages.isFetching, + isFailed: model.laterPages.isFailed, + onLoadMore: Message.ClickedLoadMore(), + className: "border-t", + }) + return vstack(h, {}, [eventsTable(h, model, events, clock), ...(footer === null ? [] : [footer])]) + }, + }) + +export const view = Submodel.defineView((model, inputs, h) => { + const clock = { nowMs: model.nowMs, timeZone: inputs.context.timeZone } + return securityLayout( + h, + { source: "auditLog" }, + !model.isGrantsRead + ? loading(h) + : !canRead(model) + ? [adminsOnly(h)] + : [ + panel(h, { className: "overflow-hidden" }, [ + toolbar(h, model), + listBody(h, model, clock), + ]), + ], + ) +}) diff --git a/apps/web/src/page/security/kit/layout.ts b/apps/web/src/page/security/kit/layout.ts index 6ed4099422..aba940519a 100644 --- a/apps/web/src/page/security/kit/layout.ts +++ b/apps/web/src/page/security/kit/layout.ts @@ -10,7 +10,7 @@ import { vstack } from "@maple/views/ui/stack" // The frame of each Security source's page: the rail of sources, the source's title block, content. // The code security audit is the one source with tabs of its own (Overview, Findings, Runs). -export type SecuritySource = "code" | "dependencies" | "runtimes" | "exposure" +export type SecuritySource = "code" | "dependencies" | "runtimes" | "exposure" | "auditLog" export type CodeTab = "overview" | "findings" | "runs" const SOURCE_LABEL = { @@ -18,6 +18,7 @@ const SOURCE_LABEL = { dependencies: "Dependencies", runtimes: "Runtimes", exposure: "Exposure", + auditLog: "Audit log", } satisfies Record const SECTIONS: ReadonlyArray> = [ @@ -55,6 +56,19 @@ const SECTIONS: ReadonlyArray> = [ }, ], }, + { + id: "activity", + title: "Activity", + items: [ + { + kind: "link", + id: "auditLog", + label: SOURCE_LABEL.auditLog, + icon: "HistoryIcon", + href: hrefOf(AppRoute.SecurityAuditLog({ search: {} })), + }, + ], + }, ] /** The rail's rows are all links, so no row sends a message. */ @@ -132,6 +146,7 @@ const DESCRIPTION = { "The language runtime each service reports, checked for known vulnerabilities and end-of-life dates.", dependencies: "Known vulnerable packages in your connected repositories' lockfiles, rescanned on every push.", + auditLog: "Who changed what in Maple, and what it refused. Visible to org admins.", } satisfies Record /** The page's blocks at their own height: the scroll area scrolls them, never squeezes them. */ diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index f209e30a92..2f0f913dd2 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -25,6 +25,7 @@ import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" import { textLink } from "@maple/views/ui/text-link" import { iconButton } from "@maple/views/ui/icon-button" +import { jsonBlock } from "../../kit/json-block" type H = HtmlBuilder @@ -145,14 +146,7 @@ const metadataList = (h: H, metadata: Readonly>): Readon const block = metadataBlock(metadata[key]) return field(h, { label: metadataLabel(key) }, [ block !== null - ? h.pre( - [ - h.Class( - "bg-surface-well max-h-64 overflow-auto rounded-md border px-2.5 py-2 font-mono text-2xs leading-relaxed whitespace-pre-wrap wrap-anywhere", - ), - ], - [block], - ) + ? jsonBlock(h, block) : typography(h, { size: "2xs", mono: true, className: "wrap-anywhere" }, [ changeValue(h, metadata[key]), ]), diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 42b945f251..c491d5c687 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -59,6 +59,7 @@ export const AppRoute = defineRouteUnion({ SecurityExposure: {}, SecurityRuntimes: {}, SecurityDependencies: { search: Search.SecurityDependenciesSearch }, + SecurityAuditLog: { search: Search.SecurityAuditLogSearch }, SecurityRun: { runId: SecurityAuditRunId }, SecurityFinding: { targetId: SecurityAuditTargetId, fingerprint: Schema.String }, Dashboard: { dashboardId: DashboardId, search: Search.DashboardSearch }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index 699ecec3f5..3a107722cb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -180,6 +180,7 @@ const isSecurityRoute = AppRoute.isAnyOf([ "SecurityExposure", "SecurityRuntimes", "SecurityDependencies", + "SecurityAuditLog", "SecurityRun", "SecurityFinding", ]) diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 9c4e9eaa89..a0ce09f9ab 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -347,6 +347,10 @@ describe("routes", () => { _tag: "SecurityDependencies", search: { repo: "repo_1" }, }) + expect(parse("/security/audit-log?provider=github&outcome=denied")).toMatchObject({ + _tag: "SecurityAuditLog", + search: { provider: "github", outcome: "denied" }, + }) expect(parse("/security/code/runs/run_9f3c2a1e7b")).toMatchObject({ _tag: "SecurityRun", runId: "run_9f3c2a1e7b", diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index ccb4d333a6..81fa151244 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -208,6 +208,11 @@ const routers = { search(Search.SecurityDependenciesSearch), Route.mapTo(AppRoute.SecurityDependencies), ), + SecurityAuditLog: pipe( + path("security", "audit-log"), + search(Search.SecurityAuditLogSearch), + Route.mapTo(AppRoute.SecurityAuditLog), + ), SecurityRun: pipe( path("security", "code", "runs"), slash(schemaSegment("runId", SecurityAuditRunId)), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index a54b276952..084e8ce3dd 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -1083,6 +1083,16 @@ export const SecurityDependenciesSearch = Schema.Struct({ view: Schema.optional(Schema.Literals(["repositories", "advisories"])), }) +/** The Security hub's audit log filters; each absent one shows everything. */ +export const SecurityAuditLogSearch = Schema.Struct({ + provider: StringParam, + category: StringParam, + outcome: StringParam, + /** An actor's id or email on the platform the event came from. */ + actor: StringParam, +}) +export type SecurityAuditLogSearch = typeof SecurityAuditLogSearch.Type + export type SecurityFindingsSearch = typeof SecurityFindingsSearch.Type export type SecurityRunsSearch = typeof SecurityRunsSearch.Type export type SecurityDependenciesSearch = typeof SecurityDependenciesSearch.Type diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index b5b2601711..fe0cb23d2c 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -236,6 +236,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/issue-actor.ts` `resolveActorIdentity`, `identityAvatar`, `actorAvatar`, `noActor`, `actorChip`: Actor avatars and chips. - `apps/web/src/page/kit/issue-severity.ts` `isIssueSeverity`, `severityDot`, `severityBadge`, `severityIcon`: The severity badge's maps and marks. - `apps/web/src/page/kit/issue-workflow.ts` `workflowRingIcon`: The state as a ring that fills as the work progresses. +- `apps/web/src/page/kit/json-block.ts` `jsonBlock`: A JSON document or other preformatted text in a scrolling well (an audit entry's payload). - `apps/web/src/page/kit/k8s-chart.ts` `k8sChart`: The Kubernetes metric chart and the detail-chart wrappers around it: one metric at a time, 280 tall, series legend, in a chart card. - `apps/web/src/page/kit/keyed-step.ts` `keyed`, `keyedSlot`, `applySteps`, `runSteps`, `seriesSteps`, `slotStep`, `chain`, `chainAll`: One request slot's step and the way steps compose: the Model threaded through, the Commands concatenated, the last OutMessage kept. - `apps/web/src/page/kit/last-by.ts` `lastBy`: A derivation that views call on every message, recomputed only when one of its inputs changes. From e26901db45f4cab6d82fa16b8c5622ee1ffee080 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 01:51:20 +0100 Subject: [PATCH 05/30] feat(security): an outcome reason on audit trail events, and Maple entries under their public id - OutcomeReason holds why an event failed or was refused, in the platform's words, as its own column instead of inside the payload. Maple's denial reason goes there. - A Maple entry's provider event id is its public alog_ id, the one the Settings audit log shows and looks up, so an id copied from one page finds it in the other. The table has no rows anywhere yet, so the column lands in migration 0064 itself. --- apps/api/src/routes/internal/audit-trail.http.test.ts | 1 + apps/ingest/src/clickhouse_insert_mappings.rs | 2 +- .../src/services/audit-trail/AuditTrailService.test.ts | 2 ++ .../backend/src/services/audit-trail/audit-trail-row.ts | 2 ++ .../src/services/audit-trail/providers/maple.test.ts | 7 +++++-- .../backend/src/services/audit-trail/providers/maple.ts | 5 +++-- packages/domain/src/audit-trail.ts | 2 ++ .../src/clickhouse/migrations/0064_audit_trail_events.ts | 2 +- packages/domain/src/generated/clickhouse-schema.ts | 4 ++-- packages/domain/src/generated/tinybird-project-manifest.ts | 4 ++-- packages/domain/src/http/audit-trail.ts | 2 ++ packages/domain/src/tinybird/datasources.ts | 2 ++ .../local-server/src/schema/history/local-schema-v56.sql | 3 ++- packages/local-server/src/schema/local-inserts.json | 2 +- packages/local-server/src/schema/local-schema.sql | 3 ++- packages/query-engine/src/__sql_baseline__/catalog.sql | 6 ++++-- packages/query-engine/src/ch/queries/audit-trail.ts | 1 + 17 files changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/internal/audit-trail.http.test.ts b/apps/api/src/routes/internal/audit-trail.http.test.ts index 23ee0b1daf..14ebd32529 100644 --- a/apps/api/src/routes/internal/audit-trail.http.test.ts +++ b/apps/api/src/routes/internal/audit-trail.http.test.ts @@ -46,6 +46,7 @@ const event = (overrides: Partial = {}): AuditTrailEntry => ({ action: "org.add_member", category: "membership", outcome: "success", + outcomeReason: null, target: { type: "user", id: "hubot", name: null }, originIp: null, originCountry: null, diff --git a/apps/ingest/src/clickhouse_insert_mappings.rs b/apps/ingest/src/clickhouse_insert_mappings.rs index 3a7d59cdc2..a676e35bfa 100644 --- a/apps/ingest/src/clickhouse_insert_mappings.rs +++ b/apps/ingest/src/clickhouse_insert_mappings.rs @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-insert-mappings.ts // Do not edit manually. -pub const PROJECT_REVISION: &str = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138"; +pub const PROJECT_REVISION: &str = "5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7"; // Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the // Tinybird-coupled PROJECT_REVISION. Compared against // org_clickhouse_settings.schema_version. See @maple/domain/clickhouse diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts index f8b6a07e04..3d488b4f33 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts @@ -76,6 +76,7 @@ const stored = (id: string, overrides: Partial> = {}) => action: "org.add_member", category: "membership", outcome: "success", + outcomeReason: "", targetType: "user", targetId: "hubot", targetName: "", @@ -159,6 +160,7 @@ describe("AuditTrailService.list", () => { providerEventId: `evt_${ids[0]}`, actor: { type: "user", id: "octocat", name: null, email: null }, target: { type: "user", id: "hubot", name: null }, + outcomeReason: null, originIp: null, raw: { action: "org.add_member" }, }) diff --git a/packages/backend/src/services/audit-trail/audit-trail-row.ts b/packages/backend/src/services/audit-trail/audit-trail-row.ts index 682e38768b..dbf06eea52 100644 --- a/packages/backend/src/services/audit-trail/audit-trail-row.ts +++ b/packages/backend/src/services/audit-trail/audit-trail-row.ts @@ -47,6 +47,7 @@ export const auditTrailEventToRow = ( Action: event.action, Category: event.category, Outcome: event.outcome, + OutcomeReason: event.outcomeReason ?? "", TargetType: event.target?.type ?? "", TargetId: event.target?.id ?? "", TargetName: event.target?.name ?? "", @@ -82,6 +83,7 @@ export const storedRowToEntry = ( action: row.action, category: row.category, outcome: row.outcome, + outcomeReason: absent(row.outcomeReason), target: { type: absent(row.targetType), id: absent(row.targetId), name: absent(row.targetName) }, originIp: absent(row.originIp), originCountry: absent(row.originCountry), diff --git a/packages/backend/src/services/audit-trail/providers/maple.test.ts b/packages/backend/src/services/audit-trail/providers/maple.test.ts index 06fc4d13d7..a2f2762263 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.test.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.test.ts @@ -8,11 +8,12 @@ import { mapleAuditTrailEvents } from "./maple" const ORG = Schema.decodeSync(OrgId)("org_trail_maple") const USER = Schema.decodeSync(UserId)("user_trail_maple") const KEY_ID = "5b8f2c1e-7a55-4c0e-9a3b-0d2c7e5b9a11" +const ENTRY_ID = "9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f" const entry = (overrides: Partial[0]> = {}) => new AuditLogEvent({ orgId: ORG, - id: Schema.decodeSync(AuditLogEvent.fields.id)("9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f"), + id: Schema.decodeSync(AuditLogEvent.fields.id)(ENTRY_ID), actorType: "user", userId: USER, source: "dashboard", @@ -35,6 +36,8 @@ describe("mapleAuditTrailEvents", () => { expect(event).toMatchObject({ provider: "maple", + // The id the Settings audit log shows for the same entry. + providerEventId: encodePublicId(PublicIdPrefixes.auditLogEntry, ENTRY_ID), actor: { type: "user", id: USER }, action: "alert_rule.updated", category: "configuration", @@ -53,7 +56,7 @@ describe("mapleAuditTrailEvents", () => { expect(trailEventOf({ ...read, outcome: "denied", denialReason: "missing scope" })).toMatchObject({ category: "data_access", outcome: "denied", - raw: { denialReason: "missing scope" }, + outcomeReason: "missing scope", }) }) diff --git a/packages/backend/src/services/audit-trail/providers/maple.ts b/packages/backend/src/services/audit-trail/providers/maple.ts index a4f2c712e0..eb7ff45f81 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.ts @@ -124,7 +124,6 @@ const actorOf = (event: AuditLogEvent): AuditTrailEvent["actor"] => { /** What the trail's detail view shows of a Maple entry beyond the shared columns. */ const rawOf = (event: AuditLogEvent): Record => ({ source: event.source, - ...(event.denialReason !== undefined ? { denialReason: event.denialReason } : undefined), ...(event.affectedUserId !== undefined ? { affectedUserId: event.affectedUserId } : undefined), ...(event.changes !== undefined ? { changes: event.changes } : undefined), ...(event.metadata !== undefined ? { metadata: event.metadata } : undefined), @@ -149,12 +148,14 @@ const mapleAuditTrailEvent = (event: AuditLogEvent): AuditTrailEvent | null => { : undefined return { provider: "maple", - providerEventId: event.id, + // The entry's public id, the one the Settings audit log shows and looks up. + providerEventId: encodePublicId(PublicIdPrefixes.auditLogEntry, event.id), occurredAtMs: event.occurredAtMs, actor: actorOf(event), action: event.action, category: categoryOf(event), outcome: event.outcome === "denied" ? "denied" : "success", + ...(event.denialReason !== undefined ? { outcomeReason: event.denialReason } : undefined), ...(target !== undefined ? { target } : undefined), ...(event.originIp !== undefined ? { originIp: event.originIp } : undefined), ...(event.originCountry !== undefined ? { originCountry: event.originCountry } : undefined), diff --git a/packages/domain/src/audit-trail.ts b/packages/domain/src/audit-trail.ts index 2f0fdadfd4..c587cdaa12 100644 --- a/packages/domain/src/audit-trail.ts +++ b/packages/domain/src/audit-trail.ts @@ -51,6 +51,8 @@ export interface AuditTrailEvent { readonly action: string readonly category: AuditTrailCategory readonly outcome: AuditTrailOutcome + /** Why it failed or was refused, in the platform's words. */ + readonly outcomeReason?: string readonly target?: { readonly type?: string readonly id?: string diff --git a/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts b/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts index 8334994041..f76efc0466 100644 --- a/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts +++ b/packages/domain/src/clickhouse/migrations/0064_audit_trail_events.ts @@ -6,7 +6,7 @@ export const migration_0064_audit_trail_events = { description: "Create audit_trail_events, normalized audit events from connected platforms", requiredForIngest: false, statements: [ - "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", + "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n OutcomeReason String,\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", "ALTER TABLE audit_trail_events MODIFY SETTING ttl_only_drop_parts = 1", ], } as const diff --git a/packages/domain/src/generated/clickhouse-schema.ts b/packages/domain/src/generated/clickhouse-schema.ts index daac431c7a..8dc9d82201 100644 --- a/packages/domain/src/generated/clickhouse-schema.ts +++ b/packages/domain/src/generated/clickhouse-schema.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-clickhouse-schema.ts // Do not edit manually. -export const projectRevision = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138" as const +export const projectRevision = "5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7" as const export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS ai_crawler_requests (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n ServiceName LowCardinality(String),\n Crawler LowCardinality(String),\n Host LowCardinality(String),\n Path String,\n HttpStatus UInt16\n)\nENGINE = MergeTree\nPARTITION BY toDate(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 30 DAY", @@ -10,7 +10,7 @@ export const latestSnapshotStatements: ReadonlyArray = [ "CREATE TABLE IF NOT EXISTS attribute_keys_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, Hour, AttributeKey)\nTTL toDate(Hour) + INTERVAL 90 DAY", "CREATE TABLE IF NOT EXISTS attribute_values_hourly (\n OrgId LowCardinality(String),\n Hour DateTime,\n AttributeKey LowCardinality(String),\n AttributeValue String,\n AttributeScope LowCardinality(String),\n UsageCount SimpleAggregateFunction(sum, UInt64)\n)\nENGINE = AggregatingMergeTree\nPARTITION BY toDate(Hour)\nORDER BY (OrgId, AttributeScope, AttributeKey, Hour, AttributeValue)\nTTL toDate(Hour) + INTERVAL 90 DAY", "CREATE TABLE IF NOT EXISTS audit_log (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n RecordedAt DateTime64(3),\n ActorType LowCardinality(String),\n UserId String,\n ApiKeyId String,\n ActorId String,\n ActorLabel String,\n AffectedUserId String,\n Source LowCardinality(String),\n Action LowCardinality(String),\n Outcome LowCardinality(String),\n DenialReason String,\n ResourceType LowCardinality(String),\n ResourceId String,\n ChangedFields Array(String),\n Changes String,\n Metadata String,\n RequestId String,\n OriginIp String,\n OriginCountry LowCardinality(String)\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 2190 DAY", - "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", + "CREATE TABLE IF NOT EXISTS audit_trail_events (\n OrgId LowCardinality(String),\n Id String,\n OccurredAt DateTime64(3),\n IngestedAt DateTime64(3),\n Provider LowCardinality(String),\n Account String,\n ProviderEventId String,\n ActorType LowCardinality(String),\n ActorId String,\n ActorName String,\n ActorEmail String,\n Action LowCardinality(String),\n Category LowCardinality(String),\n Outcome LowCardinality(String),\n OutcomeReason String,\n TargetType LowCardinality(String),\n TargetId String,\n TargetName String,\n OriginIp String,\n OriginCountry LowCardinality(String),\n UserAgent String,\n Raw String,\n Version UInt64,\n INDEX idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(OccurredAt)\nORDER BY (OrgId, OccurredAt, Id)\nTTL toDate(OccurredAt) + INTERVAL 400 DAY", "CREATE TABLE IF NOT EXISTS ci_jobs (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n Owner LowCardinality(String),\n Repository LowCardinality(String),\n Pipeline LowCardinality(String),\n RunId String,\n Attempt UInt16,\n Job LowCardinality(String),\n Result LowCardinality(String),\n StatusCode LowCardinality(String),\n Duration UInt64,\n QueueMs UInt32,\n Url String,\n FailureReason String,\n RunnerName LowCardinality(String),\n RunnerLabels LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toYYYYMM(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 365 DAY", "CREATE TABLE IF NOT EXISTS ci_runs (\n OrgId LowCardinality(String),\n Timestamp DateTime64(9),\n TraceId String,\n SpanId String,\n Owner LowCardinality(String),\n Repository LowCardinality(String),\n Pipeline LowCardinality(String),\n RunId String,\n RunNumber UInt64,\n Attempt UInt16,\n Result LowCardinality(String),\n Duration UInt64,\n Url String,\n Branch String,\n Revision String,\n TriggerEvent LowCardinality(String),\n Actor LowCardinality(String),\n PullRequest UInt32,\n WorkflowPath LowCardinality(String)\n)\nENGINE = MergeTree\nPARTITION BY toYYYYMM(Timestamp)\nORDER BY (OrgId, Timestamp, TraceId)\nTTL toDate(Timestamp) + INTERVAL 365 DAY", "CREATE TABLE IF NOT EXISTS cost_daily (\n OrgId LowCardinality(String),\n SourceId LowCardinality(String),\n Provider LowCardinality(String),\n AccountId LowCardinality(String),\n Date DateTime,\n CloudService LowCardinality(String),\n ServiceCategory LowCardinality(String),\n UsageType LowCardinality(String),\n Region LowCardinality(String),\n ResourceId String,\n ResourceType LowCardinality(String),\n ServiceName LowCardinality(String),\n ChargeCategory LowCardinality(String),\n Basis LowCardinality(String),\n Currency LowCardinality(String),\n BilledCost Float64,\n EffectiveCost Float64,\n ListCost Float64,\n ConsumedQuantity Float64,\n ConsumedUnit LowCardinality(String),\n Tags Map(LowCardinality(String), String),\n Estimated UInt8,\n IngestedAt DateTime64(3)\n)\nENGINE = ReplacingMergeTree\nPARTITION BY toYYYYMM(Date)\nORDER BY (OrgId, SourceId, Date, CloudService, UsageType, Region, ResourceId, ChargeCategory, AccountId, ServiceName, Currency)\nTTL toDate(Date) + INTERVAL 760 DAY", diff --git a/packages/domain/src/generated/tinybird-project-manifest.ts b/packages/domain/src/generated/tinybird-project-manifest.ts index 517e302e6e..97a6e7fcd4 100644 --- a/packages/domain/src/generated/tinybird-project-manifest.ts +++ b/packages/domain/src/generated/tinybird-project-manifest.ts @@ -1,7 +1,7 @@ // This file is generated by scripts/generate-tinybird-project-manifest.ts // Do not edit manually. -export const projectRevision = "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138" as const +export const projectRevision = "5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7" as const export const datasources = [ { @@ -37,7 +37,7 @@ export const datasources = [ { name: "audit_trail_events", content: - 'DESCRIPTION >\n Audit events from connected platforms and Maple itself, normalized to one shape: who did what to which target, with what outcome. Admin-only; read through the Security hub.\n\nSCHEMA >\n OrgId LowCardinality(String) `json:$.OrgId`,\n Id String `json:$.Id`,\n OccurredAt DateTime64(3) `json:$.OccurredAt`,\n IngestedAt DateTime64(3) `json:$.IngestedAt`,\n Provider LowCardinality(String) `json:$.Provider`,\n Account String `json:$.Account`,\n ProviderEventId String `json:$.ProviderEventId`,\n ActorType LowCardinality(String) `json:$.ActorType`,\n ActorId String `json:$.ActorId`,\n ActorName String `json:$.ActorName`,\n ActorEmail String `json:$.ActorEmail`,\n Action LowCardinality(String) `json:$.Action`,\n Category LowCardinality(String) `json:$.Category`,\n Outcome LowCardinality(String) `json:$.Outcome`,\n TargetType LowCardinality(String) `json:$.TargetType`,\n TargetId String `json:$.TargetId`,\n TargetName String `json:$.TargetName`,\n OriginIp String `json:$.OriginIp`,\n OriginCountry LowCardinality(String) `json:$.OriginCountry`,\n UserAgent String `json:$.UserAgent`,\n Raw String `json:$.Raw`,\n Version UInt64 `json:$.Version`\n\nENGINE "ReplacingMergeTree"\nENGINE_PARTITION_KEY "toYYYYMM(OccurredAt)"\nENGINE_SORTING_KEY "OrgId, OccurredAt, Id"\nENGINE_TTL "toDate(OccurredAt) + INTERVAL 400 DAY"\nENGINE_VER "Version"\n\nINDEXES >\n idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1', + 'DESCRIPTION >\n Audit events from connected platforms and Maple itself, normalized to one shape: who did what to which target, with what outcome. Admin-only; read through the Security hub.\n\nSCHEMA >\n OrgId LowCardinality(String) `json:$.OrgId`,\n Id String `json:$.Id`,\n OccurredAt DateTime64(3) `json:$.OccurredAt`,\n IngestedAt DateTime64(3) `json:$.IngestedAt`,\n Provider LowCardinality(String) `json:$.Provider`,\n Account String `json:$.Account`,\n ProviderEventId String `json:$.ProviderEventId`,\n ActorType LowCardinality(String) `json:$.ActorType`,\n ActorId String `json:$.ActorId`,\n ActorName String `json:$.ActorName`,\n ActorEmail String `json:$.ActorEmail`,\n Action LowCardinality(String) `json:$.Action`,\n Category LowCardinality(String) `json:$.Category`,\n Outcome LowCardinality(String) `json:$.Outcome`,\n OutcomeReason String `json:$.OutcomeReason`,\n TargetType LowCardinality(String) `json:$.TargetType`,\n TargetId String `json:$.TargetId`,\n TargetName String `json:$.TargetName`,\n OriginIp String `json:$.OriginIp`,\n OriginCountry LowCardinality(String) `json:$.OriginCountry`,\n UserAgent String `json:$.UserAgent`,\n Raw String `json:$.Raw`,\n Version UInt64 `json:$.Version`\n\nENGINE "ReplacingMergeTree"\nENGINE_PARTITION_KEY "toYYYYMM(OccurredAt)"\nENGINE_SORTING_KEY "OrgId, OccurredAt, Id"\nENGINE_TTL "toDate(OccurredAt) + INTERVAL 400 DAY"\nENGINE_VER "Version"\n\nINDEXES >\n idx_ingested_at IngestedAt TYPE minmax GRANULARITY 1', }, { name: "ci_jobs", diff --git a/packages/domain/src/http/audit-trail.ts b/packages/domain/src/http/audit-trail.ts index 29dbd96982..b9c06ce928 100644 --- a/packages/domain/src/http/audit-trail.ts +++ b/packages/domain/src/http/audit-trail.ts @@ -33,6 +33,8 @@ export const AuditTrailEntry = Schema.Struct({ action: Schema.String, category: Schema.String, outcome: Schema.String, + /** Why it failed or was refused, in the platform's words. */ + outcomeReason: Schema.NullOr(Schema.String), target: Schema.Struct({ type: Schema.NullOr(Schema.String), id: Schema.NullOr(Schema.String), diff --git a/packages/domain/src/tinybird/datasources.ts b/packages/domain/src/tinybird/datasources.ts index 3fffbbfebf..db1e6cda63 100644 --- a/packages/domain/src/tinybird/datasources.ts +++ b/packages/domain/src/tinybird/datasources.ts @@ -2127,6 +2127,8 @@ export const auditTrailEvents = defineDatasource("audit_trail_events", { Action: t.string().lowCardinality(), Category: t.string().lowCardinality(), Outcome: t.string().lowCardinality(), + // Why it failed or was refused, in the platform's words. + OutcomeReason: t.string(), TargetType: t.string().lowCardinality(), TargetId: t.string(), TargetName: t.string(), diff --git a/packages/local-server/src/schema/history/local-schema-v56.sql b/packages/local-server/src/schema/history/local-schema-v56.sql index 834972c572..885fcad907 100644 --- a/packages/local-server/src/schema/history/local-schema-v56.sql +++ b/packages/local-server/src/schema/history/local-schema-v56.sql @@ -1,6 +1,6 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: 91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138 +-- projectRevision: 5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7 CREATE TABLE IF NOT EXISTS ai_crawler_requests ( OrgId LowCardinality(String), @@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS audit_trail_events ( Action LowCardinality(String), Category LowCardinality(String), Outcome LowCardinality(String), + OutcomeReason String, TargetType LowCardinality(String), TargetId String, TargetName String, diff --git a/packages/local-server/src/schema/local-inserts.json b/packages/local-server/src/schema/local-inserts.json index 3414a9f913..1ffa0e1cd4 100644 --- a/packages/local-server/src/schema/local-inserts.json +++ b/packages/local-server/src/schema/local-inserts.json @@ -1,5 +1,5 @@ { - "projectRevision": "91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138", + "projectRevision": "5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7", "orgPlaceholder": "__ORG__", "datasources": { "traces": { diff --git a/packages/local-server/src/schema/local-schema.sql b/packages/local-server/src/schema/local-schema.sql index 834972c572..885fcad907 100644 --- a/packages/local-server/src/schema/local-schema.sql +++ b/packages/local-server/src/schema/local-schema.sql @@ -1,6 +1,6 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: 91e15ef049be064b40b9cd4223cb6e6d851fe1847dfecf6a4e6afcd34b96a138 +-- projectRevision: 5059e1601b59566f096b277902cfa023b21cbd0b62e1d9e53a7bb05cc92473f7 CREATE TABLE IF NOT EXISTS ai_crawler_requests ( OrgId LowCardinality(String), @@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS audit_trail_events ( Action LowCardinality(String), Category LowCardinality(String), Outcome LowCardinality(String), + OutcomeReason String, TargetType LowCardinality(String), TargetId String, TargetName String, diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index f9fac39834..456c8baef8 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -373,7 +373,7 @@ SELECT OFFSET 50 FORMAT JSON --- builder:audit-trail:auditTrailEventsQuery:default [ac8596a7] +-- builder:audit-trail:auditTrailEventsQuery:default [9ab77231] SELECT audit_trail_events.Id AS id, audit_trail_events.OccurredAt AS occurredAt, @@ -388,6 +388,7 @@ SELECT audit_trail_events.Action AS action, audit_trail_events.Category AS category, audit_trail_events.Outcome AS outcome, + audit_trail_events.OutcomeReason AS outcomeReason, audit_trail_events.TargetType AS targetType, audit_trail_events.TargetId AS targetId, audit_trail_events.TargetName AS targetName, @@ -401,7 +402,7 @@ SELECT LIMIT 51 FORMAT JSON --- builder:audit-trail:auditTrailEventsQuery:filtered [a97c670a] +-- builder:audit-trail:auditTrailEventsQuery:filtered [fb4fb694] SELECT audit_trail_events.Id AS id, audit_trail_events.OccurredAt AS occurredAt, @@ -416,6 +417,7 @@ SELECT audit_trail_events.Action AS action, audit_trail_events.Category AS category, audit_trail_events.Outcome AS outcome, + audit_trail_events.OutcomeReason AS outcomeReason, audit_trail_events.TargetType AS targetType, audit_trail_events.TargetId AS targetId, audit_trail_events.TargetName AS targetName, diff --git a/packages/query-engine/src/ch/queries/audit-trail.ts b/packages/query-engine/src/ch/queries/audit-trail.ts index 138a531151..c3c4fb400e 100644 --- a/packages/query-engine/src/ch/queries/audit-trail.ts +++ b/packages/query-engine/src/ch/queries/audit-trail.ts @@ -49,6 +49,7 @@ export function auditTrailEventsQuery(opts: AuditTrailEventsOpts) { action: $.Action, category: $.Category, outcome: $.Outcome, + outcomeReason: $.OutcomeReason, targetType: $.TargetType, targetId: $.TargetId, targetName: $.TargetName, From a098f135b8e3096758923eaebcfadc22595634b4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 01:53:35 +0100 Subject: [PATCH 06/30] fix(security): the audit trail endpoint's errors carry a public title and message Both become public tagged errors, like the integrations group's: an outage now reads "The audit log is temporarily unavailable" on the page instead of a generic failure, and a refusal carries a code the page can tell apart from an outage. --- .../audit-trail/AuditTrailService.test.ts | 4 +-- .../anticipated-error-identifiers.ts | 2 +- packages/domain/src/http/audit-trail.ts | 28 +++++++++++++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts index 3d488b4f33..67ed89a3d4 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.test.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.test.ts @@ -201,7 +201,7 @@ describe("AuditTrailService.list", () => { trail.list(ORG, { limit: 2 }), ).pipe(Effect.flip) - expect(error._tag).toBe("@maple/http/audit-trail/AuditTrailPersistenceError") + expect(error._tag).toBe("@maple/http/errors/AuditTrailPersistenceError") }), ) }) @@ -277,7 +277,7 @@ describe("AuditTrailService.write", () => { trail.write(ORG, [event]), ).pipe(Effect.flip) - expect(error._tag).toBe("@maple/http/audit-trail/AuditTrailPersistenceError") + expect(error._tag).toBe("@maple/http/errors/AuditTrailPersistenceError") }), ) }) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 56be12756c..11d93dd0a1 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -11,7 +11,6 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/anomalies/AnomalyForbiddenError", "@maple/http/anomalies/AnomalyIncidentNotFoundError", "@maple/http/anomalies/AnomalyLinkedIssueNotFoundError", - "@maple/http/audit-trail/AuditTrailForbiddenError", "@maple/http/data-sources/CatalogEntryNotFoundError", "@maple/http/data-sources/DataSourceInvalidError", "@maple/http/data-sources/DataSourceNotFoundError", @@ -34,6 +33,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/AlertSilenceNotFoundError", "@maple/http/errors/AlertValidationError", "@maple/http/errors/ApiKeyNotFoundError", + "@maple/http/errors/AuditTrailForbiddenError", "@maple/http/errors/AwsAccountAlreadyConnectedError", "@maple/http/errors/AwsIntegrationUnavailableError", "@maple/http/errors/BillingConflictError", diff --git a/packages/domain/src/http/audit-trail.ts b/packages/domain/src/http/audit-trail.ts index b9c06ce928..c096a287ab 100644 --- a/packages/domain/src/http/audit-trail.ts +++ b/packages/domain/src/http/audit-trail.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" // The Security hub's audit log: audit events from connected platforms and Maple itself in one // list. Dashboard only (the session tier refuses API keys) and behind `audit_log:read`. @@ -85,17 +86,32 @@ export const AuditTrailListResponse = Schema.Struct({ }) export type AuditTrailListResponse = typeof AuditTrailListResponse.Type -export class AuditTrailPersistenceError extends Schema.TaggedError()( - "@maple/http/audit-trail/AuditTrailPersistenceError", +export class AuditTrailPersistenceError extends HttpTaggedError()( + "@maple/http/errors/AuditTrailPersistenceError", /** `cause` names the failure behind it (its tag), never its details. */ { message: Schema.String, cause: Schema.optionalKey(Schema.String) }, - { httpApiStatus: 503 }, + { + status: 503, + code: "audit_trail_unavailable", + title: "The audit log is temporarily unavailable", + message: "The audit log is temporarily unavailable. Retry in a few seconds.", + retry: "backoff", + recovery: "retry", + exposure: "redacted", + }, ) {} -export class AuditTrailForbiddenError extends Schema.TaggedError()( - "@maple/http/audit-trail/AuditTrailForbiddenError", +export class AuditTrailForbiddenError extends HttpTaggedError()( + "@maple/http/errors/AuditTrailForbiddenError", { message: Schema.String }, - { httpApiStatus: 403 }, + { + status: 403, + code: "audit_trail_forbidden", + title: "Permission required", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, ) {} export class AuditTrailApiGroup extends HttpApiGroup.make("auditTrail") From 0153b0cdb7a686ee4bb475cad59db7fa87514215 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 02:00:10 +0100 Subject: [PATCH 07/30] fix(security): audit log page review fixes - A next page is neither asked for nor accepted while other filters are loading, and an answer has to match the cursor it was asked from. - A session read that fails no longer locks an admin out: the API is asked and its refusal is what shows the admins-only state. - The rows dim while other filters load. - Nothing the row cuts off is lost: full values on hover, and the detail lists the action, actor, actor type and target. The refusal reason shows under the action. - Anything but a success is marked, so an unknown outcome does not read as one. - The outcome filter offers what a platform writes today. A footnote says allowed reads are in the Settings audit log, not here. - Kit: tableGroupRow (the releases list moves onto it), dayEyebrow, refusedBadge and expandChevron (the settings audit log moves onto them), prettyJson, and a controlled value on nativeSelect so a filter change keeps keyboard focus. - The page entry takes the auth mode as an argument so the hosted gate is tested. --- .../web/src/lab/security-audit-log-fixture.ts | 10 +- apps/web/src/page/kit/day-label.ts | 6 + apps/web/src/page/kit/expand-chevron.ts | 10 + apps/web/src/page/kit/json-block.ts | 4 + apps/web/src/page/kit/refused-badge.ts | 7 + apps/web/src/page/releases/list/view-table.ts | 23 +- .../audit-log/audit-log.scene.test.ts | 198 ++++++++---- .../src/page/security/audit-log/command.ts | 3 +- .../web/src/page/security/audit-log/derive.ts | 57 ++-- apps/web/src/page/security/audit-log/model.ts | 15 +- .../web/src/page/security/audit-log/update.ts | 53 ++-- apps/web/src/page/security/audit-log/view.ts | 300 +++++++++--------- .../page/settings/audit-log/view-detail.ts | 7 +- apps/web/src/page/settings/audit-log/view.ts | 22 +- apps/web/src/route/route.test.ts | 4 +- apps/web/src/route/search.ts | 1 - packages/views/src/ui/INDEX.md | 8 +- packages/views/src/ui/native-select.ts | 7 +- packages/views/src/ui/table.ts | 29 ++ 19 files changed, 451 insertions(+), 313 deletions(-) create mode 100644 apps/web/src/page/kit/expand-chevron.ts create mode 100644 apps/web/src/page/kit/refused-badge.ts diff --git a/apps/web/src/lab/security-audit-log-fixture.ts b/apps/web/src/lab/security-audit-log-fixture.ts index 6870e1a1c6..ac70bbceb0 100644 --- a/apps/web/src/lab/security-audit-log-fixture.ts +++ b/apps/web/src/lab/security-audit-log-fixture.ts @@ -11,6 +11,7 @@ type Seed = Readonly<{ action: string category: string outcome?: string + outcomeReason?: string actor: AuditTrailEntry["actor"] target?: Partial originIp?: string @@ -61,7 +62,8 @@ const SEEDS: ReadonlyArray = [ actor: deployKey, originIp: "198.51.100.24", originCountry: "US", - raw: { source: "api", denialReason: "The key lacks the dashboards:write scope." }, + outcomeReason: "The key lacks the dashboards:write scope.", + raw: { source: "api" }, }, { minutesAgo: 47, @@ -108,7 +110,8 @@ const SEEDS: ReadonlyArray = [ outcome: "denied", actor: { type: "service", id: "actor_8UjMki9OlP0qWeRtY1uI2o", name: "Triage agent", email: null }, target: { type: "telemetry" }, - raw: { source: "mcp", denialReason: "The query reads a table this agent may not." }, + outcomeReason: "The query reads a table this agent may not.", + raw: { source: "mcp" }, }, { minutesAgo: 24 * 60 + 300, @@ -128,13 +131,14 @@ const eventOf = (seed: Seed, index: number, nowMs: number): AuditTrailEntry => { id: Str.padStart(32, "0")(index.toString(16)), provider: "maple", account: null, - providerEventId: `9d2c1e3a-6a1b-4f0e-9c1d-${Str.padStart(12, "0")(index.toString(16))}`, + providerEventId: `alog_4CzLmR1pTxWvYbNhQd82K${index}`, occurredAt, ingestedAt: occurredAt + 900, actor: seed.actor, action: seed.action, category: seed.category, outcome: seed.outcome ?? "success", + outcomeReason: seed.outcomeReason ?? null, target: { type: null, id: null, name: null, ...seed.target }, originIp: seed.originIp ?? null, originCountry: seed.originCountry ?? null, diff --git a/apps/web/src/page/kit/day-label.ts b/apps/web/src/page/kit/day-label.ts index e0a5c7b898..e85269d2b2 100644 --- a/apps/web/src/page/kit/day-label.ts +++ b/apps/web/src/page/kit/day-label.ts @@ -1,4 +1,6 @@ import { Array as Arr, Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "@maple/views/ui/eyebrow" // Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). @@ -38,3 +40,7 @@ export const dayLabel = (atMs: number, clock: DayClock): string => { if (day === today - 1) return "Yesterday" return timeFormat(clock.timeZone, { weekday: "short", day: "numeric", month: "short" }).format(atMs) } + +/** The day as a list's group heading: a calendar glyph and "Today", "Yesterday" or "Mon 2 Nov". */ +export const dayEyebrow = (h: HtmlBuilder, atMs: number, clock: DayClock): Html => + eyebrow(h, { variant: "mono", icon: "CalendarIcon", className: "gap-2" }, [dayLabel(atMs, clock)]) diff --git a/apps/web/src/page/kit/expand-chevron.ts b/apps/web/src/page/kit/expand-chevron.ts new file mode 100644 index 0000000000..001bcbecdb --- /dev/null +++ b/apps/web/src/page/kit/expand-chevron.ts @@ -0,0 +1,10 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" + +/** The chevron at the start of a row that opens in place: right when closed, down when open. */ +export const expandChevron = (h: HtmlBuilder, isExpanded: boolean): Html => + icon(h, "ChevronRightIcon", { + size: 12, + className: cn("text-foreground-subtle shrink-0 transition-transform", isExpanded && "rotate-90"), + }) diff --git a/apps/web/src/page/kit/json-block.ts b/apps/web/src/page/kit/json-block.ts index 6fa10e71b1..f3ffb5d877 100644 --- a/apps/web/src/page/kit/json-block.ts +++ b/apps/web/src/page/kit/json-block.ts @@ -1,6 +1,10 @@ import { cn } from "@maple/ui/lib/utils" +import { Schema } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" +/** A value as indented JSON text, for `jsonBlock`. */ +export const prettyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })) + /** A JSON document or other preformatted text in a scrolling well (an audit entry's payload). */ export const jsonBlock = (h: HtmlBuilder, text: string, className?: string): Html => h.pre( diff --git a/apps/web/src/page/kit/refused-badge.ts b/apps/web/src/page/kit/refused-badge.ts new file mode 100644 index 0000000000..94939c653e --- /dev/null +++ b/apps/web/src/page/kit/refused-badge.ts @@ -0,0 +1,7 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { badge } from "@maple/views/ui/display" +import { icon } from "@maple/views/ui/icon" + +/** The mark on an audit entry that did not go through: a crossed circle and what happened ("Denied", "Failed"). */ +export const refusedBadge = (h: HtmlBuilder, label: string): Html => + badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, [icon(h, "CircleXmarkIcon"), label]) diff --git a/apps/web/src/page/releases/list/view-table.ts b/apps/web/src/page/releases/list/view-table.ts index 8057efb44e..0829f76ab3 100644 --- a/apps/web/src/page/releases/list/view-table.ts +++ b/apps/web/src/page/releases/list/view-table.ts @@ -15,7 +15,15 @@ import { eyebrow } from "@maple/views/ui/eyebrow" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { panel } from "@maple/views/ui/panel" -import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" +import { + table, + tableBody, + tableCell, + tableGroupRow, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" import { emptyValue, errorRateValue, latencyValue, serviceDot } from "@maple/views/ui/values" import { relativeTime } from "../../kit/relative-time" import { releaseHealthFigure, releaseHref } from "../kit" @@ -268,18 +276,7 @@ export const releasesTable = (h: H, input: TableInput): Html => { const counts = input.issueCounts === undefined ? null : input.issueCounts.get(group.commitSha) return [ ...(showDay - ? [ - tableRow( - h, - [ - tableCell(h, [eyebrow(h, {}, [day])], "bg-surface-sunken py-1", [ - h.Attribute("colspan", "6"), - ]), - ], - "hover:bg-transparent active:bg-transparent", - [h.Key(`day:${group.commitSha}`)], - ), - ] + ? [tableGroupRow(h, { colSpan: 6, key: `day:${group.commitSha}` }, [eyebrow(h, {}, [day])])] : []), tableRow( h, diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 38ee2cea60..838f4ad43d 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -1,16 +1,16 @@ import { OrgId } from "@maple/domain" -import { AuditTrailPersistenceError } from "@maple/domain/http" +import { AuditTrailForbiddenError, AuditTrailPersistenceError } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { Result, Schema, Array as Arr, String as Str } from "effect" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { expect as expectView, given, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import { toLoadFailure } from "@maple/views/data/load-failure" import { auditEventsOf, noAuditEvents } from "../../../lab/security-audit-log-fixture" import { AppRoute, hrefOf } from "../../../route" import type { PageContext } from "../../contract" -import { eventsOf } from "./derive" +import { eventsOf, nextCursorOf } from "./derive" import { type EventsPage, Message, type Model, type Search } from "./model" -import { init, routeChanged, update } from "./update" +import { enter, routeChanged, update } from "./update" import { view as pageView } from "./view" const context: PageContext = { @@ -23,10 +23,15 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => Arr.map(commands ?? [], ({ name }) => name) +const FIRST_PAGE = "SecurityApiAuditLogFirstPage" +const NEXT_PAGE = "SecurityApiAuditLogNextPage" const routeOf = (search: Search = {}) => AppRoute.SecurityAuditLog({ search }) +/** The hosted product: the session, not the build, says what the viewer may read. */ +const init = (route: ReturnType, pageContext: PageContext) => enter(route, pageContext, true) const ADMIN = Policy.allPermissions const ENGINEER = Policy.grantsOf(["org:engineer"]) +const eventsTable = role("table", { name: "Audit events" }) const fold = (model: Model, messages: ReadonlyArray<(model: Model) => Message>): Model => Arr.reduce(messages, model, (current, toMessage) => pageUpdate(current, toMessage(current)).model) @@ -37,22 +42,32 @@ const grantsRead = (grants: ReadonlyArray) => () => const firstPage = (page: EventsPage) => (model: Model) => Message.CompletedFetchFirstPage({ key: model.firstPage.key ?? "", result: Result.succeed(page) }) +const firstPageFailed = (error: unknown) => (model: Model) => + Message.CompletedFetchFirstPage({ + key: model.firstPage.key ?? "", + result: Result.fail(toLoadFailure(error)), + }) + +const PAGE = auditEventsOf(NOW) + /** The page as an admin sees it once the first page answered. */ -const loaded = (page: EventsPage = auditEventsOf(NOW), search: Search = {}): Model => +const loaded = (page: EventsPage = PAGE, search: Search = {}): Model => fold(init(routeOf(search), context).model, [ () => Message.ReadClock({ nowMs: NOW }), grantsRead(ADMIN), firstPage(page), ]) -const nextPage = (model: Model, page: EventsPage, generation = model.laterPages.generation) => +/** The answer to the next-page request `model` has in flight. */ +const nextPage = (model: Model, result: Result.Result>) => Message.CompletedFetchNextPage({ key: model.laterPages.key ?? "", - generation, - result: Result.succeed(page), + generation: model.laterPages.generation, + cursor: nextCursorOf(model) ?? "", + result, }) -/** A second page: the same events a day older, under other ids. */ +/** A second page: the same events three days older, under other ids. */ const olderPage = (hasMore: boolean): EventsPage => { const page = auditEventsOf(NOW - 3 * 24 * 3600 * 1000, hasMore) return { @@ -61,46 +76,96 @@ const olderPage = (hasMore: boolean): EventsPage => { } } +const down = toLoadFailure(new AuditTrailPersistenceError({ message: "The audit trail could not be read" })) + describe("security audit log", () => { + test("nothing is asked of the API until the session says who is looking", () => { + expect(names(init(routeOf(), context).commands)).not.toContain(FIRST_PAGE) + }) + + test("a self-hosted install has one role, so the first page is asked for on entry", () => { + expect(names(enter(routeOf(), context, false).commands)).toContain(FIRST_PAGE) + }) + test("an engineer is told the log is for admins and nothing is asked of the API", () => { - const entered = init(routeOf(), context) - const answered = pageUpdate(entered.model, grantsRead(ENGINEER)()) + const answered = pageUpdate(init(routeOf(), context).model, grantsRead(ENGINEER)()) expect(names(answered.commands)).toEqual([]) scene( { update: pageUpdate, view }, given(answered.model), - expectView(text("Only org admins can read the audit log.")).toExist(), - expectView(role("table", { name: "Audit events" })).toBeAbsent(), + expectView(text(Policy.denialFor("audit_log:read").message)).toExist(), + expectView(eventsTable).toBeAbsent(), ) }) - test("an admin's first page is asked for once, whichever of the session and the page comes first", () => { - const entered = init(routeOf(), context) - const answered = pageUpdate(entered.model, grantsRead(ADMIN)()) + test("an admin's session answer asks for the first page", () => { + const answered = pageUpdate(init(routeOf(), context).model, grantsRead(ADMIN)()) + + expect(names(answered.commands)).toEqual([FIRST_PAGE]) + }) + + // A failed session read answers with no grants at all. That is "unknown", not "member": an + // admin must not be locked out of the log by it. + test("a session that could not be read asks the API, and the API's refusal is what locks the page", () => { + const asked = pageUpdate(init(routeOf(), context).model, grantsRead([])()) + expect(names(asked.commands)).toEqual([FIRST_PAGE]) - expect( - Arr.filter( - [...names(entered.commands), ...names(answered.commands)], - (name) => name === "SecurityApiAuditLogFirstPage", + scene( + { update: pageUpdate, view }, + given(fold(asked.model, [firstPage(PAGE)])), + expectView(eventsTable).toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + fold(asked.model, [ + firstPageFailed( + new AuditTrailForbiddenError({ message: "Only org admins can read the audit log." }), + ), + ]), ), - ).toHaveLength(1) + expectView(text(Policy.denialFor("audit_log:read").message)).toExist(), + expectView(role("button", { name: "Try again" })).toBeAbsent(), + ) }) - test("lists who did what, marks a refusal and groups the events by day", () => { + test("lists who did what, marks a refusal with its reason and groups the events by day", () => { scene( { update: pageUpdate, view }, given(loaded()), - expectView(role("table", { name: "Audit events" })).toExist(), - expectView(text("Ada Lovelace")).toExist(), - expectView(text("alert_rule.updated")).toExist(), - expectView(text("Denied")).toExist(), - expectView(text("Today")).toExist(), + expectView(within(eventsTable, text("Ada Lovelace"))).toExist(), + expectView(within(eventsTable, text("alert_rule.updated"))).toExist(), + expectView(within(eventsTable, text("Denied"))).toExist(), + expectView(within(eventsTable, text("The key lacks the dashboards:write scope."))).toExist(), + expectView(within(eventsTable, text("Today"))).toExist(), expectView(role("button", { name: "Load more" })).toExist(), ) }) - test("opening an event shows what the platform recorded about it", () => { + test("a log with no refusals shows no refusal mark", () => { + const calm = { ...PAGE, events: Arr.filter(PAGE.events, (event) => event.outcome === "success") } + + scene( + { update: pageUpdate, view }, + given(loaded(calm)), + expectView(within(eventsTable, text("Denied"))).toBeAbsent(), + ) + }) + + test("an outcome or category this build has no name for shows as itself, and is marked", () => { + const [first] = PAGE.events + const odd = first === undefined ? [] : [{ ...first, category: "licensing", outcome: "throttled" }] + + scene( + { update: pageUpdate, view }, + given(loaded({ events: odd, nextCursor: null })), + expectView(within(eventsTable, text("licensing"))).toExist(), + expectView(within(eventsTable, text("throttled"))).toExist(), + ) + }) + + test("opening an event shows what was recorded about it, and closing hides it", () => { const model = loaded() const first = eventsOf(model)?.[0] const opened = pageUpdate(model, Message.ToggledEvent({ id: first?.id ?? "" })).model @@ -124,7 +189,7 @@ describe("security audit log", () => { expect(denied.outMessage).toMatchObject({ url: hrefOf(routeOf({ outcome: "denied" })) }) const cleared = pageUpdate( - loaded(auditEventsOf(NOW), { outcome: "denied", category: "credential" }), + loaded(PAGE, { outcome: "denied", category: "credential" }), Message.ChangedFilter({ filter: "outcome", value: "" }), ) expect(cleared.outMessage).toMatchObject({ url: hrefOf(routeOf({ category: "credential" })) }) @@ -138,28 +203,13 @@ describe("security audit log", () => { expect(submitted.outMessage).toMatchObject({ url: hrefOf(routeOf({ actor: "ada@acme.test" })) }) }) - test("new filters in the URL read the list again and start the later pages over", () => { - const model = loaded() - const withMore = fold(pageUpdate(model, Message.ClickedLoadMore()).model, [ - (current) => nextPage(current, olderPage(true)), - ]) - const changed = routeChanged(withMore, routeOf({ outcome: "denied" }), context) - - expect(names(changed.commands)).toEqual(["SecurityApiAuditLogFirstPage"]) - expect(changed.model.laterPages.pages).toEqual([]) - }) - test("load more continues from the last event shown and appends what comes back", () => { - const model = loaded() - const asked = pageUpdate(model, Message.ClickedLoadMore()) - const last = Arr.last(auditEventsOf(NOW).events) + const asked = pageUpdate(loaded(), Message.ClickedLoadMore()) - expect(asked.commands?.[0]).toMatchObject({ - name: "SecurityApiAuditLogNextPage", - args: { cursor: last._tag === "Some" ? `${last.value.occurredAt}:${last.value.id}` : "" }, - }) - const appended = pageUpdate(asked.model, nextPage(asked.model, olderPage(false))).model - expect(eventsOf(appended)).toHaveLength(16) + expect(asked.commands?.[0]).toMatchObject({ name: NEXT_PAGE, args: { cursor: PAGE.nextCursor } }) + const older = olderPage(false) + const appended = pageUpdate(asked.model, nextPage(asked.model, Result.succeed(older))).model + expect(eventsOf(appended)).toHaveLength(PAGE.events.length + older.events.length) scene( { update: pageUpdate, view }, given(appended), @@ -167,37 +217,47 @@ describe("security audit log", () => { ) }) - test("a page that answers after the filters changed is dropped", () => { + test("an event repeated across a page boundary shows once", () => { const asked = pageUpdate(loaded(), Message.ClickedLoadMore()).model - const stale = pageUpdate( - asked, - nextPage(asked, olderPage(true), asked.laterPages.generation - 1), - ).model + const [repeated] = PAGE.events + const overlapping = { events: repeated === undefined ? [] : [repeated], nextCursor: null } - expect(eventsOf(stale)).toHaveLength(8) + expect(eventsOf(pageUpdate(asked, nextPage(asked, Result.succeed(overlapping))).model)).toHaveLength( + PAGE.events.length, + ) }) - test("a failed next page keeps the events shown and offers to try again", () => { + // The rows on screen are the old filters' until the new first page lands. A next page asked + // from their cursor would be the new filters' page two glued under the old page one. + test("no next page is asked for, or accepted, while other filters are loading", () => { + const inFlight = pageUpdate(loaded(), Message.ClickedLoadMore()).model + const lateAnswer = nextPage(inFlight, Result.succeed(olderPage(true))) + const changed = routeChanged(inFlight, routeOf({ outcome: "denied" }), context) + + expect(names(changed.commands)).toEqual([FIRST_PAGE]) + expect(names(pageUpdate(changed.model, Message.ClickedLoadMore()).commands)).toEqual([]) + expect(pageUpdate(changed.model, lateAnswer).model.laterPages.pages).toEqual([]) + scene( + { update: pageUpdate, view }, + given(changed.model), + expectView(role("button", { name: "Load more" })).toBeAbsent(), + ) + }) + + test("a failed next page keeps the events shown, and trying again asks from the same place", () => { const asked = pageUpdate(loaded(), Message.ClickedLoadMore()).model - const failed = pageUpdate( - asked, - Message.CompletedFetchNextPage({ - key: asked.laterPages.key ?? "", - generation: asked.laterPages.generation, - result: Result.fail( - toLoadFailure( - new AuditTrailPersistenceError({ message: "The audit trail could not be read" }), - ), - ), - }), - ).model + const failed = pageUpdate(asked, nextPage(asked, Result.fail(down))).model - expect(eventsOf(failed)).toHaveLength(8) + expect(eventsOf(failed)).toHaveLength(PAGE.events.length) scene( { update: pageUpdate, view }, given(failed), expectView(role("button", { name: "Try again" })).toExist(), ) + expect(pageUpdate(failed, Message.ClickedLoadMore()).commands?.[0]).toMatchObject({ + name: NEXT_PAGE, + args: { cursor: PAGE.nextCursor }, + }) }) test("an empty log says what will show up; an empty filter result offers to clear the filters", () => { diff --git a/apps/web/src/page/security/audit-log/command.ts b/apps/web/src/page/security/audit-log/command.ts index bd24fa3f5d..1109175ffc 100644 --- a/apps/web/src/page/security/audit-log/command.ts +++ b/apps/web/src/page/security/audit-log/command.ts @@ -23,7 +23,6 @@ const listEvents = (search: SecurityAuditLogSearch, cursor: string | undefined) Effect.flatMap(MapleInternalApiClient, (api) => api.auditTrail.list({ query: { - ...(search.provider !== undefined ? { provider: search.provider } : undefined), ...(search.category !== undefined ? { category: search.category } : undefined), ...(search.outcome !== undefined ? { outcome: search.outcome } : undefined), ...(search.actor !== undefined ? { actor: search.actor } : undefined), @@ -52,6 +51,6 @@ export const FetchNextPage = Command.define("SecurityApiAuditLogNextPage", { messages: [Message.CompletedFetchNextPage], execute: ({ key, generation, search, cursor }) => listEvents(search, cursor).pipe( - settleTo((result) => Message.CompletedFetchNextPage({ key, generation, result })), + settleTo((result) => Message.CompletedFetchNextPage({ key, generation, cursor, result })), ), }) diff --git a/apps/web/src/page/security/audit-log/derive.ts b/apps/web/src/page/security/audit-log/derive.ts index 5a58f6c86b..a98638cc9c 100644 --- a/apps/web/src/page/security/audit-log/derive.ts +++ b/apps/web/src/page/security/audit-log/derive.ts @@ -1,13 +1,12 @@ -import type { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import type { AuditTrailCategory, AuditTrailOutcome } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" -import { Array as Arr, Record as Rec } from "effect" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Option, Record as Rec } from "effect" import { AsyncData } from "foldkit" import type { Model, Search } from "./model" -// What the audit log shows of an event: the names of platforms, categories and outcomes, who -// acted and on what. A value this build does not know shows as itself. - -export const PROVIDER_LABEL = { maple: "Maple" } satisfies Record +// What the audit log shows of an event: the names of categories and outcomes, who acted and on +// what. A value this build does not know shows as itself. export const CATEGORY_LABEL = { authentication: "Authentication", @@ -28,24 +27,27 @@ export const OUTCOME_LABEL = { unknown: "Unknown", } satisfies Record -const labelOf = - (labels: Readonly>) => - (value: string): string => - Rec.get(labels, value).pipe((label) => (label._tag === "Some" ? label.value : value)) - -export const providerLabel = labelOf(PROVIDER_LABEL) -export const categoryLabel = labelOf(CATEGORY_LABEL) -export const outcomeLabel = labelOf(OUTCOME_LABEL) +/** The outcomes a platform writes today: Maple records what went through and what it refused. */ +export const OUTCOME_FILTERS = ["success", "denied"] as const satisfies ReadonlyArray const ACTOR_TYPE_LABEL = { + user: "User", service: "Service", system: "System", unknown: "Unknown actor", } as const +/** The label for `value`, or the value itself when this build has no name for it. */ +export const labelIn = (labels: Readonly>, value: string): string => + Option.getOrElse(Rec.get(labels, value), () => value) + +export const categoryLabel = (category: string): string => labelIn(CATEGORY_LABEL, category) +export const outcomeLabel = (outcome: string): string => labelIn(OUTCOME_LABEL, outcome) +export const actorTypeLabel = (type: string): string => labelIn(ACTOR_TYPE_LABEL, type) + /** Who acted: their name, else email, else id, else what kind of actor it was. */ export const actorText = (event: AuditTrailEntry): string => - event.actor.name ?? event.actor.email ?? event.actor.id ?? labelOf(ACTOR_TYPE_LABEL)(event.actor.type) + event.actor.name ?? event.actor.email ?? event.actor.id ?? actorTypeLabel(event.actor.type) /** A second line under the actor when the first is a name: the email or id that tells two apart. */ export const actorDetail = (event: AuditTrailEntry): string | null => @@ -54,15 +56,14 @@ export const actorDetail = (event: AuditTrailEntry): string | null => /** What was acted on: its name or id, null when the event names nothing. */ export const targetText = (event: AuditTrailEntry): string | null => event.target.name ?? event.target.id -/** An outcome worth a mark in the list: a success is the unmarked case. */ -export const isRefused = (event: AuditTrailEntry): boolean => - event.outcome === "denied" || event.outcome === "failure" +/** Anything but a success gets a mark in the list: an unknown outcome must not read as one. */ +export const isMarked = (event: AuditTrailEntry): boolean => event.outcome !== "success" export const isFiltered = (search: Search): boolean => - search.provider !== undefined || - search.category !== undefined || - search.outcome !== undefined || - search.actor !== undefined + search.category !== undefined || search.outcome !== undefined || search.actor !== undefined + +/** The API refused the viewer: the same thing the session says for a member without the permission. */ +export const isForbidden = (failure: LoadFailure): boolean => failure.code === "audit_trail_forbidden" /** Every event loaded so far, newest first; null until the first page answers. */ export const eventsOf = ( @@ -80,3 +81,15 @@ export const eventsOf = ( ) }, }) + +/** The cursor after everything loaded so far, or null at the end of the list. */ +export const nextCursorOf = (model: Pick): string | null => + Option.match(Arr.last(model.laterPages.pages), { + onNone: () => + AsyncData.matchData(model.firstPage.data, { + onEmpty: () => null, + onFailure: () => null, + onData: (page) => page.nextCursor, + }), + onSome: (page) => page.nextCursor, + }) diff --git a/apps/web/src/page/security/audit-log/model.ts b/apps/web/src/page/security/audit-log/model.ts index ea5f158cb1..5b7ad3fba9 100644 --- a/apps/web/src/page/security/audit-log/model.ts +++ b/apps/web/src/page/security/audit-log/model.ts @@ -25,7 +25,7 @@ export const LaterPages = Schema.Struct({ export type LaterPages = typeof LaterPages.Type /** The filters a select sets; the actor is typed and applied on Enter. */ -export const SelectFilter = Schema.Literals(["provider", "category", "outcome"]) +export const SelectFilter = Schema.Literals(["category", "outcome"]) export type SelectFilter = typeof SelectFilter.Type export const Model = Schema.Struct({ @@ -43,7 +43,16 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type -export const canRead = (model: Pick): boolean => Policy.has(model.grants, "audit_log:read") +/** + * What the session says: `denied` only when it answered and the permission is missing. A session + * that answered with nothing failed to read, so the API is asked and gets the last word. + */ +export const accessOf = (model: Pick): "unknown" | "ask" | "denied" => + !model.isGrantsRead + ? "unknown" + : model.grants.length === 0 || Policy.has(model.grants, "audit_log:read") + ? "ask" + : "denied" export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Finite }, @@ -52,6 +61,8 @@ export const Message = defineMessageUnion({ CompletedFetchNextPage: { key: Schema.String, generation: Schema.Number, + /** The cursor the page was asked from: an answer for another one is not the next page. */ + cursor: Schema.String, result: Schema.Result(EventsPage, LoadFailure), }, ClickedRetry: {}, diff --git a/apps/web/src/page/security/audit-log/update.ts b/apps/web/src/page/security/audit-log/update.ts index a81e6c14ea..2ee12fca36 100644 --- a/apps/web/src/page/security/audit-log/update.ts +++ b/apps/web/src/page/security/audit-log/update.ts @@ -1,8 +1,7 @@ import * as Policy from "@maple/domain/policy" import * as Keyed from "@maple/views/data/keyed" import { requestKey } from "@maple/views/data/request-key" -import { Array as Arr, Option, String as Str } from "effect" -import { AsyncData } from "foldkit" +import { Array as Arr, String as Str } from "effect" import { isClerkAuthEnabled } from "../../../platform/auth-mode" import { AppRoute, hrefOf } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" @@ -10,7 +9,8 @@ import { appendedPage, fetchingNext, freshLaterPages } from "../../kit/infinite- import { chain, slotStep } from "../../kit/keyed-step" import { acceptGrants } from "../../kit/session-grants" import { FetchFirstPage, FetchNextPage, ReadAuditLogClock, ReadAuditLogGrants } from "./command" -import { canRead, type LaterPages, Message, type Model, type Search } from "./model" +import { nextCursorOf } from "./derive" +import { accessOf, type LaterPages, Message, type Model, type Search } from "./model" type Return = PageReturn @@ -24,11 +24,11 @@ const freshPages = (key: string | null, generation: number): LaterPages => ({ }) /** - * The first page under the URL's filters, once the viewer is known to hold `audit_log:read`. + * The first page under the URL's filters, unless the session said the viewer may not read it. * New filters, or a refresh, start the later pages over. */ const readFirstPage = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { - if (!canRead(model)) return { model } + if (accessOf(model) !== "ask") return { model } const key = keyOf(context, model.search) const laterPages = model.laterPages.key === key && mode === "Load" @@ -44,22 +44,17 @@ const readFirstPage = (model: Model, context: PageContext, mode: Keyed.ReadMode) ) } -/** The cursor after everything loaded so far, or null at the end of the list. */ -export const nextCursorOf = (model: Pick): string | null => - Option.match(Arr.last(model.laterPages.pages), { - onNone: () => - AsyncData.matchData(model.firstPage.data, { - onEmpty: () => null, - onFailure: () => null, - onData: (page) => page.nextCursor, - }), - onSome: (page) => page.nextCursor, - }) - const fetchNext = (model: Model): Return => { const cursor = nextCursorOf(model) const { laterPages } = model - if (cursor === null || laterPages.key === null || laterPages.isFetching) return { model } + // While another first page is on its way the cursor belongs to rows about to be replaced. + if ( + cursor === null || + laterPages.key === null || + laterPages.isFetching || + !Keyed.canExtend(model.firstPage) + ) + return { model } return { model: { ...model, laterPages: fetchingNext(laterPages) }, commands: [ @@ -87,12 +82,20 @@ const withFilter = (search: Search, filter: keyof Search, value: string): Search return value === "" ? rest : { ...rest, [filter]: value } } -export const init = (route: SecurityAuditLogRoute, context: PageContext): Return => { +/** + * Entering the page. `sessionDecides`: the hosted product, where the session says what the viewer + * may read and nothing is asked before it answers. Self-hosted has one role and reads at once. + */ +export const enter = ( + route: SecurityAuditLogRoute, + context: PageContext, + sessionDecides: boolean, +): Return => { const model: Model = { search: route.search, nowMs: 0, - grants: isClerkAuthEnabled ? [] : Policy.allPermissions, - isGrantsRead: !isClerkAuthEnabled, + grants: sessionDecides ? [] : Policy.allPermissions, + isGrantsRead: !sessionDecides, firstPage: Keyed.idle(), laterPages: freshPages(null, 0), expanded: [], @@ -102,12 +105,15 @@ export const init = (route: SecurityAuditLogRoute, context: PageContext): Return model, commands: [ ReadAuditLogClock(), - ...(isClerkAuthEnabled ? [ReadAuditLogGrants({ orgId: context.orgId })] : []), + ...(sessionDecides ? [ReadAuditLogGrants({ orgId: context.orgId })] : []), ], } return chain(entered, (next) => readFirstPage(next, context, "Load")) } +export const init = (route: SecurityAuditLogRoute, context: PageContext): Return => + enter(route, context, isClerkAuthEnabled) + /** Same instance, other filters: the URL is the filter state, so the list follows it. */ export const routeChanged = (model: Model, route: SecurityAuditLogRoute, context: PageContext): Return => readFirstPage( @@ -128,9 +134,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchFirstPage: ({ key, result }) => ({ model: { ...model, firstPage: Keyed.accept(model.firstPage, key, result) }, }), - CompletedFetchNextPage: ({ key, generation, result }) => + CompletedFetchNextPage: ({ key, generation, cursor, result }) => key !== model.laterPages.key || generation !== model.laterPages.generation || + cursor !== nextCursorOf(model) || !model.laterPages.isFetching ? { model } : { model: { ...model, laterPages: appendedPage(model.laterPages, result) } }, diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index b28298064e..f435c58ef7 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -1,13 +1,13 @@ -import { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import { AuditTrailCategory } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" -import { cn } from "@maple/ui/lib/utils" -import { Array as Arr, DateTime, Schema } from "effect" +import { refreshingClass } from "@maple/ui/lib/refreshing" +import * as Keyed from "@maple/views/data/keyed" +import { Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { compactSearchInput } from "@maple/views/ui/compact-search-input" -import { badge } from "@maple/views/ui/display" import { empty, emptyDescription, @@ -26,44 +26,57 @@ import { panel } from "@maple/views/ui/panel" import { pressable } from "@maple/views/ui/pressable" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { hstack, vstack } from "@maple/views/ui/stack" -import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" +import { + table, + tableBody, + tableCell, + tableGroupRow, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" +import { textLink } from "@maple/views/ui/text-link" import { timestamp } from "@maple/views/ui/timestamp" -import { typography } from "@maple/views/ui/typography" +import { truncatedText } from "@maple/views/ui/truncated-text" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { AppRoute, hrefOf } from "../../../route" import type { PageViewInputs } from "../../contract" -import { dayKey, dayLabel } from "../../kit/day-label" -import { jsonBlock } from "../../kit/json-block" +import { dayEyebrow, dayKey } from "../../kit/day-label" +import { expandChevron } from "../../kit/expand-chevron" +import { jsonBlock, prettyJson } from "../../kit/json-block" +import { refusedBadge } from "../../kit/refused-badge" import { relativeTime } from "../../kit/relative-time" import { securityLayout, WIDE_COLUMN } from "../kit/layout" -import { placeholder } from "../kit/parts" +import { placeholderRows } from "../kit/parts" import { actorDetail, actorText, + actorTypeLabel, CATEGORY_LABEL, categoryLabel, eventsOf, isFiltered, - isRefused, + isForbidden, + isMarked, + labelIn, + nextCursorOf, + OUTCOME_FILTERS, OUTCOME_LABEL, outcomeLabel, - PROVIDER_LABEL, - providerLabel, targetText, } from "./derive" -import { canRead, Message, type Model, type SelectFilter } from "./model" -import { nextCursorOf } from "./update" +import { accessOf, Message, type Model, type SelectFilter } from "./model" type H = HtmlBuilder type Clock = Readonly<{ nowMs: number; timeZone: string }> -const COLUMN_COUNT = 6 +const COLUMN_COUNT = 5 // On a phone the two text columns share the width left of the time, so neither scrolls away. const NARROW_ACTOR = "max-w-32 sm:max-w-56" const NARROW_ACTION = "max-w-36 sm:max-w-72" -const encodePrettyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })) - -/** One filter as a native select: "All …" clears it. Keyed on its value so a URL change shows. */ +/** One filter as a native select: "All …" clears it. The URL holds the value it shows. */ const filterSelect = ( h: H, filter: SelectFilter, @@ -76,34 +89,20 @@ const filterSelect = ( h, { size: "sm", - defaultValue: value ?? "", + value: value ?? "", attributes: [ - h.Key(`${filter}:${value ?? ""}`), h.AriaLabel(all), h.OnChange((next) => Message.ChangedFilter({ filter, value: next })), ], }, [ nativeOption("", all), - ...Arr.map(values, (option) => nativeOption(option, labels[option] ?? option)), + ...Arr.map(values, (option) => nativeOption(option, labelIn(labels, option))), ], ) const toolbar = (h: H, model: Model): Html => hstack(h, { gap: "2", align: "center", wrap: true, className: "border-b px-4 py-2.5" }, [ - // One platform has nothing to choose between; the select appears with the second. - ...(AuditTrailProvider.literals.length > 1 - ? [ - filterSelect( - h, - "provider", - model.search.provider, - "All platforms", - PROVIDER_LABEL, - AuditTrailProvider.literals, - ), - ] - : []), filterSelect( h, "category", @@ -112,22 +111,15 @@ const toolbar = (h: H, model: Model): Html => CATEGORY_LABEL, AuditTrailCategory.literals, ), - filterSelect( - h, - "outcome", - model.search.outcome, - "All outcomes", - OUTCOME_LABEL, - AuditTrailOutcome.literals, - ), + filterSelect(h, "outcome", model.search.outcome, "All outcomes", OUTCOME_LABEL, OUTCOME_FILTERS), compactSearchInput(h, { value: model.actorText, - placeholder: "Actor id or email", + placeholder: "Actor id or email, then Enter", ariaLabel: "Filter by actor", onInput: (value) => Message.ChangedActorText({ value }), onClear: Message.ClearedActor(), onEnter: Message.SubmittedActor(), - className: "w-56", + className: "w-64", }), ...(isFiltered(model.search) ? [ @@ -144,50 +136,34 @@ const toolbar = (h: H, model: Model): Html => : []), ]) -const outcomeBadge = (h: H, event: AuditTrailEntry): ReadonlyArray => - isRefused(event) - ? [ - badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, [ - icon(h, "CircleXmarkIcon"), - outcomeLabel(event.outcome), - ]), - ] - : [] +const outcomeMark = (h: H, event: AuditTrailEntry): ReadonlyArray => + isMarked(event) ? [refusedBadge(h, outcomeLabel(event.outcome))] : [] const detailField = (h: H, label: string, value: string | null, mono = false): ReadonlyArray => value === null ? [] : [keyValue(h, { label, wrap: true, mono }, [value])] -/** Everything the platform said about one event, under its row. */ -const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => - vstack(h, { gap: "3", className: "px-4 py-3" }, [ +/** Everything recorded about one event, under its row: nothing the row cut off is lost. */ +const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => { + const at = (value: number) => timestamp(h, { value, ...clock, withYear: true, className: "tabular-nums" }) + return vstack(h, { gap: "3", className: "px-4 py-3" }, [ keyValueList(h, { layout: "grid" }, [ - keyValue(h, { label: "Happened", wrap: true }, [ - timestamp(h, { - value: isoOf(event.occurredAt), - ...clock, - withYear: true, - className: "tabular-nums", - }), - ]), - keyValue(h, { label: "Recorded", wrap: true }, [ - timestamp(h, { - value: isoOf(event.ingestedAt), - ...clock, - withYear: true, - className: "tabular-nums", - }), - ]), - keyValue(h, { label: "Platform", wrap: true }, [providerLabel(event.provider)]), - ...detailField(h, "Account", event.account), + keyValue(h, { label: "Action", wrap: true, mono: true }, [event.action]), keyValue(h, { label: "Category", wrap: true }, [categoryLabel(event.category)]), keyValue(h, { label: "Outcome", wrap: true }, [outcomeLabel(event.outcome)]), + ...detailField(h, "Reason", event.outcomeReason), + ...detailField(h, "Actor", event.actor.name), + keyValue(h, { label: "Actor type", wrap: true }, [actorTypeLabel(event.actor.type)]), ...detailField(h, "Actor id", event.actor.id, true), ...detailField(h, "Actor email", event.actor.email), + ...detailField(h, "Target", event.target.name), ...detailField(h, "Target type", event.target.type, true), ...detailField(h, "Target id", event.target.id, true), - ...detailField(h, "Origin IP", event.originIp, true), - ...detailField(h, "Origin country", event.originCountry), + ...detailField(h, "IP address", event.originIp, true), + ...detailField(h, "Country", event.originCountry), ...detailField(h, "User agent", event.userAgent, true), + ...detailField(h, "Account", event.account, true), + keyValue(h, { label: "Occurred", wrap: true }, [at(event.occurredAt)]), + keyValue(h, { label: "Recorded", wrap: true }, [at(event.ingestedAt)]), keyValue(h, { label: "Event id", wrap: true, mono: true }, [event.providerEventId]), ]), ...(event.raw === null @@ -195,55 +171,51 @@ const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => : [ vstack(h, { gap: "1.5" }, [ eyebrow(h, { variant: "mono" }, ["Payload"]), - jsonBlock(h, encodePrettyJson(event.raw)), + jsonBlock(h, prettyJson(event.raw)), ]), ]), ]) - -const isoOf = (epochMs: number): string => DateTime.formatIso(DateTime.makeUnsafe(epochMs)) +} const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): ReadonlyArray => { const isExpanded = Arr.contains(model.expanded, event.id) const detailId = `audit-event-${event.id}` const actor = actorDetail(event) const target = targetText(event) + const small = typographyClass({ size: "2xs", tone: "muted" }) return [ tableRow( h, [ - tableCell(h, [ - pressable( - h, - { - onClick: Message.ToggledEvent({ id: event.id }), - label: `${isExpanded ? "Hide" : "Show"} details: ${event.action}`, - className: - "focus-visible:ring-ring rounded-sm focus-visible:ring-1 focus-visible:outline-none", - attributes: [h.AriaExpanded(isExpanded), h.AriaControls(detailId)], - }, - [ - hstack(h, { gap: "1.5", align: "center", as: "span" }, [ - icon(h, "ChevronRightIcon", { - size: 12, - className: cn( - "text-foreground-subtle shrink-0 transition-transform", - isExpanded && "rotate-90", - ), - }), - relativeTime(h, { value: isoOf(event.occurredAt), ...clock }), - ]), - ], - ), - ]), - tableCell(h, [providerLabel(event.provider)], WIDE_COLUMN), + tableCell( + h, + [ + // The whole cell, so a thumb has more than a line of text to hit. + pressable( + h, + { + onClick: Message.ToggledEvent({ id: event.id }), + label: `${isExpanded ? "Hide" : "Show"} details: ${event.action}`, + className: + "focus-visible:ring-ring flex min-h-10 w-full items-center gap-1.5 px-2.5 focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset", + attributes: [h.AriaExpanded(isExpanded), h.AriaControls(detailId)], + }, + [ + expandChevron(h, isExpanded), + relativeTime(h, { value: event.occurredAt, ...clock, variant: "short" }), + ], + ), + ], + "p-0", + ), tableCell( h, [ vstack(h, { shrink: true }, [ - typography(h, { truncate: true }, [actorText(event)]), + truncatedText(h, { text: actorText(event), tooltip: "overflow" }), ...(actor === null ? [] - : [typography(h, { size: "2xs", tone: "muted", truncate: true }, [actor])]), + : [truncatedText(h, { text: actor, tooltip: "overflow", className: small })]), ]), ], NARROW_ACTOR, @@ -252,16 +224,30 @@ const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): Re h, [ vstack(h, { shrink: true }, [ - typography(h, { mono: true, size: "xs", truncate: true }, [event.action]), + truncatedText(h, { + text: event.action, + mono: true, + tooltip: "overflow", + className: typographyClass({ size: "xs" }), + }), hstack(h, { gap: "1.5", align: "center", wrap: true }, [ typography(h, { size: "2xs", tone: "muted" }, [ categoryLabel(event.category), ]), - // A phone has no Outcome column: a refusal still shows, under its action. - ...Arr.map(outcomeBadge(h, event), (mark) => + // A phone has no Outcome column: the mark still shows, under its action. + ...Arr.map(outcomeMark(h, event), (mark) => h.span([h.Class("sm:hidden")], [mark]), ), ]), + ...(event.outcomeReason === null + ? [] + : [ + truncatedText(h, { + text: event.outcomeReason, + tooltip: "overflow", + className: small, + }), + ]), ]), ], NARROW_ACTION, @@ -271,13 +257,16 @@ const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): Re target === null ? [] : [ - typography(h, { mono: true, size: "xs", tone: "muted", truncate: true }, [ - target, - ]), + truncatedText(h, { + text: target, + mono: true, + tooltip: "overflow", + className: typographyClass({ size: "xs", tone: "muted" }), + }), ], - WIDE_COLUMN, + `max-w-64 ${WIDE_COLUMN}`, ), - tableCell(h, outcomeBadge(h, event), WIDE_COLUMN), + tableCell(h, outcomeMark(h, event), WIDE_COLUMN), ], isExpanded ? "bg-surface-faint" : undefined, [h.Key(event.id)], @@ -313,25 +302,9 @@ const rowsByDay = ( return [ ...(isNewDay ? [ - tableRow( - h, - [ - tableCell( - h, - [ - eyebrow( - h, - { variant: "mono", icon: "CalendarIcon", className: "gap-2" }, - [dayLabel(event.occurredAt, clock)], - ), - ], - "bg-surface-faint py-1.5", - [h.Attribute("colspan", String(COLUMN_COUNT))], - ), - ], - "hover:bg-transparent active:bg-transparent", - [h.Key(`day-${day}`)], - ), + tableGroupRow(h, { colSpan: COLUMN_COUNT, key: `day-${day}` }, [ + dayEyebrow(h, event.occurredAt, clock), + ]), ] : []), ...eventRows(h, model, event, clock), @@ -342,25 +315,17 @@ const eventsTable = (h: H, model: Model, events: ReadonlyArray, table(h, { ariaLabel: "Audit events" }, [ tableHeader(h, [ tableRow(h, [ - tableHead(h, ["Time"], "w-28 sm:w-36"), - tableHead(h, ["Platform"], WIDE_COLUMN), + tableHead(h, ["Time"], "w-24"), tableHead(h, ["Actor"]), tableHead(h, ["Action"]), tableHead(h, ["Target"], WIDE_COLUMN), - tableHead(h, ["Outcome"], cn("w-28", WIDE_COLUMN)), + tableHead(h, ["Outcome"], `w-28 ${WIDE_COLUMN}`), ]), ]), tableBody(h, rowsByDay(h, model, events, clock)), ]) -const loading = (h: H): ReadonlyArray => [ - vstack(h, { gap: "4", attributes: [h.AriaBusy(true), h.AriaLabel("Loading")] }, [ - placeholder(h, "h-9 w-full"), - placeholder(h, "h-80 w-full"), - ]), -] - -/** Not an org admin: the page says who can read the log, and loads nothing. */ +/** Not an org admin: the page says who can read the log. */ const adminsOnly = (h: H): Html => empty( h, @@ -392,7 +357,7 @@ const noEvents = (h: H): Html => const listBody = (h: H, model: Model, clock: Clock): Html => AsyncData.matchData(model.firstPage.data, { - onEmpty: () => vstack(h, { className: "p-4" }, loading(h)), + onEmpty: () => placeholderRows(h, 6), onFailure: (failure) => errorState(h, { failure, @@ -410,33 +375,66 @@ const listBody = (h: H, model: Model, clock: Clock): Html => className: "py-8", }) if (events.length === 0) return noEvents(h) + // While other filters load, the rows on screen are the old answer and cannot be extended. + const canExtend = Keyed.canExtend(model.firstPage) const footer = listFooter(h, { shown: events.length, noun: "events", - hasMore: nextCursorOf(model) !== null, + hasMore: canExtend && nextCursorOf(model) !== null, isLoading: model.laterPages.isFetching, - isFailed: model.laterPages.isFailed, + isFailed: canExtend && model.laterPages.isFailed, onLoadMore: Message.ClickedLoadMore(), className: "border-t", }) - return vstack(h, {}, [eventsTable(h, model, events, clock), ...(footer === null ? [] : [footer])]) + const isRefetching = Keyed.isInFlight(model.firstPage) + return vstack( + h, + { className: refreshingClass(isRefetching), attributes: [h.AriaBusy(isRefetching)] }, + [eventsTable(h, model, events, clock), ...(footer === null ? [] : [footer])], + ) }, }) +/** Allowed reads are not here, and a reader should not take their absence for "nobody read". */ +const footnote = (h: H): Html => + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Allowed reads, tool calls and SQL runs are not listed here. They are in ", + textLink( + h, + { + tone: "muted", + className: "underline", + href: hrefOf(AppRoute.Settings({ search: { tab: "audit-log" } })), + }, + ["Settings, Audit log"], + ), + ".", + ]) + +/** The session, or the API when the session could not say, refused the viewer. */ +const isRefused = (model: Model): boolean => + accessOf(model) === "denied" || + AsyncData.matchData(model.firstPage.data, { + onEmpty: () => false, + onFailure: isForbidden, + onData: () => false, + }) + export const view = Submodel.defineView((model, inputs, h) => { const clock = { nowMs: model.nowMs, timeZone: inputs.context.timeZone } return securityLayout( h, { source: "auditLog" }, - !model.isGrantsRead - ? loading(h) - : !canRead(model) + accessOf(model) === "unknown" + ? [placeholderRows(h, 6)] + : isRefused(model) ? [adminsOnly(h)] : [ panel(h, { className: "overflow-hidden" }, [ toolbar(h, model), listBody(h, model, clock), ]), + footnote(h), ], ) }) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 2f0f913dd2..d963beec57 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -25,7 +25,7 @@ import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" import { textLink } from "@maple/views/ui/text-link" import { iconButton } from "@maple/views/ui/icon-button" -import { jsonBlock } from "../../kit/json-block" +import { jsonBlock, prettyJson } from "../../kit/json-block" type H = HtmlBuilder @@ -58,7 +58,6 @@ const formatScalar = (value: unknown): string => typeof value === "string" ? value : Option.getOrElse(encodeJsonOption(value), () => "undefined") const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) -const encodePrettyJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })) /** Structured or long metadata renders as a block. */ const metadataBlock = (value: unknown): string | null => { @@ -68,10 +67,10 @@ const metadataBlock = (value: unknown): string | null => { Str.startsWith("{")(trimmed) || Str.startsWith("[")(trimmed) ? Option.getOrNull(parseJson(trimmed)) : null - if (parsed !== null && typeof parsed === "object") return encodePrettyJson(parsed) + if (parsed !== null && typeof parsed === "object") return prettyJson(parsed) return value.length > 72 || Str.includes("\n")(value) ? value : null } - if (value !== null && typeof value === "object") return encodePrettyJson(value) + if (value !== null && typeof value === "object") return prettyJson(value) return null } diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 01b2d5cc4e..9823e0c56d 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -54,7 +54,9 @@ import { auditDetail, memberName, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" import { Array as Arr, DateTime, String as Str, Option, Order, Record as Rec } from "effect" -import { dayKey, dayLabel } from "../../kit/day-label" +import { dayEyebrow, dayKey } from "../../kit/day-label" +import { expandChevron } from "../../kit/expand-chevron" +import { refusedBadge } from "../../kit/refused-badge" import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder @@ -424,11 +426,7 @@ const actionLabel = (h: H, action: string, isQuiet: boolean): ReadonlyArray - badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, [ - icon(h, OUTCOME_ICONS.denied), - "Denied", - ]) +export const deniedBadge = (h: H): Html => refusedBadge(h, "Denied") /** * A non-person actor in a row: its glyph alone, the size of a person's avatar. It names the actor @@ -524,13 +522,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs h.span( [h.Class(cn(COL.time, typographyClass({ size: "xs", tone: "muted" })))], [ - icon(h, "ChevronRightIcon", { - size: 12, - className: cn( - "text-foreground-subtle shrink-0 transition-transform", - isExpanded && "rotate-90", - ), - }), + expandChevron(h, isExpanded), relativeTime(h, { value: entry.occurred_at, nowMs: inputs.nowMs, @@ -675,9 +667,7 @@ const withDayHeadings = ( ], [ // The gap the time column has, so the day sits above the times. - eyebrow(h, { variant: "mono", icon: "CalendarIcon", className: "gap-2" }, [ - dayLabel(atMs(entry), clock), - ]), + dayEyebrow(h, atMs(entry), clock), ], ), ] diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index a0ce09f9ab..391dc7a7ca 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -347,9 +347,9 @@ describe("routes", () => { _tag: "SecurityDependencies", search: { repo: "repo_1" }, }) - expect(parse("/security/audit-log?provider=github&outcome=denied")).toMatchObject({ + expect(parse("/security/audit-log?category=credential&outcome=denied")).toMatchObject({ _tag: "SecurityAuditLog", - search: { provider: "github", outcome: "denied" }, + search: { category: "credential", outcome: "denied" }, }) expect(parse("/security/code/runs/run_9f3c2a1e7b")).toMatchObject({ _tag: "SecurityRun", diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 084e8ce3dd..ef841f2a7d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -1085,7 +1085,6 @@ export const SecurityDependenciesSearch = Schema.Struct({ /** The Security hub's audit log filters; each absent one shows everything. */ export const SecurityAuditLogSearch = Schema.Struct({ - provider: StringParam, category: StringParam, outcome: StringParam, /** An actor's id or email on the platform the event came from. */ diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index fe0cb23d2c..a7bd68956b 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -164,7 +164,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/swatch.ts` `swatch`: A small square colour key beside a legend, tooltip or table label; `dashed` draws an outline in `color` instead of a fill. - `packages/views/src/ui/switch.ts` `switchControl`, `labelId`, `view` (stateful: Model, init, update): Base UI `Switch`: on or off, with a thumb and a visually hidden checkbox. - `packages/views/src/ui/table-skeleton.ts` `tableSkeleton`: The loading placeholder for row tables. -- `packages/views/src/ui/table.ts` `table`, `tableHeader`, `tableBody`, `tableRow`, `clickableRowClass`, `tableHead`, `tableCell`, `divTableHead`, `truncatedTableCell`, `numericTableCell`, `laneTableCell`, `rowLink`, `ariaSort`, `sortButton`: The Table and its parts. +- `packages/views/src/ui/table.ts` `table`, `tableHeader`, `tableBody`, `tableRow`, `tableGroupRow`, `clickableRowClass`, `tableHead`, `tableCell`, `divTableHead`, `truncatedTableCell`, `numericTableCell`, `laneTableCell`, `rowLink`, `ariaSort`, `sortButton`: The Table and its parts. - `packages/views/src/ui/tabs-view.ts` `view`: Base UI's Tabs DOM: the same data attributes, roles, roving tabindex and `useId` ids. - `packages/views/src/ui/tabs.ts` `tabId`, `panelId` (stateful: Model, init, update): Base UI Tabs, horizontal, manual activation: arrow keys and Home/End move the focused tab (Base UI's highlight), click/Enter/Space select it. - `packages/views/src/ui/text-link.ts` `textLinkClass`, `textLink`: `TextLink`: an inline action that reads as a link inside running text or a card footer, with no button box. @@ -213,12 +213,13 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/danger-zone.ts` `dangerZone`, `dangerRow`: The one "Danger zone" section: a destructive-edged panel of rows, each a title, what it does, and its action. - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: its filter rail, content column, sticky header and scroll area. - `apps/web/src/page/kit/data-table.ts` `headSort`, `columnHead`, `dataTable`, `dataTableEmpty`, `skeletonRows`: A sortable data table: its column heads, numeric cells, row links, and empty and skeleton rows. -- `apps/web/src/page/kit/day-label.ts` `dayKey`, `dayLabel`: Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). +- `apps/web/src/page/kit/day-label.ts` `dayKey`, `dayLabel`, `dayEyebrow`: Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: The alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: A detail page's title block and its loading skeleton. - `apps/web/src/page/kit/docs.ts` `docsUrl`: Every docs page the app links to from an empty state, as a path under maple.dev. - `apps/web/src/page/kit/environment-switcher.ts` `environmentEntries`, `entryLabel`, `stepEnvironmentSelect`, `environmentSwitcher`: The environment switcher the overview, the service map and a service's page share. - `apps/web/src/page/kit/error-chip.ts` `errorChip`: Tool failures read amber: the chip class for a tool error that is not a count. +- `apps/web/src/page/kit/expand-chevron.ts` `expandChevron`: The chevron at the start of a row that opens in place: right when closed, down when open. - `apps/web/src/page/kit/facets.ts` `toggledValue`, `onlyValue`, `excludedValue`, `editFacet`, `facetChipsOf`, `facetChips`: The include/exclude arithmetic of a tri-state facet filter, as applied to a page's URL search: an empty list leaves the URL, and a value is never both included and excluded. - `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: One row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack in a card under 36rem wide (a phone, or a form besid... - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: One `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. @@ -236,7 +237,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/issue-actor.ts` `resolveActorIdentity`, `identityAvatar`, `actorAvatar`, `noActor`, `actorChip`: Actor avatars and chips. - `apps/web/src/page/kit/issue-severity.ts` `isIssueSeverity`, `severityDot`, `severityBadge`, `severityIcon`: The severity badge's maps and marks. - `apps/web/src/page/kit/issue-workflow.ts` `workflowRingIcon`: The state as a ring that fills as the work progresses. -- `apps/web/src/page/kit/json-block.ts` `jsonBlock`: A JSON document or other preformatted text in a scrolling well (an audit entry's payload). +- `apps/web/src/page/kit/json-block.ts` `prettyJson`, `jsonBlock`: A value as indented JSON text, for `jsonBlock`. - `apps/web/src/page/kit/k8s-chart.ts` `k8sChart`: The Kubernetes metric chart and the detail-chart wrappers around it: one metric at a time, 280 tall, series legend, in a chart card. - `apps/web/src/page/kit/keyed-step.ts` `keyed`, `keyedSlot`, `applySteps`, `runSteps`, `seriesSteps`, `slotStep`, `chain`, `chainAll`: One request slot's step and the way steps compose: the Model threaded through, the Commands concatenated, the last OutMessage kept. - `apps/web/src/page/kit/last-by.ts` `lastBy`: A derivation that views call on every message, recomputed only when one of its inputs changes. @@ -275,6 +276,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/query-panel-bar.ts` `queryPanelBar`: A query panel's header bar on the sunken wash: its collapse toggle, query badge, source and actions. - `apps/web/src/page/kit/recommendations.ts` `listRecommendations`, `listAttributeMappings`: Instrumentation recommendations, as the recommendation page and Settings > Ingestion both show them: the reads and the kind and status words. - `apps/web/src/page/kit/refresh-controls.ts` `formatRefreshInterval`, `refreshControls`: Reload joined to the auto-refresh cadence menu. +- `apps/web/src/page/kit/refused-badge.ts` `refusedBadge`: The mark on an audit entry that did not go through: a crossed circle and what happened ("Denied", "Failed"). - `apps/web/src/page/kit/relative-time.ts` `relativeTime`, `listRelativeTime`: A timestamp as relative text. - `apps/web/src/page/kit/resource-not-found.ts` `resourceNotFound` - `apps/web/src/page/kit/route-window.ts` `isWindowChange`, `snapModeOf`, `routeWindowStep`: A page's time window across `routeChanged`: re-resolved only when its own fields moved, and only Reload revalidates unchanged keys. diff --git a/packages/views/src/ui/native-select.ts b/packages/views/src/ui/native-select.ts index 8cc1a03419..4d1cdaf39a 100644 --- a/packages/views/src/ui/native-select.ts +++ b/packages/views/src/ui/native-select.ts @@ -44,6 +44,8 @@ export const nativeSelect = ( size?: "sm" | "default" className?: string defaultValue?: string + /** Controlled: the select shows this value on every render, whatever was picked last. */ + value?: string isDisabled?: boolean /** A mark before the value (the picked option's status dot); decorative, the value names it. */ leading?: Html @@ -70,10 +72,11 @@ export const nativeSelect = ( h.DataAttribute("size", size), h.DataAttribute("slot", "native-select"), ...(options.isDisabled === true ? [h.Disabled(true)] : []), + ...(options.value === undefined ? [] : [h.Value(options.value)]), ], Arr.map(entries, (entry) => entry._tag === "Option" - ? optionView(h, entry.option, options.defaultValue) + ? optionView(h, entry.option, options.value ?? options.defaultValue) : h.optgroup( [ h.Attribute("class", ""), @@ -81,7 +84,7 @@ export const nativeSelect = ( h.LabelAttr(entry.label), ], Arr.map(entry.options, (option) => - optionView(h, option, options.defaultValue), + optionView(h, option, options.value ?? options.defaultValue), ), ), ), diff --git a/packages/views/src/ui/table.ts b/packages/views/src/ui/table.ts index 6d0b55906c..43d3ae498a 100644 --- a/packages/views/src/ui/table.ts +++ b/packages/views/src/ui/table.ts @@ -101,6 +101,35 @@ export const tableRow = ( children, ) +/** A row that heads the rows under it (a day in a list grouped by day): one header cell across every column. */ +export const tableGroupRow = ( + h: HtmlBuilder, + options: Readonly<{ colSpan: number; key: string; className?: string }>, + children: Children, +): Html => + tableRow( + h, + [ + h.th( + [ + h.Attribute( + "class", + cn( + tableCellStyles, + "bg-surface-sunken py-1 text-left font-normal", + options.className, + ), + ), + h.DataAttribute("slot", "table-group"), + h.Attribute("colspan", String(options.colSpan)), + ], + children, + ), + ], + "hover:bg-transparent active:bg-transparent", + [h.Key(options.key)], + ) + /** A `tableRow` whose whole row opens something: the pointer and the sunken hover, strong while selected. */ export const clickableRowClass = (isSelected = false): string => cn("cursor-pointer hover:bg-surface-sunken active:bg-surface-sunken", isSelected && "bg-surface-strong") From cc6dca80607ab6890cfca0623b4e288a548a8e5b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 02:03:21 +0100 Subject: [PATCH 08/30] test(web): a sample URL for the security audit log page --- apps/web/src/view.lazy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index c3379bbffe..5b3179a5a6 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -93,6 +93,7 @@ const SAMPLE_PATHS = [ "/security/exposure", "/security/runtimes", "/security/dependencies", + "/security/audit-log", "/security/code/runs/run_1", "/security/code/findings/00000000-0000-4000-8000-000000000005/fp_1", "/agent-sessions", From 001b331477efb8b0d8b1902460c329d975549cfb Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 04:20:50 +0100 Subject: [PATCH 09/30] fix(web): the audit log row toggle says when the event happened Its label replaces its content for a screen reader, and the content was the only place the time was. The label now carries the action and the timestamp. --- .../page/security/audit-log/audit-log.scene.test.ts | 10 ++++++++++ apps/web/src/page/security/audit-log/view.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 838f4ad43d..c3d77e6add 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -143,6 +143,16 @@ describe("security audit log", () => { ) }) + // The toggle's label replaces its content for a screen reader: without the time in it, the + // row would say what happened and never when. + test("a row's toggle names the action and when it happened", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(role("button", { name: /Show details: alert_rule\.updated, .*2026/ })).toExist(), + ) + }) + test("a log with no refusals shows no refusal mark", () => { const calm = { ...PAGE, events: Arr.filter(PAGE.events, (event) => event.outcome === "success") } diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index f435c58ef7..53c27ebf64 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -2,6 +2,7 @@ import { AuditTrailCategory } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { refreshingClass } from "@maple/ui/lib/refreshing" +import { formatTimestampInTimezone } from "@maple/ui/lib/timezone-format" import * as Keyed from "@maple/views/data/keyed" import { Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -190,12 +191,13 @@ const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): Re tableCell( h, [ - // The whole cell, so a thumb has more than a line of text to hit. + // The whole cell, so a thumb has more than a line of text to hit. Its label + // replaces its content for a screen reader, so it says when as well as what. pressable( h, { onClick: Message.ToggledEvent({ id: event.id }), - label: `${isExpanded ? "Hide" : "Show"} details: ${event.action}`, + label: `${isExpanded ? "Hide" : "Show"} details: ${event.action}, ${formatTimestampInTimezone(event.occurredAt, { timeZone: clock.timeZone, withYear: true })}`, className: "focus-visible:ring-ring flex min-h-10 w-full items-center gap-1.5 px-2.5 focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset", attributes: [h.AriaExpanded(isExpanded), h.AriaControls(detailId)], From 2c15b63fbc24f46bd5c278035ef00c42980736ec Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:22:58 +0100 Subject: [PATCH 10/30] fix(web): name the audit log's filter selects for what they filter A screen reader heard "All outcomes, Denied": the select was named after its clear option. They are now Category and Outcome. The scene test's second page also carries a cursor on its own last event instead of the first page's. --- .../audit-log/audit-log.scene.test.ts | 22 ++++++++++++++----- apps/web/src/page/security/audit-log/view.ts | 15 +++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index c3d77e6add..1949705cc3 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -67,13 +67,14 @@ const nextPage = (model: Model, result: Result.Result { - const page = auditEventsOf(NOW - 3 * 24 * 3600 * 1000, hasMore) - return { - ...page, - events: Arr.map(page.events, (event) => ({ ...event, id: `f${Str.slice(1)(event.id)}` })), - } + const events = Arr.map(auditEventsOf(NOW - 3 * 24 * 3600 * 1000).events, (event) => ({ + ...event, + id: `f${Str.slice(1)(event.id)}`, + })) + const last = events.at(-1) + return { events, nextCursor: hasMore && last !== undefined ? `${last.occurredAt}:${last.id}` : null } } const down = toLoadFailure(new AuditTrailPersistenceError({ message: "The audit trail could not be read" })) @@ -145,6 +146,15 @@ describe("security audit log", () => { // The toggle's label replaces its content for a screen reader: without the time in it, the // row would say what happened and never when. + test("each filter is named for what it filters, whatever value it shows", () => { + scene( + { update: pageUpdate, view }, + given(loaded(PAGE, { outcome: "denied" })), + expectView(role("combobox", { name: "Outcome" })).toExist(), + expectView(role("combobox", { name: "Category" })).toExist(), + ) + }) + test("a row's toggle names the action and when it happened", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index 53c27ebf64..b36a0079c9 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -81,6 +81,8 @@ const NARROW_ACTION = "max-w-36 sm:max-w-72" const filterSelect = ( h: H, filter: SelectFilter, + /** What the select filters, as a screen reader names it. */ + name: string, value: string | undefined, all: string, labels: Readonly>, @@ -92,7 +94,7 @@ const filterSelect = ( size: "sm", value: value ?? "", attributes: [ - h.AriaLabel(all), + h.AriaLabel(name), h.OnChange((next) => Message.ChangedFilter({ filter, value: next })), ], }, @@ -107,12 +109,21 @@ const toolbar = (h: H, model: Model): Html => filterSelect( h, "category", + "Category", model.search.category, "All categories", CATEGORY_LABEL, AuditTrailCategory.literals, ), - filterSelect(h, "outcome", model.search.outcome, "All outcomes", OUTCOME_LABEL, OUTCOME_FILTERS), + filterSelect( + h, + "outcome", + "Outcome", + model.search.outcome, + "All outcomes", + OUTCOME_LABEL, + OUTCOME_FILTERS, + ), compactSearchInput(h, { value: model.actorText, placeholder: "Actor id or email, then Enter", From 736658ea72abd0fc19a2b92f0b1ed213e93318de Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:42:43 +0100 Subject: [PATCH 11/30] fix(security): a refused API request is a permission event with a target Found testing on the preview: a key refused for a missing scope was filed under authentication with an empty target, so filtering refusals by permission found nothing and the row did not say what was asked for. It is now a permission event whose target is the request's method and path. --- .../audit-trail/providers/maple.test.ts | 20 +++++++++++++++++++ .../services/audit-trail/providers/maple.ts | 15 ++++++++++++-- packages/domain/src/audit-trail.ts | 4 ++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/services/audit-trail/providers/maple.test.ts b/packages/backend/src/services/audit-trail/providers/maple.test.ts index a2f2762263..1f39e143f3 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.test.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.test.ts @@ -60,6 +60,26 @@ describe("mapleAuditTrailEvents", () => { }) }) + // A key that authenticated and was refused for a missing scope: a rule on refused + // permissions has to find it, and the row has to say what was asked for. + it("files a refused API request under permission, aimed at its method and path", () => { + const event = trailEventOf({ + actorType: "api_key", + action: "api.request", + resourceType: "api", + outcome: "denied", + denialReason: "The key lacks the dashboards:write scope.", + metadata: { method: "POST", path: "/v2/dashboards" }, + }) + + expect(event).toMatchObject({ + category: "permission", + outcome: "denied", + outcomeReason: "The key lacks the dashboards:write scope.", + target: { type: "api", name: "POST /v2/dashboards" }, + }) + }) + it("names an API key by its public id and the name it had, not by the person who minted it", () => { const event = trailEventOf({ actorType: "api_key", diff --git a/packages/backend/src/services/audit-trail/providers/maple.ts b/packages/backend/src/services/audit-trail/providers/maple.ts index eb7ff45f81..419f001129 100644 --- a/packages/backend/src/services/audit-trail/providers/maple.ts +++ b/packages/backend/src/services/audit-trail/providers/maple.ts @@ -19,8 +19,8 @@ const CATEGORY_BY_RESOURCE = { alert_maintenance_window: "configuration", anomaly_incident: "resource", anomaly_settings: "configuration", - // The only `api` action is a refused request: a credential that did not get in. - api: "authentication", + // The only `api` action is a request refused for a scope or role the credential lacks. + api: "permission", api_key: "credential", attribute_mapping: "configuration", ingest_rule: "configuration", @@ -130,6 +130,15 @@ const rawOf = (event: AuditLogEvent): Record => ({ ...(event.requestId !== undefined ? { requestId: event.requestId } : undefined), }) +/** A refused API request has no resource: what it was aimed at is its method and path. */ +const requestName = (event: AuditLogEvent): string | undefined => { + const method = event.metadata?.["method"] + const path = event.metadata?.["path"] + return event.action === "api.request" && typeof method === "string" && typeof path === "string" + ? `${method} ${path}` + : undefined +} + /** * Maple's own audit entry as a trail event, or null for one the trail leaves out: an allowed * read, tool call or SQL run. A heavy agent user writes hundreds a day, and they would bury @@ -139,11 +148,13 @@ const mapleAuditTrailEvent = (event: AuditLogEvent): AuditTrailEvent | null => { if (isAuditAccessAction(event.action) && event.outcome !== "denied") return null // A membership change names the member it happened to, not a resource id. const targetId = event.resourceId ?? event.affectedUserId + const targetName = requestName(event) const target = event.resourceType !== undefined || targetId !== undefined ? { ...(event.resourceType !== undefined ? { type: event.resourceType } : undefined), ...(targetId !== undefined ? { id: targetId } : undefined), + ...(targetName !== undefined ? { name: targetName } : undefined), } : undefined return { diff --git a/packages/domain/src/audit-trail.ts b/packages/domain/src/audit-trail.ts index c587cdaa12..da87d4c27c 100644 --- a/packages/domain/src/audit-trail.ts +++ b/packages/domain/src/audit-trail.ts @@ -6,8 +6,8 @@ export type AuditTrailProvider = typeof AuditTrailProvider.Type /** * What an event is about, the same across platforms so one rule covers all of them. - * `configuration` is how the platform behaves (settings, rules, monitors, integrations); - * `resource` is something people make and own in it (a repository, a dashboard, a deployment). + * `configuration` is how the platform behaves; `resource` is something people make and own in it. + * `permission` is what someone may do: a grant changed, or a request refused for lacking one. */ export const AuditTrailCategory = Schema.Literals([ "authentication", From e150a501b1bfab035f72a9c5f5413846d674e5e3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:44:58 +0100 Subject: [PATCH 12/30] fix(security): audit log page fixes from testing on the preview - An empty answer to the previous filters no longer shows as "no matches" while the new filters load: the list shows loading rows until they answer. - Row times tick: the clock is read every minute. - A Maple event's id links to the same entry in the Settings audit log, and the Settings lookup field now opens an entry when its alog_ id is pasted. - The footnote opens the Data access tab, where the reads it names are. - The lab fixture records a refused request the way the API now does. --- .../web/src/lab/security-audit-log-fixture.ts | 3 +- .../audit-log/audit-log.scene.test.ts | 32 +++++++++++++++++++ apps/web/src/page/security/audit-log/index.ts | 2 ++ .../page/security/audit-log/subscription.ts | 23 +++++++++++++ apps/web/src/page/security/audit-log/view.ts | 25 ++++++++++++--- .../web/src/page/settings/audit-log/update.ts | 19 +++++++++-- .../settings/kit/audit-log-filters.test.ts | 7 ++++ .../page/settings/kit/audit-log-filters.ts | 4 +++ 8 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/page/security/audit-log/subscription.ts diff --git a/apps/web/src/lab/security-audit-log-fixture.ts b/apps/web/src/lab/security-audit-log-fixture.ts index ac70bbceb0..71345ba1cd 100644 --- a/apps/web/src/lab/security-audit-log-fixture.ts +++ b/apps/web/src/lab/security-audit-log-fixture.ts @@ -57,9 +57,10 @@ const SEEDS: ReadonlyArray = [ { minutesAgo: 19, action: "api.request", - category: "authentication", + category: "permission", outcome: "denied", actor: deployKey, + target: { type: "api", name: "POST /v2/dashboards" }, originIp: "198.51.100.24", originCountry: "US", outcomeReason: "The key lacks the dashboards:write scope.", diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 1949705cc3..44fcc9f3b5 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -280,6 +280,38 @@ describe("security audit log", () => { }) }) + // An empty answer to the old filters is not an answer to the new ones. + test("an empty result does not stand in for the filters that are still loading", () => { + const changed = routeChanged( + loaded(noAuditEvents, { outcome: "denied" }), + routeOf({ outcome: "success" }), + context, + ) + + scene( + { update: pageUpdate, view }, + given(changed.model), + expectView(role("button", { name: "Clear filters" })).toExist(), + expectView(text("No events match these filters")).toBeAbsent(), + expectView(text("No audit events yet")).toBeAbsent(), + ) + }) + + test("a Maple event's id opens the same entry in the Settings audit log", () => { + const model = loaded() + const first = eventsOf(model)?.[0] + const opened = pageUpdate(model, Message.ToggledEvent({ id: first?.id ?? "" })).model + + scene( + { update: pageUpdate, view }, + given(opened), + expectView(role("link", { name: first?.providerEventId ?? "" })).toHaveAttr( + "href", + hrefOf(AppRoute.Settings({ search: { tab: "audit-log", entry: first?.providerEventId } })), + ), + ) + }) + test("an empty log says what will show up; an empty filter result offers to clear the filters", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/security/audit-log/index.ts b/apps/web/src/page/security/audit-log/index.ts index 7ef02f7481..f4a253e95d 100644 --- a/apps/web/src/page/security/audit-log/index.ts +++ b/apps/web/src/page/security/audit-log/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { breadcrumbsOf } from "../kit/layout" import { Message, Model } from "./model" +import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { view } from "./view" @@ -15,6 +16,7 @@ export const page = definePage( update, routeChanged, view, + subscriptions, breadcrumbs: () => breadcrumbsOf("auditLog"), }, ) diff --git a/apps/web/src/page/security/audit-log/subscription.ts b/apps/web/src/page/security/audit-log/subscription.ts new file mode 100644 index 0000000000..d1f8bbcb26 --- /dev/null +++ b/apps/web/src/page/security/audit-log/subscription.ts @@ -0,0 +1,23 @@ +import { Clock, Duration, Effect, Stream } from "effect" +import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" +import type { PageSubscriptionInput } from "../../contract" +import { Message, type Model } from "./model" + +/** The clock is read every minute, so the rows' relative times move. */ +const CLOCK = Duration.minutes(1) + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + clock: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => + pollTicks(CLOCK).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), + ), + ), + }, + ), +})) diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index b36a0079c9..5a53d11867 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -154,6 +154,21 @@ const outcomeMark = (h: H, event: AuditTrailEntry): ReadonlyArray => const detailField = (h: H, label: string, value: string | null, mono = false): ReadonlyArray => value === null ? [] : [keyValue(h, { label, wrap: true, mono }, [value])] +/** A Maple event is an entry of the Settings audit log too: its id opens it there, diff and all. */ +const eventIdValue = (h: H, event: AuditTrailEntry): Html | string => + event.provider === "maple" + ? textLink( + h, + { + className: "underline", + href: hrefOf( + AppRoute.Settings({ search: { tab: "audit-log", entry: event.providerEventId } }), + ), + }, + [event.providerEventId], + ) + : event.providerEventId + /** Everything recorded about one event, under its row: nothing the row cut off is lost. */ const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => { const at = (value: number) => timestamp(h, { value, ...clock, withYear: true, className: "tabular-nums" }) @@ -176,7 +191,7 @@ const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => { ...detailField(h, "Account", event.account, true), keyValue(h, { label: "Occurred", wrap: true }, [at(event.occurredAt)]), keyValue(h, { label: "Recorded", wrap: true }, [at(event.ingestedAt)]), - keyValue(h, { label: "Event id", wrap: true, mono: true }, [event.providerEventId]), + keyValue(h, { label: "Event id", wrap: true, mono: true }, [eventIdValue(h, event)]), ]), ...(event.raw === null ? [] @@ -380,6 +395,9 @@ const listBody = (h: H, model: Model, clock: Clock): Html => }), onData: () => { const events = eventsOf(model) ?? [] + const isRefetching = Keyed.isInFlight(model.firstPage) + // An empty answer to the previous filters says nothing about the ones now loading. + if (events.length === 0 && isRefetching) return placeholderRows(h, 6) if (events.length === 0 && isFiltered(model.search)) return filteredEmpty(h, { noun: "events", @@ -399,7 +417,6 @@ const listBody = (h: H, model: Model, clock: Clock): Html => onLoadMore: Message.ClickedLoadMore(), className: "border-t", }) - const isRefetching = Keyed.isInFlight(model.firstPage) return vstack( h, { className: refreshingClass(isRefetching), attributes: [h.AriaBusy(isRefetching)] }, @@ -417,9 +434,9 @@ const footnote = (h: H): Html => { tone: "muted", className: "underline", - href: hrefOf(AppRoute.Settings({ search: { tab: "audit-log" } })), + href: hrefOf(AppRoute.Settings({ search: { tab: "audit-log", kind: "access" } })), }, - ["Settings, Audit log"], + ["Settings, Audit log, Data access"], ), ".", ]) diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 70c0c1cf5a..3af59dd4a3 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -473,13 +473,26 @@ export const update = (model: Model, message: Message, search: SettingsSearch): onNone: () => ({ model: { ...model, isLookupUnknown: Str.isNonEmpty(Str.trim(model.lookup)) }, }), - onSome: (lookup) => - navigateFilters({ ...model, lookup: "", isLookupUnknown: false }, search, { + onSome: (lookup) => { + const cleared = { ...model, lookup: "", isLookupUnknown: false } + // An entry's own id is a link to it, not a filter over the list. + if (lookup.key === "entry") + return { + model: cleared, + outMessage: PageOutMessage.RequestedNavigation({ + url: hrefOf( + AppRoute.Settings({ search: { ...search, entry: lookup.value } }), + ), + isReplace: true, + }), + } + return navigateFilters(cleared, search, { ...filters, [lookup.key]: lookup.value, // A looked-up action names its resource type; a stale one would hide it. ...(lookup.key === "action" && { resourceType: undefined }), - }), + }) + }, }), RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), ClickedPivot: ({ key, value }) => { diff --git a/apps/web/src/page/settings/kit/audit-log-filters.test.ts b/apps/web/src/page/settings/kit/audit-log-filters.test.ts index 278e95bb1b..29d6df127f 100644 --- a/apps/web/src/page/settings/kit/audit-log-filters.test.ts +++ b/apps/web/src/page/settings/kit/audit-log-filters.test.ts @@ -1,5 +1,6 @@ import { Option } from "effect" import { describe, expect, it } from "vitest" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { auditLogSearchFrom, @@ -132,6 +133,12 @@ describe("the resource, action, request and kind filters", () => { }) describe("parseAuditLookup", () => { + it("reads an entry's own id as that entry, not as a resource to filter by", () => { + const entryId = encodePublicId(PublicIdPrefixes.auditLogEntry, "9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f") + + expect(Option.getOrNull(parseAuditLookup(entryId))).toEqual({ key: "entry", value: entryId }) + }) + it("reads each id shape as its filter", () => { expect(Option.getOrNull(parseAuditLookup("user_2fj3K9dLqWm8xYbT"))).toEqual({ key: "actorId", diff --git a/apps/web/src/page/settings/kit/audit-log-filters.ts b/apps/web/src/page/settings/kit/audit-log-filters.ts index c0d8d08eb1..ff53a49436 100644 --- a/apps/web/src/page/settings/kit/audit-log-filters.ts +++ b/apps/web/src/page/settings/kit/audit-log-filters.ts @@ -139,6 +139,8 @@ export type AuditLookup = | { readonly key: "resourceId"; readonly value: string } | { readonly key: "requestId"; readonly value: string } | { readonly key: "action"; readonly value: string } + /** Not a filter: an entry's own id (`alog_…`), which opens that entry. */ + | { readonly key: "entry"; readonly value: string } /** Read a pasted id or action as the filter it stands for; none for anything else. */ export function parseAuditLookup(raw: string): Option.Option { @@ -146,6 +148,8 @@ export function parseAuditLookup(raw: string): Option.Option { if (isActorId(value)) return Option.some({ key: "actorId", value }) if (isRequestId(value)) return Option.some({ key: "requestId", value }) if (isKnownAction(value)) return Option.some({ key: "action", value }) + if (decodePublicId(PublicIdPrefixes.auditLogEntry, value) !== null) + return Option.some({ key: "entry", value }) if (isPublicId(value)) return Option.some({ key: "resourceId", value }) return Option.none() } From d3a9f4092864cb85a667ac3cea8cccd0b7385e85 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:50:56 +0100 Subject: [PATCH 13/30] fix(web): an actor typed but not applied survives a change of another audit log filter The field was rebuilt from the URL on every route change, so typing an actor and then picking a category threw the text away. It now follows the URL only when it holds nothing unapplied, which keeps Back clearing an applied actor. --- .../security/audit-log/audit-log.scene.test.ts | 14 ++++++++++++++ apps/web/src/page/security/audit-log/update.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 44fcc9f3b5..707620ed4d 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -223,6 +223,20 @@ describe("security audit log", () => { expect(submitted.outMessage).toMatchObject({ url: hrefOf(routeOf({ actor: "ada@acme.test" })) }) }) + test("an actor typed but not applied survives a change of another filter", () => { + const typed = pageUpdate(loaded(), Message.ChangedActorText({ value: "ada@acme.test" })).model + + expect(routeChanged(typed, routeOf({ outcome: "denied" }), context).model.actorText).toBe( + "ada@acme.test", + ) + }) + + test("the actor field follows the URL when Back leaves an applied actor behind", () => { + const applied = loaded(PAGE, { actor: "ada@acme.test" }) + + expect(routeChanged(applied, routeOf(), context).model.actorText).toBe("") + }) + test("load more continues from the last event shown and appends what comes back", () => { const asked = pageUpdate(loaded(), Message.ClickedLoadMore()) diff --git a/apps/web/src/page/security/audit-log/update.ts b/apps/web/src/page/security/audit-log/update.ts index 2ee12fca36..93036f396c 100644 --- a/apps/web/src/page/security/audit-log/update.ts +++ b/apps/web/src/page/security/audit-log/update.ts @@ -114,13 +114,23 @@ export const enter = ( export const init = (route: SecurityAuditLogRoute, context: PageContext): Return => enter(route, context, isClerkAuthEnabled) -/** Same instance, other filters: the URL is the filter state, so the list follows it. */ -export const routeChanged = (model: Model, route: SecurityAuditLogRoute, context: PageContext): Return => - readFirstPage( - { ...model, search: route.search, actorText: route.search.actor ?? "", expanded: [] }, +/** + * Same instance, other filters: the URL is the filter state, so the list follows it. The actor + * field follows it too, unless it holds text that was typed and not yet applied. + */ +export const routeChanged = (model: Model, route: SecurityAuditLogRoute, context: PageContext): Return => { + const hasDraft = model.actorText !== (model.search.actor ?? "") + return readFirstPage( + { + ...model, + search: route.search, + actorText: hasDraft ? model.actorText : (route.search.actor ?? ""), + expanded: [], + }, context, "Load", ) +} export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { From 9f6d7fc971f1de1e7c6c0c33395db733515f8f9e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 14:07:11 +0100 Subject: [PATCH 14/30] fix(security): the audit log's event id link carries the entry's time window Retesting on the preview showed the Settings lookup change did not work: that page cannot fetch an entry by id alone, so pasting an alog_ id set the URL and opened nothing. The lookup is back to what it was. The link from the Security page now goes through the same builder as the Settings page's own entry links (the minute ending at the entry, changes and reads both shown), so it finds old entries too. --- .../web/src/page/kit/audit-entry-link.test.ts | 33 +++++++++++++++++++ apps/web/src/page/kit/audit-entry-link.ts | 24 ++++++++++++++ .../audit-log/audit-log.scene.test.ts | 3 +- apps/web/src/page/security/audit-log/view.ts | 8 ++--- .../web/src/page/settings/audit-log/update.ts | 19 ++--------- .../page/settings/audit-log/view-detail.ts | 24 ++++---------- .../settings/kit/audit-log-filters.test.ts | 7 ---- .../page/settings/kit/audit-log-filters.ts | 4 --- packages/views/src/ui/INDEX.md | 1 + 9 files changed, 72 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/page/kit/audit-entry-link.test.ts create mode 100644 apps/web/src/page/kit/audit-entry-link.ts diff --git a/apps/web/src/page/kit/audit-entry-link.test.ts b/apps/web/src/page/kit/audit-entry-link.test.ts new file mode 100644 index 0000000000..4e397bc8e0 --- /dev/null +++ b/apps/web/src/page/kit/audit-entry-link.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" +import { parseAuditLogSearch, linkedAuditEntryId } from "../settings/kit/audit-log-filters" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { Option } from "effect" +import { fromString } from "foldkit/url" +import { urlToAppRoute } from "../../route" +import { auditEntryHref } from "./audit-entry-link" + +const ENTRY_UUID = "9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f" +const ENTRY = encodePublicId(PublicIdPrefixes.auditLogEntry, ENTRY_UUID) +const AT = Date.UTC(2026, 10, 12, 13, 57, 40, 412) + +describe("auditEntryHref", () => { + // The Settings audit log has no lookup by entry id: the link finds an old entry only because + // its window ends on the entry and reads and changes are both shown. + it("opens the Settings audit log on the minute that ends at the entry, with the entry named", () => { + const route = urlToAppRoute( + Option.getOrThrow(fromString(`http://maple.test${auditEntryHref(AT, ENTRY)}`)), + ) + const search = route._tag === "Settings" ? route.search : {} + const filters = parseAuditLogSearch(search) + + expect(route._tag).toBe("Settings") + expect(linkedAuditEntryId(search)).toBe(ENTRY_UUID) + expect(filters.kind).toBe("all") + expect(filters.range).toMatchObject({ kind: "custom" }) + expect( + filters.range.kind === "custom" + ? [Date.parse(filters.range.since ?? ""), Date.parse(filters.range.until ?? "")] + : [], + ).toEqual([AT - 60_000, AT]) + }) +}) diff --git a/apps/web/src/page/kit/audit-entry-link.ts b/apps/web/src/page/kit/audit-entry-link.ts new file mode 100644 index 0000000000..63103bbbab --- /dev/null +++ b/apps/web/src/page/kit/audit-entry-link.ts @@ -0,0 +1,24 @@ +import { DateTime } from "effect" +import { AppRoute, hrefOf } from "../../route" + +/** + * The Settings audit log ending at one entry, with that entry open. Newest first, so the entry + * heads the first page however busy the minute was, and the minute before it follows as context. + */ +export const auditEntryHref = ( + occurredAtMs: number, + /** The entry's public `alog_…` id. */ + entry: string, + until: string = DateTime.formatIso(DateTime.makeUnsafe(occurredAtMs)), +): string => + hrefOf( + AppRoute.Settings({ + search: { + tab: "audit-log", + kind: "all", + since: DateTime.formatIso(DateTime.makeUnsafe(occurredAtMs - 60_000)), + until, + entry, + }, + }), + ) diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 707620ed4d..872f89cabd 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -8,6 +8,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { auditEventsOf, noAuditEvents } from "../../../lab/security-audit-log-fixture" import { AppRoute, hrefOf } from "../../../route" import type { PageContext } from "../../contract" +import { auditEntryHref } from "../../kit/audit-entry-link" import { eventsOf, nextCursorOf } from "./derive" import { type EventsPage, Message, type Model, type Search } from "./model" import { enter, routeChanged, update } from "./update" @@ -321,7 +322,7 @@ describe("security audit log", () => { given(opened), expectView(role("link", { name: first?.providerEventId ?? "" })).toHaveAttr( "href", - hrefOf(AppRoute.Settings({ search: { tab: "audit-log", entry: first?.providerEventId } })), + auditEntryHref(first?.occurredAt ?? 0, first?.providerEventId ?? ""), ), ) }) diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index 5a53d11867..a333c936d2 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -42,6 +42,7 @@ import { truncatedText } from "@maple/views/ui/truncated-text" import { typography, typographyClass } from "@maple/views/ui/typography" import { AppRoute, hrefOf } from "../../../route" import type { PageViewInputs } from "../../contract" +import { auditEntryHref } from "../../kit/audit-entry-link" import { dayEyebrow, dayKey } from "../../kit/day-label" import { expandChevron } from "../../kit/expand-chevron" import { jsonBlock, prettyJson } from "../../kit/json-block" @@ -159,12 +160,7 @@ const eventIdValue = (h: H, event: AuditTrailEntry): Html | string => event.provider === "maple" ? textLink( h, - { - className: "underline", - href: hrefOf( - AppRoute.Settings({ search: { tab: "audit-log", entry: event.providerEventId } }), - ), - }, + { className: "underline", href: auditEntryHref(event.occurredAt, event.providerEventId) }, [event.providerEventId], ) : event.providerEventId diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 3af59dd4a3..70c0c1cf5a 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -473,26 +473,13 @@ export const update = (model: Model, message: Message, search: SettingsSearch): onNone: () => ({ model: { ...model, isLookupUnknown: Str.isNonEmpty(Str.trim(model.lookup)) }, }), - onSome: (lookup) => { - const cleared = { ...model, lookup: "", isLookupUnknown: false } - // An entry's own id is a link to it, not a filter over the list. - if (lookup.key === "entry") - return { - model: cleared, - outMessage: PageOutMessage.RequestedNavigation({ - url: hrefOf( - AppRoute.Settings({ search: { ...search, entry: lookup.value } }), - ), - isReplace: true, - }), - } - return navigateFilters(cleared, search, { + onSome: (lookup) => + navigateFilters({ ...model, lookup: "", isLookupUnknown: false }, search, { ...filters, [lookup.key]: lookup.value, // A looked-up action names its resource type; a stale one would hide it. ...(lookup.key === "action" && { resourceType: undefined }), - }) - }, + }), }), RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), ClickedPivot: ({ key, value }) => { diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index d963beec57..334c8b48af 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -25,6 +25,7 @@ import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" import { textLink } from "@maple/views/ui/text-link" import { iconButton } from "@maple/views/ui/icon-button" +import { auditEntryHref } from "../../kit/audit-entry-link" import { jsonBlock, prettyJson } from "../../kit/json-block" type H = HtmlBuilder @@ -231,24 +232,13 @@ const isLabelledKey = (key: string): key is keyof typeof METADATA_LABELS => const metadataLabel = (key: string): string => isLabelledKey(key) ? METADATA_LABELS[key] : Str.capitalize(Str.replaceAll("_", " ")(key)) -/** - * The audit log ending at one entry, with that entry open. Newest first, so the entry heads the - * first page however busy the minute was, and the minute before it follows as context. - */ -export const entryHref = (entry: V2AuditLogEntry): string => { - const at = DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)) - return hrefOf( - AppRoute.Settings({ - search: { - tab: "audit-log", - kind: "all", - since: new Date(at - 60_000).toISOString(), - until: entry.occurred_at, - entry: encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), - }, - }), +/** The audit log ending at one entry, with that entry open. */ +export const entryHref = (entry: V2AuditLogEntry): string => + auditEntryHref( + DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)), + encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), + entry.occurred_at, ) -} // Every entry has a surface, so the section shows even when Maple itself acted and nothing else is known. const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArray => { diff --git a/apps/web/src/page/settings/kit/audit-log-filters.test.ts b/apps/web/src/page/settings/kit/audit-log-filters.test.ts index 29d6df127f..278e95bb1b 100644 --- a/apps/web/src/page/settings/kit/audit-log-filters.test.ts +++ b/apps/web/src/page/settings/kit/audit-log-filters.test.ts @@ -1,6 +1,5 @@ import { Option } from "effect" import { describe, expect, it } from "vitest" -import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { auditLogSearchFrom, @@ -133,12 +132,6 @@ describe("the resource, action, request and kind filters", () => { }) describe("parseAuditLookup", () => { - it("reads an entry's own id as that entry, not as a resource to filter by", () => { - const entryId = encodePublicId(PublicIdPrefixes.auditLogEntry, "9d2c1e3a-6a1b-4f0e-9c1d-2b3a4c5d6e7f") - - expect(Option.getOrNull(parseAuditLookup(entryId))).toEqual({ key: "entry", value: entryId }) - }) - it("reads each id shape as its filter", () => { expect(Option.getOrNull(parseAuditLookup("user_2fj3K9dLqWm8xYbT"))).toEqual({ key: "actorId", diff --git a/apps/web/src/page/settings/kit/audit-log-filters.ts b/apps/web/src/page/settings/kit/audit-log-filters.ts index ff53a49436..c0d8d08eb1 100644 --- a/apps/web/src/page/settings/kit/audit-log-filters.ts +++ b/apps/web/src/page/settings/kit/audit-log-filters.ts @@ -139,8 +139,6 @@ export type AuditLookup = | { readonly key: "resourceId"; readonly value: string } | { readonly key: "requestId"; readonly value: string } | { readonly key: "action"; readonly value: string } - /** Not a filter: an entry's own id (`alog_…`), which opens that entry. */ - | { readonly key: "entry"; readonly value: string } /** Read a pasted id or action as the filter it stands for; none for anything else. */ export function parseAuditLookup(raw: string): Option.Option { @@ -148,8 +146,6 @@ export function parseAuditLookup(raw: string): Option.Option { if (isActorId(value)) return Option.some({ key: "actorId", value }) if (isRequestId(value)) return Option.some({ key: "requestId", value }) if (isKnownAction(value)) return Option.some({ key: "action", value }) - if (decodePublicId(PublicIdPrefixes.auditLogEntry, value) !== null) - return Option.some({ key: "entry", value }) if (isPublicId(value)) return Option.some({ key: "resourceId", value }) return Option.none() } diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index a7bd68956b..e0df5ea4b7 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -194,6 +194,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/alert-time.ts` `msIso`, `formatSpan`, `everyMinutes`: How alert pages write times: ISO stamps, spans in their two largest units, check cadences. - `apps/web/src/page/kit/anomaly-evidence.ts` `anomalySummary`, `anomalySignalChart`, `anomalyFingerprintRow`: The anomaly detector's evidence: the observed value against its baseline, the signal chart with its baseline and threshold, and the error fingerprints grouped into it. - `apps/web/src/page/kit/anomaly-row.ts` `anomalyHref`, `anomalyRow`: One anomaly as a list row. +- `apps/web/src/page/kit/audit-entry-link.ts` `auditEntryHref`: The Settings audit log ending at one entry, with that entry open. - `apps/web/src/page/kit/autocomplete-lookups.ts` `names`, `lookupField`, `autocompleteLookup`: The autocomplete lookups a query builder fetches once it is first focused, each reduced to the names it suggests. - `apps/web/src/page/kit/billing/checkout-return.ts` `buildCheckoutSuccessUrl`, `isCheckoutReturn`, `withoutCheckoutReturn`: The round trip through Stripe Checkout. - `apps/web/src/page/kit/billing/plan-gating.ts` `isLegacyPlan`, `isUsableCustomer`, `getActivePlan`, `hasSelectedPlan`, `getLapsedPlan`, `hasLapsedPlan`, `resolvePlanAccess`, `getTrialStatus`, `hasBringYourOwnCloudAddOn`, `getFeatureQuotas`, `getQuotaStatus`, `getPastDueSubscription`, `getLegacyPlanInfo`: A plan from the live `listPlans` catalog , the source of truth for which plans are currently offered and for per-feature overage rates. From 22849d01518f17031d7d6aec4bbf42007773c1e8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 14:54:28 +0100 Subject: [PATCH 15/30] feat(security): Vercel and GCP audit events go to the audit trail, not to logs An audit event names a person, their email and their IP address. As a log it was readable by every member and writable by anything holding an ingest key. The gateway now writes these to audit_trail_events, behind audit_log:read. Gateway (apps/ingest): - New audit_trail module: Vercel audit drain events and GCP Admin Activity and Policy Denied entries as rows, with the same key derivation as the TypeScript writer. GCP System Event entries are Google's own actions and stay logs. - The Vercel audit drain has its own handler. It refuses the public ingest key, which ships in browser bundles, and acks and drops while the integration's source is paused. - A GCP audit entry is stored ahead of the plan check on logs and is not metered. - A gateway that only forwards has no such table: there both feeds stay logs. Web: - The audit log page gets its platform filter, column and detail row back, with Vercel and Google Cloud named. Target shows on wide screens only, so Outcome stays on screen beside the rail. - /infra/vercel no longer reads audit events from logs. Its rail points at the Security audit log when the drain is on. The query, endpoint and row schema behind the old panel are removed. - The Vercel and GCP setup copy and the public docs say where these events land. --- .../src/routes/internal/query-engine.http.ts | 13 - apps/ingest/src/audit_trail.rs | 563 ++++++++++++++++++ apps/ingest/src/lib.rs | 1 + apps/ingest/src/main.rs | 332 +++++++++++ apps/ingest/src/telemetry.rs | 73 +++ .../src/content/docs/integrations/gcp.md | 2 +- .../src/content/docs/integrations/vercel.md | 11 +- apps/web/src/lab/infra-lab/update.ts | 2 - apps/web/src/lab/infra-lab/view.ts | 20 +- .../web/src/lab/security-audit-log-fixture.ts | 34 +- apps/web/src/lab/vercel-infra-fixtures.ts | 29 +- .../gcp/integration/gcp-configure-copy.ts | 3 +- .../gcp/integration/view-flow-gcp.ts | 2 +- .../integration/view-gcp-configure-filter.ts | 2 +- .../page/infra-integrations/vercel/data.ts | 8 - .../vercel/integration/vercel.scene.test.ts | 9 +- .../integration/view-vercel-configure.ts | 2 +- .../vercel/integration/view-vercel.ts | 2 +- .../infra-integrations/vercel/list/command.ts | 8 - .../infra-integrations/vercel/list/model.ts | 4 - .../infra-integrations/vercel/list/update.ts | 11 - .../vercel/list/vercel.scene.test.ts | 38 +- .../vercel/list/view-rail.ts | 54 +- .../infra-integrations/vercel/sections.ts | 91 --- .../audit-log/audit-log.scene.test.ts | 64 +- .../src/page/security/audit-log/command.ts | 1 + .../web/src/page/security/audit-log/derive.ts | 26 +- apps/web/src/page/security/audit-log/model.ts | 2 +- apps/web/src/page/security/audit-log/view.ts | 29 +- apps/web/src/page/security/kit/layout.ts | 3 +- apps/web/src/route/search.ts | 2 + packages/backend/src/queries/registry.ts | 9 - packages/domain/src/audit-trail.ts | 2 +- packages/domain/src/http/query-engine.ts | 22 - .../src/__sql_baseline__/integrations.sql | 18 - .../src/benchmark/index.ts | 6 - .../src/vercel/index.ts | 1 - .../src/vercel/vercel-event-drains.ts | 27 +- .../src/vercel/vercel.test.ts | 13 - .../web-data/src/warehouse/vercel-infra.ts | 18 - 40 files changed, 1174 insertions(+), 383 deletions(-) create mode 100644 apps/ingest/src/audit_trail.rs diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 5116a41498..7ce5ac0c4c 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -21,7 +21,6 @@ import { PlanetScaleInfraTimeseriesResponse, RailwayInfraRowsResponse, VercelAiGatewayResponse, - VercelAuditLogResponse, VercelInfraDependenciesResponse, VercelInfraFunctionsResponse, VercelInfraFunctionTimeseriesResponse, @@ -1000,18 +999,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query }) }), ) - .handle("vercelAuditLog", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const rows = yield* runQuery(Queries.vercelAuditLog, tenant, payload) - return new VercelAuditLogResponse({ - events: Arr.map(rows, (row) => ({ - ...row, - timestamp: DateTime.formatIso(row.timestamp), - })), - }) - }), - ) .handle("gcpInfraPresence", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/ingest/src/audit_trail.rs b/apps/ingest/src/audit_trail.rs new file mode 100644 index 0000000000..0a51b26df1 --- /dev/null +++ b/apps/ingest/src/audit_trail.rs @@ -0,0 +1,563 @@ +//! Audit events from provider feeds as rows of `audit_trail_events`, the table +//! behind the Security hub's audit log. They are written here, never as OTLP +//! logs: a log can be sent by anything holding an ingest key and read by every +//! member, and an audit record must be neither. +//! +//! The row shape and the id derivation mirror the TypeScript writer +//! (`packages/backend/src/services/audit-trail/audit-trail-row.ts`). The two +//! must agree: an event a provider redelivers has to land on the same key. + +use crate::vercel_logs::text; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{json, Map as JsonMap, Value as JsonValue}; +use sha2::{Digest, Sha256}; + +type JsonObject = JsonMap; + +pub const DATASOURCE: &str = "audit_trail_events"; + +/// Above any ingest time in ms, so `Version` stays positive and falls as time +/// rises: the table keeps the highest version, which is the first write. +const VERSION_CEILING_MS: i64 = 9_999_999_999_999; + +/// A payload larger than this is stored as the fields the row was built from. +const RAW_LIMIT_BYTES: usize = 32 * 1024; + +/// One audit event, normalized. Empty strings are absent values. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct AuditEvent { + pub provider: &'static str, + pub account: String, + pub provider_event_id: String, + pub occurred_at_ms: i64, + pub actor_type: &'static str, + pub actor_id: String, + pub actor_name: String, + pub actor_email: String, + pub action: String, + pub category: &'static str, + pub outcome: &'static str, + pub outcome_reason: String, + pub target_type: String, + pub target_id: String, + pub target_name: String, + pub origin_ip: String, + pub user_agent: String, + pub raw: String, +} + +/// The row's key: the first 32 hex characters of sha256 over +/// `provider\naccount\neventId`. +pub fn event_id(provider: &str, account: &str, provider_event_id: &str) -> String { + sha256_prefix(format!("{provider}\n{account}\n{provider_event_id}").as_bytes()) +} + +const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; + +/// The first 16 bytes of a sha256, as 32 hex characters. +fn sha256_prefix(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut hex = String::with_capacity(32); + for byte in &digest[..16] { + hex.push(char::from(HEX_DIGITS[usize::from(byte >> 4)])); + hex.push(char::from(HEX_DIGITS[usize::from(byte & 0x0f)])); + } + hex +} + +/// `YYYY-MM-DD HH:MM:SS.mmm` in UTC, the form the warehouse's DateTime64(3) takes. +fn warehouse_datetime(epoch_ms: i64) -> String { + DateTime::::from_timestamp_millis(epoch_ms) + .unwrap_or_default() + .format("%Y-%m-%d %H:%M:%S%.3f") + .to_string() +} + +impl AuditEvent { + /// The event as one NDJSON row for `org_id`, stored at `ingested_at_ms`. + pub fn to_row(&self, org_id: &str, ingested_at_ms: i64) -> Vec { + json!({ + "OrgId": org_id, + "Id": event_id(self.provider, &self.account, &self.provider_event_id), + "OccurredAt": warehouse_datetime(self.occurred_at_ms), + "IngestedAt": warehouse_datetime(ingested_at_ms), + "Provider": self.provider, + "Account": self.account, + "ProviderEventId": self.provider_event_id, + "ActorType": self.actor_type, + "ActorId": self.actor_id, + "ActorName": self.actor_name, + "ActorEmail": self.actor_email, + "Action": self.action, + "Category": self.category, + "Outcome": self.outcome, + "OutcomeReason": self.outcome_reason, + "TargetType": self.target_type, + "TargetId": self.target_id, + "TargetName": self.target_name, + "OriginIp": self.origin_ip, + "OriginCountry": "", + "UserAgent": self.user_agent, + "Raw": self.raw, + "Version": VERSION_CEILING_MS - ingested_at_ms, + }) + .to_string() + .into_bytes() + } +} + +fn owned(value: Option<&str>) -> String { + value.unwrap_or_default().to_owned() +} + +fn object<'a>(parent: &'a JsonObject, key: &str) -> Option<&'a JsonObject> { + parent.get(key)?.as_object() +} + +/// The payload as JSON text, or `summary` when the payload is too large to keep. +fn raw_of(payload: &JsonObject, summary: impl FnOnce() -> JsonValue) -> String { + let full = JsonValue::Object(payload.clone()).to_string(); + if full.len() <= RAW_LIMIT_BYTES { + full + } else { + summary().to_string() + } +} + +/// A key for an event the provider gave no id: the same payload is the same event. +fn content_id(payload: &JsonObject) -> String { + sha256_prefix(JsonValue::Object(payload.clone()).to_string().as_bytes()) +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +/// What a Vercel action is about, read off its name. Vercel publishes no closed +/// list, so this is by the words the names use; anything unrecognised is a +/// change to a resource. +fn vercel_category(action: &str) -> &'static str { + let action = action.to_ascii_lowercase(); + if contains_any( + &action, + &["login", "logout", "sso", "saml", "mfa", "2fa", "passkey"], + ) { + "authentication" + } else if contains_any( + &action, + &["token", "secret", "env", "certificate", "password", "key"], + ) { + "credential" + } else if contains_any(&action, &["member", "invite", "join", "leave"]) { + "membership" + } else if contains_any( + &action, + &["role", "permission", "access-group", "access_group", "acl"], + ) { + "permission" + } else if contains_any( + &action, + &["billing", "invoice", "payment", "plan", "subscription"], + ) { + "billing" + } else if contains_any( + &action, + &[ + "setting", + "drain", + "integration", + "webhook", + "firewall", + "config", + "protection", + ], + ) { + "configuration" + } else { + "resource" + } +} + +/// One event of Vercel's Audit Log drain. `None` for an event with no action: +/// there is nothing to say happened. +pub fn vercel_event(event: &JsonObject, now_ms: i64) -> Option { + let action = text(event, "action")?; + let actor = object(event, "actor"); + let actor_text = |key: &str| owned(actor.and_then(|actor| text(actor, key))); + let actor_type = match actor.and_then(|actor| text(actor, "type")) { + Some("user") => "user", + Some("system") => "system", + Some(_) => "service", + None => "unknown", + }; + // Epoch milliseconds, whole in practice; a fractional one is cut to its millisecond. + #[expect( + clippy::cast_possible_truncation, + reason = "a finite positive epoch-ms value is far inside i64" + )] + let occurred_at_ms = event + .get("timestamp") + .and_then(JsonValue::as_f64) + .filter(|ms| ms.is_finite() && *ms > 0.0) + .map_or(now_ms, |ms| ms.trunc() as i64); + Some(AuditEvent { + provider: "vercel", + account: owned(text(event, "teamId")), + provider_event_id: text(event, "id").map_or_else(|| content_id(event), str::to_owned), + occurred_at_ms, + actor_type, + actor_id: actor_text("id"), + actor_name: actor_text("name"), + actor_email: actor_text("email"), + action: action.to_owned(), + category: vercel_category(action), + // The drain reports what was done; Vercel does not log refused attempts in it. + outcome: "success", + origin_ip: owned(text(event, "ipAddress")), + user_agent: owned(text(event, "userAgent")), + raw: raw_of( + event, + || json!({ "id": event.get("id"), "action": action, "actor": event.get("actor") }), + ), + ..AuditEvent::default() + }) +} + +/// Which of Google's audit logs an entry is from, by its `logName`. +fn gcp_audit_log(log_name: &str) -> Option<&'static str> { + let (_, log) = log_name.rsplit_once("/logs/")?; + match log.replace("%2F", "/").as_str() { + "cloudaudit.googleapis.com/activity" => Some("activity"), + "cloudaudit.googleapis.com/policy" => Some("policy"), + _ => None, + } +} + +/// What a Google API call is about, read off its service and method. +fn gcp_category(service: &str, method: &str) -> &'static str { + let method_lower = method.to_ascii_lowercase(); + if contains_any(&method_lower, &["setiampolicy", "testiampermissions"]) + || (service == "iam.googleapis.com" && contains_any(&method_lower, &["role", "policy"])) + { + "permission" + } else if contains_any( + &method_lower, + &["serviceaccountkey", "createkey", "deletekey"], + ) || contains_any(service, &["secretmanager", "apikeys", "cloudkms"]) + { + "credential" + } else if contains_any(service, &["cloudbilling", "billingbudgets"]) { + "billing" + } else if contains_any(&method_lower, &["login", "signin"]) { + "authentication" + } else { + "resource" + } +} + +/// gRPC status codes Google uses for a refusal: PERMISSION_DENIED and UNAUTHENTICATED. +const GCP_DENIED_CODES: [i64; 2] = [7, 16]; + +/// A Cloud Logging entry from the Admin Activity or Policy Denied audit log. +/// `None` for every other entry, System Event included: those are Google's own +/// actions on a workload and stay with its logs. +pub fn gcp_entry(entry: &JsonObject, now_ms: i64) -> Option { + let log_name = text(entry, "logName")?; + let log = gcp_audit_log(log_name)?; + let payload = object(entry, "protoPayload")?; + let method = text(payload, "methodName")?; + let service = text(payload, "serviceName").unwrap_or_default(); + + let principal = object(payload, "authenticationInfo") + .and_then(|info| text(info, "principalEmail")) + .unwrap_or_default(); + let actor_type = if principal.is_empty() { + "unknown" + } else if principal.ends_with("gserviceaccount.com") { + "service" + } else { + "user" + }; + + let status = object(payload, "status"); + let code = status + .and_then(|status| status.get("code")) + .and_then(JsonValue::as_i64) + .unwrap_or(0); + let outcome = if log == "policy" || GCP_DENIED_CODES.contains(&code) { + "denied" + } else if code == 0 { + "success" + } else { + "failure" + }; + + let resource = object(entry, "resource"); + let project = resource + .and_then(|resource| object(resource, "labels")) + .and_then(|labels| text(labels, "project_id")); + // `projects//logs/...`, or a folder's or organization's own log. + let account = project.map_or_else( + || { + log_name + .split("/logs/") + .next() + .unwrap_or_default() + .to_owned() + }, + str::to_owned, + ); + let request = object(payload, "requestMetadata"); + let occurred_at_ms = text(entry, "timestamp") + .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok()) + .map_or(now_ms, |timestamp| timestamp.timestamp_millis()); + + Some(AuditEvent { + provider: "gcp", + account, + provider_event_id: text(entry, "insertId").map_or_else(|| content_id(entry), str::to_owned), + occurred_at_ms, + actor_type, + actor_id: principal.to_owned(), + actor_email: principal.to_owned(), + action: method.to_owned(), + category: if log == "policy" { + "permission" + } else { + gcp_category(service, method) + }, + outcome, + outcome_reason: owned(status.and_then(|status| text(status, "message"))), + target_type: owned(resource.and_then(|resource| text(resource, "type"))), + target_name: owned(text(payload, "resourceName")), + origin_ip: owned(request.and_then(|request| text(request, "callerIp"))), + user_agent: owned(request.and_then(|request| text(request, "callerSuppliedUserAgent"))), + raw: raw_of(entry, || { + json!({ + "logName": log_name, + "insertId": entry.get("insertId"), + "timestamp": entry.get("timestamp"), + "protoPayload": { + "serviceName": service, + "methodName": method, + "resourceName": payload.get("resourceName"), + "authenticationInfo": payload.get("authenticationInfo"), + "status": payload.get("status"), + "requestMetadata": payload.get("requestMetadata"), + }, + }) + }), + ..AuditEvent::default() + }) +} + +/// Now, as the warehouse and this module count it. +pub fn now_ms() -> i64 { + Utc::now().timestamp_millis() +} + +/// An RFC 3339 instant for a row, for tests and logs. +pub fn iso(epoch_ms: i64) -> String { + DateTime::::from_timestamp_millis(epoch_ms) + .unwrap_or_default() + .to_rfc3339_opts(SecondsFormat::Millis, true) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_700_000_060_000; + + fn parse(json: &str) -> JsonObject { + serde_json::from_str(json).expect("fixture is a JSON object") + } + + fn row(event: &AuditEvent) -> JsonObject { + serde_json::from_slice(&event.to_row("org_1", NOW)).expect("a row is a JSON object") + } + + // The TypeScript writer derives the same key (`auditTrailEventId`); these two + // vectors are asserted there as well. A provider that moved between the two + // writers would otherwise stop collapsing its redeliveries. + #[test] + fn the_row_key_matches_the_typescript_writer() { + assert_eq!( + event_id("maple", "", "evt_1"), + "851ca0eff076aad987a0192777b176cd" + ); + assert_eq!(event_id("maple", "acme", "evt_1").len(), 32); + assert_ne!( + event_id("maple", "acme", "evt_1"), + event_id("maple", "", "evt_1") + ); + } + + const VERCEL_EVENT: &str = r#"{"schema":"vercel.audit_log.v1","id":"evt_1","teamId":"team_1","action":"project.env-var.created","timestamp":1700000000123,"ipAddress":"203.0.113.7","userAgent":"Mozilla/5.0","actor":{"type":"user","id":"usr_1","name":"Ada","email":"ada@acme.test"},"payload":{"projectId":"prj_1"}}"#; + + #[test] + fn a_vercel_event_becomes_a_row_with_its_actor_action_and_origin() { + let event = vercel_event(&parse(VERCEL_EVENT), NOW).expect("an event with an action"); + let row = row(&event); + + assert_eq!(row["OrgId"], "org_1"); + assert_eq!(row["Provider"], "vercel"); + assert_eq!(row["Account"], "team_1"); + assert_eq!(row["ProviderEventId"], "evt_1"); + assert_eq!(row["Id"], event_id("vercel", "team_1", "evt_1")); + assert_eq!(row["OccurredAt"], "2023-11-14 22:13:20.123"); + assert_eq!(row["IngestedAt"], "2023-11-14 22:14:20.000"); + assert_eq!(row["ActorType"], "user"); + assert_eq!(row["ActorEmail"], "ada@acme.test"); + assert_eq!(row["Action"], "project.env-var.created"); + assert_eq!(row["Category"], "credential"); + assert_eq!(row["Outcome"], "success"); + assert_eq!(row["OriginIp"], "203.0.113.7"); + assert_eq!(row["TargetType"], ""); + assert!(row["Raw"].as_str().is_some_and(|raw| raw.contains("prj_1"))); + } + + // The table keeps the highest version: a redelivery must lose to the first write. + #[test] + fn a_later_write_of_the_same_event_has_the_same_key_and_a_lower_version() { + let event = vercel_event(&parse(VERCEL_EVENT), NOW).expect("an event with an action"); + let first: JsonObject = serde_json::from_slice(&event.to_row("org_1", NOW)).unwrap(); + let later: JsonObject = + serde_json::from_slice(&event.to_row("org_1", NOW + 60_000)).unwrap(); + + assert_eq!(first["Id"], later["Id"]); + assert!(later["Version"].as_i64() < first["Version"].as_i64()); + } + + #[test] + fn a_vercel_event_without_an_id_keys_on_its_content_and_one_without_an_action_is_skipped() { + let no_id = + parse(r#"{"teamId":"team_1","action":"drain-created","timestamp":1700000000000}"#); + let first = vercel_event(&no_id, NOW).expect("an event with an action"); + let again = vercel_event(&no_id, NOW + 5).expect("an event with an action"); + + assert!(!first.provider_event_id.is_empty()); + assert_eq!(first.provider_event_id, again.provider_event_id); + assert_eq!(first.category, "configuration"); + assert_eq!(first.actor_type, "unknown"); + assert_eq!(vercel_event(&parse(r#"{"id":"evt_2"}"#), NOW), None); + } + + const GCP_ACTIVITY: &str = r#"{"logName":"projects/acme-prod/logs/cloudaudit.googleapis.com%2Factivity","insertId":"abc123","timestamp":"2023-11-14T22:13:20.123Z","resource":{"type":"service_account","labels":{"project_id":"acme-prod"}},"protoPayload":{"serviceName":"iam.googleapis.com","methodName":"google.iam.admin.v1.CreateServiceAccountKey","resourceName":"projects/-/serviceAccounts/123","authenticationInfo":{"principalEmail":"ada@acme.test"},"requestMetadata":{"callerIp":"203.0.113.7","callerSuppliedUserAgent":"gcloud"}}}"#; + + #[test] + fn an_admin_activity_entry_becomes_a_row() { + let event = gcp_entry(&parse(GCP_ACTIVITY), NOW).expect("an Admin Activity entry"); + let row = row(&event); + + assert_eq!(row["Provider"], "gcp"); + assert_eq!(row["Account"], "acme-prod"); + assert_eq!(row["ProviderEventId"], "abc123"); + assert_eq!(row["OccurredAt"], "2023-11-14 22:13:20.123"); + assert_eq!(row["ActorType"], "user"); + assert_eq!(row["ActorEmail"], "ada@acme.test"); + assert_eq!(row["Action"], "google.iam.admin.v1.CreateServiceAccountKey"); + assert_eq!(row["Category"], "credential"); + assert_eq!(row["Outcome"], "success"); + assert_eq!(row["TargetType"], "service_account"); + assert_eq!(row["TargetName"], "projects/-/serviceAccounts/123"); + assert_eq!(row["OriginIp"], "203.0.113.7"); + assert_eq!(row["UserAgent"], "gcloud"); + } + + #[test] + fn a_refused_call_is_denied_with_its_reason_and_a_service_account_is_a_service() { + let mut entry = parse(GCP_ACTIVITY); + let payload = entry["protoPayload"].as_object_mut().unwrap(); + payload.insert( + "status".into(), + json!({ "code": 7, "message": "Permission iam.serviceAccountKeys.create denied" }), + ); + payload.insert( + "authenticationInfo".into(), + json!({ "principalEmail": "ci@acme-prod.iam.gserviceaccount.com" }), + ); + let event = gcp_entry(&entry, NOW).expect("an Admin Activity entry"); + + assert_eq!(event.outcome, "denied"); + assert_eq!( + event.outcome_reason, + "Permission iam.serviceAccountKeys.create denied" + ); + assert_eq!(event.actor_type, "service"); + } + + #[test] + fn a_failed_call_that_was_not_refused_is_a_failure() { + let mut entry = parse(GCP_ACTIVITY); + entry["protoPayload"].as_object_mut().unwrap().insert( + "status".into(), + json!({ "code": 9, "message": "Precondition failed" }), + ); + + assert_eq!( + gcp_entry(&entry, NOW).map(|event| event.outcome), + Some("failure") + ); + } + + #[test] + fn an_iam_policy_change_is_a_permission_event_and_a_policy_denial_is_denied() { + let mut policy_change = parse(GCP_ACTIVITY); + policy_change["protoPayload"] + .as_object_mut() + .unwrap() + .insert("methodName".into(), json!("SetIamPolicy")); + let mut denial = parse(GCP_ACTIVITY); + denial.insert( + "logName".into(), + json!("projects/acme-prod/logs/cloudaudit.googleapis.com%2Fpolicy"), + ); + + assert_eq!( + gcp_entry(&policy_change, NOW).map(|event| event.category), + Some("permission") + ); + let denied = gcp_entry(&denial, NOW).expect("a Policy Denied entry"); + assert_eq!((denied.category, denied.outcome), ("permission", "denied")); + } + + // System Event and Data Access entries, and a workload's own log lines, are not + // audit events of the org's people: they stay logs. + #[test] + fn only_admin_activity_and_policy_denied_entries_are_audit_events() { + for log in [ + "cloudaudit.googleapis.com%2Fsystem_event", + "cloudaudit.googleapis.com%2Fdata_access", + "run.googleapis.com%2Fstdout", + ] { + let mut entry = parse(GCP_ACTIVITY); + entry.insert( + "logName".into(), + json!(format!("projects/acme-prod/logs/{log}")), + ); + assert_eq!(gcp_entry(&entry, NOW), None, "{log}"); + } + let mut no_payload = parse(GCP_ACTIVITY); + no_payload.remove("protoPayload"); + assert_eq!(gcp_entry(&no_payload, NOW), None); + } + + #[test] + fn an_oversized_payload_is_stored_as_the_fields_the_row_was_built_from() { + let mut entry = parse(GCP_ACTIVITY); + entry["protoPayload"] + .as_object_mut() + .unwrap() + .insert("request".into(), json!("x".repeat(RAW_LIMIT_BYTES))); + let event = gcp_entry(&entry, NOW).expect("an Admin Activity entry"); + + assert!(event.raw.len() < RAW_LIMIT_BYTES); + assert!(event.raw.contains("CreateServiceAccountKey")); + } + + #[test] + fn iso_formats_an_instant_in_utc() { + assert_eq!(iso(1_700_000_000_123), "2023-11-14T22:13:20.123Z"); + } +} diff --git a/apps/ingest/src/lib.rs b/apps/ingest/src/lib.rs index a2395a69f5..0ab28003d3 100644 --- a/apps/ingest/src/lib.rs +++ b/apps/ingest/src/lib.rs @@ -1,4 +1,5 @@ pub mod ai_session; +pub mod audit_trail; pub mod aws; pub mod aws_metric_stream; pub mod clickhouse_insert_mappings; diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index 64ae6185f1..79c3b486a0 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -46,6 +46,7 @@ use flate2::write::GzEncoder; use flate2::Compression; use hmac::{Hmac, Mac}; use maple_ingest::ai_session; +use maple_ingest::audit_trail; use maple_ingest::aws::CredentialsProvider as AwsCredentialsProvider; use maple_ingest::aws_metric_stream; use maple_ingest::clickhouse_insert_mappings::SCHEMA_VERSION as CLICKHOUSE_SCHEMA_VERSION; @@ -1730,6 +1731,8 @@ enum GcpPushOutcome { SourcePaused, /// The org's ingest rules need more scanning than one entry may use; redelivery would too. RulesScanBudget, + /// An audit log entry, stored in the audit trail instead of the logs. + AuditEvent, } #[derive(Clone, Copy)] @@ -4103,9 +4106,178 @@ async fn handle_vercel_event_drain( let Some(drain) = EventDrain::from_path(&drain) else { return StatusCode::NOT_FOUND.into_response(); }; + // Audit events go to the audit trail wherever the gateway writes its own + // rows. A gateway that only forwards has no such table, so there they + // stay logs, as before. + if drain == EventDrain::AuditLog && state.config.write_mode.uses_tinybird() { + return handle_vercel_audit_trail(state, headers, body).await; + } handle_signal(state, headers, body, Ingress::VercelEventDrain(drain)).await } +/// Vercel's Audit Log drain as rows of `audit_trail_events`. +async fn handle_vercel_audit_trail( + state: Arc, + headers: HeaderMap, + body: Bytes, +) -> Response { + metrics::request_started(); + let _guard = InFlightGuard; + let span = tracing::info_span!( + "ingest_vercel_audit_trail", + otel.name = "POST /v1/vercel/audit-log", + otel.kind = "server", + otel.status_code = tracing::field::Empty, + otel.status_description = tracing::field::Empty, + "maple.ingest.reject_reason" = tracing::field::Empty, + "http.request.method" = "POST", + "http.route" = "/v1/vercel/audit-log", + "http.request.body.size" = body.len(), + "http.response.status_code" = tracing::field::Empty, + "error.type" = tracing::field::Empty, + "maple.signal" = "audit_trail", + "maple.org_id" = tracing::field::Empty, + "maple.audit_trail.events" = tracing::field::Empty, + "maple.audit_trail.skipped" = tracing::field::Empty, + ); + let span_handle = span.clone(); + match handle_vercel_audit_trail_inner(&state, &headers, &body) + .instrument(span) + .await + { + Ok(count) => { + span_handle.record("http.response.status_code", 200u16); + span_handle.record("otel.status_code", "Ok"); + (StatusCode::OK, axum::Json(AcceptedBody { accepted: count })).into_response() + } + Err(error) => { + let status = error.status.as_u16(); + span_handle.record("http.response.status_code", status); + span_handle.record("error.type", error.error_kind()); + record_rejection_reason( + &span_handle, + status, + error.error_kind(), + error.message.as_str(), + ); + error.into_response() + } + } +} + +async fn handle_vercel_audit_trail_inner( + state: &AppState, + headers: &HeaderMap, + body: &Bytes, +) -> Result { + let Some(resolved_key) = resolve_replay_key(state, headers, &[SourceSignal::Logs]).await? + else { + return Ok(0); + }; + // The drain is created with the org's private key. The public key ships in + // browser bundles: with it, anyone could write the org an audit event. + if matches!(resolved_key.key_type, IngestKeyType::Public) { + return Err(ApiError::unauthorized( + "Audit events need a private ingest key", + )); + } + let org_id = resolved_key.org_id.clone(); + Span::current().record("maple.org_id", org_id.as_str()); + + // The Vercel integration's source, named on the drain: paused, the delivery + // is acked and dropped like its logs are. + let claim = headers + .get(SOURCE_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|claim| !claim.is_empty() && claim.len() <= MAX_PUBLIC_ID_LEN) + .filter(|_| resolved_key.accepts_source_claims()); + if let Some(claim) = claim { + let verified = state + .resolver + .verify_source_claims(&org_id, vec![claim.to_owned()]) + .await; + if verified.paused.iter().any(|paused| paused == claim) { + metrics::source_paused_drop(&org_id, TelemetrySignal::AuditTrail.as_str()); + Span::current().record("maple.ingest.reject_reason", "source_paused"); + return Ok(0); + } + } + + let _org_inflight_permit = + state + .org_inflight_limiter + .try_acquire(&org_id) + .ok_or_else(|| { + warn!(org_id = %org_id, "Per-org in-flight ingest limit exceeded"); + ApiError::too_many_requests("Per-org ingest limit exceeded") + })?; + let pipeline = native_rows_pipeline_for( + state, + ExportDestination::Tinybird, + "Audit event storage is not configured", + )?; + + let content_encoding = headers + .get(CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty() && value != "identity"); + let payload = decode_payload( + body, + content_encoding.as_deref(), + state.config.max_decoded_body_bytes, + )?; + let parsed = vercel_logs::parse(&payload).map_err(|e| { + ApiError::bad_request(format!("Invalid Vercel Audit Log drain payload: {e}")) + })?; + + let now_ms = audit_trail::now_ms(); + let rows: Vec> = parsed + .entries + .iter() + .filter_map(|event| audit_trail::vercel_event(event, now_ms)) + .map(|event| event.to_row(&org_id, now_ms)) + .collect(); + let skipped = parsed.entries.len() - rows.len() + parsed.malformed_lines; + Span::current().record("maple.audit_trail.events", rows.len()); + Span::current().record("maple.audit_trail.skipped", skipped); + if skipped > 0 { + warn!(org_id = %org_id, skipped, "Vercel audit drain events without an action were skipped"); + } + let count = store_audit_trail_rows(pipeline, &org_id, rows).await?; + state.resolver.note_source_use(&resolved_key, false); + Ok(count) +} + +/// Audit rows to the managed warehouse: the table is pinned there, whatever +/// warehouse the org reads its telemetry from. Not metered: an audit event is +/// evidence, not usage. +async fn store_audit_trail_rows( + pipeline: &TelemetryPipeline, + org_id: &str, + rows: Vec>, +) -> Result { + if rows.is_empty() { + return Ok(0); + } + let count = rows.len(); + pipeline + .accept_rows_to( + org_id, + audit_trail::DATASOURCE.to_owned(), + rows, + TelemetrySignal::AuditTrail, + ExportDestination::Tinybird, + ) + .await + .map_err(|e| { + warn!(org_id = %org_id, error = %e, "audit trail enqueue rejected"); + api_error_from_pipeline(&e) + })?; + Ok(count) +} + // Session replay ingest /// Running total of decompressed rrweb bytes per in-flight replay session, used @@ -6182,6 +6354,18 @@ async fn handle_gcp_logpush( span_handle.record("maple.ingest.reject_reason", "source_paused"); metrics::request_completed("logs", "ok", "none", duration); } + GcpPushOutcome::AuditEvent => { + span_handle.record("otel.status_code", "Ok"); + span_handle.record("maple.ingest.item_count", 1); + span_handle.record("maple.signal", TelemetrySignal::AuditTrail.as_str()); + metrics::request_completed( + TelemetrySignal::AuditTrail.as_str(), + "ok", + "none", + duration, + ); + metrics::gcp_entry(); + } GcpPushOutcome::RulesScanBudget => { // Acked, but the entry is gone: an `Error`, like a decode failure. let kind = INGEST_RULES_SCAN_BUDGET.error_kind; @@ -6376,6 +6560,33 @@ async fn handle_gcp_logpush_inner( return Ok((StatusCode::OK.into_response(), GcpPushOutcome::NotALogEntry)); }; + // An Admin Activity or Policy Denied entry is an audit event, not a log: + // it goes to the audit trail, unmetered, ahead of the plan check on logs. + // A gateway that only forwards has no such table, so there it stays a log. + if state.config.write_mode.uses_tinybird() { + let now_ms = audit_trail::now_ms(); + if let Some(event) = audit_trail::gcp_entry(&entry, now_ms) { + let pipeline = native_rows_pipeline_for( + state, + ExportDestination::Tinybird, + "Audit event storage is not configured", + ) + .map_err(|error| (error, "forward"))?; + if let Err(error) = + store_audit_trail_rows(pipeline, org_id, vec![event.to_row(org_id, now_ms)]).await + { + state + .gcp_resolver + .record_health(connector_id, Some(gcp_logging::ENTRY_NOT_STORED)) + .await; + return Err((error, "forward")); + } + state.gcp_resolver.record_health(connector_id, None).await; + state.resolver.note_source_use(&resolved.key, false); + return Ok((StatusCode::OK.into_response(), GcpPushOutcome::AuditEvent)); + } + } + if let Some(error) = entitlement_rejection(state, org_id, Signal::Logs.path()).await { state .gcp_resolver @@ -12952,6 +13163,83 @@ mod tests { let _ = std::fs::remove_dir_all(&queue_dir); } + const VERCEL_AUDIT_DELIVERY: &str = concat!( + r#"{"id":"evt_1","teamId":"team_1","action":"project.created","timestamp":1700000000000,"actor":{"type":"user","id":"usr_1"}}"#, + "\n", + r#"{"id":"evt_2","teamId":"team_1","timestamp":1700000000000}"#, + "\n", + ); + + /// A gateway that writes its own rows, as the hosted one does: the fixture's + /// default only forwards, and there an audit drain stays a log. + async fn audit_trail_test_state(raw_key: &str, org_id: &str, queue_dir: PathBuf) -> AppState { + let mut state = replay_blob_test_state(raw_key, org_id, queue_dir).await; + state.config.write_mode = WriteMode::Tinybird; + state + } + + #[tokio::test] + async fn vercel_audit_drain_stores_each_event_that_names_an_action() { + let queue_dir = unique_main_test_dir("vercel-audit-trail"); + let state = + audit_trail_test_state("maple_sk_test_audit", "org_audit", queue_dir.clone()).await; + + let accepted = handle_vercel_audit_trail_inner( + &state, + &bearer_headers("maple_sk_test_audit"), + &Bytes::from_static(VERCEL_AUDIT_DELIVERY.as_bytes()), + ) + .await + .expect("a private key's delivery should be accepted"); + + // The second event names no action: there is nothing to say happened. + assert_eq!(accepted, 1); + let _ = std::fs::remove_dir_all(&queue_dir); + } + + // The public key ships in browser bundles. Audit events written with it + // could be anyone's invention. + #[tokio::test] + async fn vercel_audit_drain_refuses_the_public_ingest_key() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-public"); + let store = Arc::new(FakeKeyStore::default()); + let hash = hash_ingest_key("maple_pk_test_audit", "test-hmac-key").unwrap(); + store.set_org_routing( + "org_audit_public", + OrgRouting { + self_managed: false, + clickhouse_ready: false, + }, + ); + store.keys.lock().unwrap().insert( + (hash, "public_key_hash"), + KeyRow { + org_id: "org_audit_public".to_owned(), + self_managed: false, + clickhouse_ready: false, + }, + ); + let mut state = test_app_state( + store, + queue_dir.clone(), + "http://127.0.0.1:1".to_owned(), + Duration::from_secs(30), + ) + .await; + state.config.write_mode = WriteMode::Tinybird; + + let refused = handle_vercel_audit_trail_inner( + &state, + &bearer_headers("maple_pk_test_audit"), + &Bytes::from_static(VERCEL_AUDIT_DELIVERY.as_bytes()), + ) + .await + .expect_err("a public key must not write audit events"); + + assert_eq!(refused.status, StatusCode::UNAUTHORIZED); + let _ = std::fs::remove_dir_all(&queue_dir); + } + #[tokio::test] async fn custom_session_events_are_metered_as_product_events_but_gated_on_browser_sessions() { let queue_dir = unique_main_test_dir("session-events-custom-meter"); @@ -14540,6 +14828,50 @@ mod tests { drop(std::fs::remove_dir_all(queue_dir)); } + const GCP_AUDIT_ENTRY: &str = r#"{"logName":"projects/acme-prod/logs/cloudaudit.googleapis.com%2Factivity","insertId":"abc123","timestamp":"2026-10-09T10:00:00Z","resource":{"type":"service_account","labels":{"project_id":"acme-prod"}},"protoPayload":{"serviceName":"iam.googleapis.com","methodName":"google.iam.admin.v1.CreateServiceAccountKey","authenticationInfo":{"principalEmail":"ada@acme.test"}}}"#; + + // An Admin Activity entry names a person and what they changed. It goes to + // the audit trail, which only admins read, and never to the org's logs. + #[tokio::test] + async fn gcp_logpush_stores_an_audit_entry_in_the_audit_trail_and_not_as_a_log() { + let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; + let (mut state, store, queue_dir) = gcp_logpush_state("gcp-logpush-audit", &ch_url).await; + Arc::get_mut(&mut state) + .expect("the state is not shared yet") + .config + .write_mode = WriteMode::Tinybird; + + let accepted = gcp_push(&state, "gcp_conn_1", Some("gcp-secret"), GCP_AUDIT_ENTRY).await; + + assert_eq!(accepted.status(), StatusCode::OK); + assert_eq!(store.connector_successes.load(Ordering::Relaxed), 1); + assert!( + tokio::time::timeout(Duration::from_millis(200), ch_rx.recv()) + .await + .is_err(), + "an audit entry must not be written to the org's logs" + ); + drop(std::fs::remove_dir_all(queue_dir)); + } + + // A gateway that only forwards has no audit trail table: there the entry + // stays a log, as it was before the trail existed. + #[tokio::test] + async fn gcp_logpush_keeps_an_audit_entry_as_a_log_where_the_gateway_only_forwards() { + let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; + let (state, _store, queue_dir) = gcp_logpush_state("gcp-logpush-audit-fwd", &ch_url).await; + + let accepted = gcp_push(&state, "gcp_conn_1", Some("gcp-secret"), GCP_AUDIT_ENTRY).await; + + assert_eq!(accepted.status(), StatusCode::OK); + let stored = tokio::time::timeout(Duration::from_secs(2), ch_rx.recv()) + .await + .expect("the entry should reach the org's ClickHouse") + .expect("ClickHouse channel should stay open"); + assert!(stored.query.starts_with("INSERT INTO logs")); + drop(std::fs::remove_dir_all(queue_dir)); + } + #[tokio::test] async fn gcp_logpush_acks_an_entry_past_the_rule_scan_budget() { let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; diff --git a/apps/ingest/src/telemetry.rs b/apps/ingest/src/telemetry.rs index 70eda0d3e2..6c34601e8d 100644 --- a/apps/ingest/src/telemetry.rs +++ b/apps/ingest/src/telemetry.rs @@ -533,6 +533,9 @@ pub enum TelemetrySignal { /// `POST /v1/events` (NDJSON, gateway-written). Lands in `product_events` /// next to the browser rows the `session_events` MV materializes there. ProductEvents, + /// Audit events from provider feeds (a Vercel audit drain, GCP audit logs), + /// gateway-written into `audit_trail_events`, never as OTLP logs. + AuditTrail, } impl TelemetrySignal { @@ -549,6 +552,7 @@ impl TelemetrySignal { Self::SessionReplays => "session_replays", Self::SessionEvents => "session_events", Self::ProductEvents => "product_events", + Self::AuditTrail => "audit_trail", } } } @@ -3068,6 +3072,7 @@ fn signal_tag(signal: TelemetrySignal) -> u8 { TelemetrySignal::SessionReplays => 4, TelemetrySignal::SessionEvents => 5, TelemetrySignal::ProductEvents => 6, + TelemetrySignal::AuditTrail => 7, } } @@ -3079,6 +3084,7 @@ fn signal_from_tag(tag: u8) -> Option { 4 => Some(TelemetrySignal::SessionReplays), 5 => Some(TelemetrySignal::SessionEvents), 6 => Some(TelemetrySignal::ProductEvents), + 7 => Some(TelemetrySignal::AuditTrail), _ => None, } } @@ -5713,6 +5719,71 @@ mod tests { drop(std::fs::remove_dir_all(queue_dir)); } + // Audit rows are built by the gateway, not derived from OTLP: they have to + // reach the warehouse under their own datasource, never as a log. + #[tokio::test] + async fn audit_trail_rows_reach_tinybird_under_their_own_datasource() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new() + .route("/v0/events", post(fake_tinybird_import)) + .with_state(tx); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let queue_dir = unique_test_dir("fake-tinybird-audit-trail"); + let mut cfg = test_cfg(); + cfg.endpoint = format!("http://{addr}"); + cfg.queue_dir = queue_dir.clone(); + cfg.wal_shards = 1; + cfg.batch_max_wait = Duration::from_millis(1); + let pipeline = TelemetryPipeline::new( + cfg, + Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + ) + .await + .unwrap(); + + let event: serde_json::Map = serde_json::from_str( + r#"{"id":"evt_1","teamId":"team_1","action":"project.created","timestamp":1700000000000}"#, + ) + .unwrap(); + let row = crate::audit_trail::vercel_event(&event, 1_700_000_060_000) + .expect("an event with an action") + .to_row("org_e2e", 1_700_000_060_000); + let stats = pipeline + .accept_rows_to( + "org_e2e", + crate::audit_trail::DATASOURCE.to_owned(), + vec![row], + TelemetrySignal::AuditTrail, + ExportDestination::Tinybird, + ) + .await + .unwrap(); + assert_eq!(stats.rows, 1); + + let import = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("fake Tinybird should receive an import") + .expect("fake Tinybird channel should stay open"); + assert_eq!(import.datasource, "audit_trail_events"); + let stored: Value = serde_json::from_str(import.body.trim()).unwrap(); + assert_eq!(stored["OrgId"], "org_e2e"); + assert_eq!(stored["Provider"], "vercel"); + assert_eq!(stored["Action"], "project.created"); + + wait_for_export_drain(queue_dir.clone(), 0, ExportDestination::Tinybird).await; + drop(std::fs::remove_dir_all(queue_dir)); + } + #[tokio::test] #[expect( clippy::too_many_lines, @@ -6457,6 +6528,7 @@ mod tests { assert_eq!(TelemetrySignal::SessionReplays.as_str(), "session_replays"); assert_eq!(TelemetrySignal::SessionEvents.as_str(), "session_events"); assert_eq!(TelemetrySignal::ProductEvents.as_str(), "product_events"); + assert_eq!(TelemetrySignal::AuditTrail.as_str(), "audit_trail"); } #[test] @@ -6470,6 +6542,7 @@ mod tests { TelemetrySignal::SessionReplays, TelemetrySignal::SessionEvents, TelemetrySignal::ProductEvents, + TelemetrySignal::AuditTrail, ] { assert_eq!(signal_from_tag(signal_tag(signal)), Some(signal)); } diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 1cfa691a86..9cfc6c4695 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -76,7 +76,7 @@ Turning something off takes effect in Maple at once. Google Cloud keeps the sink ### Log filter -Platform, request and audit logs are forwarded. The default filter leaves out what costs a lot and says little: +Platform, request and audit logs are forwarded. Admin Activity and Policy Denied audit entries do not land in logs: they go to the audit log in the Security section, which only organization admins can read. System Event audit entries stay in logs. The default filter leaves out what costs a lot and says little: | Left out | Reason | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/apps/landing/src/content/docs/integrations/vercel.md b/apps/landing/src/content/docs/integrations/vercel.md index 35d219d618..3533dcd2d4 100644 --- a/apps/landing/src/content/docs/integrations/vercel.md +++ b/apps/landing/src/content/docs/integrations/vercel.md @@ -69,13 +69,18 @@ Request logs carry `http.request.method`, `url.path` (and `url.query` when the r Vercel sends deployment traces with the `vercel.projectId` and `vercel.deploymentId` resource attributes. Its platform spans come in two services: `vercel.edge-network` for routing and caching, and `vercel.serverless-runtime` for functions. Maple moves each function invocation (`Invoke Function`) and its outbound calls to a service named after the project, the same name its logs use, so every project is its own service on the service map with its own dependencies. Cold-start steps (`Start VM`, `Spawn Node.js`, `Init User Code`) and `waitUntil` stay under `vercel.serverless-runtime`. Every function span keeps `maple_vercel.platform_service = vercel.serverless-runtime` as a resource attribute. -### Speed Insights, Web Analytics and audit log +### Speed Insights and Web Analytics -These drains arrive as logs, each under its own `service.name`: `vercel-speed-insights`, `vercel-web-analytics` and `vercel-audit-log`. The resource carries `cloud.provider` set to `vercel`, the team id and, where Vercel sends them, the project id, deployment id and environment. +These drains arrive as logs, each under its own `service.name`: `vercel-speed-insights` and `vercel-web-analytics`. The resource carries `cloud.provider` set to `vercel`, the team id and, where Vercel sends them, the project id, deployment id and environment. - **Speed Insights**: one log per measurement, such as `LCP 2.41s /dashboard`. The metric is in `vercel.speed_insights.metric` (`LCP`, `INP`, `CLS`, `FCP`, `TTFB` or `FID`) and the value in `vercel.speed_insights.value`, in milliseconds (CLS is a score). Each log also carries `url.path`, `vercel.route`, the visitor's country, region and city (`geo.*`), browser and OS (`user_agent.*`) and device. - **Web Analytics**: one log per page view or custom event, such as `pageview /pricing`. It carries `vercel.web_analytics.event.type` and `.event.name`, the event data as JSON in `vercel.web_analytics.event.data`, `url.path`, `url.query`, the referrer, and the same visitor fields as Speed Insights. -- **Audit log**: one log per change, such as `drain-created by Jane Doe`. It carries `vercel.audit.action`, the actor (`vercel.audit.actor.type`, `.id`, `.name`, `.email`), `client.address`, `user_agent.original`, and the event's details as JSON in `vercel.audit.payload`. + +### Audit log + +Audit events do not arrive as logs. They go to the audit log in the Security section, which only organization admins can read: an audit event names a person, their email and their IP address, and every member can read logs. + +Each event shows who acted (name, email and id), the action as Vercel names it, such as `drain-created`, the time, the IP address and user agent, and the event's full payload. The drain has to be created with your organization's private ingest key, which is what Maple uses when it creates the drain for you. ## AI Gateway diff --git a/apps/web/src/lab/infra-lab/update.ts b/apps/web/src/lab/infra-lab/update.ts index 299a462ea3..893170e0c3 100644 --- a/apps/web/src/lab/infra-lab/update.ts +++ b/apps/web/src/lab/infra-lab/update.ts @@ -22,7 +22,6 @@ import { VERCEL_API_AI_GATEWAY, VERCEL_API_DEPLOYMENTS, VERCEL_API_DETAIL, - VERCEL_AUDIT_LOG, VERCEL_BREAKDOWN, VERCEL_BUCKETS, VERCEL_DEPENDENCIES, @@ -106,7 +105,6 @@ const vercelList = (flags: Flags): VercelList.Model => ({ deployments: answered(VERCEL_TEAM_DEPLOYMENTS), speedInsights: answered(VERCEL_SPEED_INSIGHTS), webAnalytics: answered(VERCEL_WEB_ANALYTICS), - auditLog: answered(VERCEL_AUDIT_LOG), }) const vercelProject = (flags: Flags): VercelProject.Model => ({ diff --git a/apps/web/src/lab/infra-lab/view.ts b/apps/web/src/lab/infra-lab/view.ts index a8e63e4b79..a3152c89c1 100644 --- a/apps/web/src/lab/infra-lab/view.ts +++ b/apps/web/src/lab/infra-lab/view.ts @@ -25,7 +25,6 @@ import * as VercelProject from "../../page/infra-integrations/vercel/project/mod import { projectPage } from "../../page/infra-integrations/vercel/project/view" import { projectRail } from "../../page/infra-integrations/vercel/project/view-rail" import { - auditLogSection, buildsSection, dependenciesSection, functionsSection, @@ -36,12 +35,13 @@ import { import { pickTimeSearch } from "../../page/kit/view-tabs" import { sectionHeading } from "../../page/kit/section-heading" import { sortRowsBy } from "../../page/kit/table-sort" +import { cn } from "@maple/ui/lib/utils" import { eyebrow } from "@maple/views/ui/eyebrow" +import { pageRightSidebarWidthStyles } from "@maple/views/ui/page-layout.styles" import { AsyncData } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { VERCEL_ALL_DRAINS_STATUS, - VERCEL_AUDIT_LOG, VERCEL_DEPENDENCIES, VERCEL_EMPTY_SPEED_INSIGHTS, VERCEL_EMPTY_WEB_ANALYTICS, @@ -184,7 +184,13 @@ type VercelInputs = Readonly<{ timeZone: string }> /** `VercelListPage` over the fixtures, in the view its search names. */ /** A page's main column beside its right rail, as `dashboardPage` lays them out at lg. */ const withRail = (h: HtmlBuilder, main: Html, rail: Html): Html => - h.div([h.Class("flex items-start gap-6")], [h.div([h.Class("min-w-0 flex-1")], [main]), rail]) + h.div( + [h.Class("flex items-start gap-6")], + [ + h.div([h.Class("min-w-0 flex-1")], [main]), + h.div([h.Class(cn("shrink-0", pageRightSidebarWidthStyles))], [rail]), + ], + ) const vercelListView = Submodel.defineView( (list, inputs, h) => { @@ -271,14 +277,6 @@ const vercelSections = (h: H, model: Model): Html => { compact: false, }), ), - ...variants("Audit log", (state) => - auditLogSection(h, { - read: sectionRead(state, VERCEL_AUDIT_LOG, []), - enabled: state !== "off", - clock, - projectName: nameOf, - }), - ), ]) } diff --git a/apps/web/src/lab/security-audit-log-fixture.ts b/apps/web/src/lab/security-audit-log-fixture.ts index 71345ba1cd..c8ddb6b941 100644 --- a/apps/web/src/lab/security-audit-log-fixture.ts +++ b/apps/web/src/lab/security-audit-log-fixture.ts @@ -7,6 +7,8 @@ import { Array as Arr, String as Str } from "effect" const MINUTE = 60_000 type Seed = Readonly<{ + provider?: string + account?: string minutesAgo: number action: string category: string @@ -40,6 +42,34 @@ const deployKey: AuditTrailEntry["actor"] = { const system: AuditTrailEntry["actor"] = { type: "system", id: null, name: null, email: null } const SEEDS: ReadonlyArray = [ + { + provider: "vercel", + account: "team_acme", + minutesAgo: 2, + action: "project.env-var.created", + category: "credential", + actor: { type: "user", id: "usr_9f2c", name: "Grace Hopper", email: "grace@acme.test" }, + originIp: "203.0.113.90", + raw: { id: "evt_1", action: "project.env-var.created", payload: { projectId: "prj_web" } }, + }, + { + provider: "gcp", + account: "acme-prod", + minutesAgo: 11, + action: "google.iam.admin.v1.CreateServiceAccountKey", + category: "credential", + outcome: "denied", + outcomeReason: "Permission iam.serviceAccountKeys.create denied", + actor: { + type: "service", + id: "ci@acme-prod.iam.gserviceaccount.com", + name: null, + email: "ci@acme-prod.iam.gserviceaccount.com", + }, + target: { type: "service_account", name: "projects/-/serviceAccounts/deploy@acme-prod" }, + originIp: "198.51.100.24", + raw: { insertId: "abc123", protoPayload: { serviceName: "iam.googleapis.com" } }, + }, { minutesAgo: 4, action: "alert_rule.updated", @@ -130,8 +160,8 @@ const eventOf = (seed: Seed, index: number, nowMs: number): AuditTrailEntry => { const occurredAt = nowMs - seed.minutesAgo * MINUTE return { id: Str.padStart(32, "0")(index.toString(16)), - provider: "maple", - account: null, + provider: seed.provider ?? "maple", + account: seed.account ?? null, providerEventId: `alog_4CzLmR1pTxWvYbNhQd82K${index}`, occurredAt, ingestedAt: occurredAt + 900, diff --git a/apps/web/src/lab/vercel-infra-fixtures.ts b/apps/web/src/lab/vercel-infra-fixtures.ts index fd2c11e40b..6708727e62 100644 --- a/apps/web/src/lab/vercel-infra-fixtures.ts +++ b/apps/web/src/lab/vercel-infra-fixtures.ts @@ -1,6 +1,6 @@ // Fixture rows for the /infra/vercel lab frames: four projects (one erroring, one with no traces, // one forwarded but silent), AI Gateway usage across three models, team builds, one project's -// detail, functions and dependencies, and Speed Insights, Web Analytics and audit log readings. +// detail, functions and dependencies, and Speed Insights and Web Analytics readings. import { VercelAiGatewayResponse, @@ -14,7 +14,6 @@ import { VercelProjectStatus, VercelTeam, type VercelAiGatewayBucket, - type VercelAuditEvent, type VercelInfraDependency, type VercelInfraFunction, type VercelInfraFunctionBucket, @@ -418,7 +417,7 @@ export const VERCEL_API_DEPLOYMENTS: ReadonlyArray = [ deployment("api-v1", "READY", "main", "Products: cache the price table for 60s", 3 * 24 * HOUR), ] -/** Every drain on: the overview's Speed Insights, Web Analytics and audit log sections read. */ +/** Every drain on: the Speed Insights and Web Analytics sections read, the rail points at the audit log. */ export const VERCEL_ALL_DRAINS_STATUS = new VercelIntegrationStatus({ ...VERCEL_STATUS, speedInsightsEnabled: true, @@ -556,27 +555,3 @@ export const VERCEL_EMPTY_WEB_ANALYTICS = new VercelWebAnalyticsResponse({ countries: [], events: [], }) - -export const VERCEL_AUDIT_LOG: ReadonlyArray = [ - { - timestamp: new Date(NOW - 12 * MINUTE).toISOString(), - action: "project.env-variable.updated", - actor: "Rosa Moreau", - actorType: "user", - projectId: "prj_api", - }, - { - timestamp: new Date(NOW - 3 * HOUR).toISOString(), - action: "drain-created", - actor: "Maple", - actorType: "app", - projectId: "", - }, - { - timestamp: new Date(NOW - 26 * HOUR).toISOString(), - action: "team.member.added", - actor: "Sam Okafor", - actorType: "user", - projectId: "", - }, -] diff --git a/apps/web/src/page/infra-integrations/gcp/integration/gcp-configure-copy.ts b/apps/web/src/page/infra-integrations/gcp/integration/gcp-configure-copy.ts index b5a556f14a..ff068f26ef 100644 --- a/apps/web/src/page/infra-integrations/gcp/integration/gcp-configure-copy.ts +++ b/apps/web/src/page/infra-integrations/gcp/integration/gcp-configure-copy.ts @@ -60,7 +60,8 @@ export const CAPABILITIES = { logs: { title: "Log forwarding", flag: "logs_enabled", - description: "Request, audit and managed-service logs, through a Log Router sink and Pub/Sub.", + description: + "Request and managed-service logs, through a Log Router sink and Pub/Sub. Admin activity goes to the Security audit log.", run: { on: "Log sink and Pub/Sub topic", off: "Removes the log sink" }, effects: { "starts-after-script": "Starts once it's set up in Google Cloud", diff --git a/apps/web/src/page/infra-integrations/gcp/integration/view-flow-gcp.ts b/apps/web/src/page/infra-integrations/gcp/integration/view-flow-gcp.ts index 853243c670..c6020dffd4 100644 --- a/apps/web/src/page/infra-integrations/gcp/integration/view-flow-gcp.ts +++ b/apps/web/src/page/infra-integrations/gcp/integration/view-flow-gcp.ts @@ -188,7 +188,7 @@ const chooseRail = (h: H): Html => { icon: "UnorderedListIcon", title: "Google Cloud's logs", - detail: "Request, audit and managed-service logs", + detail: "Request and managed-service logs, and admin activity for the audit log", }, { icon: "ServerIcon", diff --git a/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-filter.ts b/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-filter.ts index 7229cca8f4..7a65b3610f 100644 --- a/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-filter.ts +++ b/apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-filter.ts @@ -134,7 +134,7 @@ export const filterField = ( h, icon(h, "CheckIcon", { size: 14, className: "text-severity-info" }), "Platform logs", - "Requests, audit, deploys, crashes, scaling", + "Requests, deploys, crashes, scaling, and admin activity for the audit log", ), summaryRow( h, diff --git a/apps/web/src/page/infra-integrations/vercel/data.ts b/apps/web/src/page/infra-integrations/vercel/data.ts index 9607d94121..2f31cd3a3c 100644 --- a/apps/web/src/page/infra-integrations/vercel/data.ts +++ b/apps/web/src/page/infra-integrations/vercel/data.ts @@ -6,7 +6,6 @@ import type { } from "@maple/domain/http" import { getVercelAiGateway, - getVercelAuditLog, getVercelBreakdown, getVercelLatencyTimeseries, getVercelDependencies, @@ -199,13 +198,6 @@ export const loadVercelSpeedInsights = (input: WindowInput) => export const loadVercelWebAnalytics = (input: WindowInput) => getVercelWebAnalytics({ data: { ...input } }).pipe(Effect.mapError(toLoadFailure), Effect.result) -/** `vercelAuditLogResultAtom({ data: window })`. */ -export const loadVercelAuditLog = (window: TimeWindow) => - getVercelAuditLog({ data: { startTime: window.startTime, endTime: window.endTime } }).pipe( - Effect.mapBoth({ onSuccess: (response) => response.events, onFailure: toLoadFailure }), - Effect.result, - ) - export const DEPLOYMENTS_LIMIT = 20 /** `integrations.vercelDeployments`: the newest deployments of one project, or of the whole team. */ diff --git a/apps/web/src/page/infra-integrations/vercel/integration/vercel.scene.test.ts b/apps/web/src/page/infra-integrations/vercel/integration/vercel.scene.test.ts index 9e2fe1a837..4c41df33d1 100644 --- a/apps/web/src/page/infra-integrations/vercel/integration/vercel.scene.test.ts +++ b/apps/web/src/page/infra-integrations/vercel/integration/vercel.scene.test.ts @@ -337,6 +337,9 @@ describe("vercel card: connected", () => { expectView(text("Connected")).toExist(), expectView(within(role("row", { name: /Logs/ }), text("live"))).toExist(), expectView(within(role("row", { name: /Speed Insights/ }), text("off"))).toExist(), + expectView( + within(role("row", { name: /Audit log/ }), text(/Security audit log, org admins only/)), + ).toExist(), expectView(role("link", { name: "storefront" })).toHaveAttr("href", "/infra/vercel/prj_web"), expectView(role("link", { name: /Open Vercel dashboard/ })).toHaveAttr("href", "/infra/vercel"), ) @@ -527,16 +530,14 @@ describe("vercel card: configure", () => { ).toEqual(expect.objectContaining({ request: { aiGatewayEnabled: false } })) }) - test("turning on the audit log names its plan and saves only that switch at once", () => { + test("turning on the audit log names where its events show and its plan, and saves only that switch at once", () => { const opened = configured(connected()) scene( { update: step, view }, given(opened), mounted(), expectView( - text( - "Who changed what on the team, for the whole team whatever projects you pick. Enterprise plan only.", - ), + text(/Shown in the Security audit log to org admins\. Enterprise plan only\./), ).toExist(), ) const sent = step( diff --git a/apps/web/src/page/infra-integrations/vercel/integration/view-vercel-configure.ts b/apps/web/src/page/infra-integrations/vercel/integration/view-vercel-configure.ts index f6ee9f2597..3b353ea13a 100644 --- a/apps/web/src/page/infra-integrations/vercel/integration/view-vercel-configure.ts +++ b/apps/web/src/page/infra-integrations/vercel/integration/view-vercel-configure.ts @@ -348,7 +348,7 @@ const SIGNALS = [ title: "Audit log", drain: "Maple audit log", description: - "Who changed what on the team, for the whole team whatever projects you pick. Enterprise plan only.", + "Who changed what on the team, whatever projects you pick. Shown in the Security audit log to org admins. Enterprise plan only.", }, ] as const diff --git a/apps/web/src/page/infra-integrations/vercel/integration/view-vercel.ts b/apps/web/src/page/infra-integrations/vercel/integration/view-vercel.ts index 3ad8961deb..65a45bdaab 100644 --- a/apps/web/src/page/infra-integrations/vercel/integration/view-vercel.ts +++ b/apps/web/src/page/infra-integrations/vercel/integration/view-vercel.ts @@ -270,7 +270,7 @@ const SIGNALS = { auditLog: { title: "Audit log", carries: "Team actions: env vars, domains, members", - powers: "Team activity", + powers: "Security audit log, org admins only", }, } as const satisfies Record<(typeof VERCEL_DRAINS)[number]["key"], unknown> diff --git a/apps/web/src/page/infra-integrations/vercel/list/command.ts b/apps/web/src/page/infra-integrations/vercel/list/command.ts index d1df6f932a..050bc76f0f 100644 --- a/apps/web/src/page/infra-integrations/vercel/list/command.ts +++ b/apps/web/src/page/infra-integrations/vercel/list/command.ts @@ -4,7 +4,6 @@ import { linkedCursorStream } from "@maple/views/charts/linked-cursor" import { resolveWindow, TimeWindow } from "../../../../data/time-range" import { loadVercelAiGateway, - loadVercelAuditLog, loadVercelDeployments, loadVercelLatencySeries, loadVercelProjects, @@ -102,13 +101,6 @@ export const FetchWebAnalytics = Command.define("FetchVercelWebAnalytics", { ), }) -export const FetchAuditLog = Command.define("FetchVercelAuditLog", { - args: { key: Schema.String, window: TimeWindow }, - messages: [Message.CompletedFetchAuditLog], - execute: ({ key, window }) => - Effect.map(loadVercelAuditLog(window), (result) => Message.CompletedFetchAuditLog({ key, result })), -}) - export const CompleteReloadSpin = Command.define("CompleteVercelReloadSpin", { args: { version: Schema.Number }, messages: [Message.CompletedReloadSpin], diff --git a/apps/web/src/page/infra-integrations/vercel/list/model.ts b/apps/web/src/page/infra-integrations/vercel/list/model.ts index 959b226be8..4255065a37 100644 --- a/apps/web/src/page/infra-integrations/vercel/list/model.ts +++ b/apps/web/src/page/infra-integrations/vercel/list/model.ts @@ -1,6 +1,5 @@ import { VercelAiGatewayResponse, - VercelAuditEvent, VercelDeploymentSummary, VercelInfraLatencyBucket, VercelInfraProject, @@ -37,7 +36,6 @@ export const Model = Schema.Struct({ deployments: KeyedSchema(Schema.Array(VercelDeploymentSummary), LoadFailure), speedInsights: KeyedSchema(VercelSpeedInsightsResponse, LoadFailure), webAnalytics: KeyedSchema(VercelWebAnalyticsResponse, LoadFailure), - auditLog: KeyedSchema(Schema.Array(VercelAuditEvent), LoadFailure), charts: Schema.Record(Schema.String, Chart.Model), chartRuntime: ChartRuntime, themeColors: Schema.NullOr(ThemeColors), @@ -65,7 +63,6 @@ export const Message = defineMessageUnion({ CompletedFetchDeployments: result(Schema.Array(VercelDeploymentSummary)), CompletedFetchSpeedInsights: result(VercelSpeedInsightsResponse), CompletedFetchWebAnalytics: result(VercelWebAnalyticsResponse), - CompletedFetchAuditLog: result(Schema.Array(VercelAuditEvent)), CompletedLoadChartRuntime: {}, FailedLoadChartRuntime: { reason: Schema.String }, ResolvedThemeColors: { colors: ThemeColors }, @@ -80,7 +77,6 @@ export const Message = defineMessageUnion({ ClickedRetryDeployments: {}, ClickedRetrySpeedInsights: {}, ClickedRetryWebAnalytics: {}, - ClickedRetryAuditLog: {}, ChangedSearch: { value: Schema.String }, ClickedClearSearch: {}, /** A fleet band cell: its health filter on the projects table, or none to leave it. */ diff --git a/apps/web/src/page/infra-integrations/vercel/list/update.ts b/apps/web/src/page/infra-integrations/vercel/list/update.ts index 7a07f9792f..851d163e2e 100644 --- a/apps/web/src/page/infra-integrations/vercel/list/update.ts +++ b/apps/web/src/page/infra-integrations/vercel/list/update.ts @@ -16,7 +16,6 @@ import { DEFAULT_PRESET, DEPLOYMENTS_LIMIT, seriesInput } from "../data" import { CompleteReloadSpin, FetchAiGateway, - FetchAuditLog, FetchBuckets, FetchDeployments, FetchLatency, @@ -62,7 +61,6 @@ type Slot = | "deployments" | "speedInsights" | "webAnalytics" - | "auditLog" /** The reads the overview shows, once the status says connected: each only while its drain is on. */ const sync = ( @@ -96,7 +94,6 @@ const sync = ( const deploymentsKey = requestKey(org, "integrations.vercelDeployments", { limit: DEPLOYMENTS_LIMIT }) const speedKey = requestKey(org, "getVercelSpeedInsights", { data: window }) const webKey = requestKey(org, "getVercelWebAnalytics", { data: window }) - const auditKey = requestKey(org, "getVercelAuditLog", { data: window }) return { model: { ...model, @@ -125,9 +122,6 @@ const sync = ( webAnalytics: take("webAnalytics", status.webAnalyticsEnabled, model.webAnalytics, webKey, () => FetchWebAnalytics({ key: webKey, input: window }), ), - auditLog: take("auditLog", status.auditLogEnabled, model.auditLog, auditKey, () => - FetchAuditLog({ key: auditKey, window }), - ), }, commands, } @@ -154,7 +148,6 @@ export const init = (route: { readonly search: Search }, context: PageContext): deployments: Keyed.idle(), speedInsights: Keyed.idle(), webAnalytics: Keyed.idle(), - auditLog: Keyed.idle(), charts: {}, chartRuntime: { _tag: "Loading" }, themeColors: null, @@ -221,9 +214,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchWebAnalytics: ({ key, result }) => ({ model: { ...model, webAnalytics: Keyed.accept(model.webAnalytics, key, result) }, }), - CompletedFetchAuditLog: ({ key, result }) => ({ - model: { ...model, auditLog: Keyed.accept(model.auditLog, key, result) }, - }), CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), FailedLoadChartRuntime: ({ reason }) => ({ model: { ...model, chartRuntime: { _tag: "Failed", reason } }, @@ -239,7 +229,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedRetryDeployments: () => sync(model, context, "Refresh", ["deployments"]), ClickedRetrySpeedInsights: () => sync(model, context, "Refresh", ["speedInsights"]), ClickedRetryWebAnalytics: () => sync(model, context, "Refresh", ["webAnalytics"]), - ClickedRetryAuditLog: () => sync(model, context, "Refresh", ["auditLog"]), ChangedSearch: ({ value }) => navigate(model, { ...model.search, q: value === "" ? undefined : value }), ClickedClearSearch: () => navigate(model, { ...model.search, q: undefined }), diff --git a/apps/web/src/page/infra-integrations/vercel/list/vercel.scene.test.ts b/apps/web/src/page/infra-integrations/vercel/list/vercel.scene.test.ts index 46afb70f54..71641b89d9 100644 --- a/apps/web/src/page/infra-integrations/vercel/list/vercel.scene.test.ts +++ b/apps/web/src/page/infra-integrations/vercel/list/vercel.scene.test.ts @@ -22,7 +22,6 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { VERCEL_AI_GATEWAY, VERCEL_ALL_DRAINS_STATUS, - VERCEL_AUDIT_LOG, VERCEL_BUCKETS, VERCEL_LATENCY_BUCKETS, VERCEL_PROJECTS, @@ -108,11 +107,6 @@ const loaded = ( key: model.webAnalytics.key ?? "", result: Result.succeed(VERCEL_WEB_ANALYTICS), }), - (model) => - Message.CompletedFetchAuditLog({ - key: model.auditLog.key ?? "", - result: Result.succeed(VERCEL_AUDIT_LOG), - }), ]) /** The commands the status answer starts, the window already resolved. */ @@ -169,17 +163,12 @@ describe("vercel overview", () => { const defaults = readsFor(VERCEL_STATUS) expect(defaults).not.toContain("FetchVercelSpeedInsights") expect(defaults).not.toContain("FetchVercelWebAnalytics") - expect(defaults).not.toContain("FetchVercelAuditLog") expect(readsFor(VERCEL_TRACES_ONLY_STATUS)).not.toContain("FetchVercelRequestSeries") expect( readsFor(new VercelIntegrationStatus({ ...VERCEL_STATUS, tracesEnabled: false })), ).not.toContain("FetchVercelLatencySeries") expect(readsFor(VERCEL_ALL_DRAINS_STATUS)).toEqual( - expect.arrayContaining([ - "FetchVercelSpeedInsights", - "FetchVercelWebAnalytics", - "FetchVercelAuditLog", - ]), + expect.arrayContaining(["FetchVercelSpeedInsights", "FetchVercelWebAnalytics"]), ) }) @@ -298,22 +287,41 @@ describe("vercel overview", () => { expectView(text(VERCEL_AI_GATEWAY.models[0]?.model ?? "")).toExist(), expectView(text(/Speed Insights is off/)).toExist(), expectView(text(/Web Analytics is off/)).toExist(), - expectView(text("Team activity")).toBeAbsent(), ) }) - test("with every drain on, the overview shows Web Vitals, page views and team activity", () => { + test("with every drain on, the overview shows Web Vitals and page views", () => { scene( { update: pageUpdate, view }, given(loaded(VERCEL_ALL_DRAINS_STATUS, VERCEL_PROJECTS)), themeResolved(), expectView(text("needs work")).toExist(), expectView(text("184.2K")).toExist(), - expectView(text("project.env-variable.updated")).toExist(), expectView(text(/Speed Insights is off/)).toBeAbsent(), ) }) + test("the audit log drain puts a link to the Security audit log in the rail, and nothing when it is off", () => { + scene( + { update: pageUpdate, view }, + given(loaded(VERCEL_ALL_DRAINS_STATUS, VERCEL_PROJECTS)), + themeResolved(), + expectView(text("Team activity")).toExist(), + expectView(text("Audit events from this team are in the Security audit log.")).toExist(), + expectView(role("link", { name: "Open the audit log" })).toHaveAttr( + "href", + "/security/audit-log?provider=vercel", + ), + ) + scene( + { update: pageUpdate, view }, + given(loaded(VERCEL_STATUS, VERCEL_PROJECTS)), + themeResolved(), + expectView(text("Team activity")).toBeAbsent(), + expectView(role("link", { name: "Open the audit log" })).toBeAbsent(), + ) + }) + test("a retired projects link lands on the one overview", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/infra-integrations/vercel/list/view-rail.ts b/apps/web/src/page/infra-integrations/vercel/list/view-rail.ts index 093b67ce19..f7103935ca 100644 --- a/apps/web/src/page/infra-integrations/vercel/list/view-rail.ts +++ b/apps/web/src/page/infra-integrations/vercel/list/view-rail.ts @@ -16,14 +16,14 @@ import { type Clock, dataOf } from "../view-parts" import type { Overview } from "./derive" import type { Message, Model } from "./model" -// `VercelOverviewRail`: the team, its drains, the deploys in the window and the team's activity. +// `VercelOverviewRail`: the team, its drains, the deploys in the window and where its audit events are. type H = HtmlBuilder const RAIL_DEPLOYS = 5 -const RAIL_EVENTS = 3 const settingsHref = () => hrefOf(AppRoute.Integrations({ search: { integration: "vercel" } })) +const auditLogHref = () => hrefOf(AppRoute.SecurityAuditLog({ search: { provider: "vercel" } })) const value = (h: H, text: string): Html => typography(h, { size: "xs", tone: "foreground", mono: true, truncate: true }, [text]) @@ -170,42 +170,18 @@ const deploysGroup = (h: H, model: Model, o: Overview, clock: Clock): Html | nul ) } -const activityGroup = (h: H, model: Model, o: Overview, clock: Clock): Html | null => { - const events = dataOf(model.auditLog.data) - if (!o.status.auditLogEnabled || events === null) return null - const newest = Arr.take( - Arr.sort( - events, - Order.mapInput(Order.flip(Order.String), (e: (typeof events)[number]) => e.timestamp), - ), - RAIL_EVENTS, - ) - return detailRailGroup( - h, - "Team activity", - newest.length === 0 - ? [typography(h, { size: "2xs", tone: "muted", as: "p" }, ["No team activity in this window"])] - : Arr.map(newest, (event) => - vstack(h, { gap: "0.5", className: "py-1" }, [ - typography(h, { size: "xs", tone: "foreground", mono: true, truncate: true }, [ - event.action, - ]), - hstack(h, { gap: "1" }, [ - typography(h, { size: "3xs", tone: "muted", truncate: true }, [ - `${event.actor} · ${event.projectId === "" ? "team" : o.projectName(event.projectId)} ·`, - ]), - relativeTime(h, { - value: event.timestamp, - ...clock, - variant: "short", - mono: true, - className: "shrink-0 text-3xs text-muted-foreground", - }), - ]), - ]), - ), - ) -} +// Audit events are admin-only, so they live in the Security audit log: the rail points there. +const activityGroup = (h: H, o: Overview): Html | null => + o.status.auditLogEnabled + ? detailRailGroup(h, "Team activity", [ + typography(h, { size: "2xs", tone: "muted", as: "p" }, [ + "Audit events from this team are in the Security audit log.", + ]), + typography(h, { size: "2xs", className: "self-start" }, [ + textLink(h, { href: auditLogHref() }, ["Open the audit log"]), + ]), + ]) + : null export const overviewRail = (h: H, model: Model, o: Overview, clock: Clock): Html => vstack( @@ -216,7 +192,7 @@ export const overviewRail = (h: H, model: Model, o: Overview, clock: Clock): Htm teamGroup(h, o, clock), drainsGroup(h, o), deploysGroup(h, model, o, clock), - activityGroup(h, model, o, clock), + activityGroup(h, o), ], (group): group is Html => group !== null, ), diff --git a/apps/web/src/page/infra-integrations/vercel/sections.ts b/apps/web/src/page/infra-integrations/vercel/sections.ts index 91d140dccb..94cab70dc5 100644 --- a/apps/web/src/page/infra-integrations/vercel/sections.ts +++ b/apps/web/src/page/infra-integrations/vercel/sections.ts @@ -1,5 +1,4 @@ import type { - VercelAuditEvent, VercelDeploymentSummary, VercelInfraDependency, VercelInfraFunction, @@ -912,93 +911,3 @@ export const webAnalyticsSection = ( ] }, }) - -// Audit log - -const AUDIT_LOG_OFF: DrainOff = { - lead: "The audit log is off.", - text: "Turn on the Audit Log drain to see who changed the team: members, domains, env vars and drains.", -} - -/** The team's newest audit log events. */ -export const auditLogSection = ( - h: HtmlBuilder, - options: Readonly<{ - read: SectionRead, M> - enabled: boolean - clock: Clock - projectName: (projectId: string) => string - }>, -): Html => - readSection(h, { - title: "Audit log", - hint: "newest first", - read: options.read, - off: options.enabled ? null : AUDIT_LOG_OFF, - failure: "Couldn't load the audit log", - loading: () => tableLoading(h, "Audit log"), - body: (events) => [ - dataTable( - h, - { ariaLabel: "Audit log" }, - [ - columnHead(h, { label: "Action", width: LEAD_COLUMN }), - columnHead(h, { label: "Actor", width: "w-[160px]" }), - columnHead(h, { label: "Project", width: "w-[140px]", hidden: "hidden md:flex" }), - columnHead(h, { label: "When", width: "w-[72px]", align: "right" }), - ], - events.length === 0 - ? [dataTableEmpty(h, ["No audit events in this window"])] - : Arr.map(events, (event, index) => - h.div( - [h.Key(`${event.timestamp}:${index}`), h.Class(ROW_CLASS)], - [ - typography( - h, - { - size: "xs", - tone: "foreground", - mono: true, - truncate: true, - className: LEAD_COLUMN, - }, - [event.action], - ), - typography( - h, - { size: "xs", tone: "soft", truncate: true, className: "w-[160px]" }, - [event.actor === "" ? event.actorType || EMPTY_VALUE : event.actor], - ), - typography( - h, - { - size: "xs", - tone: "muted", - mono: true, - truncate: true, - className: "hidden w-[140px] md:block", - }, - [ - event.projectId === "" - ? "team" - : options.projectName(event.projectId), - ], - ), - h.span( - [h.Class("w-[72px] text-right")], - [ - relativeTime(h, { - value: event.timestamp, - ...options.clock, - variant: "short", - mono: true, - className: typographyClass({ size: "2xs", tone: "muted" }), - }), - ], - ), - ], - ), - ), - ), - ], - }) diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 872f89cabd..502fa30e70 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -1,7 +1,7 @@ import { OrgId } from "@maple/domain" import { AuditTrailForbiddenError, AuditTrailPersistenceError } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" -import { Result, Schema, Array as Arr, String as Str } from "effect" +import { Option, Result, Schema, Array as Arr, String as Str } from "effect" import { expect as expectView, given, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import { toLoadFailure } from "@maple/views/data/load-failure" @@ -186,25 +186,55 @@ describe("security audit log", () => { ) }) - test("opening an event shows what was recorded about it, and closing hides it", () => { + /** The first loaded event from `provider`, opened. */ + const openedFrom = (provider: string) => { const model = loaded() - const first = eventsOf(model)?.[0] - const opened = pageUpdate(model, Message.ToggledEvent({ id: first?.id ?? "" })).model + const event = Arr.findFirst(eventsOf(model) ?? [], (candidate) => candidate.provider === provider) + const id = Option.match(event, { onNone: () => "", onSome: (found) => found.id }) + return { + event: Option.getOrUndefined(event), + id, + opened: pageUpdate(model, Message.ToggledEvent({ id })).model, + } + } + + test("opening an event shows what was recorded about it, and closing hides it", () => { + const { event, id, opened } = openedFrom("maple") scene( { update: pageUpdate, view }, given(opened), expectView(text("Payload")).toExist(), - expectView(text(first?.providerEventId ?? "")).toExist(), - expectView(text("203.0.113.7")).toExist(), + expectView(text(event?.providerEventId ?? "")).toExist(), + expectView(text(event?.originIp ?? "")).toExist(), ) scene( { update: pageUpdate, view }, - given(pageUpdate(opened, Message.ToggledEvent({ id: first?.id ?? "" })).model), + given(pageUpdate(opened, Message.ToggledEvent({ id })).model), expectView(text("Payload")).toBeAbsent(), ) }) + test("each event names the platform it came from, and the account on it", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(within(eventsTable, text("Vercel"))).toExist(), + expectView(within(eventsTable, text("Google Cloud"))).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(openedFrom("gcp").opened), + expectView(text("acme-prod")).toExist(), + ) + }) + + test("the platform filter goes to the URL like the others", () => { + const vercel = pageUpdate(loaded(), Message.ChangedFilter({ filter: "provider", value: "vercel" })) + + expect(vercel.outMessage).toMatchObject({ url: hrefOf(routeOf({ provider: "vercel" })) }) + }) + test("a filter change goes to the URL, and choosing All drops it", () => { const denied = pageUpdate(loaded(), Message.ChangedFilter({ filter: "outcome", value: "denied" })) expect(denied.outMessage).toMatchObject({ url: hrefOf(routeOf({ outcome: "denied" })) }) @@ -313,20 +343,30 @@ describe("security audit log", () => { }) test("a Maple event's id opens the same entry in the Settings audit log", () => { - const model = loaded() - const first = eventsOf(model)?.[0] - const opened = pageUpdate(model, Message.ToggledEvent({ id: first?.id ?? "" })).model + const { event, opened } = openedFrom("maple") scene( { update: pageUpdate, view }, given(opened), - expectView(role("link", { name: first?.providerEventId ?? "" })).toHaveAttr( + expectView(role("link", { name: event?.providerEventId ?? "" })).toHaveAttr( "href", - auditEntryHref(first?.occurredAt ?? 0, first?.providerEventId ?? ""), + auditEntryHref(event?.occurredAt ?? 0, event?.providerEventId ?? ""), ), ) }) + // Only Maple's own events are entries of the Settings audit log. + test("another platform's event id is text, not a link into Maple's own audit log", () => { + const { event, opened } = openedFrom("vercel") + + scene( + { update: pageUpdate, view }, + given(opened), + expectView(text(event?.providerEventId ?? "")).toExist(), + expectView(role("link", { name: event?.providerEventId ?? "" })).toBeAbsent(), + ) + }) + test("an empty log says what will show up; an empty filter result offers to clear the filters", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/security/audit-log/command.ts b/apps/web/src/page/security/audit-log/command.ts index 1109175ffc..040819ae77 100644 --- a/apps/web/src/page/security/audit-log/command.ts +++ b/apps/web/src/page/security/audit-log/command.ts @@ -23,6 +23,7 @@ const listEvents = (search: SecurityAuditLogSearch, cursor: string | undefined) Effect.flatMap(MapleInternalApiClient, (api) => api.auditTrail.list({ query: { + ...(search.provider !== undefined ? { provider: search.provider } : undefined), ...(search.category !== undefined ? { category: search.category } : undefined), ...(search.outcome !== undefined ? { outcome: search.outcome } : undefined), ...(search.actor !== undefined ? { actor: search.actor } : undefined), diff --git a/apps/web/src/page/security/audit-log/derive.ts b/apps/web/src/page/security/audit-log/derive.ts index a98638cc9c..5235d8fbed 100644 --- a/apps/web/src/page/security/audit-log/derive.ts +++ b/apps/web/src/page/security/audit-log/derive.ts @@ -1,12 +1,18 @@ -import type { AuditTrailCategory, AuditTrailOutcome } from "@maple/domain/audit-trail" +import type { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" import type { LoadFailure } from "@maple/views/data/load-failure" import { Array as Arr, Option, Record as Rec } from "effect" import { AsyncData } from "foldkit" import type { Model, Search } from "./model" -// What the audit log shows of an event: the names of categories and outcomes, who acted and on -// what. A value this build does not know shows as itself. +// What the audit log shows of an event: the names of platforms, categories and outcomes, who acted +// and on what. A value this build does not know shows as itself. + +export const PROVIDER_LABEL = { + maple: "Maple", + vercel: "Vercel", + gcp: "Google Cloud", +} satisfies Record export const CATEGORY_LABEL = { authentication: "Authentication", @@ -27,8 +33,12 @@ export const OUTCOME_LABEL = { unknown: "Unknown", } satisfies Record -/** The outcomes a platform writes today: Maple records what went through and what it refused. */ -export const OUTCOME_FILTERS = ["success", "denied"] as const satisfies ReadonlyArray +/** The outcomes worth filtering by. `unknown` is a platform that did not say, not something to look for. */ +export const OUTCOME_FILTERS = [ + "success", + "failure", + "denied", +] as const satisfies ReadonlyArray const ACTOR_TYPE_LABEL = { user: "User", @@ -41,6 +51,7 @@ const ACTOR_TYPE_LABEL = { export const labelIn = (labels: Readonly>, value: string): string => Option.getOrElse(Rec.get(labels, value), () => value) +export const providerLabel = (provider: string): string => labelIn(PROVIDER_LABEL, provider) export const categoryLabel = (category: string): string => labelIn(CATEGORY_LABEL, category) export const outcomeLabel = (outcome: string): string => labelIn(OUTCOME_LABEL, outcome) export const actorTypeLabel = (type: string): string => labelIn(ACTOR_TYPE_LABEL, type) @@ -60,7 +71,10 @@ export const targetText = (event: AuditTrailEntry): string | null => event.targe export const isMarked = (event: AuditTrailEntry): boolean => event.outcome !== "success" export const isFiltered = (search: Search): boolean => - search.category !== undefined || search.outcome !== undefined || search.actor !== undefined + search.provider !== undefined || + search.category !== undefined || + search.outcome !== undefined || + search.actor !== undefined /** The API refused the viewer: the same thing the session says for a member without the permission. */ export const isForbidden = (failure: LoadFailure): boolean => failure.code === "audit_trail_forbidden" diff --git a/apps/web/src/page/security/audit-log/model.ts b/apps/web/src/page/security/audit-log/model.ts index 5b7ad3fba9..70e7fc450b 100644 --- a/apps/web/src/page/security/audit-log/model.ts +++ b/apps/web/src/page/security/audit-log/model.ts @@ -25,7 +25,7 @@ export const LaterPages = Schema.Struct({ export type LaterPages = typeof LaterPages.Type /** The filters a select sets; the actor is typed and applied on Enter. */ -export const SelectFilter = Schema.Literals(["category", "outcome"]) +export const SelectFilter = Schema.Literals(["provider", "category", "outcome"]) export type SelectFilter = typeof SelectFilter.Type export const Model = Schema.Struct({ diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index a333c936d2..16997f057a 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -1,4 +1,4 @@ -import { AuditTrailCategory } from "@maple/domain/audit-trail" +import { AuditTrailCategory, AuditTrailProvider } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" import { refreshingClass } from "@maple/ui/lib/refreshing" @@ -48,7 +48,7 @@ import { expandChevron } from "../../kit/expand-chevron" import { jsonBlock, prettyJson } from "../../kit/json-block" import { refusedBadge } from "../../kit/refused-badge" import { relativeTime } from "../../kit/relative-time" -import { securityLayout, WIDE_COLUMN } from "../kit/layout" +import { ROOMY_COLUMN, securityLayout, WIDE_COLUMN } from "../kit/layout" import { placeholderRows } from "../kit/parts" import { actorDetail, @@ -65,6 +65,8 @@ import { OUTCOME_FILTERS, OUTCOME_LABEL, outcomeLabel, + PROVIDER_LABEL, + providerLabel, targetText, } from "./derive" import { accessOf, Message, type Model, type SelectFilter } from "./model" @@ -72,11 +74,11 @@ import { accessOf, Message, type Model, type SelectFilter } from "./model" type H = HtmlBuilder type Clock = Readonly<{ nowMs: number; timeZone: string }> -const COLUMN_COUNT = 5 +const COLUMN_COUNT = 6 // On a phone the two text columns share the width left of the time, so neither scrolls away. const NARROW_ACTOR = "max-w-32 sm:max-w-56" -const NARROW_ACTION = "max-w-36 sm:max-w-72" +const NARROW_ACTION = "max-w-36 sm:max-w-64" /** One filter as a native select: "All …" clears it. The URL holds the value it shows. */ const filterSelect = ( @@ -107,6 +109,15 @@ const filterSelect = ( const toolbar = (h: H, model: Model): Html => hstack(h, { gap: "2", align: "center", wrap: true, className: "border-b px-4 py-2.5" }, [ + filterSelect( + h, + "provider", + "Platform", + model.search.provider, + "All platforms", + PROVIDER_LABEL, + AuditTrailProvider.literals, + ), filterSelect( h, "category", @@ -184,6 +195,7 @@ const eventDetail = (h: H, event: AuditTrailEntry, clock: Clock): Html => { ...detailField(h, "IP address", event.originIp, true), ...detailField(h, "Country", event.originCountry), ...detailField(h, "User agent", event.userAgent, true), + keyValue(h, { label: "Platform", wrap: true }, [providerLabel(event.provider)]), ...detailField(h, "Account", event.account, true), keyValue(h, { label: "Occurred", wrap: true }, [at(event.occurredAt)]), keyValue(h, { label: "Recorded", wrap: true }, [at(event.ingestedAt)]), @@ -232,6 +244,7 @@ const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): Re ], "p-0", ), + tableCell(h, [providerLabel(event.provider)], WIDE_COLUMN), tableCell( h, [ @@ -288,7 +301,7 @@ const eventRows = (h: H, model: Model, event: AuditTrailEntry, clock: Clock): Re className: typographyClass({ size: "xs", tone: "muted" }), }), ], - `max-w-64 ${WIDE_COLUMN}`, + `max-w-64 ${ROOMY_COLUMN}`, ), tableCell(h, outcomeMark(h, event), WIDE_COLUMN), ], @@ -340,9 +353,11 @@ const eventsTable = (h: H, model: Model, events: ReadonlyArray, tableHeader(h, [ tableRow(h, [ tableHead(h, ["Time"], "w-24"), + tableHead(h, ["Platform"], WIDE_COLUMN), tableHead(h, ["Actor"]), tableHead(h, ["Action"]), - tableHead(h, ["Target"], WIDE_COLUMN), + // Beside the rail a laptop has no room for it; the detail and the wide screen do. + tableHead(h, ["Target"], ROOMY_COLUMN), tableHead(h, ["Outcome"], `w-28 ${WIDE_COLUMN}`), ]), ]), @@ -372,7 +387,7 @@ const noEvents = (h: H): Html => emptyMedia(h, { variant: "icon" }, [icon(h, "HistoryIcon")]), emptyTitle(h, ["No audit events yet"]), emptyDescription(h, [ - "Changes made in Maple, and requests it refused, appear here as they happen.", + "Changes made in Maple, and requests it refused, appear here as they happen. So do audit events from Vercel and Google Cloud once their audit feeds are on.", ]), ]), ], diff --git a/apps/web/src/page/security/kit/layout.ts b/apps/web/src/page/security/kit/layout.ts index aba940519a..84a801686c 100644 --- a/apps/web/src/page/security/kit/layout.ts +++ b/apps/web/src/page/security/kit/layout.ts @@ -146,7 +146,8 @@ const DESCRIPTION = { "The language runtime each service reports, checked for known vulnerabilities and end-of-life dates.", dependencies: "Known vulnerable packages in your connected repositories' lockfiles, rescanned on every push.", - auditLog: "Who changed what in Maple, and what it refused. Visible to org admins.", + auditLog: + "Who changed what, and what was refused, in Maple and your connected platforms. Visible to org admins.", } satisfies Record /** The page's blocks at their own height: the scroll area scrolls them, never squeezes them. */ diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index ef841f2a7d..52ec3f5bd9 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -1085,6 +1085,8 @@ export const SecurityDependenciesSearch = Schema.Struct({ /** The Security hub's audit log filters; each absent one shows everything. */ export const SecurityAuditLogSearch = Schema.Struct({ + /** The platform the events came from (`maple`, `vercel`, `gcp`). */ + provider: StringParam, category: StringParam, outcome: StringParam, /** An actor's id or email on the platform the event came from. */ diff --git a/packages/backend/src/queries/registry.ts b/packages/backend/src/queries/registry.ts index 499bc856e6..4d4c16fc16 100644 --- a/packages/backend/src/queries/registry.ts +++ b/packages/backend/src/queries/registry.ts @@ -558,14 +558,6 @@ const vercelWebAnalyticsReferrers = vercelWebAnalyticsTop("vercelWebAnalyticsRef const vercelWebAnalyticsCountries = vercelWebAnalyticsTop("vercelWebAnalyticsCountries", "country") const vercelWebAnalyticsEvents = vercelWebAnalyticsTop("vercelWebAnalyticsEvents", "event") -const vercelAuditLog = defineQuery({ - id: "vercelAuditLog", - profile: "aggregation", - cache: 15, - compile: (payload: VercelInfraProjectsRequest, orgId: OrgId) => - CH.compile(Integrations.vercelAuditLogSQL(), vercelWindow(payload, orgId)), -}) - // /infra/supabase: gauges and counter rates are separate reads (metrics_gauge, metrics_sum), // merged per project or bucket by the handler. const supabaseInfraProjectGauges = defineQuery({ @@ -1317,7 +1309,6 @@ export const Queries = { vercelWebAnalyticsReferrers, vercelWebAnalyticsCountries, vercelWebAnalyticsEvents, - vercelAuditLog, supabaseInfraProjectGauges, supabaseInfraProjectCpu, supabaseInfraProjectGaugeSeries, diff --git a/packages/domain/src/audit-trail.ts b/packages/domain/src/audit-trail.ts index da87d4c27c..cdcd36db4d 100644 --- a/packages/domain/src/audit-trail.ts +++ b/packages/domain/src/audit-trail.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" /** Platforms whose audit events the trail holds. One literal per built adapter; `maple` is Maple's own audit log. */ -export const AuditTrailProvider = Schema.Literals(["maple"]) +export const AuditTrailProvider = Schema.Literals(["maple", "vercel", "gcp"]) export type AuditTrailProvider = typeof AuditTrailProvider.Type /** diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index b362946620..a259c5c03f 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -1106,21 +1106,6 @@ export class VercelWebAnalyticsResponse extends Schema.Class("VercelAuditLogResponse")({ - events: Schema.Array(VercelAuditEvent), -}) {} - // Google Cloud infrastructure page (/infra/gcp): `gcp.*` metrics from the Google Cloud poller. const GcpInfraService = Schema.Literals(GCP_INFRA_SERVICE_IDS) @@ -4421,13 +4406,6 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) - .add( - HttpApiEndpoint.post("vercelAuditLog", "/vercel-audit-log", { - payload: VercelInfraProjectsRequest, - success: VercelAuditLogResponse, - error: queryEngineEndpointErrors, - }), - ) .add( HttpApiEndpoint.post("gcpInfraPresence", "/gcp-infra-presence", { payload: GcpInfraPresenceRequest, diff --git a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql index c16dc043e2..c1b39afafe 100644 --- a/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql +++ b/packages/query-engine-integrations/src/__sql_baseline__/integrations.sql @@ -5495,24 +5495,6 @@ SELECT LIMIT 2000 FORMAT JSON --- builder:vercel-event-drains:vercelAuditLogSQL:default -SELECT - logs.Timestamp AS timestamp, - logs.LogAttributes['vercel.audit.action'] AS action, - if(logs.LogAttributes['vercel.audit.actor.name'] != '', logs.LogAttributes['vercel.audit.actor.name'], logs.LogAttributes['vercel.audit.actor.id']) AS actor, - logs.LogAttributes['vercel.audit.actor.type'] AS actorType, - logs.ResourceAttributes['vercel.project.id'] AS projectId - FROM logs - WHERE logs.OrgId = 'org_sql_catalog' - AND logs.ServiceName = 'vercel-audit-log' - AND logs.TimestampTime >= '2026-01-01 10:30:00' - AND logs.TimestampTime <= '2026-01-03 14:15:00' - AND logs.Timestamp >= '2026-01-01 10:30:00' - AND logs.Timestamp <= '2026-01-03 14:15:00' - ORDER BY timestamp DESC - LIMIT 25 - FORMAT JSON - -- builder:vercel-event-drains:vercelSpeedInsightsSQL:device SELECT logs.LogAttributes['vercel.device.type'] AS key, diff --git a/packages/query-engine-integrations/src/benchmark/index.ts b/packages/query-engine-integrations/src/benchmark/index.ts index a7455814a2..eb360a09a1 100644 --- a/packages/query-engine-integrations/src/benchmark/index.ts +++ b/packages/query-engine-integrations/src/benchmark/index.ts @@ -960,12 +960,6 @@ export const integrationFixtures: ReadonlyArray = [ compile: () => compileUnsafe(CH.vercelWebAnalyticsTopSQL("event", { projectId: VERCEL_PROJECT_ID }), window), }, - { - module: "vercel-event-drains", - name: "vercelAuditLogSQL", - label: "default", - compile: () => compileUnsafe(CH.vercelAuditLogSQL(), window), - }, { module: "vercel-ai-gateway", name: "vercelAiGatewaySummarySQL", diff --git a/packages/query-engine-integrations/src/vercel/index.ts b/packages/query-engine-integrations/src/vercel/index.ts index 52293b51ba..ea70f1f249 100644 --- a/packages/query-engine-integrations/src/vercel/index.ts +++ b/packages/query-engine-integrations/src/vercel/index.ts @@ -32,7 +32,6 @@ export { vercelProjectFunctionsSQL, } from "./vercel-functions" export { - vercelAuditLogSQL, vercelSpeedInsightsSQL, vercelWebAnalyticsSummarySQL, vercelWebAnalyticsTopSQL, diff --git a/packages/query-engine-integrations/src/vercel/vercel-event-drains.ts b/packages/query-engine-integrations/src/vercel/vercel-event-drains.ts index b3999aa325..4d600058cf 100644 --- a/packages/query-engine-integrations/src/vercel/vercel-event-drains.ts +++ b/packages/query-engine-integrations/src/vercel/vercel-event-drains.ts @@ -1,9 +1,9 @@ -// Vercel Speed Insights, Web Analytics and audit log (/infra/vercel) +// Vercel Speed Insights and Web Analytics (/infra/vercel) // // Each drain lands in `logs` under its own service (ingest maps them in // `apps/ingest/src/vercel_event_drains.rs`), one record per event, the project on -// resource `vercel.project.id` (empty for team events). Filtering on the service -// prunes on the logs sort key. +// resource `vercel.project.id`. Filtering on the service prunes on the logs sort key. +// Audit log events are not in `logs`: they go to the admin-only `audit_trail_events`. import * as CH from "@maple-dev/effect-orm/expr" import { from, param, type ColumnAccessor } from "@maple-dev/effect-orm/clickhouse" @@ -16,7 +16,6 @@ type LogColumns = ColumnAccessor const SPEED_INSIGHTS_SERVICE = "vercel-speed-insights" const WEB_ANALYTICS_SERVICE = "vercel-web-analytics" -const AUDIT_LOG_SERVICE = "vercel-audit-log" const drainWhere = ($: LogColumns, service: string, scope: VercelProjectScope) => [ $.OrgId.eq(orgIdParam), @@ -152,23 +151,3 @@ export function vercelWebAnalyticsTopSQL( .limit(10) .format("JSON") } - -/** The team's newest audit events. */ -export function vercelAuditLogSQL() { - return from(Datasources.logs) - .select(($) => ({ - timestamp: $.Timestamp, - action: $.LogAttributes.get("vercel.audit.action"), - actor: CH.if_( - $.LogAttributes.get("vercel.audit.actor.name").neq(""), - $.LogAttributes.get("vercel.audit.actor.name"), - $.LogAttributes.get("vercel.audit.actor.id"), - ), - actorType: $.LogAttributes.get("vercel.audit.actor.type"), - projectId: $.ResourceAttributes.get("vercel.project.id"), - })) - .where(($) => drainWhere($, AUDIT_LOG_SERVICE, {})) - .orderBy(["timestamp", "desc"]) - .limit(25) - .format("JSON") -} diff --git a/packages/query-engine-integrations/src/vercel/vercel.test.ts b/packages/query-engine-integrations/src/vercel/vercel.test.ts index f7ca9274ae..c5849b901f 100644 --- a/packages/query-engine-integrations/src/vercel/vercel.test.ts +++ b/packages/query-engine-integrations/src/vercel/vercel.test.ts @@ -22,7 +22,6 @@ import { vercelProjectFunctionsSQL, } from "./vercel-functions" import { - vercelAuditLogSQL, vercelSpeedInsightsSQL, vercelWebAnalyticsSummarySQL, vercelWebAnalyticsTopSQL, @@ -281,18 +280,6 @@ describe("Web Analytics", () => { }) }) -describe("vercelAuditLogSQL", () => { - it("lists the newest audit events, the actor by name or else id", () => { - const { sql } = compileUnsafe(vercelAuditLogSQL(), window) - expect(sql).toContain("logs.ServiceName = 'vercel-audit-log'") - expect(sql).toContain( - "if(logs.LogAttributes['vercel.audit.actor.name'] != '', logs.LogAttributes['vercel.audit.actor.name'], logs.LogAttributes['vercel.audit.actor.id']) AS actor", - ) - expect(sql).toContain("ORDER BY timestamp DESC") - expect(sql).toContain("LIMIT 25") - }) -}) - describe("AI Gateway", () => { it("counts gateway server spans, parented or not, and sums the USD cost", () => { const { sql } = compileUnsafe(vercelAiGatewaySummarySQL(), window) diff --git a/packages/web-data/src/warehouse/vercel-infra.ts b/packages/web-data/src/warehouse/vercel-infra.ts index fd899ba465..b4fa1905a2 100644 --- a/packages/web-data/src/warehouse/vercel-infra.ts +++ b/packages/web-data/src/warehouse/vercel-infra.ts @@ -287,24 +287,6 @@ export const getVercelWebAnalytics = Effect.fn("QueryEngine.getVercelWebAnalytic ) }) -export const getVercelAuditLog = Effect.fn("QueryEngine.getVercelAuditLog")(function* ({ - data, -}: { - data: VercelTimeRangeInput -}) { - const input = yield* decodeInput(TimeRangeInputSchema, data, "getVercelAuditLog") - const nowMillis = yield* Clock.currentTimeMillis - const range = resolveRange(input, nowMillis) - return yield* runWarehouseQuery("vercelAuditLog", () => - Effect.gen(function* () { - const client = yield* MapleInternalHttp - return yield* client.queryEngine.vercelAuditLog({ - payload: new VercelInfraProjectsRequest(range), - }) - }), - ) -}) - /** One project's functions per bucket, for every route or one. */ export const getVercelFunctionTimeseries = Effect.fn("QueryEngine.getVercelFunctionTimeseries")(function* ({ data, From 5e11c1122c92ed8f45b5c3b1fb5cbcf43dcfbe26 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 15:31:38 +0100 Subject: [PATCH 16/30] fix(security): the audit trail accepts only the org's own drain, bounded and off by default Review of the Vercel and GCP layer: - The Vercel audit drain accepted any private key or source token. It now takes only the drain Maple creates: the private key naming one of the org's integration sources. - A delivery is decoded inside the shared decode memory budget and capped at 4 MiB; a small gzip could otherwise parse into gigabytes. - Audit rows are unmetered, so each org has a delivery rate, every column a cap, and a timestamp outside the table's retention is replaced by the time of receipt. - Kubernetes API writes stay logs. Categories are matched on whole words, a federated workload is a named service, and Vercel rows carry a target. - INGEST_AUDIT_TRAIL_ENABLED (default off) turns the trail on, so the gateway can deploy before the table exists and is switched off rather than rolled back past the new WAL frame tag. - The handler records request metrics like the other ingest routes. - Tests read the stored row instead of the returned count. --- apps/ingest/alchemy.run.ts | 3 + apps/ingest/alchemy/gateway-env.ts | 3 + apps/ingest/src/audit_trail.rs | 514 +++++++++++++---- apps/ingest/src/main.rs | 532 ++++++++++++++---- .../src/content/docs/integrations/gcp.md | 2 +- .../src/content/docs/integrations/vercel.md | 17 +- 6 files changed, 849 insertions(+), 222 deletions(-) diff --git a/apps/ingest/alchemy.run.ts b/apps/ingest/alchemy.run.ts index bb92b22d32..728a867f3e 100644 --- a/apps/ingest/alchemy.run.ts +++ b/apps/ingest/alchemy.run.ts @@ -522,6 +522,9 @@ export const createMapleIngest = ({ stage, region, hostname, profile, dbLogin }: ...(yield* blockedIngestKeysEnv), // The org Maple's own telemetry is filed under. Required, no fallback. MAPLE_INTERNAL_ORG_ID: yield* requiredPlain("MAPLE_INTERNAL_ORG_ID"), + // On wherever a stage is deployed whole and never rolled back to an older gateway; prd + // turns it on in its environment once `audit_trail_events` is there. + ...(stage.kind !== "prd" && { INGEST_AUDIT_TRAIL_ENABLED: "true" }), ...(yield* gatewayTuningEnv), // `INGEST_COMMIT_SHA` (prd) is the image's own revision, so a deploy that leaves // `apps/ingest` alone does not change the task definition and roll the hosts. diff --git a/apps/ingest/alchemy/gateway-env.ts b/apps/ingest/alchemy/gateway-env.ts index 58d015b06b..5846d29ddc 100644 --- a/apps/ingest/alchemy/gateway-env.ts +++ b/apps/ingest/alchemy/gateway-env.ts @@ -24,6 +24,9 @@ export const gatewayTuningEnv: Config.Config = Config.all([ // switch rule application off in an emergency. optionalPlain("INGEST_RULES_ENABLED"), optionalPlain("INGEST_RULES_CACHE_TTL_SECS"), + // Provider audit events go to the audit trail instead of the logs. The gateway defaults to off: + // set "true" once `audit_trail_events` exists and every task runs a build that knows the switch. + optionalPlain("INGEST_AUDIT_TRAIL_ENABLED"), optionalPlain("INGEST_MAX_REQUEST_BODY_BYTES"), optionalPlain("INGEST_MAX_DECODED_BODY_BYTES"), optionalPlain("INGEST_MAX_DECODE_HEAP_BYTES"), diff --git a/apps/ingest/src/audit_trail.rs b/apps/ingest/src/audit_trail.rs index 0a51b26df1..57fdd02f35 100644 --- a/apps/ingest/src/audit_trail.rs +++ b/apps/ingest/src/audit_trail.rs @@ -8,9 +8,10 @@ //! must agree: an event a provider redelivers has to land on the same key. use crate::vercel_logs::text; -use chrono::{DateTime, SecondsFormat, Utc}; +use chrono::{DateTime, Utc}; use serde_json::{json, Map as JsonMap, Value as JsonValue}; use sha2::{Digest, Sha256}; +use std::borrow::Cow; type JsonObject = JsonMap; @@ -20,9 +21,19 @@ pub const DATASOURCE: &str = "audit_trail_events"; /// rises: the table keeps the highest version, which is the first write. const VERSION_CEILING_MS: i64 = 9_999_999_999_999; -/// A payload larger than this is stored as the fields the row was built from. +/// A payload larger than this is not kept; the row's columns say what happened. const RAW_LIMIT_BYTES: usize = 32 * 1024; +/// The table drops a row this long after it happened. +const RETENTION_MS: i64 = 400 * 24 * 60 * 60 * 1000; +/// How far ahead of this gateway's clock a sender's may run. +const MAX_AHEAD_MS: i64 = 24 * 60 * 60 * 1000; + +/// The most bytes a column keeps. The sender sets every one of them, and the +/// table is shared by every org. +const NAME_LIMIT_BYTES: usize = 256; +const TEXT_LIMIT_BYTES: usize = 1024; + /// One audit event, normalized. Empty strings are absent values. #[derive(Debug, Default, PartialEq, Eq)] pub struct AuditEvent { @@ -73,31 +84,57 @@ fn warehouse_datetime(epoch_ms: i64) -> String { .to_string() } +/// `value` cut to at most `max_bytes`, on a character boundary. +fn clip(value: &str, max_bytes: usize) -> &str { + if value.len() <= max_bytes { + return value; + } + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +/// An id is part of the row's key, so one too long to keep is hashed, not cut: +/// two long ids sharing a prefix stay two events. +fn bounded_id(id: &str) -> Cow<'_, str> { + if id.len() <= NAME_LIMIT_BYTES { + Cow::Borrowed(id) + } else { + Cow::Owned(sha256_prefix(id.as_bytes())) + } +} + impl AuditEvent { /// The event as one NDJSON row for `org_id`, stored at `ingested_at_ms`. pub fn to_row(&self, org_id: &str, ingested_at_ms: i64) -> Vec { + let name = |value: &'_ str| clip(value, NAME_LIMIT_BYTES).to_owned(); + let long = |value: &'_ str| clip(value, TEXT_LIMIT_BYTES).to_owned(); + let account = name(&self.account); + let provider_event_id = bounded_id(&self.provider_event_id); json!({ "OrgId": org_id, - "Id": event_id(self.provider, &self.account, &self.provider_event_id), + "Id": event_id(self.provider, &account, &provider_event_id), "OccurredAt": warehouse_datetime(self.occurred_at_ms), "IngestedAt": warehouse_datetime(ingested_at_ms), "Provider": self.provider, - "Account": self.account, - "ProviderEventId": self.provider_event_id, + "Account": account, + "ProviderEventId": provider_event_id, "ActorType": self.actor_type, - "ActorId": self.actor_id, - "ActorName": self.actor_name, - "ActorEmail": self.actor_email, - "Action": self.action, + "ActorId": name(&self.actor_id), + "ActorName": name(&self.actor_name), + "ActorEmail": name(&self.actor_email), + "Action": name(&self.action), "Category": self.category, "Outcome": self.outcome, - "OutcomeReason": self.outcome_reason, - "TargetType": self.target_type, - "TargetId": self.target_id, - "TargetName": self.target_name, - "OriginIp": self.origin_ip, + "OutcomeReason": long(&self.outcome_reason), + "TargetType": name(&self.target_type), + "TargetId": name(&self.target_id), + "TargetName": long(&self.target_name), + "OriginIp": name(&self.origin_ip), "OriginCountry": "", - "UserAgent": self.user_agent, + "UserAgent": long(&self.user_agent), "Raw": self.raw, "Version": VERSION_CEILING_MS - ingested_at_ms, }) @@ -114,70 +151,135 @@ fn object<'a>(parent: &'a JsonObject, key: &str) -> Option<&'a JsonObject> { parent.get(key)?.as_object() } -/// The payload as JSON text, or `summary` when the payload is too large to keep. -fn raw_of(payload: &JsonObject, summary: impl FnOnce() -> JsonValue) -> String { - let full = JsonValue::Object(payload.clone()).to_string(); +/// The payload as JSON text, or a marker of its size when it is too large to keep. +fn raw_of(payload: &JsonObject) -> String { + let full = serde_json::to_string(payload).unwrap_or_default(); if full.len() <= RAW_LIMIT_BYTES { full } else { - summary().to_string() + json!({ "truncated": true, "bytes": full.len() }).to_string() } } /// A key for an event the provider gave no id: the same payload is the same event. fn content_id(payload: &JsonObject) -> String { - sha256_prefix(JsonValue::Object(payload.clone()).to_string().as_bytes()) + sha256_prefix( + serde_json::to_string(payload) + .unwrap_or_default() + .as_bytes(), + ) } -fn contains_any(haystack: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| haystack.contains(needle)) +/// When the event happened, as the sender says, if that is a time a row can +/// hold: inside the table's retention and not ahead of this clock. Any other +/// time is replaced by now, so a sender cannot mint partitions on the shared +/// table, plant a row that never expires, or send one that expires on arrival. +fn occurred_at_ms(claimed: Option, now_ms: i64) -> i64 { + claimed + .filter(|ms| (now_ms - RETENTION_MS..=now_ms + MAX_AHEAD_MS).contains(ms)) + .unwrap_or(now_ms) +} + +/// The words of an action name, lowercased: split at punctuation and at a +/// camelCase hump. Matching whole words keeps `environment` from reading as `env`. +fn words(name: &str) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut after_lowercase = false; + for character in name.chars() { + let hump = character.is_ascii_uppercase() && after_lowercase; + if (hump || !character.is_ascii_alphanumeric()) && !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + if character.is_ascii_alphanumeric() { + word.push(character.to_ascii_lowercase()); + } + after_lowercase = character.is_ascii_lowercase() || character.is_ascii_digit(); + } + if !word.is_empty() { + words.push(word); + } + words +} + +/// Whether any of `words` is one of `needles`, or its plural. +fn has_word(words: &[String], needles: &[&str]) -> bool { + words.iter().any(|word| { + needles + .iter() + .any(|needle| word == needle || word.strip_suffix('s') == Some(*needle)) + }) } /// What a Vercel action is about, read off its name. Vercel publishes no closed /// list, so this is by the words the names use; anything unrecognised is a -/// change to a resource. +/// change to a resource. The order decides a name with two readings: a member's +/// role change is about permission, and password protection is a setting. fn vercel_category(action: &str) -> &'static str { - let action = action.to_ascii_lowercase(); - if contains_any( - &action, - &["login", "logout", "sso", "saml", "mfa", "2fa", "passkey"], - ) { + let words = words(action); + let has = |needles: &[&str]| has_word(&words, needles); + if has(&[ + "login", "logout", "sso", "saml", "mfa", "2fa", "passkey", "signin", + ]) { "authentication" - } else if contains_any( - &action, - &["token", "secret", "env", "certificate", "password", "key"], - ) { + } else if has(&["protection"]) { + "configuration" + } else if has(&["role", "permission", "acl"]) || (has(&["access"]) && has(&["group"])) { + "permission" + } else if has(&[ + "token", + "secret", + "env", + "variable", + "certificate", + "cert", + "password", + "key", + ]) { "credential" - } else if contains_any(&action, &["member", "invite", "join", "leave"]) { + } else if has(&["member", "invite", "invitation", "join", "leave"]) { "membership" - } else if contains_any( - &action, - &["role", "permission", "access-group", "access_group", "acl"], - ) { - "permission" - } else if contains_any( - &action, - &["billing", "invoice", "payment", "plan", "subscription"], - ) { + } else if has(&["billing", "invoice", "payment", "plan", "subscription"]) { "billing" - } else if contains_any( - &action, - &[ - "setting", - "drain", - "integration", - "webhook", - "firewall", - "config", - "protection", - ], - ) { + } else if has(&[ + "setting", + "drain", + "integration", + "webhook", + "firewall", + "config", + "configuration", + ]) { "configuration" } else { "resource" } } +/// What a Vercel action was done to: the action without its closing verb +/// (`project.env-var.created` is about a `project.env-var`). +fn vercel_target_type(action: &str) -> &str { + action + .rfind(['.', '-', '_']) + .map_or("", |verb_at| &action[..verb_at]) +} + +/// The id of what a Vercel action was done to, from the event's `payload`: the +/// id named after the action's subject (`drainId` for `drain-created`), or the +/// only id there is. +fn vercel_target_id<'a>(action: &str, payload: &'a JsonObject) -> Option<&'a str> { + let subject = words(action).into_iter().next().unwrap_or_default(); + if let Some(id) = text(payload, &format!("{subject}Id")) { + return Some(id); + } + let mut ids = payload + .iter() + .filter(|(key, _)| key.ends_with("Id")) + .filter_map(|(_, value)| value.as_str()); + let only = ids.next()?; + ids.next().is_none().then_some(only) +} + /// One event of Vercel's Audit Log drain. `None` for an event with no action: /// there is nothing to say happened. pub fn vercel_event(event: &JsonObject, now_ms: i64) -> Option { @@ -195,16 +297,17 @@ pub fn vercel_event(event: &JsonObject, now_ms: i64) -> Option { clippy::cast_possible_truncation, reason = "a finite positive epoch-ms value is far inside i64" )] - let occurred_at_ms = event + let claimed_ms = event .get("timestamp") .and_then(JsonValue::as_f64) .filter(|ms| ms.is_finite() && *ms > 0.0) - .map_or(now_ms, |ms| ms.trunc() as i64); + .map(|ms| ms.trunc() as i64); + let payload = object(event, "payload"); Some(AuditEvent { provider: "vercel", account: owned(text(event, "teamId")), provider_event_id: text(event, "id").map_or_else(|| content_id(event), str::to_owned), - occurred_at_ms, + occurred_at_ms: occurred_at_ms(claimed_ms, now_ms), actor_type, actor_id: actor_text("id"), actor_name: actor_text("name"), @@ -213,12 +316,12 @@ pub fn vercel_event(event: &JsonObject, now_ms: i64) -> Option { category: vercel_category(action), // The drain reports what was done; Vercel does not log refused attempts in it. outcome: "success", + target_type: vercel_target_type(action).to_owned(), + target_id: owned(payload.and_then(|payload| vercel_target_id(action, payload))), + target_name: owned(payload.and_then(|payload| text(payload, "name"))), origin_ip: owned(text(event, "ipAddress")), user_agent: owned(text(event, "userAgent")), - raw: raw_of( - event, - || json!({ "id": event.get("id"), "action": action, "actor": event.get("actor") }), - ), + raw: raw_of(event), ..AuditEvent::default() }) } @@ -233,28 +336,67 @@ fn gcp_audit_log(log_name: &str) -> Option<&'static str> { } } -/// What a Google API call is about, read off its service and method. +/// Kubernetes API writes reach the Admin Activity log under this service. Most +/// are a cluster's own controllers at work, thousands an hour: they stay logs. +const GCP_KUBERNETES_SERVICE: &str = "k8s.io"; + +/// Services whose every change is to how the organization itself is set up. +const GCP_CONFIGURATION_SERVICES: [&str; 6] = [ + "orgpolicy", + "logging", + "serviceusage", + "accesscontextmanager", + "essentialcontacts", + "securitycenter", +]; + +/// What a Google API call is about, read off its service and method. Only the +/// call itself is read (`SetIamPolicy`), not the package it sits in. fn gcp_category(service: &str, method: &str) -> &'static str { - let method_lower = method.to_ascii_lowercase(); - if contains_any(&method_lower, &["setiampolicy", "testiampermissions"]) - || (service == "iam.googleapis.com" && contains_any(&method_lower, &["role", "policy"])) + let call = words(method.rsplit('.').next().unwrap_or(method)); + let has = |needles: &[&str]| has_word(&call, needles); + let service_is = |names: &[&str]| names.iter().any(|name| service.contains(name)); + if has(&["iam"]) + || (service == "iam.googleapis.com" && has(&["role", "policy", "binding", "deny"])) { "permission" - } else if contains_any( - &method_lower, - &["serviceaccountkey", "createkey", "deletekey"], - ) || contains_any(service, &["secretmanager", "apikeys", "cloudkms"]) - { + } else if has(&["key"]) || service_is(&["secretmanager", "apikeys", "cloudkms"]) { "credential" - } else if contains_any(service, &["cloudbilling", "billingbudgets"]) { + } else if service_is(&["cloudbilling", "billingbudgets"]) { "billing" - } else if contains_any(&method_lower, &["login", "signin"]) { + } else if has(&["login", "signin"]) { "authentication" + } else if service_is(&GCP_CONFIGURATION_SERVICES) || (has(&["org"]) && has(&["policy"])) { + "configuration" } else { "resource" } } +/// Who made a Google API call: `(type, id, email)`. A federated workload has +/// no email, only a `principalSubject`. +fn gcp_actor(payload: &JsonObject) -> (&'static str, &str, &str) { + let info = object(payload, "authenticationInfo"); + let email = info + .and_then(|info| text(info, "principalEmail")) + .unwrap_or_default(); + if !email.is_empty() { + let service = email.ends_with("gserviceaccount.com") || email.starts_with("system:"); + let address = if email.contains('@') { email } else { "" }; + return (if service { "service" } else { "user" }, email, address); + } + let subject = info + .and_then(|info| text(info, "principalSubject")) + .unwrap_or_default(); + if subject.is_empty() { + ("unknown", "", "") + } else if subject.starts_with("user:") { + ("user", subject, "") + } else { + ("service", subject, "") + } +} + /// gRPC status codes Google uses for a refusal: PERMISSION_DENIED and UNAUTHENTICATED. const GCP_DENIED_CODES: [i64; 2] = [7, 16]; @@ -267,17 +409,10 @@ pub fn gcp_entry(entry: &JsonObject, now_ms: i64) -> Option { let payload = object(entry, "protoPayload")?; let method = text(payload, "methodName")?; let service = text(payload, "serviceName").unwrap_or_default(); - - let principal = object(payload, "authenticationInfo") - .and_then(|info| text(info, "principalEmail")) - .unwrap_or_default(); - let actor_type = if principal.is_empty() { - "unknown" - } else if principal.ends_with("gserviceaccount.com") { - "service" - } else { - "user" - }; + if service == GCP_KUBERNETES_SERVICE { + return None; + } + let (actor_type, actor_id, actor_email) = gcp_actor(payload); let status = object(payload, "status"); let code = status @@ -308,18 +443,18 @@ pub fn gcp_entry(entry: &JsonObject, now_ms: i64) -> Option { str::to_owned, ); let request = object(payload, "requestMetadata"); - let occurred_at_ms = text(entry, "timestamp") + let claimed_ms = text(entry, "timestamp") .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok()) - .map_or(now_ms, |timestamp| timestamp.timestamp_millis()); + .map(|timestamp| timestamp.timestamp_millis()); Some(AuditEvent { provider: "gcp", account, provider_event_id: text(entry, "insertId").map_or_else(|| content_id(entry), str::to_owned), - occurred_at_ms, + occurred_at_ms: occurred_at_ms(claimed_ms, now_ms), actor_type, - actor_id: principal.to_owned(), - actor_email: principal.to_owned(), + actor_id: actor_id.to_owned(), + actor_email: actor_email.to_owned(), action: method.to_owned(), category: if log == "policy" { "permission" @@ -332,21 +467,7 @@ pub fn gcp_entry(entry: &JsonObject, now_ms: i64) -> Option { target_name: owned(text(payload, "resourceName")), origin_ip: owned(request.and_then(|request| text(request, "callerIp"))), user_agent: owned(request.and_then(|request| text(request, "callerSuppliedUserAgent"))), - raw: raw_of(entry, || { - json!({ - "logName": log_name, - "insertId": entry.get("insertId"), - "timestamp": entry.get("timestamp"), - "protoPayload": { - "serviceName": service, - "methodName": method, - "resourceName": payload.get("resourceName"), - "authenticationInfo": payload.get("authenticationInfo"), - "status": payload.get("status"), - "requestMetadata": payload.get("requestMetadata"), - }, - }) - }), + raw: raw_of(entry), ..AuditEvent::default() }) } @@ -356,13 +477,6 @@ pub fn now_ms() -> i64 { Utc::now().timestamp_millis() } -/// An RFC 3339 instant for a row, for tests and logs. -pub fn iso(epoch_ms: i64) -> String { - DateTime::::from_timestamp_millis(epoch_ms) - .unwrap_or_default() - .to_rfc3339_opts(SecondsFormat::Millis, true) -} - #[cfg(test)] mod tests { use super::*; @@ -386,10 +500,9 @@ mod tests { event_id("maple", "", "evt_1"), "851ca0eff076aad987a0192777b176cd" ); - assert_eq!(event_id("maple", "acme", "evt_1").len(), 32); - assert_ne!( + assert_eq!( event_id("maple", "acme", "evt_1"), - event_id("maple", "", "evt_1") + "1d19ea3d78217e97b45a7a9d28300146" ); } @@ -413,7 +526,8 @@ mod tests { assert_eq!(row["Category"], "credential"); assert_eq!(row["Outcome"], "success"); assert_eq!(row["OriginIp"], "203.0.113.7"); - assert_eq!(row["TargetType"], ""); + assert_eq!(row["TargetType"], "project.env-var"); + assert_eq!(row["TargetId"], "prj_1"); assert!(row["Raw"].as_str().is_some_and(|raw| raw.contains("prj_1"))); } @@ -544,20 +658,182 @@ mod tests { } #[test] - fn an_oversized_payload_is_stored_as_the_fields_the_row_was_built_from() { + fn an_oversized_payload_is_not_kept_and_the_row_still_says_what_happened() { let mut entry = parse(GCP_ACTIVITY); entry["protoPayload"] .as_object_mut() .unwrap() .insert("request".into(), json!("x".repeat(RAW_LIMIT_BYTES))); let event = gcp_entry(&entry, NOW).expect("an Admin Activity entry"); + let raw: JsonObject = serde_json::from_str(&event.raw).expect("raw is JSON"); + + assert_eq!(raw["truncated"], true); + assert!(event.raw.len() < 100); + assert_eq!(event.action, "google.iam.admin.v1.CreateServiceAccountKey"); + } + + // Every column is the sender's to fill, and the table is every org's. + #[test] + fn a_row_keeps_no_more_of_a_field_than_its_column_takes() { + let long = "é".repeat(4000); + let mut entry = parse(GCP_ACTIVITY); + entry.insert("insertId".into(), json!(long)); + let payload = entry["protoPayload"].as_object_mut().unwrap(); + payload.insert("methodName".into(), json!(long)); + payload.insert("resourceName".into(), json!(long)); + payload.insert("status".into(), json!({ "code": 9, "message": long })); + let row = row(&gcp_entry(&entry, NOW).expect("an Admin Activity entry")); + let bytes = |column: &str| row[column].as_str().map_or(0, str::len); + + assert_eq!(bytes("Action"), NAME_LIMIT_BYTES); + assert_eq!(bytes("TargetName"), TEXT_LIMIT_BYTES); + assert_eq!(bytes("OutcomeReason"), TEXT_LIMIT_BYTES); + // An id is hashed rather than cut: it is part of the row's key. + assert_eq!(bytes("ProviderEventId"), 32); + assert!(bytes("Raw") <= RAW_LIMIT_BYTES); + } - assert!(event.raw.len() < RAW_LIMIT_BYTES); - assert!(event.raw.contains("CreateServiceAccountKey")); + #[test] + fn two_long_ids_that_share_a_prefix_stay_two_events() { + let id = |tail: &str| format!("{}{tail}", "a".repeat(NAME_LIMIT_BYTES)); + let key = |id: String| { + let event = AuditEvent { + provider: "vercel", + provider_event_id: id, + ..AuditEvent::default() + }; + row(&event)["Id"].clone() + }; + + assert_ne!(key(id("1")), key(id("2"))); } + // The table is partitioned by the month an event happened and drops rows by + // age: a sender's own clock must not pick the partition or the expiry. #[test] - fn iso_formats_an_instant_in_utc() { - assert_eq!(iso(1_700_000_000_123), "2023-11-14T22:13:20.123Z"); + fn a_time_the_table_could_not_hold_is_replaced_by_the_time_of_receipt() { + let at = |timestamp: i64| { + let mut event = parse(VERCEL_EVENT); + event.insert("timestamp".into(), json!(timestamp)); + vercel_event(&event, NOW).map(|event| event.occurred_at_ms) + }; + let day = 24 * 60 * 60 * 1000; + + assert_eq!(at(NOW - 399 * day), Some(NOW - 399 * day)); + assert_eq!(at(NOW + day / 2), Some(NOW + day / 2)); + assert_eq!(at(NOW - 401 * day), Some(NOW)); + assert_eq!(at(NOW + 2 * day), Some(NOW)); + // Seconds where milliseconds belong: 1970, which would expire on arrival. + assert_eq!(at(NOW / 1000), Some(NOW)); + } + + #[test] + fn a_vercel_action_is_filed_by_the_words_in_its_name() { + for (action, category) in [ + ("team.member.role.updated", "permission"), + ("team.member.added", "membership"), + ("access-group.created", "permission"), + ("project.env-var.created", "credential"), + ("project.environment.created", "resource"), + ("password_protection.enabled", "configuration"), + ("token.created", "credential"), + ("user.login", "authentication"), + ("drain-created", "configuration"), + ("deployment.deleted", "resource"), + ] { + assert_eq!(vercel_category(action), category, "{action}"); + } + } + + #[test] + fn a_vercel_event_points_at_what_its_payload_names() { + let event = parse( + r#"{"id":"evt_3","action":"drain-created","payload":{"drainId":"drn_1","projectId":"prj_1","name":"Maple logs"}}"#, + ); + let event = vercel_event(&event, NOW).expect("an event with an action"); + + assert_eq!( + ( + event.target_type.as_str(), + event.target_id.as_str(), + event.target_name.as_str() + ), + ("drain", "drn_1", "Maple logs") + ); + } + + #[test] + fn a_google_call_is_filed_by_its_service_and_the_call_itself() { + for (service, method, category) in [ + ( + "storage.googleapis.com", + "storage.setIamPermissions", + "permission", + ), + ( + "iam.googleapis.com", + "google.iam.admin.v1.CreateRole", + "permission", + ), + ( + "iam.googleapis.com", + "google.iam.admin.v1.CreateServiceAccount", + "resource", + ), + ( + "cloudresourcemanager.googleapis.com", + "SetOrgPolicy", + "configuration", + ), + ( + "logging.googleapis.com", + "google.logging.v2.ConfigServiceV2.DeleteSink", + "configuration", + ), + ( + "secretmanager.googleapis.com", + "google.cloud.secretmanager.v1.SecretManagerService.DeleteSecret", + "credential", + ), + ( + "compute.googleapis.com", + "v1.compute.instances.insert", + "resource", + ), + ] { + assert_eq!(gcp_category(service, method), category, "{method}"); + } + } + + // A cluster's controllers write to the Kubernetes API all day, and Google + // files every write under Admin Activity. + #[test] + fn a_kubernetes_api_write_stays_a_log() { + let mut entry = parse(GCP_ACTIVITY); + let payload = entry["protoPayload"].as_object_mut().unwrap(); + payload.insert("serviceName".into(), json!("k8s.io")); + payload.insert("methodName".into(), json!("io.k8s.core.v1.pods.create")); + + assert_eq!(gcp_entry(&entry, NOW), None); + } + + #[test] + fn a_federated_workload_is_a_service_named_by_its_subject() { + let subject = "principal://iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/ci/subject/repo"; + let mut entry = parse(GCP_ACTIVITY); + entry["protoPayload"].as_object_mut().unwrap().insert( + "authenticationInfo".into(), + json!({ "principalSubject": subject }), + ); + let event = gcp_entry(&entry, NOW).expect("an Admin Activity entry"); + + assert_eq!( + ( + event.actor_type, + event.actor_id.as_str(), + event.actor_email.as_str() + ), + ("service", subject, "") + ); } } diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index 79c3b486a0..c058a0b4fa 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -206,6 +206,9 @@ struct AppConfig { org_routing_cache_ttl_secs: u64, /// `INGEST_RULES_ENABLED` (default on): apply customer drop/redact rules. Off reads nothing and costs nothing. ingest_rules_enabled: bool, + /// `INGEST_AUDIT_TRAIL_ENABLED` (default off): keep provider audit events in + /// the audit trail instead of as logs. See `stores_audit_trail`. + audit_trail_enabled: bool, /// `INGEST_RULES_CACHE_TTL_SECS`: how long an org's compiled rules are reused. ingest_rules_cache_ttl_secs: u64, /// Ceiling on the total decompressed rrweb payload a single replay session @@ -304,6 +307,12 @@ enum KeyStoreBackend { } impl AppConfig { + /// Whether provider audit events go to the audit trail. Off, or on a gateway + /// that only forwards and so has no such table, they stay logs. + fn stores_audit_trail(&self) -> bool { + self.audit_trail_enabled && self.write_mode.uses_tinybird() + } + #[expect( clippy::too_many_lines, reason = "one branch per environment variable, read in the order the deployment docs list \ @@ -629,6 +638,14 @@ impl AppConfig { std::env::var("INGEST_RULES_ENABLED").ok(), true, )?; + // Off by default. Turn it on once the table exists and every running task + // is a build that reads the audit trail's WAL frames: an older build that + // recovers one drops the rest of that segment. + let audit_trail_enabled = parse_bool( + "INGEST_AUDIT_TRAIL_ENABLED", + std::env::var("INGEST_AUDIT_TRAIL_ENABLED").ok(), + false, + )?; let ingest_rules_cache_ttl_secs = parse_u64( "INGEST_RULES_CACHE_TTL_SECS", std::env::var("INGEST_RULES_CACHE_TTL_SECS").ok(), @@ -790,6 +807,7 @@ impl AppConfig { ingest_key_cache_ttl_secs, org_routing_cache_ttl_secs, ingest_rules_enabled, + audit_trail_enabled, ingest_rules_cache_ttl_secs, replay_max_session_bytes, replay_blob_store, @@ -4106,10 +4124,7 @@ async fn handle_vercel_event_drain( let Some(drain) = EventDrain::from_path(&drain) else { return StatusCode::NOT_FOUND.into_response(); }; - // Audit events go to the audit trail wherever the gateway writes its own - // rows. A gateway that only forwards has no such table, so there they - // stay logs, as before. - if drain == EventDrain::AuditLog && state.config.write_mode.uses_tinybird() { + if drain == EventDrain::AuditLog && state.config.stores_audit_trail() { return handle_vercel_audit_trail(state, headers, body).await; } handle_signal(state, headers, body, Ingress::VercelEventDrain(drain)).await @@ -4141,19 +4156,25 @@ async fn handle_vercel_audit_trail( "maple.audit_trail.skipped" = tracing::field::Empty, ); let span_handle = span.clone(); - match handle_vercel_audit_trail_inner(&state, &headers, &body) + let start = Instant::now(); + let result = handle_vercel_audit_trail_inner(&state, &headers, &body) .instrument(span) - .await - { + .await; + let duration = start.elapsed().as_secs_f64(); + let signal = TelemetrySignal::AuditTrail.as_str(); + match result { Ok(count) => { span_handle.record("http.response.status_code", 200u16); span_handle.record("otel.status_code", "Ok"); + metrics::request_completed(signal, "ok", "none", duration); (StatusCode::OK, axum::Json(AcceptedBody { accepted: count })).into_response() } Err(error) => { let status = error.status.as_u16(); span_handle.record("http.response.status_code", status); span_handle.record("error.type", error.error_kind()); + // A drain Vercel keeps retrying has to move an error rate, like any other ingest route. + metrics::request_completed(signal, "error", error.error_kind(), duration); record_rejection_reason( &span_handle, status, @@ -4165,6 +4186,35 @@ async fn handle_vercel_audit_trail( } } +/// Audit deliveries one org may make each second, across the fleet. Well above +/// what the people of a team or a cloud organization do; past it the sender is +/// told to retry, so a burst is delayed and not lost. +const AUDIT_TRAIL_VERCEL_ORG_RPS: u32 = 10; +const AUDIT_TRAIL_GCP_ORG_RPS: u32 = 100; + +/// The most a Vercel audit delivery may be once inflated. A batch of audit +/// events is kilobytes; the telemetry ceiling is for bodies that are metered. +const AUDIT_TRAIL_MAX_DECODED_BYTES: usize = 4 * 1024 * 1024; + +/// Refuses an org's audit delivery past `rps`. The rows are not metered, so +/// this is what bounds how many a sender can store. +async fn audit_trail_rate_gate( + state: &AppState, + org_id: &str, + provider: &'static str, + rps: u32, +) -> Result<(), ApiError> { + let key = format!("audit_trail:{provider}:{org_id}"); + if state.key_rate_limiter.try_acquire(&key, rps).await { + return Ok(()); + } + metrics::org_throttled(org_id, "audit_trail_rate"); + warn!(org_id, provider, "Audit event rate limit exceeded"); + Err(ApiError::too_many_requests( + "Audit event rate limit exceeded", + )) +} + async fn handle_vercel_audit_trail_inner( state: &AppState, headers: &HeaderMap, @@ -4174,36 +4224,44 @@ async fn handle_vercel_audit_trail_inner( else { return Ok(0); }; - // The drain is created with the org's private key. The public key ships in - // browser bundles: with it, anyone could write the org an audit event. - if matches!(resolved_key.key_type, IngestKeyType::Public) { - return Err(ApiError::unauthorized( - "Audit events need a private ingest key", - )); - } let org_id = resolved_key.org_id.clone(); Span::current().record("maple.org_id", org_id.as_str()); - // The Vercel integration's source, named on the drain: paused, the delivery - // is acked and dropped like its logs are. + // The drain Maple creates sends the org's private key and names the Vercel + // connection's source. Anything else posting here could write the org an + // audit event of its own invention: a source token, the public key (it + // ships in browser bundles), or a private key naming no integration source. + let unknown_sender = || { + ApiError::unauthorized( + "Audit events are accepted only from the drain Maple creates on the Vercel team", + ) + }; + if !matches!(resolved_key.key_type, IngestKeyType::Private) + || !resolved_key.accepts_source_claims() + { + return Err(unknown_sender()); + } let claim = headers .get(SOURCE_ID_HEADER) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|claim| !claim.is_empty() && claim.len() <= MAX_PUBLIC_ID_LEN) - .filter(|_| resolved_key.accepts_source_claims()); - if let Some(claim) = claim { - let verified = state - .resolver - .verify_source_claims(&org_id, vec![claim.to_owned()]) - .await; - if verified.paused.iter().any(|paused| paused == claim) { - metrics::source_paused_drop(&org_id, TelemetrySignal::AuditTrail.as_str()); - Span::current().record("maple.ingest.reject_reason", "source_paused"); - return Ok(0); - } + .ok_or_else(unknown_sender)?; + let verified = state + .resolver + .verify_source_claims(&org_id, vec![claim.to_owned()]) + .await; + // Paused, the delivery is acked and dropped like the source's logs are. + if verified.paused.iter().any(|paused| paused == claim) { + metrics::source_paused_drop(&org_id, TelemetrySignal::AuditTrail.as_str()); + Span::current().record("maple.ingest.reject_reason", "source_paused"); + return Ok(0); + } + if !verified.active.iter().any(|active| active == claim) { + return Err(unknown_sender()); } + audit_trail_rate_gate(state, &org_id, "vercel", AUDIT_TRAIL_VERCEL_ORG_RPS).await?; let _org_inflight_permit = state .org_inflight_limiter @@ -4218,28 +4276,22 @@ async fn handle_vercel_audit_trail_inner( "Audit event storage is not configured", )?; - let content_encoding = headers + let gzip = match headers .get(CONTENT_ENCODING) .and_then(|value| value.to_str().ok()) .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty() && value != "identity"); - let payload = decode_payload( - body, - content_encoding.as_deref(), - state.config.max_decoded_body_bytes, - )?; - let parsed = vercel_logs::parse(&payload).map_err(|e| { - ApiError::bad_request(format!("Invalid Vercel Audit Log drain payload: {e}")) - })?; - - let now_ms = audit_trail::now_ms(); - let rows: Vec> = parsed - .entries - .iter() - .filter_map(|event| audit_trail::vercel_event(event, now_ms)) - .map(|event| event.to_row(&org_id, now_ms)) - .collect(); - let skipped = parsed.entries.len() - rows.len() + parsed.malformed_lines; + .as_deref() + { + None | Some("" | "identity") => false, + Some("gzip") => true, + Some(_) => { + return Err(ApiError::unsupported_media_type( + "Unsupported content-encoding", + )) + } + }; + let (rows, skipped) = + decode_vercel_audit_drain(state, body.clone(), gzip, org_id.clone()).await?; Span::current().record("maple.audit_trail.events", rows.len()); Span::current().record("maple.audit_trail.skipped", skipped); if skipped > 0 { @@ -4250,6 +4302,83 @@ async fn handle_vercel_audit_trail_inner( Ok(count) } +/// A Vercel audit delivery as rows, and how many of its events were skipped. +/// Inflated and parsed on the blocking pool inside the process-wide decode +/// memory budget, like an OTLP body: a small gzip can inflate into a body that +/// parses into gigabytes. +async fn decode_vercel_audit_drain( + state: &AppState, + body: Bytes, + gzip: bool, + org_id: String, +) -> Result<(Vec>, usize), ApiError> { + let mut reservation = state.decode_memory.reservation(); + let heap_budget = state.config.max_decode_heap_bytes; + let max_decoded = state + .config + .max_decoded_body_bytes + .min(AUDIT_TRAIL_MAX_DECODED_BYTES); + tokio::task::spawn_blocking(move || { + let busy = || { + ApiError::tagged( + StatusCode::SERVICE_UNAVAILABLE, + &INGEST_DECODE_BUSY, + "Ingest gateway is busy decoding other requests; retry shortly", + ) + }; + let too_large = || ApiError::payload_too_large("Audit delivery too large"); + if body.len() > max_decoded { + return Err(too_large()); + } + if !reservation.try_add(body.len() as u64) { + return Err(busy()); + } + let payload = if gzip { + let invalid = || ApiError::bad_request("Invalid gzip body"); + let declared = decode_budget::gzip_declared_len(&body).ok_or_else(invalid)?; + let declared = usize::try_from(declared).unwrap_or(usize::MAX); + if declared > max_decoded { + return Err(too_large()); + } + if !reservation.try_add(declared as u64) { + return Err(busy()); + } + let inflated = gunzip_declared(&body, declared).map_err(|error| match error { + GunzipError::Invalid(_) => invalid(), + GunzipError::TooLarge => too_large(), + })?; + drop(body); + Bytes::from(inflated) + } else { + body + }; + let estimate = decode_budget::json_cost(&payload, heap_budget) + .map_err(|decode_budget::OverBudget| over_decode_budget(heap_budget))?; + if !reservation.try_add(estimate) { + return Err(busy()); + } + let parsed = vercel_logs::parse(&payload).map_err(|e| { + ApiError::bad_request(format!("Invalid Vercel Audit Log drain payload: {e}")) + })?; + let now_ms = audit_trail::now_ms(); + let rows: Vec> = parsed + .entries + .iter() + .filter_map(|event| audit_trail::vercel_event(event, now_ms)) + .map(|event| event.to_row(&org_id, now_ms)) + .collect(); + let skipped = parsed.entries.len() - rows.len() + parsed.malformed_lines; + Ok((rows, skipped)) + }) + .await + .unwrap_or_else(|join_error| { + error!(error = %join_error, "Audit delivery decode task failed"); + Err(ApiError::service_unavailable( + "Ingest gateway failed to decode the request", + )) + }) +} + /// Audit rows to the managed warehouse: the table is pinned there, whatever /// warehouse the org reads its telemetry from. Not metered: an audit event is /// evidence, not usage. @@ -6561,11 +6690,15 @@ async fn handle_gcp_logpush_inner( }; // An Admin Activity or Policy Denied entry is an audit event, not a log: - // it goes to the audit trail, unmetered, ahead of the plan check on logs. - // A gateway that only forwards has no such table, so there it stays a log. - if state.config.write_mode.uses_tinybird() { + // it goes to the audit trail, unmetered, ahead of the plan check on logs + // and of the org's ingest rules, which are about what it keeps of its logs. + if state.config.stores_audit_trail() { let now_ms = audit_trail::now_ms(); if let Some(event) = audit_trail::gcp_entry(&entry, now_ms) { + // Refused, Pub/Sub redelivers with backoff: delayed, not lost. + audit_trail_rate_gate(state, org_id, "gcp", AUDIT_TRAIL_GCP_ORG_RPS) + .await + .map_err(|error| (error, "throttle"))?; let pipeline = native_rows_pipeline_for( state, ExportDestination::Tinybird, @@ -12611,6 +12744,7 @@ mod tests { ingest_key_cache_ttl_secs: 60, org_routing_cache_ttl_secs: 5, ingest_rules_enabled: false, + audit_trail_enabled: false, ingest_rules_cache_ttl_secs: 30, replay_max_session_bytes: 1024 * 1024 * 1024, replay_blob_store: None, @@ -13170,74 +13304,267 @@ mod tests { "\n", ); - /// A gateway that writes its own rows, as the hosted one does: the fixture's - /// default only forwards, and there an audit drain stays a log. - async fn audit_trail_test_state(raw_key: &str, org_id: &str, queue_dir: PathBuf) -> AppState { - let mut state = replay_blob_test_state(raw_key, org_id, queue_dir).await; + /// The Vercel connection's source in the fixtures, as its uuid and as the + /// public id a drain names in `x-maple-source-id`. + const AUDIT_SOURCE_UUID: &str = "0f8e6c1a-2b3d-4e5f-8a9b-0c1d2e3f4a5b"; + const AUDIT_SOURCE_ID: &str = "src_aXunrkWtzvXSkw9Z2smcft"; + + /// What the fake Tinybird is sent: `(datasource, NDJSON rows)`. + type TinybirdImports = tokio::sync::mpsc::UnboundedReceiver<(String, String)>; + + async fn fake_tinybird_import( + State(tx): State>, + Query(query): Query>, + body: Bytes, + ) -> StatusCode { + let rows = gunzip_bounded(&body, usize::MAX).unwrap_or_else(|_| body.to_vec()); + drop(tx.send(( + query.get("name").cloned().unwrap_or_default(), + String::from_utf8(rows).expect("Tinybird should receive UTF-8 NDJSON"), + ))); + StatusCode::OK + } + + /// Turns the audit trail on, as the hosted gateway has it, with a pipeline + /// that writes to a fake Tinybird the test can read. + async fn store_audit_trail( + state: &mut AppState, + queue_dir: &std::path::Path, + ) -> TinybirdImports { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route("/v0/events", post(fake_tinybird_import)) + .with_state(tx); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let pipeline = TelemetryPipeline::new( + TinybirdConfig { + endpoint, + token: "token".to_owned(), + ..test_tinybird_config(queue_dir.join("audit-trail")) + }, + Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + ) + .await + .expect("the audit trail pipeline should start"); state.config.write_mode = WriteMode::Tinybird; - state + state.config.audit_trail_enabled = true; + state.telemetry_pipeline = Some(pipeline); + rx + } + + /// An org with a private key and a Vercel connection whose source is + /// `AUDIT_SOURCE_ID`, on a gateway that stores the audit trail. + async fn audit_trail_test_state( + raw_key: &str, + org_id: &str, + queue_dir: PathBuf, + ) -> (AppState, Arc, TinybirdImports) { + let store = Arc::new(FakeKeyStore::default()); + store.insert_private( + raw_key, + KeyRow { + org_id: org_id.to_owned(), + self_managed: false, + clickhouse_ready: false, + }, + ); + store.insert_source_row(org_id, AUDIT_SOURCE_UUID, "integration"); + let mut state = test_app_state( + Arc::clone(&store), + queue_dir.clone(), + "http://127.0.0.1:1".to_owned(), + Duration::from_secs(30), + ) + .await; + let imports = store_audit_trail(&mut state, &queue_dir).await; + (state, store, imports) + } + + /// The headers of the drain Maple creates: the key and the connection's source. + fn vercel_drain_headers(raw_key: &str, source_id: &str) -> HeaderMap { + let mut headers = bearer_headers(raw_key); + headers.insert(SOURCE_ID_HEADER, source_id.parse().unwrap()); + headers + } + + async fn vercel_audit_delivery( + state: &AppState, + headers: &HeaderMap, + ) -> Result { + handle_vercel_audit_trail_inner( + state, + headers, + &Bytes::from_static(VERCEL_AUDIT_DELIVERY.as_bytes()), + ) + .await + } + + async fn nothing_reaches_the_audit_trail(imports: &mut TinybirdImports) -> bool { + tokio::time::timeout(Duration::from_millis(200), imports.recv()) + .await + .is_err() } #[tokio::test] async fn vercel_audit_drain_stores_each_event_that_names_an_action() { let queue_dir = unique_main_test_dir("vercel-audit-trail"); - let state = + let (state, _store, mut imports) = audit_trail_test_state("maple_sk_test_audit", "org_audit", queue_dir.clone()).await; - let accepted = handle_vercel_audit_trail_inner( + let accepted = vercel_audit_delivery( &state, - &bearer_headers("maple_sk_test_audit"), - &Bytes::from_static(VERCEL_AUDIT_DELIVERY.as_bytes()), + &vercel_drain_headers("maple_sk_test_audit", AUDIT_SOURCE_ID), ) .await - .expect("a private key's delivery should be accepted"); + .expect("the drain's delivery should be accepted"); // The second event names no action: there is nothing to say happened. assert_eq!(accepted, 1); - let _ = std::fs::remove_dir_all(&queue_dir); + let (datasource, rows) = tokio::time::timeout(Duration::from_secs(2), imports.recv()) + .await + .expect("the event should reach Tinybird") + .expect("the Tinybird channel should stay open"); + assert_eq!(datasource, "audit_trail_events"); + let row: serde_json::Value = serde_json::from_str(rows.trim()).expect("one NDJSON row"); + assert_eq!( + ( + row["OrgId"].as_str(), + row["ProviderEventId"].as_str(), + row["Action"].as_str() + ), + (Some("org_audit"), Some("evt_1"), Some("project.created")) + ); + drop(std::fs::remove_dir_all(&queue_dir)); } - // The public key ships in browser bundles. Audit events written with it - // could be anyone's invention. + // The drain Maple creates sends the private key and names the connection's + // source. Any other sender could write the org an audit event it made up. #[tokio::test] - async fn vercel_audit_drain_refuses_the_public_ingest_key() { - let queue_dir = unique_main_test_dir("vercel-audit-trail-public"); - let store = Arc::new(FakeKeyStore::default()); - let hash = hash_ingest_key("maple_pk_test_audit", "test-hmac-key").unwrap(); - store.set_org_routing( - "org_audit_public", - OrgRouting { - self_managed: false, - clickhouse_ready: false, - }, - ); + async fn vercel_audit_drain_refuses_a_sender_that_is_not_the_orgs_drain() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-senders"); + let (state, store, mut imports) = + audit_trail_test_state("maple_sk_test_senders", "org_senders", queue_dir.clone()).await; + // The public key ships in browser bundles. + let public_hash = hash_ingest_key("maple_pk_test_senders", "test-hmac-key").unwrap(); store.keys.lock().unwrap().insert( - (hash, "public_key_hash"), + (public_hash, "public_key_hash"), KeyRow { - org_id: "org_audit_public".to_owned(), + org_id: "org_senders".to_owned(), self_managed: false, clickhouse_ready: false, }, ); - let mut state = test_app_state( - store, - queue_dir.clone(), - "http://127.0.0.1:1".to_owned(), - Duration::from_secs(30), - ) - .await; - state.config.write_mode = WriteMode::Tinybird; + // A log shipper's token: allowed to send logs, and nothing else. + let token = "maple_src_audit_senders_0000000000000000000000"; + store.insert_source( + token, + source_row( + "org_senders", + source_config(Some(r#"["logs"]"#), None, "[]"), + ), + ); + // Another org's integration source, named by this org's key. + let foreign = "5b8f2c1e-7a55-4c0e-9a3b-0d2c7e5b9a11"; + store.insert_source_row("org_other", foreign, "integration"); + let foreign_id = sources::public_source_id(foreign).expect("a uuid has a public id"); + + for (sender, headers) in [ + ( + "the public key", + vercel_drain_headers("maple_pk_test_senders", AUDIT_SOURCE_ID), + ), + ( + "a source token", + vercel_drain_headers(token, AUDIT_SOURCE_ID), + ), + ( + "the private key naming no source", + bearer_headers("maple_sk_test_senders"), + ), + ( + "the private key naming another org's source", + vercel_drain_headers("maple_sk_test_senders", &foreign_id), + ), + ] { + let refused = vercel_audit_delivery(&state, &headers) + .await + .expect_err(sender); + assert_eq!(refused.status, StatusCode::UNAUTHORIZED, "{sender}"); + } + assert!(nothing_reaches_the_audit_trail(&mut imports).await); + drop(std::fs::remove_dir_all(&queue_dir)); + } - let refused = handle_vercel_audit_trail_inner( + #[tokio::test] + async fn vercel_audit_drain_acks_and_drops_while_the_integration_is_paused() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-paused"); + let (state, store, mut imports) = + audit_trail_test_state("maple_sk_test_paused", "org_paused", queue_dir.clone()).await; + store.pause_source(AUDIT_SOURCE_UUID, current_time_millis() - 1000, None); + + let accepted = vercel_audit_delivery( &state, - &bearer_headers("maple_pk_test_audit"), - &Bytes::from_static(VERCEL_AUDIT_DELIVERY.as_bytes()), + &vercel_drain_headers("maple_sk_test_paused", AUDIT_SOURCE_ID), ) .await - .expect_err("a public key must not write audit events"); + .expect("a paused source's delivery is acked"); - assert_eq!(refused.status, StatusCode::UNAUTHORIZED); - let _ = std::fs::remove_dir_all(&queue_dir); + assert_eq!(accepted, 0); + assert!(nothing_reaches_the_audit_trail(&mut imports).await); + drop(std::fs::remove_dir_all(&queue_dir)); + } + + // A few hundred bytes of gzip can inflate into a body that parses into + // gigabytes; an audit delivery has no reason to be large. + #[tokio::test] + async fn vercel_audit_drain_refuses_a_delivery_that_inflates_past_its_limit() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-bomb"); + let (state, _store, mut imports) = + audit_trail_test_state("maple_sk_test_bomb", "org_bomb", queue_dir.clone()).await; + let mut headers = vercel_drain_headers("maple_sk_test_bomb", AUDIT_SOURCE_ID); + headers.insert(CONTENT_ENCODING, "gzip".parse().unwrap()); + let body = gzip_bytes("[],".repeat(AUDIT_TRAIL_MAX_DECODED_BYTES / 2)); + + let refused = handle_vercel_audit_trail_inner(&state, &headers, &body) + .await + .expect_err("a delivery past the limit must be refused"); + + assert_eq!(refused.status, StatusCode::PAYLOAD_TOO_LARGE); + assert!(nothing_reaches_the_audit_trail(&mut imports).await); + drop(std::fs::remove_dir_all(&queue_dir)); + } + + // Audit rows are not metered, so the rate is what bounds a sender. + #[tokio::test] + async fn an_orgs_audit_deliveries_are_refused_past_their_rate() { + let queue_dir = unique_main_test_dir("audit-trail-rate"); + let (state, _store, _imports) = + audit_trail_test_state("maple_sk_test_rate", "org_rate", queue_dir.clone()).await; + + assert!(audit_trail_rate_gate(&state, "org_rate", "gcp", 1) + .await + .is_ok()); + let refused = audit_trail_rate_gate(&state, "org_rate", "gcp", 1) + .await + .expect_err("a second delivery inside the second is past a rate of one"); + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + // Another org, and the same org's other provider, have their own allowance. + assert!(audit_trail_rate_gate(&state, "org_other", "gcp", 1) + .await + .is_ok()); + assert!(audit_trail_rate_gate(&state, "org_rate", "vercel", 1) + .await + .is_ok()); + drop(std::fs::remove_dir_all(&queue_dir)); } #[tokio::test] @@ -14836,15 +15163,30 @@ mod tests { async fn gcp_logpush_stores_an_audit_entry_in_the_audit_trail_and_not_as_a_log() { let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; let (mut state, store, queue_dir) = gcp_logpush_state("gcp-logpush-audit", &ch_url).await; - Arc::get_mut(&mut state) - .expect("the state is not shared yet") - .config - .write_mode = WriteMode::Tinybird; + let mut imports = store_audit_trail( + Arc::get_mut(&mut state).expect("the state is not shared yet"), + &queue_dir, + ) + .await; let accepted = gcp_push(&state, "gcp_conn_1", Some("gcp-secret"), GCP_AUDIT_ENTRY).await; assert_eq!(accepted.status(), StatusCode::OK); assert_eq!(store.connector_successes.load(Ordering::Relaxed), 1); + let (datasource, rows) = tokio::time::timeout(Duration::from_secs(2), imports.recv()) + .await + .expect("the entry should reach Tinybird") + .expect("the Tinybird channel should stay open"); + assert_eq!(datasource, "audit_trail_events"); + let row: serde_json::Value = serde_json::from_str(rows.trim()).expect("one NDJSON row"); + assert_eq!( + ( + row["OrgId"].as_str(), + row["ProviderEventId"].as_str(), + row["ActorEmail"].as_str() + ), + (Some("org_gcp"), Some("abc123"), Some("ada@acme.test")) + ); assert!( tokio::time::timeout(Duration::from_millis(200), ch_rx.recv()) .await @@ -14854,8 +15196,8 @@ mod tests { drop(std::fs::remove_dir_all(queue_dir)); } - // A gateway that only forwards has no audit trail table: there the entry - // stays a log, as it was before the trail existed. + // A gateway that only forwards has no audit trail table, and one where the + // trail is not turned on does not write to it: there the entry stays a log. #[tokio::test] async fn gcp_logpush_keeps_an_audit_entry_as_a_log_where_the_gateway_only_forwards() { let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 9cfc6c4695..5f9cdd3ac1 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -76,7 +76,7 @@ Turning something off takes effect in Maple at once. Google Cloud keeps the sink ### Log filter -Platform, request and audit logs are forwarded. Admin Activity and Policy Denied audit entries do not land in logs: they go to the audit log in the Security section, which only organization admins can read. System Event audit entries stay in logs. The default filter leaves out what costs a lot and says little: +Platform, request and audit logs are forwarded. Admin Activity and Policy Denied audit entries do not land in logs: they go to the audit log in the Security section, which only organization admins can read. System Event audit entries stay in logs, and so do Kubernetes API writes (`k8s.io`), which are mostly a cluster's own controllers. On a self-hosted gateway that forwards telemetry to a collector, every audit entry stays a log. The default filter leaves out what costs a lot and says little: | Left out | Reason | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/apps/landing/src/content/docs/integrations/vercel.md b/apps/landing/src/content/docs/integrations/vercel.md index 3533dcd2d4..9149058fee 100644 --- a/apps/landing/src/content/docs/integrations/vercel.md +++ b/apps/landing/src/content/docs/integrations/vercel.md @@ -80,7 +80,9 @@ These drains arrive as logs, each under its own `service.name`: `vercel-speed-in Audit events do not arrive as logs. They go to the audit log in the Security section, which only organization admins can read: an audit event names a person, their email and their IP address, and every member can read logs. -Each event shows who acted (name, email and id), the action as Vercel names it, such as `drain-created`, the time, the IP address and user agent, and the event's full payload. The drain has to be created with your organization's private ingest key, which is what Maple uses when it creates the drain for you. +Each event shows who acted (name, email and id), the action as Vercel names it, such as `drain-created`, what it was done to, the time, the IP address and user agent, and the event's payload as Vercel sent it. A payload over 32 KB is not kept. Maple accepts audit events only from the drain it creates on your team; a drain you add by hand on Vercel is refused. + +A self-hosted gateway that forwards telemetry to a collector has no audit log table. There, audit events arrive as logs under the `vercel-audit-log` service. ## AI Gateway @@ -110,13 +112,14 @@ The trace drain forwards these spans to Maple. You do not need to configure an e - **Infrastructure → Vercel → Overview**: requests, error rate, cache hit rate and cold-start rate across the team, AI Gateway spend by project, recent builds and failed builds, and, when those drains are on, Web Vitals, page views and audit log events. - **Infrastructure → Vercel → Projects**: each project with its last build, requests, 5xx rate, cache hit rate, cold-start rate and latency. Click a project for its own page. - **A project's page** has a tab for each part: - - **Traffic**: requests by status class, top paths with their cache hit rate, and top errors. - - **Functions**: invocations, cold-start rate and cold-start init time per route. - - **Dependencies**: the hosts its functions call, with calls, error rate and latency, and a link to the project on the service map. - - **Builds**: recent deployments with build time, state and why a failed build stopped. **Build logs** opens the logs explorer on that build's output. - - **AI Gateway**, **Speed Insights** (p75 Web Vitals by route and device) and **Web Analytics** (page views, devices, top pages, referrers, countries and custom events). + - **Traffic**: requests by status class, top paths with their cache hit rate, and top errors. + - **Functions**: invocations, cold-start rate and cold-start init time per route. + - **Dependencies**: the hosts its functions call, with calls, error rate and latency, and a link to the project on the service map. + - **Builds**: recent deployments with build time, state and why a failed build stopped. **Build logs** opens the logs explorer on that build's output. + - **AI Gateway**, **Speed Insights** (p75 Web Vitals by route and device) and **Web Analytics** (page views, devices, top pages, referrers, countries and custom events). + + A section whose drain is off says so and links to the setting that turns it on. - A section whose drain is off says so and links to the setting that turns it on. - **Service map**: each project with its outbound calls, from its function spans, and an edge from `vercel.edge-network` to every project it invokes. A project's Infrastructure tab links to its Vercel project page. - [Logs](/docs/explore/logs): filter by `service.name` (the project name) or `vercel.project.id`. Speed Insights, Web Analytics and audit log events are under their own `service.name`. - [Traces](/docs/explore/traces): your `@vercel/otel` service, the deployment traces, and `ai-gateway`. From 10153a2d709fffc7e1bc78dd310df733ce59837c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 15:39:04 +0100 Subject: [PATCH 17/30] fix(security): a source lookup that fails asks the Vercel audit drain to retry verify_source_claims left a claim unverified when the store could not be asked, and the audit handler read that as an unknown sender (401). The drain then looked misconfigured during a Postgres blip. The lookup now reports the failure and the handler answers 503, as the key lookup does. --- apps/ingest/src/main.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index c058a0b4fa..e3006dfddd 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -940,6 +940,8 @@ enum SourceClaim { struct VerifiedClaims { active: Vec, paused: Vec, + /// The store could not be asked: a claim in neither list may still be the org's. + unresolved: bool, } fn source_claims_cache(ttl: Duration) -> Cache<(String, String), SourceClaim> { @@ -4258,6 +4260,12 @@ async fn handle_vercel_audit_trail_inner( return Ok(0); } if !verified.active.iter().any(|active| active == claim) { + // The store being down says nothing about the sender: Vercel retries a 503. + if verified.unresolved { + return Err(ApiError::service_unavailable( + "Ingest authentication unavailable", + )); + } return Err(unknown_sender()); } @@ -8763,6 +8771,7 @@ impl IngestKeyResolver { org_id, error, "Source claim lookup failed; replacing the claims" ); + verified.unresolved = true; return verified; } }; @@ -13523,6 +13532,28 @@ mod tests { drop(std::fs::remove_dir_all(&queue_dir)); } + // A store that cannot be asked has not said the sender is unknown: the drain + // must read as retryable, not as misconfigured. + #[tokio::test] + async fn vercel_audit_drain_asks_for_a_retry_while_its_source_cannot_be_checked() { + let queue_dir = unique_main_test_dir("vercel-audit-trail-store-down"); + let (state, store, mut imports) = + audit_trail_test_state("maple_sk_test_down", "org_down", queue_dir.clone()).await; + let headers = vercel_drain_headers("maple_sk_test_down", AUDIT_SOURCE_ID); + store.source_errors.store(true, Ordering::Relaxed); + + let refused = vercel_audit_delivery(&state, &headers) + .await + .expect_err("an unchecked source must not be stored"); + assert_eq!(refused.status, StatusCode::SERVICE_UNAVAILABLE); + assert!(nothing_reaches_the_audit_trail(&mut imports).await); + + // The store back, the same delivery is taken. + store.source_errors.store(false, Ordering::Relaxed); + assert_eq!(vercel_audit_delivery(&state, &headers).await.ok(), Some(1)); + drop(std::fs::remove_dir_all(&queue_dir)); + } + // A few hundred bytes of gzip can inflate into a body that parses into // gigabytes; an audit delivery has no reason to be large. #[tokio::test] From 7cd5701e8a89e6445173f183ad9b7f4cf2561369 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 16:18:25 +0100 Subject: [PATCH 18/30] feat(security): the audit detection vocabulary, its alert_rules column and the count query An audit log detection is a managed alert rule with signal type audit: N or more matching events arrived in the last W minutes. This adds the wire schemas and endpoints on the audit trail API, the nullable audit_filters_json column on alert_rules, and auditTrailCountQuery, which counts distinct event ids by IngestedAt and bounds OccurredAt so partitions prune. --- .../migration.sql | 2 + .../snapshot.json | 22470 ++++++++++++++++ packages/db/src/tables/alerts.ts | 5 + packages/domain/src/audit-trail.ts | 14 + .../anticipated-error-identifiers.ts | 2 + packages/domain/src/http/alerts.ts | 4 + packages/domain/src/http/audit-trail.ts | 131 + .../src/__sql_baseline__/catalog.sql | 14 + .../query-engine/src/benchmark/builders.ts | 19 + .../src/ch/queries/audit-trail.ts | 38 + 10 files changed, 22699 insertions(+) create mode 100644 packages/db/drizzle/20261112150508_alert_rules_audit_filters/migration.sql create mode 100644 packages/db/drizzle/20261112150508_alert_rules_audit_filters/snapshot.json diff --git a/packages/db/drizzle/20261112150508_alert_rules_audit_filters/migration.sql b/packages/db/drizzle/20261112150508_alert_rules_audit_filters/migration.sql new file mode 100644 index 0000000000..2af509c0a7 --- /dev/null +++ b/packages/db/drizzle/20261112150508_alert_rules_audit_filters/migration.sql @@ -0,0 +1,2 @@ +-- metadata +ALTER TABLE "alert_rules" ADD COLUMN IF NOT EXISTS "audit_filters_json" jsonb; diff --git a/packages/db/drizzle/20261112150508_alert_rules_audit_filters/snapshot.json b/packages/db/drizzle/20261112150508_alert_rules_audit_filters/snapshot.json new file mode 100644 index 0000000000..c7643cdb1e --- /dev/null +++ b/packages/db/drizzle/20261112150508_alert_rules_audit_filters/snapshot.json @@ -0,0 +1,22470 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "d095c14570c658f8dc55e640481d9ed4479be1dffa59ec4022c5d1ab1c944ecf", + "prevIds": [ + "48bfea234767f5f4288d14c81fa83ff976829eb6a70140d1f826f752fb6088c5" + ], + "entities": [ + { + "kind": "table", + "name": "actors", + "primaryKey": { + "name": "actors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "agent_feedback", + "primaryKey": { + "name": "agent_feedback_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "ai_triage_settings", + "primaryKey": { + "name": "ai_triage_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "alert_delivery_events", + "primaryKey": { + "name": "alert_delivery_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_destinations", + "primaryKey": { + "name": "alert_destinations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_escalation_runs", + "primaryKey": { + "name": "alert_escalation_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incident_events", + "primaryKey": { + "name": "alert_incident_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incidents", + "primaryKey": { + "name": "alert_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_linear_issues", + "primaryKey": { + "name": "alert_linear_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_maintenance_windows", + "primaryKey": { + "name": "alert_maintenance_windows_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_member_notifications", + "primaryKey": { + "name": "alert_member_notifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_claims", + "primaryKey": { + "name": "alert_rule_claims_pkey", + "columns": [ + "rule_id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_states", + "primaryKey": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + { + "kind": "table", + "name": "alert_rules", + "primaryKey": { + "name": "alert_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_silences", + "primaryKey": { + "name": "alert_silences_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "aws_connections", + "primaryKey": { + "name": "aws_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_zone_settings", + "primaryKey": { + "name": "cloudflare_zone_settings_pkey", + "columns": [ + "org_id", + "account_id" + ] + } + }, + { + "kind": "table", + "name": "cost_sources", + "primaryKey": { + "name": "cost_sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_findings", + "primaryKey": { + "name": "dependency_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scan_matches", + "primaryKey": { + "name": "dependency_scan_matches_pkey", + "columns": [ + "scan_id", + "lockfile_path", + "ecosystem", + "package_name", + "version", + "advisory_id" + ] + } + }, + { + "kind": "table", + "name": "dependency_scans", + "primaryKey": { + "name": "dependency_scans_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_credentials", + "primaryKey": { + "name": "device_credentials_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "device_proof_replays", + "primaryKey": { + "name": "device_proof_replays_pkey", + "columns": [ + "key" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_fingerprints", + "primaryKey": { + "name": "error_issue_fingerprints_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "escalation_chains", + "primaryKey": { + "name": "escalation_chains_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "external_data_source_catalog_entries", + "primaryKey": { + "name": "external_data_source_catalog_entries_pk", + "columns": [ + "source_id", + "name" + ] + } + }, + { + "kind": "table", + "name": "external_data_sources", + "primaryKey": { + "name": "external_data_sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "gcp_connectors", + "primaryKey": { + "name": "gcp_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "gcp_resources", + "primaryKey": { + "name": "gcp_resources_pkey", + "columns": [ + "connector_id", + "name" + ] + } + }, + { + "kind": "table", + "name": "investigations", + "primaryKey": { + "name": "investigations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalation_policies", + "primaryKey": { + "name": "issue_escalation_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalations", + "primaryKey": { + "name": "issue_escalations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "live_activities", + "primaryKey": { + "name": "live_activities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_authorizations", + "primaryKey": { + "name": "mcp_oauth_authorizations_pkey", + "columns": [ + "request_id_hash" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_clients", + "primaryKey": { + "name": "mcp_oauth_clients_pkey", + "columns": [ + "client_id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_refresh_tokens", + "primaryKey": { + "name": "mcp_oauth_refresh_tokens_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mobile_devices", + "primaryKey": { + "name": "mobile_devices_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oauth_auth_states", + "primaryKey": { + "name": "oauth_auth_states_pkey", + "columns": [ + "state" + ] + } + }, + { + "kind": "table", + "name": "oauth_connections", + "primaryKey": { + "name": "oauth_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oncall_overrides", + "primaryKey": { + "name": "oncall_overrides_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oncall_schedules", + "primaryKey": { + "name": "oncall_schedules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_billing_links", + "primaryKey": { + "name": "org_billing_links_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_schema_apply_runs", + "primaryKey": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_settings", + "primaryKey": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_attribute_mappings", + "primaryKey": { + "name": "org_ingest_attribute_mappings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_keys", + "primaryKey": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_settings", + "primaryKey": { + "name": "org_ingest_rule_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rule_stats", + "primaryKey": { + "name": "org_ingest_rule_stats_pkey", + "columns": [ + "org_id", + "rule_id", + "bucket", + "signal" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_rules", + "primaryKey": { + "name": "org_ingest_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_sampling_policies", + "primaryKey": { + "name": "org_ingest_sampling_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_onboarding_state", + "primaryKey": { + "name": "org_onboarding_state_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_openrouter_keys", + "primaryKey": { + "name": "org_openrouter_keys_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_recommendation_issues", + "primaryKey": { + "name": "org_recommendation_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_support_channels", + "primaryKey": { + "name": "org_support_channels_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_connections", + "primaryKey": { + "name": "planetscale_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_databases", + "primaryKey": { + "name": "planetscale_databases_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_events", + "primaryKey": { + "name": "planetscale_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_issue_receipts", + "primaryKey": { + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "columns": [ + "org_id", + "event_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_poll_state", + "primaryKey": { + "name": "planetscale_poll_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_edits", + "primaryKey": { + "name": "pr_review_edits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_finding_embeddings", + "primaryKey": { + "name": "pr_review_finding_embeddings_pkey", + "columns": [ + "finding_id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_findings", + "primaryKey": { + "name": "pr_review_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_merge_steps", + "primaryKey": { + "name": "pr_review_merge_steps_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_replies", + "primaryKey": { + "name": "pr_review_replies_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_settings", + "primaryKey": { + "name": "pr_review_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "pr_reviews", + "primaryKey": { + "name": "pr_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_connections", + "primaryKey": { + "name": "railway_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_environments", + "primaryKey": { + "name": "railway_environments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_target_checks", + "primaryKey": { + "name": "scrape_target_checks_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_targets", + "primaryKey": { + "name": "scrape_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "search_console_sites", + "primaryKey": { + "name": "search_console_sites_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_events", + "primaryKey": { + "name": "security_audit_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_findings", + "primaryKey": { + "name": "security_audit_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_reservations", + "primaryKey": { + "name": "security_audit_org_reservations_pkey", + "columns": [ + "run_id", + "agent_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_org_settings", + "primaryKey": { + "name": "security_audit_org_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_run_findings", + "primaryKey": { + "name": "security_audit_run_findings_pkey", + "columns": [ + "run_id", + "fingerprint" + ] + } + }, + { + "kind": "table", + "name": "security_audit_runs", + "primaryKey": { + "name": "security_audit_runs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_targets", + "primaryKey": { + "name": "security_audit_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "security_audit_units", + "primaryKey": { + "name": "security_audit_units_pkey", + "columns": [ + "run_id", + "coverage_id" + ] + } + }, + { + "kind": "table", + "name": "service_map_world_entities", + "primaryKey": { + "name": "service_map_world_entities_pkey", + "columns": [ + "org_id", + "entity_id" + ] + } + }, + { + "kind": "table", + "name": "slos", + "primaryKey": { + "name": "slos_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "sources", + "primaryKey": { + "name": "sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_cleanups", + "primaryKey": { + "name": "status_page_cleanups_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_components", + "primaryKey": { + "name": "status_page_components_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_custom_domains", + "primaryKey": { + "name": "status_page_custom_domains_pkey", + "columns": [ + "page_id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_components", + "primaryKey": { + "name": "status_page_incident_components_pk", + "columns": [ + "incident_id", + "component_id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incident_updates", + "primaryKey": { + "name": "status_page_incident_updates_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_page_incidents", + "primaryKey": { + "name": "status_page_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "status_pages", + "primaryKey": { + "name": "status_pages_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_connections", + "primaryKey": { + "name": "supabase_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "supabase_project_insights", + "primaryKey": { + "name": "supabase_project_insights_pkey", + "columns": [ + "project_id" + ] + } + }, + { + "kind": "table", + "name": "supabase_projects", + "primaryKey": { + "name": "supabase_projects_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_daily", + "primaryKey": { + "name": "uptime_monitor_daily_pk", + "columns": [ + "monitor_id", + "day" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitor_states", + "primaryKey": { + "name": "uptime_monitor_states_pkey", + "columns": [ + "monitor_id" + ] + } + }, + { + "kind": "table", + "name": "uptime_monitors", + "primaryKey": { + "name": "uptime_monitors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_commits", + "primaryKey": { + "name": "vcs_commits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_installations", + "primaryKey": { + "name": "vcs_installations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repositories", + "primaryKey": { + "name": "vcs_repositories_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repository_branches", + "primaryKey": { + "name": "vcs_repository_branches_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vercel_connections", + "primaryKey": { + "name": "vercel_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vercel_deployments", + "primaryKey": { + "name": "vercel_deployments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vercel_projects", + "primaryKey": { + "name": "vercel_projects_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "column", + "table": "actors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "agent_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "model", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "capabilities_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "last_active_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "impact", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "summary", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "reason", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "details", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "related_to", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_type", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_model", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_version", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_runs_per_day", + "position": 2, + "type": "integer", + "notNull": true, + "default": "20", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_passes_per_day", + "position": 3, + "type": "integer", + "notNull": true, + "default": "90", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_by", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "rule_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "destination_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "delivery_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "scheduled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claim_expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_by", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempted_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_message", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_reference", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "response_code", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "error_message", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "silence_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 19, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "chain_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "severity", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "repeat_count", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "step_index", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "pass_index", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "next_step_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "payload_json", + "position": 12, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "steps_sent", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "last_step_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "ended_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_escalation_runs", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "actor_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "source", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incident_events", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "incident_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_key", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "signal_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "severity", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "peak_severity", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "comparator", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold_upper", + "position": 12, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "first_triggered_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_triggered_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "resolved_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_observed_value", + "position": 16, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_sample_count", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_evaluated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "dedupe_key", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_delivered_event_type", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_notified_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "hold_reason", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "held_since", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_user_id", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_by_name", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "acknowledged_via", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "anomaly_incident_id", + "position": 29, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_label", + "position": 30, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "labels_json", + "position": 31, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silence_id", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "silenced_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 35, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "thread_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_team_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_issue_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_identifier", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "linear_url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_linear_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "reason", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "matchers_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "schedule_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "exclude_from_baselines", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "once_ends_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "revision", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "created_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_maintenance_windows", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "run_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "channel", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "scheduled_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "claimed_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "claim_expires_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "payload_json", + "position": 11, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "error_message", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "attempted_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_member_notifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "rule_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "last_scheduled_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "consecutive_limit_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "group_key", + "position": 2, + "type": "text", + "notNull": true, + "default": "'__total__'", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_breaches", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_healthy", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "history_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_status", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_value", + "position": 7, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_sample_count", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_evaluated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error_kind", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notes", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notification_template_json", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "severity", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "service_names_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "exclude_service_names_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "environments_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "anomaly_signals_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "audit_filters_json", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 12, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 15, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 16, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "thresholds_json", + "position": 17, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 18, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "evaluation_interval_minutes", + "position": 19, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_interval_minutes", + "position": 20, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "slowed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 23, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "breaches_out_of", + "position": 24, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 25, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 26, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 27, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 28, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 29, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 30, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 31, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 32, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 33, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 34, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 35, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 36, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 37, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 38, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 39, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 40, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "reason", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "matchers_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "maintenance_window_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "exclude_from_baselines", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "created_by", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_silences", + "name": "updated_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "account_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "modules", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "granted_modules", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "template_version", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "verified_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_checked_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_check_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "check_lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "metric_stream_changed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_secret_hash", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_received_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "stream_last_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "model_read", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backoff_until", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backoff_streak", + "position": 20, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backoff_reason", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 6, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "account_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "watch_new_zones", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "zone_overrides", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_zone_settings", + "name": "updated_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "connection_ref", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "account_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "watermark_date", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "backfill_date", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "last_synced_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "last_settled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "last_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "last_error_reason", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "lease_until", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "next_attempt_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "consecutive_failures", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "requests_month", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "requests_count", + "position": 16, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cost_sources", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "lockfile_path", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "ecosystem", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "package_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "advisory_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "aliases", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "severity", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "summary", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "versions", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "relation", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_scan_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_scan_id", + "position": 15, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "first_seen_sha", + "position": 16, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "last_seen_sha", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_scan_id", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "fixed_sha", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_reason", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_by", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "status_changed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_findings", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "scan_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "lockfile_path", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "ecosystem", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "package_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "version", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "relation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "advisory_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "aliases", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "summary", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scan_matches", + "name": "fixed_in", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "commit_sha", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "ref", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "trigger", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "default_branch_head", + "position": 6, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'running'", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "lockfiles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "package_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "vulnerable_count", + "position": 10, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "started_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dependency_scans", + "name": "finished_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "public_jwk", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "jkt", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "roles", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "scopes", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attested", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "attestation", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "last_used_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "expires_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "hard_expires_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_credentials", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "key", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "device_proof_replays", + "name": "expires_at", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "send_hour", + "position": 8, + "type": "integer", + "notNull": true, + "default": "9", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 10, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "source", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "origin_issue_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_fingerprints", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "merged_into_issue_id", + "position": 32, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "steps_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "repeat_count", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "escalation_chains", + "name": "updated_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "source_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "row_estimate", + "position": 4, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "source_note", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "detected_time_field", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "fields", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "exposed", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "note", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "time_field_override", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "field_notes", + "position": 11, + "type": "jsonb", + "notNull": true, + "default": "'{}'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "external_data_source_catalog_entries", + "name": "seen_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "description", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "tags", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "agent_access", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "agent_pii", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "config", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "secret_ciphertext", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "secret_iv", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "secret_tag", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "secret_hint", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "status", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "last_checked_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "catalog_refreshed_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "catalog_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "examples", + "position": 19, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "updated_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "created_by", + "position": 22, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "external_data_sources", + "name": "updated_by", + "position": 23, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "project_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "logs_enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_ciphertext", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_iv", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_tag", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_hash", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_logs_enabled", + "position": 14, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_metrics_enabled", + "position": 15, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "setup_reported_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_watermark_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_received_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_lease_until", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "resources_synced_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_resources_error", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_by", + "position": 23, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "updated_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "connector_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "asset_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "project_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "location", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "display_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "labels", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "details", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "last_seen_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'investigating'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "seeded_by", + "position": 3, + "type": "text", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "subject_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_kind", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "issue_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "report_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "progress_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "severity", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "confidence", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "input_tokens", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "output_tokens", + "position": 15, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "started_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "autonomous_turns", + "position": 18, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "diagnosed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "updated_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "rules_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "severity", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "source", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "reason", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "run_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "investigation_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "payload_json", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "delivery_results_json", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "attempts", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "device_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "incident_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "activity_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "push_token", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_reason", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "request_id_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "redirect_uri", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "state", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "resource", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "scopes", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "code_challenge", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "authorization_code_hash", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_org_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_roles", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_email", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "denied_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "used_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_name", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "redirect_uris", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_uri", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "token_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "client_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "resource", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "scopes", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "org_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "roles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_email", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "access_key_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "replaced_by_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "revoked_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "platform", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "token", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "bundle_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "app_version", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "live_activity_start_token", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "device_name", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "preferences", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_reason", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_pushed_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_seen_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "state", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "initiated_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "redirect_uri", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "return_to", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "code_verifier", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "github_installation_ids", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_email", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_account_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "granted_accounts_json", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "connected_by_user_id", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "scope", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_ciphertext", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_iv", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_tag", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "expires_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "revoked_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "schedule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "starts_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "ends_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_overrides", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "time_zone", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "layers_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "created_by", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oncall_schedules", + "name": "updated_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "billing_org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "linked_by", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_billing_links", + "name": "created_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "workflow_instance_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "phase", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "current_migration", + "position": 4, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_total", + "position": 5, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_done", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "applied_versions", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "skipped", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "error_message", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "started_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "finished_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_url", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_user", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_ciphertext", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_iv", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_tag", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_database", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "sync_status", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "schema_version", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_context", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "target_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "operation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key_hash", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_hash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_rotated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_rotated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "hash_salt", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_settings", + "name": "created_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "bucket", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "signal", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "matched", + "position": 4, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped", + "position": 5, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "dropped_bytes", + "position": 6, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rule_stats", + "name": "redacted_values", + "position": 7, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "enabled", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "position", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "spec_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_rules", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "trace_sample_ratio", + "position": 1, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_error_spans", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_slow_spans_ms", + "position": 3, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "user_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "email", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "role", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "demo_data_requested", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "onboarding_completed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "checklist_dismissed_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "first_data_received_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "welcome_email_sent_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "connect_nudge_email_sent_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "stalled_email_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "activation_email_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_reserved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_ciphertext", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_iv", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_tag", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_hint", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "number", + "position": 2, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "recommendation_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "source_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "canonical_key", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "usage_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "opened_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "resolved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_name", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reservation_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reserved_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_by_user_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "ps_organization", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "connected_by_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "scrape_target_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "detected_permissions_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": "'mysql'", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "region", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "plan", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "branches_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_ciphertext", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_iv", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_secret_tag", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "webhook_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "deleted_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "branch_name", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "category", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "external_id", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "title", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "source", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "actor_login", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "payload_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "occurred_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "event_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "processed_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "dataset", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "database_id", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "watermark_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_success_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "reply_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "seq", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "path", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "old_text", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "new_text", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "finding_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "embedding", + "position": 4, + "type": "real[]", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "review_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "handle", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "path", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "line", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "category", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "title", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "comment_id", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "resolved_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_up", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_down", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "title", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "source", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "path", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "reminded_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "first_review_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "last_review_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "comment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "surface", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "thread_root_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "author_login", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "command", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "head_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "reply_url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "commit_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "model", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "defaults", + "position": 2, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "head_sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "base_sha", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "url", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "title", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "skip_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_attempt", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "report_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "score", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "check_run_url", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_url", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "review_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "input_tokens", + "position": 21, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "output_tokens", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "started_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "finished_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merged_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "telemetry_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merge_commit_sha", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_status", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_after", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "created_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "updated_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "workspace_names", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "connected_by_user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "auth_failed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "discovered_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_success_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "services_json", + "position": 7, + "type": "text", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "enabled", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "watermark_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "id", + "position": 0, + "type": "integer", + "notNull": true, + "default": null, + "identity": "by default" + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "target_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "sub_target_key", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "error", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "duration_ms", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_scraped", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_post_relabel", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "url", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "target_type", + "position": 6, + "type": "text", + "notNull": true, + "default": "'prometheus'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "discovery_config_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "scrape_interval_seconds", + "position": 8, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "labels_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_type", + "position": 10, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "managed_by", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_ciphertext", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_iv", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_tag", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_error", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "oauth_connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "site_url", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "permission_level", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "watermark_date", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "backfill_date", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "last_synced_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "last_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "next_attempt_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "consecutive_failures", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "search_console_sites", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "run_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "role", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "agent_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "outcome", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "detail", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "input_tokens", + "position": 9, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_events", + "name": "output_tokens", + "position": 10, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fingerprint", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "verdict", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "overall_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "estimated_severity", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "record", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_run_id", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_run_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "first_seen_ref", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "last_seen_ref", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "fixed_run_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_by", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "status_changed_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_findings", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "agent_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "cost_usd", + "position": 3, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "sandbox_fee_usd", + "position": 4, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_reservations", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "monthly_spend_limit_usd", + "position": 1, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "discovery_model", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "validation_model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_org_settings", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "fingerprint", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "verdict", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "overall_severity", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "estimated_severity", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_run_findings", + "name": "record", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "target_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "source_ref", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "profile", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "scope_paths", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "budget", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "metadata", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'in_progress'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "incomplete_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "architecture_md", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "reports", + "position": 12, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "execution_profile", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "invocations_spent", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "input_tokens", + "position": 15, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "output_tokens", + "position": 16, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "cost_usd", + "position": 17, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "sandbox_usd", + "position": 18, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "discovery_model", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "validation_model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "workflow_instance_id", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "started_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "finished_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_runs", + "name": "error", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "repository", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "vcs_repository_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "branch", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "profile", + "position": 6, + "type": "text", + "notNull": true, + "default": "'deep'", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "scope_paths", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "slice_interval_hours", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "revalidate_on_push", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "budget_per_run", + "position": 10, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "monthly_invocation_budget", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "discovery_model", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "validation_model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_targets", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "run_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "coverage_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "wave", + "position": 4, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "agent_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "security_audit_units", + "name": "unit", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "entity_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "data", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "revision", + "position": 4, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "service_map_world_entities", + "name": "deleted_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "environments_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "sli_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "target_percent", + "position": 7, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "window_days", + "position": 8, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "alert_rule_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_by", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "slos", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "platform", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_prefix", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "allowed_signals", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rate_limit_rps", + "position": 9, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "resource_attributes", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_until", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_used_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejected_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejection", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_ok_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "disconnected_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "poll_resources", + "position": 21, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rolled_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_by", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_by", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "slug", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "delete_snapshot", + "position": 4, + "type": "boolean", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "hostname", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "cloudflare_hostname_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "cloudflare_route_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_cleanups", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "group_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "position", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "monitor_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "manual_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "show_uptime", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_components", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "page_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "hostname", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "cloudflare_hostname_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "cloudflare_route_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "dns_records", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "last_checked_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "activated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_custom_domains", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "incident_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "component_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_components", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "body", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "source", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "overlay_entry_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incident_updates", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "page_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "title", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "impact", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "published", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "alert_incident_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_start", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "scheduled_end", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "started_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "resolved_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_page_incidents", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "slug", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "description", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "website_url", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "logo_url", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "logo_dark_url", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "favicon_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "support_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "privacy_url", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "terms_url", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "theme", + "position": 12, + "type": "jsonb", + "notNull": true, + "default": "'{\"preset\":\"default\",\"appearance\":\"auto\",\"accent\":null,\"background\":null,\"text\":null,\"card\":null,\"border\":null,\"radius\":\"md\",\"font\":\"sans\"}'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "show_powered_by", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "auto_incidents", + "position": 14, + "type": "text", + "notNull": true, + "default": "'off'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_sha256", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "break_glass_token_suffix", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "visibility", + "position": 17, + "type": "text", + "notNull": true, + "default": "'public'", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "password_hash", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "draft", + "position": 19, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "revision", + "position": 20, + "type": "bigint", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_revision", + "position": 21, + "type": "bigint", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "last_publish_error", + "position": 23, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "published_hash", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "publish_attempted_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_by", + "position": 26, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "created_at", + "position": 27, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "status_pages", + "name": "updated_at", + "position": 28, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_slug", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "supabase_organization_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "connected_by_user_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "discovery_lease_until", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "last_discovery_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_connections", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "project_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "top_queries_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_checked_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "advisors_error", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "queries_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "lease_until", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_project_insights", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "project_ref", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "region", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "status", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "scrape_target_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_enabled", + "position": 9, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_source_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drain_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_json", + "position": 13, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "log_drains_checked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_watermark_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_boundary_ids", + "position": 17, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_last_polled_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "logs_error", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "removed_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "supabase_projects", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "day", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "checks", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "failures", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "down_checks", + "position": 5, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_daily", + "name": "last_check_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "monitor_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_failures", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "consecutive_successes", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "status_changed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_check_json", + "position": 6, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "recent_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "last_ping_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitor_states", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "config_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "headers_ciphertext", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "headers_iv", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "headers_tag", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "interval_seconds", + "position": 8, + "type": "integer", + "notNull": true, + "default": "60", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "failures_to_down", + "position": 9, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "service_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "alert_rule_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "heartbeat_token", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "uptime_monitors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "message", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_email", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_avatar_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "authored_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "committed_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "html_url", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_installation_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_login", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_account_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_avatar_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_selection", + "position": 9, + "type": "text", + "notNull": true, + "default": "'all'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "suspended_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "ci_traces_enabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_discovery", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "owner", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "full_name", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "default_branch", + "position": 8, + "type": "text", + "notNull": true, + "default": "'main'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "tracked_branch", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "html_url", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_private", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_archived", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "sync_status", + "position": 14, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_synced_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_sync_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_config", + "position": 18, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "is_default", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "head_sha", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "team_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "team_slug", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "team_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "connected_by_user_id", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "project_scope", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "project_ids", + "position": 11, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "sampling_rate", + "position": 12, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "logs_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "traces_enabled", + "position": 14, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "ai_gateway_enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "speed_insights_enabled", + "position": 16, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "web_analytics_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "audit_log_enabled", + "position": 18, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "log_drain_id", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "trace_drain_id", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "ai_gateway_drain_id", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "speed_insights_drain_id", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "web_analytics_drain_id", + "position": 23, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "audit_log_drain_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "log_drain_state", + "position": 25, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "trace_drain_state", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "ai_gateway_drain_state", + "position": 27, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "speed_insights_drain_state", + "position": 28, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "web_analytics_drain_state", + "position": 29, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "audit_log_drain_state", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "drain_key_hash", + "position": 31, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "auth_failed_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "discovered_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "deployments_watermark_at", + "position": 34, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "lease_until", + "position": 35, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "last_success_at", + "position": 36, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "last_error", + "position": 37, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "last_error_at", + "position": 38, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "created_at", + "position": 39, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_connections", + "name": "updated_at", + "position": 40, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "deployment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "url", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "target", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "state", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "branch", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "commit_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "commit_message", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "creator", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "error_code", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "error_message", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "created_at_vercel", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "building_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "ready_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "inspector_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_deployments", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "framework", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "production_deployment_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vercel_projects", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_agent_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"agent_name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"claim_expires_at\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_delivery_attempt_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"attempt_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_live_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"next_step_at\"" + ], + "where": "\"status\" in ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_live_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_id\"" + ], + "where": "\"status\" IN ('active', 'acknowledged')" + }, + { + "kind": "index", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_incident_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_open_group_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"", + "\"group_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_rule_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_linear_issues", + "name": "alert_linear_issues_thread_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"destination_id\"", + "\"thread_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_maintenance_windows", + "name": "alert_maintenance_windows_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"enabled\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_maintenance_windows", + "name": "alert_maintenance_windows_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_member_notifications", + "name": "alert_member_notifications_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_member_notifications", + "name": "alert_member_notifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_member_notifications", + "name": "alert_member_notifications_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_claims", + "name": "alert_rule_claims_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_states", + "name": "alert_rule_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_silences", + "name": "alert_silences_maintenance_occurrence_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"maintenance_window_id\"", + "\"starts_at\"" + ], + "where": "\"maintenance_window_id\" IS NOT NULL" + }, + { + "kind": "index", + "table": "alert_silences", + "name": "alert_silences_org_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_org_account_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_stream_secret_hash_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"stream_secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cost_sources", + "name": "cost_sources_org_provider_ref_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"connection_ref\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"lockfile_path\"", + "\"ecosystem\"", + "\"package_name\"", + "\"advisory_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_findings", + "name": "dependency_findings_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scan_matches", + "name": "dependency_scan_matches_org_scan_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scan_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_commit_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"commit_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dependency_scans", + "name": "dependency_scans_repo_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_jkt_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"jkt\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_live_installation_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"installation_id\"" + ], + "where": "\"revoked_at\" IS NULL" + }, + { + "kind": "index", + "table": "device_credentials", + "name": "device_credentials_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "device_proof_replays", + "name": "device_proof_replays_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_fingerprints", + "name": "error_issue_fingerprints_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_merged_into_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"merged_into_issue_id\"" + ], + "where": "\"merged_into_issue_id\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "escalation_chains", + "name": "escalation_chains_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "external_data_source_catalog_entries", + "name": "external_data_source_catalog_entries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "external_data_sources", + "name": "external_data_sources_org_name_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_org_scope_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scope_type\"", + "\"scope_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_kind\"", + "\"incident_id\"" + ], + "where": "\"incident_id\" is not null" + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_device_incident_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"device_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"authorization_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_family_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"family_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_platform_token_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"platform\"", + "\"token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_auth_states", + "name": "oauth_auth_states_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_provider_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oncall_overrides", + "name": "oncall_overrides_org_schedule_ends_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"schedule_id\"", + "\"ends_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oncall_schedules", + "name": "oncall_schedules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_billing_links", + "name": "org_billing_links_billing_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"billing_org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_attribute_mappings", + "name": "org_ingest_attribute_mappings_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_private_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"private_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rule_stats", + "name": "org_ingest_rule_stats_org_bucket_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"bucket\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_rules", + "name": "org_ingest_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"recommendation_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"event_type\"", + "\"external_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_db_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_issue_receipts", + "name": "planetscale_issue_receipts_processed_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"processed_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_dataset_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dataset\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_edits", + "name": "pr_review_edits_reply_seq_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"reply_id\"", + "\"seq\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_finding_embeddings", + "name": "pr_review_finding_embeddings_org_model_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"model\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_pr_handle_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"handle\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_pr_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_repo_comment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"comment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_post_merge_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"post_merge_after\"" + ], + "where": "\"post_merge_status\" = 'waiting'" + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_head_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"head_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_org_env_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"environment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "search_console_sites", + "name": "search_console_sites_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"oauth_connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "search_console_sites", + "name": "search_console_sites_org_site_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"site_url\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_events", + "name": "security_audit_events_run_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"run_id\"", + "\"at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_findings", + "name": "security_audit_findings_target_fingerprint_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"fingerprint\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_org_reservations", + "name": "security_audit_org_reservations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_one_running_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"target_id\"" + ], + "where": "\"finished_at\" IS NULL" + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_org_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_runs", + "name": "security_audit_runs_target_started_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "security_audit_targets", + "name": "security_audit_targets_org_repo_branch_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"repository\"", + "\"branch\"" + ], + "where": null + }, + { + "kind": "index", + "table": "slos", + "name": "slos_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "slos", + "name": "slos_org_name_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_default_kind_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": "\"kind\" = 'default'" + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_cleanups", + "name": "status_page_cleanups_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_monitor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"monitor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_components", + "name": "status_page_components_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"position\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_custom_domains", + "name": "status_page_custom_domains_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_custom_domains", + "name": "status_page_custom_domains_hostname_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"hostname\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_custom_domains", + "name": "status_page_custom_domains_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_components", + "name": "status_page_incident_components_component_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"component_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"incident_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_overlay_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"overlay_entry_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_alert_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"alert_incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_open_maintenance_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"scheduled_start\"" + ], + "where": "\"kind\" = 'maintenance' and \"resolved_at\" is null" + }, + { + "kind": "index", + "table": "status_page_incidents", + "name": "status_page_incidents_page_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"page_id\"", + "\"started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_slug_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"slug\"" + ], + "where": null + }, + { + "kind": "index", + "table": "status_pages", + "name": "status_pages_unpublished_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"revision\"", + "\"published_revision\"" + ], + "where": "revision > published_revision" + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_connections", + "name": "supabase_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_project_insights", + "name": "supabase_project_insights_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_connection_ref_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connection_id\"", + "\"project_ref\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"log_drain_source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "supabase_projects", + "name": "supabase_projects_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_enabled_kind_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"enabled\"", + "\"kind\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_heartbeat_token_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"heartbeat_token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "uptime_monitors", + "name": "uptime_monitors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_org_sha_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_repo_sha_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_provider_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"provider\"", + "\"external_installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_installation_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_repo_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"external_repo_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_repo_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vercel_connections", + "name": "vercel_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vercel_connections", + "name": "vercel_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vercel_deployments", + "name": "vercel_deployments_org_deployment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"deployment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vercel_deployments", + "name": "vercel_deployments_org_project_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"project_id\"", + "\"created_at_vercel\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vercel_projects", + "name": "vercel_projects_org_project_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"project_id\"" + ], + "where": null + }, + { + "kind": "foreign_key", + "table": "alert_escalation_runs", + "name": "alert_escalation_runs_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_incident_events", + "name": "alert_incident_events_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_linear_issues", + "name": "alert_linear_issues_destination_id_alert_destinations_id_fk", + "columns": [ + "destination_id" + ], + "foreignTable": "alert_destinations", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "alert_member_notifications", + "name": "alert_member_notifications_incident_id_alert_incidents_id_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "alert_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "aws_connections", + "name": "aws_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "dashboard_shares", + "name": "dashboard_shares_dashboard_fk", + "columns": [ + "org_id", + "dashboard_id" + ], + "foreignTable": "dashboards", + "foreignColumns": [ + "org_id", + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "external_data_source_catalog_entries", + "name": "external_data_source_catalog_entries_x60VonL2VphD_fk", + "columns": [ + "source_id" + ], + "foreignTable": "external_data_sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_connectors", + "name": "gcp_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_resources", + "name": "gcp_resources_connector_id_gcp_connectors_id_fk", + "columns": [ + "connector_id" + ], + "foreignTable": "gcp_connectors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "oauth_connections", + "name": "oauth_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "oncall_overrides", + "name": "oncall_overrides_schedule_id_oncall_schedules_id_fk", + "columns": [ + "schedule_id" + ], + "foreignTable": "oncall_schedules", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "planetscale_connections", + "name": "planetscale_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "railway_connections", + "name": "railway_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "columns": [ + "target_id" + ], + "foreignTable": "scrape_targets", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_targets", + "name": "scrape_targets_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "search_console_sites", + "name": "search_console_sites_jBAHYyW36dnu_fk", + "columns": [ + "oauth_connection_id" + ], + "foreignTable": "oauth_connections", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_components", + "name": "status_page_components_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_custom_domains", + "name": "status_page_custom_domains_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_Bnqlt6wwKJty_fk", + "columns": [ + "component_id" + ], + "foreignTable": "status_page_components", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_components", + "name": "status_page_incident_components_tBn6mEz4FenY_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incident_updates", + "name": "status_page_incident_updates_3LDDUAhznHPM_fk", + "columns": [ + "incident_id" + ], + "foreignTable": "status_page_incidents", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "status_page_incidents", + "name": "status_page_incidents_page_id_status_pages_id_fk", + "columns": [ + "page_id" + ], + "foreignTable": "status_pages", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_connections", + "name": "supabase_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_project_insights", + "name": "supabase_project_insights_project_id_supabase_projects_id_fk", + "columns": [ + "project_id" + ], + "foreignTable": "supabase_projects", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_connection_id_supabase_connections_id_fk", + "columns": [ + "connection_id" + ], + "foreignTable": "supabase_connections", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "supabase_projects", + "name": "supabase_projects_log_drain_source_id_sources_id_fk", + "columns": [ + "log_drain_source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_daily", + "name": "uptime_monitor_daily_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "uptime_monitor_states", + "name": "uptime_monitor_states_monitor_id_uptime_monitors_id_fk", + "columns": [ + "monitor_id" + ], + "foreignTable": "uptime_monitors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "vcs_installations", + "name": "vcs_installations_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "vercel_connections", + "name": "vercel_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/src/tables/alerts.ts b/packages/db/src/tables/alerts.ts index cf1e9ef99d..c47f8c617f 100644 --- a/packages/db/src/tables/alerts.ts +++ b/packages/db/src/tables/alerts.ts @@ -15,6 +15,7 @@ import { import { StoredAlertMaintenanceSchedule } from "@maple/domain/alert-maintenance-windows" import { AlertIncidentLabels } from "@maple/domain/alerts" import { AnomalySignalType } from "@maple/domain/anomalies" +import { AuditDetectionFilters } from "@maple/domain/audit-trail" import { Schema } from "effect" const StringArray = Schema.Array(Schema.String) @@ -83,6 +84,10 @@ export const AlertRules = PG.table("alert_rules", { anomalySignalsJson: PG.column(PG.nullable(PG.jsonb(Schema.Array(AnomalySignalType))), { name: "anomaly_signals_json", }), + /** Audit detections only: which events the rule counts. Null/empty means every event. */ + auditFiltersJson: PG.column(PG.nullable(PG.jsonb(AuditDetectionFilters)), { + name: "audit_filters_json", + }), /** JSON-encoded `string[]` of free-form tags used to group and filter rules. */ tagsJson: PG.column(PG.nullable(PG.jsonb(StringArray)), { name: "tags_json" }), signalType: PG.column(PG.text, { name: "signal_type" }), diff --git a/packages/domain/src/audit-trail.ts b/packages/domain/src/audit-trail.ts index cdcd36db4d..753f00cd40 100644 --- a/packages/domain/src/audit-trail.ts +++ b/packages/domain/src/audit-trail.ts @@ -30,6 +30,20 @@ export type AuditTrailActorType = typeof AuditTrailActorType.Type export const AuditTrailOutcome = Schema.Literals(["success", "failure", "denied", "unknown"]) export type AuditTrailOutcome = typeof AuditTrailOutcome.Type +/** + * What an audit log detection counts. `action` matches exactly. No actor or origin filter on + * purpose: rule rows sync to every member's browser, so nothing in a rule may name a person. + */ +export const AuditDetectionFilters = Schema.Struct({ + provider: Schema.optionalKey(AuditTrailProvider), + category: Schema.optionalKey(AuditTrailCategory), + outcome: Schema.optionalKey(AuditTrailOutcome), + action: Schema.optionalKey( + Schema.String.check(Schema.isTrimmed(), Schema.isMinLength(1), Schema.isMaxLength(256)), + ), +}) +export type AuditDetectionFilters = typeof AuditDetectionFilters.Type + /** * One audit event as a provider adapter hands it over. `provider`, `account` and * `providerEventId` make the stored row's key: a redelivery collapses only when it also diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 11d93dd0a1..3ace381460 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -33,6 +33,8 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/AlertSilenceNotFoundError", "@maple/http/errors/AlertValidationError", "@maple/http/errors/ApiKeyNotFoundError", + "@maple/http/errors/AuditDetectionInvalidError", + "@maple/http/errors/AuditDetectionNotFoundError", "@maple/http/errors/AuditTrailForbiddenError", "@maple/http/errors/AwsAccountAlreadyConnectedError", "@maple/http/errors/AwsIntegrationUnavailableError", diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index a3104b5437..5f20de89f8 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -55,6 +55,8 @@ export const AlertSignalType = Schema.Literals([ "slo_burn_rate", /** Pages on the anomaly detector's open incidents in the rule's scope; it has no query. */ "anomaly", + /** The hidden rule behind an audit log detection; managed only through the Security hub. */ + "audit", ]).annotate({ identifier: "@maple/AlertSignalType", title: "Alert Signal Type", @@ -65,6 +67,7 @@ export type AlertSignalType = Schema.Schema.Type export const MANAGED_SIGNAL_TYPES = [ "uptime", "slo_burn_rate", + "audit", ] as const satisfies ReadonlyArray export type ManagedSignalType = (typeof MANAGED_SIGNAL_TYPES)[number] export const isManagedSignalType = (signalType: string): signalType is ManagedSignalType => @@ -1359,6 +1362,7 @@ export class AlertRuleStoredConfigInvalidError extends HttpTaggedError()( "@maple/http/errors/AuditTrailPersistenceError", /** `cause` names the failure behind it (its tag), never its details. */ @@ -114,6 +183,35 @@ export class AuditTrailForbiddenError extends HttpTaggedError()( + "@maple/http/errors/AuditDetectionNotFoundError", + { detectionId: AlertRuleId, message: Schema.String }, + { + status: 404, + code: "audit_detection_not_found", + title: "Detection not found", + message: "No such detection.", + param: "id", + retry: "never", + recovery: "none", + exposure: "redacted", + }, +) {} + +/** An unknown destination, a name another alert rule has, or the per-org cap. */ +export class AuditDetectionInvalidError extends HttpTaggedError()( + "@maple/http/errors/AuditDetectionInvalidError", + { message: Schema.String }, + { + status: 400, + code: "audit_detection_invalid", + title: "Invalid detection", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + export class AuditTrailApiGroup extends HttpApiGroup.make("auditTrail") .add( HttpApiEndpoint.get("list", "/events", { @@ -122,5 +220,38 @@ export class AuditTrailApiGroup extends HttpApiGroup.make("auditTrail") error: [AuditTrailPersistenceError, AuditTrailForbiddenError], }), ) + .add( + HttpApiEndpoint.get("listDetections", "/detections", { + success: AuditDetectionListResponse, + error: [AuditTrailPersistenceError, AuditTrailForbiddenError], + }), + ) + .add( + HttpApiEndpoint.post("createDetection", "/detections", { + payload: AuditDetectionCreateRequest, + success: AuditDetection, + error: [AuditTrailPersistenceError, AuditTrailForbiddenError, AuditDetectionInvalidError], + }), + ) + .add( + HttpApiEndpoint.patch("updateDetection", "/detections/:id", { + params: { id: AlertRuleId }, + payload: AuditDetectionUpdateRequest, + success: AuditDetection, + error: [ + AuditTrailPersistenceError, + AuditTrailForbiddenError, + AuditDetectionNotFoundError, + AuditDetectionInvalidError, + ], + }), + ) + .add( + HttpApiEndpoint.delete("deleteDetection", "/detections/:id", { + params: { id: AlertRuleId }, + success: AuditDetectionDeleteResponse, + error: [AuditTrailPersistenceError, AuditTrailForbiddenError, AuditDetectionNotFoundError], + }), + ) .prefix("/internal/audit-trail") .middleware(SessionAuthorization) {} diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 456c8baef8..ad457e96d2 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -373,6 +373,20 @@ SELECT OFFSET 50 FORMAT JSON +-- builder:audit-trail:auditTrailCountQuery:filtered [ee015597] +SELECT + uniqExact(audit_trail_events.Id) AS events + FROM audit_trail_events + WHERE audit_trail_events.OrgId = 'org_sql_catalog' + AND audit_trail_events.IngestedAt > '2026-01-01 10:30:00' + AND audit_trail_events.IngestedAt <= '2026-01-03 14:15:00' + AND audit_trail_events.OccurredAt >= '2026-01-01 10:30:00' - INTERVAL 604800 SECOND + AND audit_trail_events.Provider = 'vercel' + AND audit_trail_events.Category = 'membership' + AND audit_trail_events.Outcome = 'success' + AND audit_trail_events.Action = 'team.member.added' + FORMAT JSON + -- builder:audit-trail:auditTrailEventsQuery:default [9ab77231] SELECT audit_trail_events.Id AS id, diff --git a/packages/query-engine/src/benchmark/builders.ts b/packages/query-engine/src/benchmark/builders.ts index 0122c2aee8..d4e3a06efe 100644 --- a/packages/query-engine/src/benchmark/builders.ts +++ b/packages/query-engine/src/benchmark/builders.ts @@ -1502,6 +1502,25 @@ export const builderFixtures: ReadonlyArray = [ }, ), }, + // Audit detection count (AuditTrailService.ts `count`), every optional filter bound. + { + module: "audit-trail", + name: "auditTrailCountQuery", + label: "filtered", + compile: () => + CH.compileUnsafe( + CH.auditTrailCountQuery({ provider: true, category: true, outcome: true, action: true }), + { + orgId: ORG_ID, + provider: "vercel", + category: "membership", + outcome: "success", + action: "team.member.added", + start: START_TIME, + end: END_TIME, + }, + ), + }, // Cancellation evidence (CancellationTelemetryEvidence.read). { module: "cancellation-evidence", diff --git a/packages/query-engine/src/ch/queries/audit-trail.ts b/packages/query-engine/src/ch/queries/audit-trail.ts index c3c4fb400e..b1a22ad807 100644 --- a/packages/query-engine/src/ch/queries/audit-trail.ts +++ b/packages/query-engine/src/ch/queries/audit-trail.ts @@ -2,9 +2,11 @@ // Every read is pinned to the managed route, like `audit_log`: the table is written through // `ingest` and does not exist in a BYO ClickHouse. +import { Schema } from "effect" import * as CH from "@maple-dev/effect-orm/expr" import { from, param } from "@maple-dev/effect-orm/clickhouse" import * as Datasources from "@maple/domain/tinybird/datasources" +import { CHNumber } from "../schema" import { orgIdParam } from "../tables" /** @@ -88,3 +90,39 @@ export function auditTrailEventsQuery(opts: AuditTrailEventsOpts) { .route("ingest") ) } + +/** Which optional filters a count applies; each set flag binds a parameter of the same name. */ +export interface AuditTrailCountOpts { + readonly provider?: boolean + readonly category?: boolean + readonly outcome?: boolean + readonly action?: boolean +} + +export const auditTrailCountRowSchema = Schema.Struct({ events: CHNumber }) + +/** How far back an event may have happened and still be counted when it arrives. */ +const AUDIT_TRAIL_LATE_ARRIVAL_SECONDS = 7 * 24 * 60 * 60 + +/** + * How many of one org's events arrived in `(start, end]`, for a detection. Distinct ids, not + * rows: a redelivery is a second row until the table merges it. Params: orgId, start, end. + */ +export function auditTrailCountQuery(opts: AuditTrailCountOpts = {}) { + return from(Datasources.auditTrailEvents) + .select(($) => ({ events: CH.uniqExact($.Id) })) + .where(($) => [ + $.OrgId.eq(orgIdParam), + $.IngestedAt.gt(param.dateTime("start")), + $.IngestedAt.lte(param.dateTime("end")), + // Prunes partitions and the sorting key, which follow OccurredAt. An event can + // arrive late; a week covers every provider's delivery delay. + $.OccurredAt.gte(CH.intervalSub(param.dateTime("start"), AUDIT_TRAIL_LATE_ARRIVAL_SECONDS)), + CH.whenTrue(!!opts.provider, () => $.Provider.eq(param.string("provider"))), + CH.whenTrue(!!opts.category, () => $.Category.eq(param.string("category"))), + CH.whenTrue(!!opts.outcome, () => $.Outcome.eq(param.string("outcome"))), + CH.whenTrue(!!opts.action, () => $.Action.eq(param.string("action"))), + ]) + .format("JSON") + .route("ingest") +} From 80d2c180a3171fe0a0e2fe447ad7b6982a914f5e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 16:36:29 +0100 Subject: [PATCH 19/30] feat(security): audit log detections, alert rules that count audit events A detection is a hidden alert rule with signal type audit, written only through the audit trail API by callers who hold audit_log:read. AuditDetectionsService owns the rule row the way an SLO owns its burn-rate rule: destinations and the name are checked under the org's alert rule lock, an edit ends the tick's claim, and a delete takes the rule's state, deliveries and incidents with it. The evaluator counts the events that arrived in the rule's window through AuditTrailService.count. A count is a fact even at zero, so an empty window is healthy and resolves, and a count that could not be read is an evaluation failure that leaves an open incident open. Notifications carry the count as their detail and never an event. --- .../src/routes/internal/audit-trail.http.ts | 185 +++++++---- .../v2/alchemy-provider.integration.test.ts | 2 + .../src/routes/v2/alert-rule-read-families.ts | 5 +- apps/api/src/routes/v2/alerts.http.test.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + .../Maple/Components/AlertFormatting.swift | 2 + .../MapleAPI/Sources/MapleAPI/openapi.json | 3 +- .../src/content/docs/reference/webhooks.md | 46 +-- apps/web/src/page/alerts/incident/view.ts | 65 ++-- .../services/alerts/AlertDeliveryDispatch.ts | 3 + .../src/services/alerts/AlertRuleModel.ts | 38 ++- .../src/services/alerts/AlertsService.ts | 21 ++ .../src/services/alerts/alert-formatting.ts | 8 +- .../services/alerts/alert-signal-display.ts | 1 + .../alerts/alerts-service-test-support.ts | 4 + .../audit-trail/AuditDetectionsService.ts | 306 ++++++++++++++++++ .../services/audit-trail/AuditTrailService.ts | 37 ++- .../audit-trail/audit-detection-rule.ts | 118 +++++++ packages/web-data/src/alerts/form-utils.ts | 4 + packages/web-data/src/collections/alerts.ts | 1 + 20 files changed, 734 insertions(+), 119 deletions(-) create mode 100644 packages/backend/src/services/audit-trail/AuditDetectionsService.ts create mode 100644 packages/backend/src/services/audit-trail/audit-detection-rule.ts diff --git a/apps/api/src/routes/internal/audit-trail.http.ts b/apps/api/src/routes/internal/audit-trail.http.ts index a29c5acd75..ddfec81108 100644 --- a/apps/api/src/routes/internal/audit-trail.http.ts +++ b/apps/api/src/routes/internal/audit-trail.http.ts @@ -7,11 +7,14 @@ import { } from "@maple/domain/http" import type { AuditTrailEntry } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { AuditDetectionsService } from "@maple/backend/services/audit-trail/AuditDetectionsService" import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import type { OrgMember } from "@maple/backend/services/org/OrgMembersService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { Effect, Array as Arr, Option, String as Str } from "effect" import { orgMemberDirectory, orgMemberDirectoryOrFail } from "@/routes/org-member-directory" +import { auditDiff } from "@/routes/v2/audit-changes" const DEFAULT_PAGE_LIMIT = 50 @@ -50,70 +53,132 @@ const memberByEmail = (directory: ReadonlyMap, email: string) (member) => Str.toLowerCase(member.email) === Str.toLowerCase(email), ) +const detectionAuditDiff = auditDiff({ + fields: ["name", "enabled", "filters", "windowMinutes", "threshold", "severity", "destinationIds"], +}) + +/** + * The tenant, once it may read the audit log. Detections sit behind the same gate as the events: + * a rule that counts them says what the log holds, so only the people who may read it write one. + */ +const requireAuditLogRead = Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Policy.check( + tenant.roles, + "audit_log:read", + (denial) => new AuditTrailForbiddenError({ message: denial.message }), + ) + return tenant +}) + export const HttpAuditTrailLive = HttpApiBuilder.group(MapleInternalApi, "auditTrail", (handlers) => Effect.gen(function* () { const trail = yield* AuditTrailService + const detections = yield* AuditDetectionsService const members = yield* OrgMembersService - return handlers.handle("list", ({ query }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - // Every platform's actor emails and origin IPs: the same gate as Maple's own audit log. - yield* Policy.check( - tenant.roles, - "audit_log:read", - (denial) => new AuditTrailForbiddenError({ message: denial.message }), - ) - // Only Maple's own events store a user id where the page shows an email. - const emailActor = - query.actor !== undefined && - Str.includes("@")(query.actor) && - (query.provider === undefined || query.provider === "maple") - ? query.actor - : undefined - // The filter's answer depends on this lookup, so an outage fails the request: a - // page that silently left the member's Maple events out would read as complete. - const filterDirectory = - emailActor === undefined - ? undefined - : yield* orgMemberDirectoryOrFail(members, tenant.orgId).pipe( - Effect.mapError( - (error) => - new AuditTrailPersistenceError({ - message: - "The member directory is unavailable, so an email cannot be matched to a member", - cause: error._tag, - }), - ), - ) - const actorAlias = - emailActor === undefined || filterDirectory === undefined - ? Option.none() - : memberByEmail(filterDirectory, emailActor) - const page = yield* trail.list(tenant.orgId, { - ...(query.provider !== undefined ? { provider: query.provider } : undefined), - ...(query.account !== undefined ? { account: query.account } : undefined), - ...(query.category !== undefined ? { category: query.category } : undefined), - ...(query.outcome !== undefined ? { outcome: query.outcome } : undefined), - ...(query.action !== undefined ? { action: query.action } : undefined), - ...(query.actor !== undefined ? { actor: query.actor } : undefined), - ...(Option.isSome(actorAlias) ? { actorAlias: actorAlias.value.userId } : undefined), - ...(query.since !== undefined ? { sinceMs: query.since } : undefined), - ...(query.until !== undefined ? { untilMs: query.until } : undefined), - ...(query.cursor !== undefined ? { before: parseCursor(query.cursor) } : undefined), - limit: query.limit ?? DEFAULT_PAGE_LIMIT, - }) - // Names are decoration: without a directory the page still shows every id. - const directory = - filterDirectory ?? - (Arr.some(page.events, (event) => unnamedMapleUserId(event) !== null) - ? yield* orgMemberDirectory(members, tenant.orgId) - : new Map()) - return { - events: nameMapleUsers(page.events, directory), - nextCursor: page.next === null ? null : `${page.next.occurredAtMs}:${page.next.id}`, - } - }), - ) + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + // Every platform's actor emails and origin IPs: the same gate as Maple's own audit log. + const tenant = yield* requireAuditLogRead + // Only Maple's own events store a user id where the page shows an email. + const emailActor = + query.actor !== undefined && + Str.includes("@")(query.actor) && + (query.provider === undefined || query.provider === "maple") + ? query.actor + : undefined + // The filter's answer depends on this lookup, so an outage fails the request: a + // page that silently left the member's Maple events out would read as complete. + const filterDirectory = + emailActor === undefined + ? undefined + : yield* orgMemberDirectoryOrFail(members, tenant.orgId).pipe( + Effect.mapError( + (error) => + new AuditTrailPersistenceError({ + message: + "The member directory is unavailable, so an email cannot be matched to a member", + cause: error._tag, + }), + ), + ) + const actorAlias = + emailActor === undefined || filterDirectory === undefined + ? Option.none() + : memberByEmail(filterDirectory, emailActor) + const page = yield* trail.list(tenant.orgId, { + ...(query.provider !== undefined ? { provider: query.provider } : undefined), + ...(query.account !== undefined ? { account: query.account } : undefined), + ...(query.category !== undefined ? { category: query.category } : undefined), + ...(query.outcome !== undefined ? { outcome: query.outcome } : undefined), + ...(query.action !== undefined ? { action: query.action } : undefined), + ...(query.actor !== undefined ? { actor: query.actor } : undefined), + ...(Option.isSome(actorAlias) ? { actorAlias: actorAlias.value.userId } : undefined), + ...(query.since !== undefined ? { sinceMs: query.since } : undefined), + ...(query.until !== undefined ? { untilMs: query.until } : undefined), + ...(query.cursor !== undefined ? { before: parseCursor(query.cursor) } : undefined), + limit: query.limit ?? DEFAULT_PAGE_LIMIT, + }) + // Names are decoration: without a directory the page still shows every id. + const directory = + filterDirectory ?? + (Arr.some(page.events, (event) => unnamedMapleUserId(event) !== null) + ? yield* orgMemberDirectory(members, tenant.orgId) + : new Map()) + return { + events: nameMapleUsers(page.events, directory), + nextCursor: page.next === null ? null : `${page.next.occurredAtMs}:${page.next.id}`, + } + }), + ) + .handle("listDetections", () => + Effect.gen(function* () { + const tenant = yield* requireAuditLogRead + return { detections: yield* detections.list(tenant.orgId) } + }), + ) + .handle("createDetection", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* requireAuditLogRead + const created = yield* detections.create(tenant.orgId, { + ...payload, + createdBy: tenant.userId, + }) + yield* recordHttpAudit("alert_rule.created", { + resourceId: created.id, + metadata: { name: created.name, signalType: "audit" }, + }) + return created + }), + ) + .handle("updateDetection", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* requireAuditLogRead + const current = yield* detections.get(tenant.orgId, params.id) + const updated = yield* detections.update(tenant.orgId, params.id, { + ...payload, + updatedBy: tenant.userId, + }) + yield* recordHttpAudit("alert_rule.updated", { + resourceId: updated.id, + changes: detectionAuditDiff(payload, current, updated), + metadata: { name: updated.name, signalType: "audit" }, + }) + return updated + }), + ) + .handle("deleteDetection", ({ params }) => + Effect.gen(function* () { + const tenant = yield* requireAuditLogRead + const id = yield* detections.remove(tenant.orgId, params.id) + yield* recordHttpAudit("alert_rule.deleted", { + resourceId: id, + metadata: { signalType: "audit" }, + }) + return { id } + }), + ) }), ) diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index 8eabd3e17d..7bd7182ae7 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -47,6 +47,7 @@ import { AlertRuntime, AlertsService } from "@maple/backend/services/alerts/Aler import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource" import { AnomalyAlertSource } from "@maple/backend/services/alerts/anomaly/AnomalyAlertSource" import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" @@ -206,6 +207,7 @@ const makeHarness = () => { Layer.provide(Layer.effect(SloReadService, SloReadService.make)), Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)), ), + Layer.effect(AuditTrailService, AuditTrailService.make).pipe(Layer.provide(warehouseLive)), ), ), ) diff --git a/apps/api/src/routes/v2/alert-rule-read-families.ts b/apps/api/src/routes/v2/alert-rule-read-families.ts index 8b92667da9..c759e6ce6b 100644 --- a/apps/api/src/routes/v2/alert-rule-read-families.ts +++ b/apps/api/src/routes/v2/alert-rule-read-families.ts @@ -18,11 +18,12 @@ export const ruleReadFamilies = ( case "apdex": case "throughput": return ["traces"] - // No query of their own: an anomaly rule reads open incidents, and uptime and SLO rules - // are refused by the generic rule APIs before anything runs. + // No query of their own: an anomaly rule reads open incidents, and uptime, SLO and + // audit detection rules are refused by the generic rule APIs before anything runs. case "anomaly": case "uptime": case "slo_burn_rate": + case "audit": return [] case "builder_query": { const source = rule.queryBuilderDraft?.dataSource diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 3870879674..65bc9f6550 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -45,6 +45,7 @@ import { AlertRuntime, AlertsService } from "@maple/backend/services/alerts/Aler import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource" import { AnomalyAlertSource } from "@maple/backend/services/alerts/anomaly/AnomalyAlertSource" import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" @@ -219,6 +220,7 @@ const makeHarness = ( Layer.provide(Layer.effect(SloReadService, SloReadService.make)), Layer.provide(Layer.mergeAll(testDb.layer, warehouseLive)), ), + Layer.effect(AuditTrailService, AuditTrailService.make).pipe(Layer.provide(warehouseLive)), ), ), ) diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index cf06ef1b67..1e1d341940 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -107,6 +107,7 @@ import { ProductEventsService } from "@maple/backend/services/product-events/Pro import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" +import { AuditDetectionsService } from "@maple/backend/services/audit-trail/AuditDetectionsService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" import { TelemetryReadService } from "@maple/backend/services/warehouse/TelemetryReadService" @@ -190,6 +191,7 @@ export const HttpServicesLive = Layer.mergeAll( ServiceMapWorldService.layer, AuditLogService.layer, AuditTrailService.layer, + AuditDetectionsService.layer, WarehouseQueryService.layer, QueryEngineService.layer, RuntimeReleaseCatalog.layer, diff --git a/apps/ios/Maple/Components/AlertFormatting.swift b/apps/ios/Maple/Components/AlertFormatting.swift index 3e63f24353..1d977b569e 100644 --- a/apps/ios/Maple/Components/AlertFormatting.swift +++ b/apps/ios/Maple/Components/AlertFormatting.swift @@ -56,6 +56,8 @@ struct SignalDisplay: Codable { case .sloBurnRate: self.init(label: "SLO burn rate", unit: .burnRate) // An anomaly rule: the value is a severity code, shown as Warning or Critical. case .anomaly: self.init(label: "Anomaly", unit: .anomalySeverity) + // An audit log detection's hidden rule: the value is how many matching events arrived. + case .audit: self.init(label: "Audit log", unit: .count) } } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 179457999a..32088233a0 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7224,7 +7224,8 @@ "raw_query", "uptime", "slo_burn_rate", - "anomaly" + "anomaly", + "audit" ], "examples": [ "error_rate" diff --git a/apps/landing/src/content/docs/reference/webhooks.md b/apps/landing/src/content/docs/reference/webhooks.md index 9ca45fc2c0..e6413c6e65 100644 --- a/apps/landing/src/content/docs/reference/webhooks.md +++ b/apps/landing/src/content/docs/reference/webhooks.md @@ -64,29 +64,29 @@ Respond with any `2xx` within **15 seconds**. The response body is ignored. } ``` -| Field | Description | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventType` | Same as the `x-maple-event-type` header | -| `incidentId` | The incident this event belongs to. `null` for tests. | -| `incidentStatus` | `open` or `resolved` | -| `dedupeKey` | Stable for one incident across `trigger`, `renotify` and `resolve`. Use it to thread events together. | -| `rule.signalType` | `error_rate`, `p95_latency`, `p99_latency`, `apdex`, `throughput`, `builder_query`, `raw_query` or `anomaly`; `uptime` for an uptime monitor and `slo_burn_rate` for an SLO burn-rate alert | -| `rule.severity` | `warning` or `critical` | -| `rule.groupKey` | The group that breached, such as a service name. `__total__` for an ungrouped rule. | -| `rule.comparator` | `gt`, `gte`, `lt`, `lte`, `eq`, `neq`, `between` or `not_between` | -| `rule.threshold` | The threshold, or the lower bound for `between` and `not_between` | -| `rule.thresholdUpper` | The upper bound for `between` and `not_between`, otherwise `null` | -| `rule.windowMinutes` | The evaluation window | -| `rule.severities` | The severities of the rule's threshold tiers, such as `["warning", "critical"]`. May be absent | -| `peakSeverity` | The highest severity the incident reached, or `null` | -| `observed.value` | The value the check measured | -| `observed.sampleCount` | How many data points the value was computed from | -| `template` | The rule's rendered notification template, or `null` | -| `chart` | `url` of a chart image and/or a `sparkline`, or `null` | -| `linkUrl` | The incident in the Maple dashboard | -| `chatUrl` | Opens the alert rule in Maple, or the alerts list for a test | -| `sentAt` | ISO-8601 time the event was first generated. Retries keep the original value. | -| `event` | The same event as a [CloudEvents 1.0](https://cloudevents.io) envelope, for routers that speak CloudEvents | +| Field | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventType` | Same as the `x-maple-event-type` header | +| `incidentId` | The incident this event belongs to. `null` for tests. | +| `incidentStatus` | `open` or `resolved` | +| `dedupeKey` | Stable for one incident across `trigger`, `renotify` and `resolve`. Use it to thread events together. | +| `rule.signalType` | `error_rate`, `p95_latency`, `p99_latency`, `apdex`, `throughput`, `builder_query`, `raw_query` or `anomaly`; `uptime` for an uptime monitor, `slo_burn_rate` for an SLO burn-rate alert and `audit` for an audit log detection | +| `rule.severity` | `warning` or `critical` | +| `rule.groupKey` | The group that breached, such as a service name. `__total__` for an ungrouped rule. | +| `rule.comparator` | `gt`, `gte`, `lt`, `lte`, `eq`, `neq`, `between` or `not_between` | +| `rule.threshold` | The threshold, or the lower bound for `between` and `not_between` | +| `rule.thresholdUpper` | The upper bound for `between` and `not_between`, otherwise `null` | +| `rule.windowMinutes` | The evaluation window | +| `rule.severities` | The severities of the rule's threshold tiers, such as `["warning", "critical"]`. May be absent | +| `peakSeverity` | The highest severity the incident reached, or `null` | +| `observed.value` | The value the check measured | +| `observed.sampleCount` | How many data points the value was computed from | +| `template` | The rule's rendered notification template, or `null` | +| `chart` | `url` of a chart image and/or a `sparkline`, or `null` | +| `linkUrl` | The incident in the Maple dashboard | +| `chatUrl` | Opens the alert rule in Maple, or the alerts list for a test | +| `sentAt` | ISO-8601 time the event was first generated. Retries keep the original value. | +| `event` | The same event as a [CloudEvents 1.0](https://cloudevents.io) envelope, for routers that speak CloudEvents | The body is identical for every destination's webhook, regardless of the rule's notification template. Treat unknown fields as additive: new fields can appear without notice. diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f9ddf2018..baa1208b6f 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -272,6 +272,9 @@ export type IncidentBodyInputs = Readonly<{ anomaly?: AnomalyEvidenceInputs | null }> +/** Where an audit detection's events are listed; only org admins can open it. */ +const auditLogHref = () => hrefOf(AppRoute.SecurityAuditLog({ search: {} })) + /** A managed incident's note on what opened it, and a link to its SLO or monitor (its tab until known). */ const sourceNote = ( h: HtmlBuilder, @@ -332,31 +335,45 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs label: "Open SLO", fallback: { href: slosTabHref(), label: "Open SLOs" }, }) - : rule === null - ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ - "The rule behind this incident was deleted, so its signal cannot be replayed.", - ]) - : ruleChart(h, { - chartId: inputs.chartId, - chart: inputs.chart, - toChartMessage: inputs.toChartMessage, - colors: inputs.colors, - timeZone: inputs.timeZone, - preview: inputs.preview, - checks: inputs.checks, - incidents: [incident], - threshold: incident.threshold, - thresholdUpper: incident.thresholdUpper, - comparator: incident.comparator, - signalType: incident.signalType, - window: { - min: inputs.range.start, - max: inputs.range.end, + : incident.signalType === "audit" + ? sourceNote(h, { + text: "This incident follows an audit log detection: it opened when the matching events that arrived within the detection's window reached its count, and resolves when they fall back below it. The audit log lists the events.", + href: auditLogHref(), + label: "Open audit log", + fallback: { + href: auditLogHref(), + label: "Open audit log", }, - source: "preview", - isLoading: inputs.isPreviewLoading, - error: inputs.previewError, - }), + }) + : rule === null + ? typography( + h, + { size: "sm", tone: "muted", as: "p" }, + [ + "The rule behind this incident was deleted, so its signal cannot be replayed.", + ], + ) + : ruleChart(h, { + chartId: inputs.chartId, + chart: inputs.chart, + toChartMessage: inputs.toChartMessage, + colors: inputs.colors, + timeZone: inputs.timeZone, + preview: inputs.preview, + checks: inputs.checks, + incidents: [incident], + threshold: incident.threshold, + thresholdUpper: incident.thresholdUpper, + comparator: incident.comparator, + signalType: incident.signalType, + window: { + min: inputs.range.start, + max: inputs.range.end, + }, + source: "preview", + isLoading: inputs.isPreviewLoading, + error: inputs.previewError, + }), ], ), incidentTimeline(h, { diff --git a/packages/backend/src/services/alerts/AlertDeliveryDispatch.ts b/packages/backend/src/services/alerts/AlertDeliveryDispatch.ts index f36615fbaf..1761d81c32 100644 --- a/packages/backend/src/services/alerts/AlertDeliveryDispatch.ts +++ b/packages/backend/src/services/alerts/AlertDeliveryDispatch.ts @@ -84,6 +84,9 @@ export const buildSummaryLine = (context: SummaryLineContext, em: (value: string // An SLO's check names both windows and the tier, which one value and window cannot. if (context.signalType === "slo_burn_rate" && context.detail != null && context.eventType !== "test") return em(context.detail) + // A detection's check already says how many events arrived and over which window. + if (context.signalType === "audit" && context.detail != null && context.eventType !== "test") + return em(context.detail) const signal = formatSignalLabel(context) const observed = formatSignalMetric(context.value, signalDisplayOf(context)) const window = formatWindow(context.windowMinutes) diff --git a/packages/backend/src/services/alerts/AlertRuleModel.ts b/packages/backend/src/services/alerts/AlertRuleModel.ts index 683b43448c..e4bfcfebd8 100644 --- a/packages/backend/src/services/alerts/AlertRuleModel.ts +++ b/packages/backend/src/services/alerts/AlertRuleModel.ts @@ -41,6 +41,7 @@ import { isManagedSignalType, type ManagedSignalType, } from "@maple/domain/http" +import { AuditDetectionFilters } from "@maple/domain/audit-trail" import * as PG from "@maple-dev/effect-orm/postgres" import { AlertRules, type AlertRuleRow } from "@maple/db/tables" import { DateTime, Array as Arr, Effect, Match, Result, Schema, String as Str } from "effect" @@ -57,6 +58,7 @@ type StoredJsonColumn = | "tagsJson" | "destinationIdsJson" | "thresholdsJson" + | "auditFiltersJson" /** * An `alert_rules` row with its schema-typed jsonb columns read as `unknown`. The model @@ -76,6 +78,8 @@ export const selectStoredAlertRules = () => tagsJson: PG.undecoded($.tagsJson), destinationIdsJson: PG.undecoded($.destinationIdsJson), thresholdsJson: PG.undecoded($.thresholdsJson), + // Undecoded so one detection with filters this build cannot read fails alone. + auditFiltersJson: PG.undecoded($.auditFiltersJson), })) const DestinationIdArraySchema = Schema.Array(AlertDestinationDocument.fields.id) const AlertGroupByFromJson = Schema.fromJsonString(AlertGroupBySchema) @@ -105,6 +109,8 @@ export interface NormalizedRule { readonly environments: ReadonlyArray /** Anomaly rules only: detector signals in scope; empty is every signal. */ readonly anomalySignals: ReadonlyArray + /** Audit detections only: which events the rule counts; empty is every event. */ + readonly auditFilters: AuditDetectionFilters readonly tags: ReadonlyArray readonly groupBy: AlertGroupBy | null readonly signalType: AlertSignalType @@ -235,13 +241,26 @@ const planGroupingTokens = ( /** Why the generic rule APIs refuse a rule another resource owns. */ export const managedRuleMessage = (signalType: ManagedSignalType): string => - signalType === "uptime" - ? "uptime rules belong to an uptime monitor; create, edit or delete the monitor instead" - : "slo_burn_rate rules belong to an SLO; create, edit or delete the SLO instead" + Match.value(signalType).pipe( + Match.when( + "uptime", + () => "uptime rules belong to an uptime monitor; create, edit or delete the monitor instead", + ), + Match.when( + "slo_burn_rate", + () => "slo_burn_rate rules belong to an SLO; create, edit or delete the SLO instead", + ), + Match.when( + "audit", + () => + "audit rules belong to an audit log detection; create, edit or delete the detection in the Security hub instead", + ), + Match.exhaustive, + ) /** - * The stored plan of every queryless rule (`uptime`, `slo_burn_rate`, `anomaly`): the evaluator - * reads the owning resource or the anomaly detector's incidents instead. + * The stored plan of every queryless rule (`uptime`, `slo_burn_rate`, `audit`, `anomaly`): the + * evaluator reads the owning resource or the anomaly detector's incidents instead. */ export const MANAGED_RULE_PLAN = new CompiledAlertQueryPlan({ kind: "spec", @@ -646,6 +665,12 @@ export const decodeStoredAlertRuleMetadata = (row: StoredAlertRuleRow) => ), }) +/** An audit detection's filters. A detection stored without any counts every event. */ +const decodeStoredAuditFilters = (row: StoredAlertRuleRow) => + row.signalType !== "audit" || row.auditFiltersJson == null + ? Effect.succeed({}) + : decodeStoredRuleComponent(row.id, "audit_filters", AuditDetectionFilters, row.auditFiltersJson) + export const decodeStoredAlertRuleDestinationIds = ( ruleId: AlertRuleId, value: unknown, @@ -804,6 +829,7 @@ export const makeAlertRuleNormalizer = (runtime: AlertRuntimeApi) => { const destinationIds = yield* decodeStoredAlertRuleDestinationIds(row.id, row.destinationIdsJson) const thresholds = yield* decodeStoredAlertRuleThresholds(row, destinationIds) const legacy = alertThresholdLegacyFields(thresholds) + const auditFilters = yield* decodeStoredAuditFilters(row) return { id: decoded.id, name: row.name, @@ -816,6 +842,7 @@ export const makeAlertRuleNormalizer = (runtime: AlertRuntimeApi) => { excludeServiceNames: stored.excludeServiceNames, environments: stored.environments, anomalySignals: row.anomalySignalsJson ?? [], + auditFilters, tags: stored.tags, groupBy: decoded.groupBy, signalType: decoded.signalType, @@ -1040,6 +1067,7 @@ export const makeAlertRuleNormalizer = (runtime: AlertRuntimeApi) => { excludeServiceNames, environments, anomalySignals, + auditFilters: {}, tags, groupBy, signalType: request.signalType, diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index bea2a97d4d..bef4d75b4f 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -193,6 +193,8 @@ import { AlertReadModelsService } from "./AlertReadModelsService" import { makeAlertRulePersistence } from "./AlertRulesService" import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource" import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" +import { auditCountReason } from "@maple/backend/services/audit-trail/audit-detection-rule" import { AnomalyAlertSource, type AnomalyAlertObservation } from "./anomaly/AnomalyAlertSource" import { evaluateSloBurn } from "@maple/backend/services/slos/slo-alert-rule" import { @@ -631,6 +633,7 @@ export class AlertsService extends Context.Service = yield* queryEngine.evaluate( @@ -6295,6 +6315,7 @@ export class AlertsService extends Context.Service): str /** Whether a rule's notifications carry the evaluator's reason as their `detail`. */ export const signalCarriesDetail = (signalType: string): boolean => - signalType === "uptime" || signalType === "slo_burn_rate" || signalType === "anomaly" + signalType === "uptime" || + signalType === "slo_burn_rate" || + signalType === "anomaly" || + signalType === "audit" /** An anomaly rule's value is its severity code; what it says is the detector's sentence. */ const anomalyObserved = (context: Pick): string => @@ -233,7 +236,8 @@ export const formatObservedSummary = (context: ObservedContext): string => ? uptimeObserved(context) : context.signalType === "anomaly" ? anomalyObserved(context) - : context.signalType === "slo_burn_rate" && context.detail != null + : (context.signalType === "slo_burn_rate" || context.signalType === "audit") && + context.detail != null ? context.detail : context.value === null ? `No data (threshold ${formatThresholdSummary(context)})` diff --git a/packages/backend/src/services/alerts/alert-signal-display.ts b/packages/backend/src/services/alerts/alert-signal-display.ts index b2009fb091..9d3109d530 100644 --- a/packages/backend/src/services/alerts/alert-signal-display.ts +++ b/packages/backend/src/services/alerts/alert-signal-display.ts @@ -51,6 +51,7 @@ const PRESET_SIGNALS: Record = { uptime: { label: "Uptime check", unit: "plain" }, slo_burn_rate: { label: "SLO burn rate", unit: "burn" }, anomaly: { label: "Anomaly", unit: "severity" }, + audit: { label: "Audit log", unit: "count" }, } satisfies Record /** diff --git a/packages/backend/src/services/alerts/alerts-service-test-support.ts b/packages/backend/src/services/alerts/alerts-service-test-support.ts index 879bbffa9f..16ab903d8e 100644 --- a/packages/backend/src/services/alerts/alerts-service-test-support.ts +++ b/packages/backend/src/services/alerts/alerts-service-test-support.ts @@ -37,6 +37,7 @@ import { AlertSilencesService } from "./AlertSilencesService" import { AlertMaintenanceWindowsService } from "./AlertMaintenanceWindowsService" import { AlertRulesService, type AlertRulesServiceApi } from "./AlertRulesService" import { SloAlertSource } from "@maple/backend/services/slos/SloAlertSource" +import { AuditTrailService } from "@maple/backend/services/audit-trail/AuditTrailService" import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSource" import { AnomalyAlertSource } from "@maple/backend/services/alerts/anomaly/AnomalyAlertSource" import { BucketCacheService } from "@maple/query-engine/caching" @@ -361,6 +362,9 @@ export const makeLayer = ( Layer.provide(Layer.mergeAll(databaseLive, warehouseLive)), ), ), + Layer.provide( + Layer.effect(AuditTrailService, AuditTrailService.make).pipe(Layer.provide(warehouseLive)), + ), Layer.provide(chatSessions === undefined ? Layer.empty : Layer.succeed(ChatSessions, chatSessions)), ) const alertSilencesLive = AlertSilencesService.layer.pipe( diff --git a/packages/backend/src/services/audit-trail/AuditDetectionsService.ts b/packages/backend/src/services/audit-trail/AuditDetectionsService.ts new file mode 100644 index 0000000000..16f943e6f3 --- /dev/null +++ b/packages/backend/src/services/audit-trail/AuditDetectionsService.ts @@ -0,0 +1,306 @@ +import { randomUUID } from "node:crypto" +import * as PG from "@maple-dev/effect-orm/postgres" +import type { MapleDb } from "@maple/db/client" +import { AlertDestinations, AlertRuleClaims, AlertRules } from "@maple/db/tables" +import type { AuditDetectionFilters } from "@maple/domain/audit-trail" +import { + type AlertSeverity, + type AuditDetection, + AuditDetectionInvalidError, + AuditDetectionNotFoundError, + AuditTrailPersistenceError, + MAX_AUDIT_DETECTIONS_PER_ORG, +} from "@maple/domain/http" +import { type AlertDestinationId, AlertRuleId, type OrgId, type UserId } from "@maple/primitives" +import { Array as Arr, Clock, Context, Effect, Layer, Option } from "effect" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { deleteRuleRows } from "@maple/backend/services/alerts/AlertRulesService" +import { lockOrgAlertRules } from "@maple/backend/services/alerts/alert-persistence" +import { + auditDetectionFromRow, + auditDetectionRuleFields, + type AuditDetectionRuleInput, +} from "./audit-detection-rule" + +export interface CreateAuditDetectionInput { + readonly name: string + readonly enabled?: boolean + readonly filters?: AuditDetectionFilters + readonly windowMinutes: number + readonly threshold: number + readonly severity: AlertSeverity + readonly destinationIds?: ReadonlyArray + readonly createdBy: UserId +} + +export interface UpdateAuditDetectionInput { + readonly name?: string + readonly enabled?: boolean + /** Replaces the stored filters whole. */ + readonly filters?: AuditDetectionFilters + readonly windowMinutes?: number + readonly threshold?: number + readonly severity?: AlertSeverity + readonly destinationIds?: ReadonlyArray + readonly updatedBy: UserId +} + +type DetectionReadError = AuditDetectionNotFoundError | AuditTrailPersistenceError + +export interface AuditDetectionsServiceApi { + /** One org's detections, newest first. */ + readonly list: (orgId: OrgId) => Effect.Effect, AuditTrailPersistenceError> + readonly get: (orgId: OrgId, id: AlertRuleId) => Effect.Effect + readonly create: ( + orgId: OrgId, + input: CreateAuditDetectionInput, + ) => Effect.Effect + readonly update: ( + orgId: OrgId, + id: AlertRuleId, + input: UpdateAuditDetectionInput, + ) => Effect.Effect + /** Deletes the rule with its state, incidents and deliveries, open incidents included. */ + readonly remove: (orgId: OrgId, id: AlertRuleId) => Effect.Effect +} + +const makeDetectionPersistenceError = makePersistenceErrorMapper( + AuditTrailPersistenceError, + "Audit detection persistence failed", +) + +const invalid = (message: string) => new AuditDetectionInvalidError({ message }) +const notFound = (detectionId: AlertRuleId) => + new AuditDetectionNotFoundError({ detectionId, message: "Detection not found" }) +const unreadable = () => new AuditTrailPersistenceError({ message: "The stored detection cannot be read" }) + +/** The columns a detection reads back from; add a `where` to it. */ +const selectDetections = () => + PG.from(AlertRules).select(($) => ({ + id: $.id, + name: $.name, + enabled: $.enabled, + auditFiltersJson: PG.undecoded($.auditFiltersJson), + windowMinutes: $.windowMinutes, + threshold: $.threshold, + thresholdsJson: $.thresholdsJson, + createdAt: $.createdAt, + updatedAt: $.updatedAt, + })) + +const selectDetection = (orgId: OrgId, id: AlertRuleId) => + selectDetections() + .where(($) => [$.orgId.eq(orgId), $.id.eq(id), $.signalType.eq("audit")]) + .limit(1) + +/** Checks a detection write: its destinations exist and its name is free among alert rules. */ +const claimRuleWrite = Effect.fnUntraced(function* ( + db: MapleDb, + orgId: OrgId, + ruleId: AlertRuleId, + rule: AuditDetectionRuleInput, +) { + if (rule.destinationIds.length > 0) { + const found = yield* db.run( + PG.from(AlertDestinations) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.in_(...rule.destinationIds)]), + ) + const known = new Set(Arr.map(found, (row) => row.id)) + const missing = Arr.findFirst(rule.destinationIds, (id) => !known.has(id)) + if (Option.isSome(missing)) return yield* invalid(`Unknown alert destination ${missing.value}`) + } + // Alert rule names are unique per org, across every kind of rule. + const sameName = yield* db.run( + PG.from(AlertRules) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.name.eq(rule.name), $.id.neq(ruleId)]) + .limit(1), + ) + if (sameName.length > 0) { + return yield* invalid(`An alert rule named '${rule.name}' already exists; pick another name`) + } +}) + +const sameIds = (a: ReadonlyArray, b: ReadonlyArray) => + a.length === b.length && Arr.every(a, (id, index) => id === b[index]) + +const sameFilters = (a: AuditDetectionFilters, b: AuditDetectionFilters) => + a.provider === b.provider && a.category === b.category && a.outcome === b.outcome && a.action === b.action + +/** Audit log detections: each one is a hidden `audit` alert rule, written only from here. */ +export class AuditDetectionsService extends Context.Service< + AuditDetectionsService, + AuditDetectionsServiceApi +>()("@maple/backend/services/audit-trail/AuditDetectionsService", { + make: Effect.gen(function* () { + const database = yield* Database + const dbExecute = makeDbExecute(database, "AuditDetectionsService", makeDetectionPersistenceError) + + const selectOne = Effect.fnUntraced(function* (orgId: OrgId, id: AlertRuleId) { + const rows = yield* dbExecute((db) => db.run(selectDetection(orgId, id))) + const row = rows[0] + if (row === undefined) return yield* notFound(id) + const detection = auditDetectionFromRow(row) + if (Option.isNone(detection)) return yield* unreadable() + return detection.value + }) + + const list = Effect.fn("AuditDetectionsService.list")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const rows = yield* dbExecute((db) => + db.run( + selectDetections() + .where(($) => [$.orgId.eq(orgId), $.signalType.eq("audit")]) + .orderBy(["createdAt", "desc"], ["id", "desc"]), + ), + ) + // A row this build cannot read is left out, so the rest of the list still shows. + return Arr.getSomes(Arr.map(rows, auditDetectionFromRow)) + }) + + const get = Effect.fn("AuditDetectionsService.get")(function* (orgId: OrgId, id: AlertRuleId) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.rule_id": id }) + return yield* selectOne(orgId, id) + }) + + const create = Effect.fn("AuditDetectionsService.create")(function* ( + orgId: OrgId, + input: CreateAuditDetectionInput, + ) { + yield* Effect.annotateCurrentSpan({ orgId }) + const now = yield* Clock.currentTimeMillis + const id = AlertRuleId.make(randomUUID()) + const rule: AuditDetectionRuleInput = { + name: input.name, + enabled: input.enabled ?? true, + filters: input.filters ?? {}, + windowMinutes: input.windowMinutes, + threshold: input.threshold, + severity: input.severity, + destinationIds: Arr.dedupe(input.destinationIds ?? []), + } + yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrgAlertRules(db, orgId) + const [existing] = yield* db.run( + PG.from(AlertRules) + .select(() => ({ count: PG.count() })) + .where(($) => [$.orgId.eq(orgId), $.signalType.eq("audit")]), + ) + if (Number(existing?.count ?? 0) >= MAX_AUDIT_DETECTIONS_PER_ORG) { + return yield* invalid( + `An organization can have at most ${MAX_AUDIT_DETECTIONS_PER_ORG} detections`, + ) + } + yield* claimRuleWrite(db, orgId, id, rule) + yield* db.run( + PG.insertInto(AlertRules).values({ + id, + orgId, + ...auditDetectionRuleFields(rule), + lastScheduledAt: null, + createdAt: now, + updatedAt: now, + createdBy: input.createdBy, + updatedBy: input.createdBy, + }), + ) + }), + ), + ) + return yield* selectOne(orgId, id).pipe( + Effect.catchTag("@maple/http/errors/AuditDetectionNotFoundError", () => + Effect.fail(new AuditTrailPersistenceError({ message: "Created detection not found" })), + ), + ) + }) + + const update = Effect.fn("AuditDetectionsService.update")(function* ( + orgId: OrgId, + id: AlertRuleId, + input: UpdateAuditDetectionInput, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.rule_id": id }) + const now = yield* Clock.currentTimeMillis + yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + // Lock, then re-read: the rule is rebuilt from a row no other write changes + // before commit, and a concurrent delete either finished first or waits. + yield* lockOrgAlertRules(db, orgId) + const rows = yield* db.run(selectDetection(orgId, id)) + const row = rows[0] + if (row === undefined) return yield* notFound(id) + const stored = auditDetectionFromRow(row) + if (Option.isNone(stored)) return yield* unreadable() + const locked = stored.value + const rule: AuditDetectionRuleInput = { + name: input.name ?? locked.name, + enabled: input.enabled ?? locked.enabled, + filters: input.filters ?? locked.filters, + windowMinutes: input.windowMinutes ?? locked.windowMinutes, + threshold: input.threshold ?? locked.threshold, + severity: input.severity ?? locked.severity, + destinationIds: + input.destinationIds === undefined + ? locked.destinationIds + : Arr.dedupe(input.destinationIds), + } + // The rule is written only when it changes: alert_rules is synced to browsers. + const changed = + rule.name !== locked.name || + rule.enabled !== locked.enabled || + !sameFilters(rule.filters, locked.filters) || + rule.windowMinutes !== locked.windowMinutes || + rule.threshold !== locked.threshold || + rule.severity !== locked.severity || + !sameIds(rule.destinationIds, locked.destinationIds) + if (!changed) return + yield* claimRuleWrite(db, orgId, id, rule) + // Ends a tick's claim on the old config, as a rule edit does. + yield* db.run(PG.deleteFrom(AlertRuleClaims).where(($) => [$.ruleId.eq(id)])) + yield* db.run( + PG.update(AlertRules) + .set({ + ...auditDetectionRuleFields(rule), + updatedAt: now, + updatedBy: input.updatedBy, + }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(id)]), + ) + }), + ), + ) + return yield* selectOne(orgId, id) + }) + + const remove = Effect.fn("AuditDetectionsService.remove")(function* (orgId: OrgId, id: AlertRuleId) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.rule_id": id }) + yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + yield* lockOrgAlertRules(db, orgId) + // Only a detection's rule: another kind of rule is deleted through its owner. + const rows = yield* db.run( + PG.from(AlertRules) + .select("id") + .where(($) => [$.orgId.eq(orgId), $.id.eq(id), $.signalType.eq("audit")]) + .limit(1), + ) + if (rows.length === 0) return yield* notFound(id) + // The rule delete's own cascade: state, deliveries and incidents go with it. + yield* deleteRuleRows(db, orgId, id) + }), + ), + ) + return id + }) + + return { list, get, create, update, remove } satisfies AuditDetectionsServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend/src/services/audit-trail/AuditTrailService.ts b/packages/backend/src/services/audit-trail/AuditTrailService.ts index a27854a288..4bf7c5e274 100644 --- a/packages/backend/src/services/audit-trail/AuditTrailService.ts +++ b/packages/backend/src/services/audit-trail/AuditTrailService.ts @@ -1,4 +1,4 @@ -import type { AuditTrailEvent } from "@maple/domain/audit-trail" +import type { AuditDetectionFilters, AuditTrailEvent } from "@maple/domain/audit-trail" import { AuditTrailPersistenceError } from "@maple/domain/http" import type { AuditTrailEntry } from "@maple/domain/http" import type { OrgId } from "@maple/primitives" @@ -48,6 +48,12 @@ export interface AuditTrailServiceApi { orgId: OrgId, filters: AuditTrailListFilters, ) => Effect.Effect + /** How many matching events arrived in `(startMs, endMs]`; a redelivered event counts once. */ + readonly count: ( + orgId: OrgId, + filters: AuditDetectionFilters, + window: { readonly startMs: number; readonly endMs: number }, + ) => Effect.Effect } const persistenceError = (message: string) => (error: { readonly _tag: string }) => @@ -146,7 +152,34 @@ export class AuditTrailService extends Context.Service