diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index 0b91bfc76..d5608448e 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -6,6 +6,7 @@ import { AlertRulesService, AlertsService, AnomalyDetectionService, + AuditLogService, BucketCacheService, CacheBackendLive, CloudflareAnalyticsService, @@ -144,7 +145,9 @@ export const buildLayer = (env: AlertingWorkerEnv) => { const ErrorActorsServiceLive = ErrorActorsService.layer.pipe(Layer.provide(BaseLive)) const ErrorIssueWorkflowServiceLive = ErrorIssueWorkflowService.layer.pipe( - Layer.provide(Layer.mergeAll(BaseLive, ErrorActorsServiceLive)), + Layer.provide( + Layer.mergeAll(BaseLive, ErrorActorsServiceLive, AuditLogService.layer.pipe(Layer.provide(BaseLive))), + ), ) const ErrorPolicyServiceLive = ErrorPolicyService.layer.pipe(Layer.provide(BaseLive)) const ErrorIssueReadModelsServiceLive = ErrorIssueReadModelsService.layer.pipe( diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index d0f692a33..e6b943f64 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -178,6 +178,8 @@ const apiConfiguredEnv = (stage: MapleStage) => // Agent LLM path. `MAPLE_LLM_PROVIDER` flips between OpenRouter (default) and // Workers AI; both stay wired, so a switch is this one var plus a redeploy. // See `@/platform/Llm` for the provider-scoped model overrides. + // Audit log retention horizon in days; the sweep defaults to 400 when unset. + optionalPlain("AUDIT_LOG_RETENTION_DAYS"), optionalPlain("MAPLE_LLM_PROVIDER"), optionalPlain("MAPLE_TRIAGE_MODEL_OPENROUTER"), optionalPlain("MAPLE_TRIAGE_MODEL_WORKERS_AI"), @@ -318,6 +320,15 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp const planetScaleWebhookQueue = yield* Cloudflare.Queues.Queue("planetscale-webhooks", { name: planetScaleWebhookQueueName, }) + const auditEventsQueueName = resolveWorkerName("audit-events", stage) + const auditEventsQueue = yield* Cloudflare.Queues.Queue("audit-events", { + name: auditEventsQueueName, + }) + // Parking lot for audit entries that exhausted their retries. Deliberately + // has no consumer: an entry landing here is a lost audit record, and the + // point is that it survives for inspection instead of being dropped. + const auditEventsDlqName = resolveWorkerName("audit-events-dlq", stage) + yield* Cloudflare.Queues.Queue("audit-events-dlq", { name: auditEventsDlqName }) const worker = (yield* Cloudflare.Worker("api", { name: resolveWorkerName("api", stage), @@ -369,6 +380,8 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp VCS_SYNC_QUEUE_NAME: vcsSyncQueueName, PLANETSCALE_WEBHOOK_QUEUE: planetScaleWebhookQueue, PLANETSCALE_WEBHOOK_QUEUE_NAME: planetScaleWebhookQueueName, + AUDIT_EVENTS_QUEUE: auditEventsQueue, + AUDIT_EVENTS_QUEUE_NAME: auditEventsQueueName, CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow, INVESTIGATION_FANOUT_WORKFLOW: investigationFanoutWorkflow, API_V2_RATE_LIMITER: Cloudflare.RateLimit("API_V2_RATE_LIMITER", { @@ -428,6 +441,21 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp maxWaitTimeMs: 5000, }, }) + // Audit entries tolerate a few seconds of delivery latency; batch wider and + // wait longer so one insert round-trip covers many entries. + yield* Cloudflare.Queues.Consumer("audit-events-consumer", { + queueId: auditEventsQueue.queueId, + scriptName: worker.workerName, + // `maxRetries` must stay in sync with AUDIT_EVENTS_MAX_RETRIES in + // audit-events-runtime.ts, which logs the drop on the final attempt. + deadLetterQueue: auditEventsDlqName, + settings: { + batchSize: 25, + maxConcurrency: 2, + maxRetries: 5, + maxWaitTimeMs: 5000, + }, + }) // `db` is undefined on ref stages — alerting resolves the same ref itself. return { worker, db: mapleDb } diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index f822ab0f0..230952228 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -4,6 +4,7 @@ export { AlertDestinationsService } from "./services/alerts/AlertDestinationsSer export { AlertReadModelsService } from "./services/alerts/AlertReadModelsService" export { AlertRulesService } from "./services/alerts/AlertRulesService" export { AnomalyDetectionService } from "./services/alerts/AnomalyDetectionService" +export { AuditLogService } from "./services/audit/AuditLogService" export { BucketCacheService } from "@maple/query-engine/caching" export { CacheBackendLive } from "@/platform/CacheBackendLive" export { CloudflareAnalyticsService } from "./services/integrations/CloudflareAnalyticsService" diff --git a/apps/api/src/audit-events-runtime.test.ts b/apps/api/src/audit-events-runtime.test.ts new file mode 100644 index 000000000..072c9456a --- /dev/null +++ b/apps/api/src/audit-events-runtime.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { OrgId } from "@maple/domain/primitives" +import { Effect, Layer, Schema } from "effect" +import { auditLogEntries } from "@maple/db" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { Database, DatabaseError } from "@/platform/DatabaseLive" +import { processAuditEventsBatch } from "./audit-events-runtime" +import { AuditLogEvent, encodeAuditLogEventSync } from "./services/audit/audit-event" + +const asOrgId = Schema.decodeUnknownSync(OrgId) +const ORG = asOrgId("org_audit_consumer_test") +const createdDbs: TestDb[] = [] + +afterEach(() => cleanupTestDbs(createdDbs)) + +const event = (id: string) => + encodeAuditLogEventSync( + new AuditLogEvent({ + orgId: ORG, + id: Schema.decodeUnknownSync(AuditLogEvent.fields.id)(id), + actorType: "user", + source: "dashboard", + action: "dashboard.created", + outcome: "allowed", + occurredAtMs: 1_700_000_000_000, + }), + ) + +/** One queue message, recording which terminal call the consumer made on it. */ +const message = (body: unknown, attempts: number) => { + const calls: string[] = [] + return { + message: { + body, + attempts, + ack: () => calls.push("ack"), + retry: () => calls.push("retry"), + }, + calls, + } +} + +const run = (effect: Effect.Effect) => { + const db = createTestDb(createdDbs) + return effect.pipe(Effect.provide(db.layer)) +} + +const batchOf = (...messages: ReadonlyArray<{ readonly message: unknown }>) => + ({ messages: messages.map((entry) => entry.message) }) as never + +/** + * A database whose every write fails, so the consumer's retry path is exercised + * without depending on a real Postgres fault. + */ +const failingDatabase = Layer.succeed(Database, { + execute: () => + Effect.fail(new DatabaseError({ message: "insert failed", cause: new Error("insert failed") })), +}) + +describe("processAuditEventsBatch", () => { + it.effect("inserts a well-formed event and acks it", () => + run( + Effect.gen(function* () { + const first = message(event("11111111-1111-4111-8111-111111111111"), 1) + yield* processAuditEventsBatch(batchOf(first)) + + expect(first.calls).toEqual(["ack"]) + const database = yield* Database + const rows = yield* database.execute((db) => db.select().from(auditLogEntries)) + expect(rows.map((row) => row.action)).toEqual(["dashboard.created"]) + }), + ), + ) + + // Redelivery is expected — the queue retries whole batches — so a second + // delivery of an already-inserted event must be a no-op, not a duplicate row. + it.effect("is idempotent across redelivery of the same event", () => + run( + Effect.gen(function* () { + const body = event("22222222-2222-4222-8222-222222222222") + yield* processAuditEventsBatch(batchOf(message(body, 1))) + yield* processAuditEventsBatch(batchOf(message(body, 2))) + + const database = yield* Database + const rows = yield* database.execute((db) => db.select().from(auditLogEntries)) + expect(rows).toHaveLength(1) + }), + ), + ) + + // Cloudflare routes a message to the DLQ only when the consumer retries it + // past `max_retries`. Acking on the final attempt would discard the entry + // instead, which is exactly the silent drop this branch exists to prevent. + it.effect("retries a failed insert on the final attempt so the message reaches the DLQ", () => + Effect.gen(function* () { + const exhausted = message(event("33333333-3333-4333-8333-333333333333"), 6) + yield* processAuditEventsBatch(batchOf(exhausted)) + + expect(exhausted.calls).toEqual(["retry"]) + }).pipe(Effect.provide(failingDatabase)), + ) + + it.effect("retries a failed insert while attempts remain", () => + Effect.gen(function* () { + const failed = message(event("44444444-4444-4444-8444-444444444444"), 2) + yield* processAuditEventsBatch(batchOf(failed)) + + expect(failed.calls).toEqual(["retry"]) + }).pipe(Effect.provide(failingDatabase)), + ) + + // A message that cannot decode will never decode. Retrying only burns the + // attempts that would otherwise carry a recoverable message to the DLQ. + it.effect("acks a malformed message instead of retrying it forever", () => + run( + Effect.gen(function* () { + const malformed = message({ not: "an audit event" }, 1) + yield* processAuditEventsBatch(batchOf(malformed)) + + expect(malformed.calls).toEqual(["ack"]) + const database = yield* Database + const rows = yield* database.execute((db) => db.select().from(auditLogEntries)) + expect(rows).toEqual([]) + }), + ), + ) +}) diff --git a/apps/api/src/audit-events-runtime.ts b/apps/api/src/audit-events-runtime.ts new file mode 100644 index 000000000..8e6c4a926 --- /dev/null +++ b/apps/api/src/audit-events-runtime.ts @@ -0,0 +1,120 @@ +import type { MessageBatch } from "@cloudflare/workers-types" +import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" +import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors" +import { WorkerConfigProviderLayer, WorkerEnvironment } from "@maple/effect-cloudflare" +import { auditLogEntries } from "@maple/db" +import { Clock, Effect, Layer } from "effect" +import { layerPg } from "@/platform/DatabasePgLive" +import { Database } from "@/platform/DatabaseLive" +import { auditEventToInsert, decodeAuditLogEvent } from "./services/audit/audit-event" + +const telemetry = MapleCloudflareSDK.make({ + serviceName: "maple-api", + serviceNamespace: "core", + repositoryUrl: "https://github.com/MapleTechLabs/maple", + anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS], +}) + +export const buildAuditEventsLayer = (_env: Record) => { + const DatabaseLive = layerPg.pipe(Layer.provide(WorkerEnvironment.layer)) + return DatabaseLive.pipe( + Layer.provideMerge(telemetry.layer), + Layer.provideMerge(WorkerEnvironment.layer), + Layer.provideMerge(WorkerConfigProviderLayer), + ) +} + +export const flushAuditEventsTelemetry = (env: Record) => telemetry.flush(env) + +/** + * Must match `maxRetries` on the audit-events consumer in `alchemy.run.ts` and + * `wrangler.jsonc`. Cloudflare routes the message to the DLQ after this many + * retries without telling us; the check below is what makes the hand-off + * visible in logs at the moment it happens. + */ +const AUDIT_EVENTS_MAX_RETRIES = 5 + +/** + * Best-effort identity for the exhaustion log. The body reached us as queue + * JSON and may be anything at all, so these read defensively rather than + * decoding — a drop must still be reported when the payload is the problem. + */ +const auditEventField = (body: unknown, field: string): string => { + if (typeof body !== "object" || body === null || !(field in body)) return "" + // SAFETY: `field in body` established the key exists on this object. + const value = (body as Record)[field] + return typeof value === "string" ? value : "" +} +const auditEventOrgId = (body: unknown) => auditEventField(body, "orgId") +const auditEventAction = (body: unknown) => auditEventField(body, "action") + +/** + * Audit events queue consumer: lowers each event to its `audit_log_entries` + * row. The `(org_id, id)` primary key plus `onConflictDoNothing` makes queue + * redelivery idempotent; insert failures retry through the queue's policy and, + * once exhausted, land in `audit-events-dlq` rather than disappearing. + */ +export const processAuditEventsBatch = (batch: MessageBatch) => + Effect.gen(function* () { + const database = yield* Database + yield* Effect.forEach( + batch.messages, + (message) => + decodeAuditLogEvent(message.body).pipe( + Effect.matchEffect({ + // Undecodable now means undecodable on every redelivery, so retrying + // only burns attempts. Acked, but at Error: an audit entry that + // never reaches a row is lost evidence, not routine noise. + onFailure: (error) => + Effect.logError("Discarding malformed audit event queue message").pipe( + Effect.annotateLogs({ attempt: message.attempts, error: String(error) }), + Effect.flatMap(() => Effect.sync(() => message.ack())), + ), + onSuccess: (event) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + yield* database.execute((db) => + db + .insert(auditLogEntries) + .values(auditEventToInsert(event, now)) + .onConflictDoNothing(), + ) + yield* Effect.sync(() => message.ack()) + }).pipe( + Effect.withSpan("auditEvents.processMessage"), + Effect.catchCause((cause) => { + // Retrying past the limit is what hands the message to the + // DLQ; acking here would silently discard it instead. + const isFinalAttempt = message.attempts > AUDIT_EVENTS_MAX_RETRIES + const outcome = isFinalAttempt ? "exhausted_dlq" : "retry" + return Effect.annotateCurrentSpan({ + "audit.queue.message.outcome": outcome, + }).pipe( + Effect.flatMap(() => + isFinalAttempt + ? Effect.logError( + "Audit event exhausted retries; routed to dead letter queue", + ).pipe( + Effect.annotateLogs({ + attempt: message.attempts, + orgId: auditEventOrgId(message.body), + action: auditEventAction(message.body), + error: String(cause), + }), + ) + : Effect.logWarning("Audit event insert failed; retrying").pipe( + Effect.annotateLogs({ + attempt: message.attempts, + error: String(cause), + }), + ), + ), + Effect.flatMap(() => Effect.sync(() => message.retry())), + ) + }), + ), + }), + ), + { concurrency: 5, discard: true }, + ) + }).pipe(Effect.withSpan("auditEvents.processBatch")) diff --git a/apps/api/src/mcp/app.ts b/apps/api/src/mcp/app.ts index 867536a2d..c7abd49cb 100644 --- a/apps/api/src/mcp/app.ts +++ b/apps/api/src/mcp/app.ts @@ -10,6 +10,8 @@ import { InstructionsResource } from "./resources/instructions" import { sessionStore } from "./lib/session-store" import type { McpToolExecutor } from "./dispatcher" import { CurrentMcpRequestTenant, CurrentMcpTenant, resolveHttpMcpTenant } from "./lib/query-warehouse" +import { type AuditActorInfo, CurrentAuditActor } from "@/services/auth/audit-actor" +import { INTERNAL_SERVICE_PREFIX } from "./lib/resolve-tenant" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { Env } from "@/platform/Env" @@ -93,6 +95,23 @@ const mcpUnavailable = () => ), ) +/** + * Which credential an MCP request presented, as far as the transport can tell. + * Mirrors the branches in `resolveMcpTenantContext`: an internal service token + * is Maple acting on its own behalf, any other bearer is an API key or OAuth + * token, and no bearer at all means a forwarded dashboard session. + */ +const mcpAuditActor = (headers: Record): AuditActorInfo => { + const authorization = headers["authorization"] ?? headers["Authorization"] + if (authorization?.toLowerCase().startsWith("bearer ") !== true) { + return { type: "user", source: "mcp" } + } + const bearer = authorization.slice("bearer ".length).trim() + return bearer.startsWith(INTERNAL_SERVICE_PREFIX) + ? { type: "system", source: "system" } + : { type: "api_key", source: "mcp" } +} + const McpAuthorizationMiddleware = HttpRouter.middleware<{ provides: CurrentMcpTenant }>()( Effect.gen(function* () { const apiKeys = yield* ApiKeysService @@ -108,6 +127,12 @@ const McpAuthorizationMiddleware = HttpRouter.middleware<{ provides: CurrentMcpT Effect.flatMap((tenant) => Effect.provideService(httpEffect, CurrentMcpTenant, tenant).pipe( Effect.provideService(CurrentMcpRequestTenant, tenant), + // Without this an MCP mutation reads the reference's `undefined` + // default and is audited as a dashboard session. The credential + // kind is all this layer can see — `resolveMcpTenantContext` + // returns the tenant, not the key it resolved — so the key id is + // deliberately absent rather than guessed. + Effect.provideService(CurrentAuditActor, mcpAuditActor(request.headers)), ), ), Effect.catchTags({ diff --git a/apps/api/src/mcp/lib/resolve-tenant.ts b/apps/api/src/mcp/lib/resolve-tenant.ts index 3e7168602..993877c08 100644 --- a/apps/api/src/mcp/lib/resolve-tenant.ts +++ b/apps/api/src/mcp/lib/resolve-tenant.ts @@ -14,7 +14,8 @@ import { import { recordExpectedMcpFailure } from "@/mcp/expected-failures" import { sessionStore } from "@/mcp/lib/session-store" -const INTERNAL_SERVICE_PREFIX = "maple_svc_" +/** Exported so the audit layer classifies the same token the same way. */ +export const INTERNAL_SERVICE_PREFIX = "maple_svc_" const decodeOrgId = Schema.decodeUnknownEffect(OrgId) const decodeUserId = Schema.decodeUnknownEffect(UserId) const decodeActorIdOption = Schema.decodeUnknownOption(ActorId) diff --git a/apps/api/src/mcp/tools/register-agent.ts b/apps/api/src/mcp/tools/register-agent.ts index be1f258cb..5e0006543 100644 --- a/apps/api/src/mcp/tools/register-agent.ts +++ b/apps/api/src/mcp/tools/register-agent.ts @@ -8,6 +8,7 @@ import { import { Effect, Option, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ErrorActorsService } from "@/services/errors/ErrorActorsService" const decodeStringArray = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.String))) @@ -58,6 +59,16 @@ export function registerRegisterAgentTool(server: McpToolRegistrar) { ), ) + const audit = yield* AuditLogService + yield* audit.record({ + orgId: tenant.orgId, + actor: { type: "user", userId: tenant.userId }, + source: "mcp", + action: "agent.registered", + resourceId: actor.id, + metadata: { name: actor.agentName ?? name }, + }) + const lines = [ `## Agent registered`, `- Actor ID: ${actor.id}`, diff --git a/apps/api/src/mcp/tools/runtime-requirements.ts b/apps/api/src/mcp/tools/runtime-requirements.ts index baf795ab8..8b4e1f3e3 100644 --- a/apps/api/src/mcp/tools/runtime-requirements.ts +++ b/apps/api/src/mcp/tools/runtime-requirements.ts @@ -1,3 +1,4 @@ +import type { AuditLogService } from "@/services/audit/AuditLogService" import type { AlertsService } from "@/services/alerts/AlertsService" import type { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" import type { AlertRulesService } from "@/services/alerts/AlertRulesService" @@ -22,6 +23,7 @@ import type { CurrentMcpTenant } from "../lib/query-warehouse" */ export type McpToolRuntimeRequirements = | AlertsService + | AuditLogService | AlertReadModelsService | AlertRulesService | DashboardPersistenceService diff --git a/apps/api/src/queue-dispatch.ts b/apps/api/src/queue-dispatch.ts index 77f3e320e..44502e140 100644 --- a/apps/api/src/queue-dispatch.ts +++ b/apps/api/src/queue-dispatch.ts @@ -1,4 +1,4 @@ -export type WorkerQueueKind = "planetscale-webhook" | "vcs-sync" | "unknown" +export type WorkerQueueKind = "planetscale-webhook" | "vcs-sync" | "audit-events" | "unknown" export const classifyWorkerQueue = (queueName: string, env: Record): WorkerQueueKind => { if ( @@ -10,5 +10,8 @@ export const classifyWorkerQueue = (queueName: string, env: Record Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.upsert(tenant.orgId, tenant.userId, tenant.roles, payload) + const updated = yield* service.upsert( + tenant.orgId, + tenant.userId, + tenant.roles, + payload, + ) + // URL/user/database identify the connection; the password in the + // payload is write-only and never reaches an audit row. + yield* recordHttpAudit("warehouse_settings.updated", { + metadata: { url: payload.url, user: payload.user, database: payload.database }, + }) + return updated }), ) .handle("schemaDiff", () => @@ -32,7 +44,9 @@ export const HttpOrgClickHouseSettingsLive = HttpApiBuilder.group( .handle("applySchema", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.applySchema(tenant.orgId, tenant.userId, tenant.roles) + const applied = yield* service.applySchema(tenant.orgId, tenant.userId, tenant.roles) + yield* recordHttpAudit("warehouse_settings.schema_applied") + return applied }), ) .handle("applySchemaStatus", () => @@ -50,7 +64,9 @@ export const HttpOrgClickHouseSettingsLive = HttpApiBuilder.group( .handle("delete", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* service.delete(tenant.orgId, tenant.roles) + const deleted = yield* service.delete(tenant.orgId, tenant.roles) + yield* recordHttpAudit("warehouse_settings.deleted") + return deleted }), ) }), diff --git a/apps/api/src/routes/v1/organizations.http.ts b/apps/api/src/routes/v1/organizations.http.ts index 231b7ce75..6c427c446 100644 --- a/apps/api/src/routes/v1/organizations.http.ts +++ b/apps/api/src/routes/v1/organizations.http.ts @@ -1,6 +1,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant, MapleApi } from "@maple/domain/http" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { OrganizationService } from "@/services/org/OrganizationService" export const HttpOrganizationsLive = HttpApiBuilder.group(MapleApi, "organizations", (handlers) => @@ -10,7 +11,12 @@ export const HttpOrganizationsLive = HttpApiBuilder.group(MapleApi, "organizatio return handlers.handle("delete", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - return yield* organizationService.delete(tenant.orgId, tenant.roles) + const deleted = yield* organizationService.delete(tenant.orgId, tenant.roles) + // Recorded after the fact so a refused delete cannot leave an entry + // claiming the org is gone. The row outlives the org: nothing + // cascades `audit_log_entries`, which is the point of a trail. + yield* recordHttpAudit("organization.deleted") + return deleted }), ) }), 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 2cf2cc363..28f4a286e 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -30,6 +30,7 @@ import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglit import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -174,6 +175,7 @@ const makeHarness = () => { Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index ea2660a9d..9f8325bba 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -1,5 +1,5 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" -import type { AlertDestinationDocument, AlertDestinationUpdateRequest } from "@maple/domain/http" +import type { AlertDestinationDocument, AlertDestinationUpdateRequest, AuditChanges } from "@maple/domain/http" import { CurrentTenant, DiscordAlertDestinationConfig, @@ -20,6 +20,7 @@ import type { } from "@maple/domain/http/v2" import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { AlertDestinationsService } from "@/services/alerts/AlertDestinationsService" const toV2Destination = (doc: AlertDestinationDocument): V2AlertDestination => ({ @@ -191,6 +192,64 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati } } +/** Credential-bearing config keys; their values must never reach the audit row. */ +const destinationSecretKeys = new Set(["integrationKey", "signingSecret", "url", "webhookUrl", "botToken"]) + +/** Fields of an update that are readable back off the destination document. */ +const destinationObservableValue = ( + doc: AlertDestinationDocument, + key: string, +): string | boolean | ReadonlyArray | null | undefined => { + switch (key) { + case "name": + return doc.name + case "enabled": + return doc.enabled + case "memberUserIds": + return doc.memberUserIds + default: + return undefined + } +} + +/** + * Diff an update against the pre/post documents. Secrets are recorded as + * ``; config knobs the wire doc doesn't echo (channel ids, chat ids) + * are recorded as touched with `` placeholders. + */ +const buildDestinationChanges = ( + request: AlertDestinationUpdateRequest, + before: AlertDestinationDocument | undefined, + after: AlertDestinationDocument, +): AuditChanges | undefined => { + const fields: string[] = [] + const beforeOut: Record = {} + const afterOut: Record = {} + for (const key of Object.keys(request)) { + if (key === "type") continue + const wireName = key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`) + if (destinationSecretKeys.has(key)) { + fields.push(wireName) + beforeOut[wireName] = "" + afterOut[wireName] = "" + continue + } + const prev = before === undefined ? undefined : destinationObservableValue(before, key) + const next = destinationObservableValue(after, key) + if (prev === undefined && next === undefined) { + fields.push(wireName) + beforeOut[wireName] = "" + afterOut[wireName] = "" + continue + } + if (JSON.stringify(prev) === JSON.stringify(next)) continue + fields.push(wireName) + beforeOut[wireName] = prev + afterOut[wireName] = next + } + return fields.length === 0 ? undefined : { fields, before: beforeOut, after: afterOut } +} + export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "alertDestinations", (handlers) => Effect.gen(function* () { const destinations = yield* AlertDestinationsService @@ -241,20 +300,35 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale toCreateRequest(payload), ) + yield* recordHttpAudit("alert_destination.created", { + resourceId: created.id, + metadata: { name: created.name, type: created.type }, + }) + return toV2DestinationMutation(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const request = toUpdateRequest(payload) + const existing = yield* destinations.listDestinations(tenant.orgId) + const current = existing.destinations.find((doc) => doc.id === params.id) const updated = yield* destinations.updateDestination( tenant.orgId, tenant.userId, tenant.roles, params.id, - toUpdateRequest(payload), + request, ) + const changes = buildDestinationChanges(request, current, updated) + yield* recordHttpAudit("alert_destination.updated", { + resourceId: updated.id, + changes, + metadata: { name: updated.name, type: updated.type }, + }) + return toV2DestinationMutation(updated) }), ) @@ -266,6 +340,9 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale tenant.roles, params.id, ) + yield* recordHttpAudit("alert_destination.deleted", { + resourceId: deleted.id, + }) return { id: deleted.id, diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 73d7e3d84..abfb40c5b 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -19,6 +19,8 @@ import type { import { MapleApiV2, paginateArray, scopeAllows, timestamp, V2ParameterInvalid } from "@maple/domain/http/v2" import { AlertForbiddenError } from "@maple/domain/http" import { Effect, Encoding, Result, Schema } from "effect" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { AlertsService } from "@/services/alerts/AlertsService" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@/services/alerts/AlertRulesService" @@ -94,6 +96,38 @@ const toV2Rule = (doc: AlertRuleDocument): V2AlertRule => ({ updated_by: doc.updatedBy, }) +/** Update-payload fields diffable through the wire shape (drafts get summarized). */ +const ruleAuditDiff = auditDiff({ + fields: [ + "name", + "notes", + "notification_template", + "enabled", + "severity", + "service_names", + "exclude_service_names", + "environments", + "tags", + "group_by", + "signal_type", + "comparator", + "threshold", + "threshold_upper", + "window_minutes", + "minimum_sample_count", + "consecutive_breaches_required", + "consecutive_healthy_required", + "renotify_interval_minutes", + "apdex_threshold_ms", + "query_builder_draft", + "raw_query_sql", + "raw_query_reducer", + "destination_ids", + ], + // Query drafts and raw SQL are config blobs — audit that they changed, not their bodies. + summarize: { query_builder_draft: "", raw_query_sql: "" }, +}) + const toV2RuleMutationResponse = (doc: AlertRuleDocument): V2AlertRuleMutationResponse => ({ ...toV2Rule(doc), ...(doc.txid !== undefined ? { txid: doc.txid } : undefined), @@ -345,6 +379,11 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules request, ) + yield* recordHttpAudit("alert_rule.created", { + resourceId: created.id, + metadata: { name: created.name }, + }) + return toV2RuleMutationResponse(created) }), ) @@ -361,6 +400,12 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules request, ) + yield* recordHttpAudit("alert_rule.updated", { + resourceId: updated.id, + changes: ruleAuditDiff(payload, toV2Rule(current), toV2Rule(updated)), + metadata: { name: updated.name }, + }) + return toV2RuleMutationResponse(updated) }), ) @@ -368,6 +413,7 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const deleted = yield* rules.deleteRule(tenant.orgId, tenant.roles, params.id) + yield* recordHttpAudit("alert_rule.deleted", { resourceId: deleted.id }) return { id: deleted.id, diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 418a76f00..da3466686 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -18,6 +18,7 @@ import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@/platfor import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -164,6 +165,7 @@ const makeHarness = ( Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/anomalies.http.ts b/apps/api/src/routes/v2/anomalies.http.ts index ed51bf9d1..62049a999 100644 --- a/apps/api/src/routes/v2/anomalies.http.ts +++ b/apps/api/src/routes/v2/anomalies.http.ts @@ -15,6 +15,7 @@ import { import { MapleApiV2, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" import type { V2AnomalyIncident, V2AnomalyIncidentTimeseries, V2AnomalySettings } from "@maple/domain/http/v2" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { requireAdmin } from "@/services/auth/auth" import { AnomalyDetectionService } from "@/services/alerts/AnomalyDetectionService" import { ErrorsService } from "@/services/errors/ErrorsService" @@ -186,6 +187,13 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const incident = yield* anomalies.resolveIncidentManually(tenant.orgId, params.id) + yield* recordHttpAudit("anomaly_incident.resolved", { + resourceId: incident.id, + metadata: { + signal_type: incident.signalType, + service_name: incident.serviceName, + }, + }) return toV2Incident(incident) }), @@ -244,6 +252,10 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", }), ) + yield* recordHttpAudit("anomaly_settings.updated", { + metadata: { enabled: settings.enabled, sensitivity: settings.sensitivity }, + }) + return toV2Settings(settings) }), ) diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index b73d59b7c..b2da944b7 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -12,6 +12,7 @@ import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiV2RateLimiter, type ApiV2RateLimiterApi } from "@/services/auth/ApiV2RateLimiter" import { V2TransportErrorBoundaryLive } from "./error-envelope" import { @@ -69,6 +70,7 @@ const makeHarness = ( Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(Layer.succeed(ApiV2RateLimiter, { check: checkRateLimit })), Layer.provideMerge(servicesLive), Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)), diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts index 581de9352..0e737a3e5 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -10,6 +10,7 @@ import { } from "@maple/domain/http/v2" import type { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "@maple/domain/http/v2" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { requireAdmin } from "@/services/auth/auth" @@ -106,6 +107,10 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha ? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } } : undefined), }) + yield* recordHttpAudit("api_key.created", { + resourceId: created.id, + metadata: { name: created.name, kind: created.kind, scopes: created.scopes }, + }) return toV2ApiKeyWithSecret(created) }), ) @@ -117,6 +122,10 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha const rolled = yield* apiKeysService.roll(tenant.orgId, tenant.userId, params.id, { createdByEmail, }) + yield* recordHttpAudit("api_key.rolled", { + resourceId: rolled.id, + metadata: { name: rolled.name, scopes: rolled.scopes }, + }) return toV2ApiKeyWithSecret(rolled) }), ) @@ -132,6 +141,10 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha yield* requireAdmin(tenant.roles, adminOnly("revoke")) } const revoked = yield* apiKeysService.revoke(tenant.orgId, params.id) + yield* recordHttpAudit("api_key.revoked", { + resourceId: revoked.id, + metadata: { name: revoked.name }, + }) return toV2ApiKeyMutationResponse(revoked) }), ) diff --git a/apps/api/src/routes/v2/attribute-mappings.http.ts b/apps/api/src/routes/v2/attribute-mappings.http.ts index 3ff480b7f..948e70b90 100644 --- a/apps/api/src/routes/v2/attribute-mappings.http.ts +++ b/apps/api/src/routes/v2/attribute-mappings.http.ts @@ -9,6 +9,8 @@ import { import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" import type { V2AttributeMapping } from "@maple/domain/http/v2" import { Array as Arr, Effect, Option } from "effect" +import { diffAuditChanges, pickPresentFields } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { IngestAttributeMappingService } from "@/services/org/IngestAttributeMappingService" const toV2AttributeMapping = (mapping: IngestAttributeMapping): V2AttributeMapping => ({ @@ -24,6 +26,11 @@ const toV2AttributeMapping = (mapping: IngestAttributeMapping): V2AttributeMappi updated_at: mapping.updatedAt, }) +/** Update-payload fields that are diffable through the wire shape. */ +const mappingAuditKeys: ReadonlyArray< + "name" | "source_context" | "source_key" | "target_key" | "operation" | "enabled" +> = ["name", "source_context", "source_key", "target_key", "operation", "enabled"] + export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "attributeMappings", (handlers) => Effect.gen(function* () { const service = yield* IngestAttributeMappingService @@ -80,12 +87,18 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att }), ) + yield* recordHttpAudit("attribute_mapping.created", { + resourceId: created.id, + metadata: { name: created.name }, + }) + return toV2AttributeMapping(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const current = yield* findMapping(tenant.orgId, params.id) const updated = yield* service.update( tenant.orgId, params.id, @@ -109,6 +122,16 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att }), ) + const changes = diffAuditChanges( + pickPresentFields(mappingAuditKeys, payload, toV2AttributeMapping(current)), + pickPresentFields(mappingAuditKeys, payload, toV2AttributeMapping(updated)), + ) + yield* recordHttpAudit("attribute_mapping.updated", { + resourceId: updated.id, + changes, + metadata: { name: updated.name }, + }) + return toV2AttributeMapping(updated) }), ) @@ -116,6 +139,9 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const deleted = yield* service.delete(tenant.orgId, params.id) + yield* recordHttpAudit("attribute_mapping.deleted", { + resourceId: deleted.id, + }) return { id: deleted.id, object: "attribute_mapping" as const, deleted: true as const } }), diff --git a/apps/api/src/routes/v2/audit-changes.test.ts b/apps/api/src/routes/v2/audit-changes.test.ts new file mode 100644 index 000000000..a7f31fd58 --- /dev/null +++ b/apps/api/src/routes/v2/audit-changes.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { auditDiff, redactAuditUrl } from "./audit-changes" + +const targetDiff = auditDiff({ + fields: ["name", "url", "enabled", "labels_json"], + summarize: { labels_json: "" }, + redact: { url: redactAuditUrl }, + writeOnly: ["auth_credentials"], +}) + +describe("auditDiff", () => { + it("diffs only the fields the payload carried", () => { + const changes = targetDiff( + { name: "renamed" }, + { name: "before", url: "https://a.test/x", enabled: true, labels_json: "{}" }, + { name: "renamed", url: "https://b.test/y", enabled: false, labels_json: "{}" }, + ) + // `url` and `enabled` moved, but the request did not ask for them. + expect(changes).toEqual({ + fields: ["name"], + before: { name: "before" }, + after: { name: "renamed" }, + }) + }) + + it("returns undefined when a touched field is unchanged", () => { + expect( + targetDiff( + { name: "same" }, + { name: "same", url: "https://a.test", enabled: true, labels_json: "{}" }, + { name: "same", url: "https://a.test", enabled: true, labels_json: "{}" }, + ), + ).toBeUndefined() + }) + + it("redacts credentials out of a changed URL", () => { + const changes = targetDiff( + { url: "https://user:secret@b.test/m?token=live" }, + { name: "n", url: "https://a.test/m", enabled: true, labels_json: "{}" }, + { name: "n", url: "https://user:secret@b.test/m?token=live", enabled: true, labels_json: "{}" }, + ) + expect(changes?.after["url"]).toBe("https://b.test/m") + expect(JSON.stringify(changes)).not.toContain("secret") + expect(JSON.stringify(changes)).not.toContain("token=live") + }) + + it("summarizes config blobs instead of recording their bodies", () => { + const changes = targetDiff( + { labels_json: '{"team":"infra"}' }, + { name: "n", url: "https://a.test", enabled: true, labels_json: "{}" }, + { name: "n", url: "https://a.test", enabled: true, labels_json: '{"team":"infra"}' }, + ) + expect(changes).toEqual({ + fields: ["labels_json"], + before: { labels_json: "" }, + after: { labels_json: "" }, + }) + }) + + it("records a write-only field as rotated whenever the payload carries it", () => { + const changes = targetDiff( + { auth_credentials: "hunter2" }, + { name: "n", url: "https://a.test", enabled: true, labels_json: "{}" }, + { name: "n", url: "https://a.test", enabled: true, labels_json: "{}" }, + ) + expect(changes).toEqual({ + fields: ["auth_credentials"], + before: { auth_credentials: "" }, + after: { auth_credentials: "" }, + }) + expect(JSON.stringify(changes)).not.toContain("hunter2") + }) + + it("merges a rotated credential into an observable diff", () => { + const changes = targetDiff( + { name: "renamed", auth_credentials: "hunter2" }, + { name: "before", url: "https://a.test", enabled: true, labels_json: "{}" }, + { name: "renamed", url: "https://a.test", enabled: true, labels_json: "{}" }, + ) + expect(changes?.fields).toEqual(["name", "auth_credentials"]) + expect(changes?.after).toEqual({ name: "renamed", auth_credentials: "" }) + }) +}) diff --git a/apps/api/src/routes/v2/audit-changes.ts b/apps/api/src/routes/v2/audit-changes.ts new file mode 100644 index 000000000..4986d06dc --- /dev/null +++ b/apps/api/src/routes/v2/audit-changes.ts @@ -0,0 +1,151 @@ +import type { AuditChanges } from "@maple/domain/http" + +/** + * Structural equality, insensitive to object key order (a server-rebuilt + * `timeRange` must not diff against the decoded payload echo). Arrays stay + * order-sensitive; anything non-JSON-shaped falls back to reference equality. + */ +export const structuralEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + return a.every((item, index) => structuralEqual(item, b[index])) + } + const aEntries = Object.entries(a) + const bEntries = new Map(Object.entries(b)) + if (aEntries.length !== bEntries.size) return false + return aEntries.every(([key, value]) => bEntries.has(key) && structuralEqual(value, bEntries.get(key))) +} + +/** + * Diff two snapshots restricted to the keys of `after` (the fields the request + * actually touched — omitted fields are unchanged by contract). Returns + * undefined when nothing changed so the audit entry can omit `changes`. + */ +export const diffAuditChanges = ( + before: Record, + after: Record, +): AuditChanges | undefined => { + const fields: string[] = [] + const beforeOut: Record = {} + const afterOut: Record = {} + for (const key of Object.keys(after)) { + const prev = before[key] + const next = after[key] + if (structuralEqual(prev, next)) continue + fields.push(key) + beforeOut[key] = prev + afterOut[key] = next + } + return fields.length === 0 ? undefined : { fields, before: beforeOut, after: afterOut } +} + +/** + * Snapshot only the fields the update payload actually carries, reading their + * values from a wire-shaped view of the resource (pre- or post-update). + */ +export const pickPresentFields = ( + keys: ReadonlyArray, + payload: { readonly [P in K]?: unknown }, + source: { readonly [P in K]: unknown }, +): Record => { + const out: Record = {} + for (const key of keys) { + if (payload[key] !== undefined) out[key] = source[key] + } + return out +} + +/** + * Replace selected fields' before/after values with a static placeholder so + * large config blobs (dashboard widgets, query drafts) and secrets don't reach + * the audit row. Null survives, so "cleared" still reads as cleared. + */ +export const compactAuditChanges = ( + changes: AuditChanges | undefined, + // Keyed by the resource's declared field names (see `auditDiff`) so a wire-key + // rename cannot silently disable a placeholder. + placeholders: Record, +): AuditChanges | undefined => { + if (changes === undefined) return undefined + const before = { ...changes.before } + const after = { ...changes.after } + for (const field of changes.fields) { + const placeholder = placeholders[field] + if (placeholder === undefined) continue + if (field in before && before[field] !== null) before[field] = placeholder + if (field in after && after[field] !== null) after[field] = placeholder + } + return { fields: changes.fields, before, after } +} + +/** + * Strip userinfo, query string, and fragment from a URL destined for an audit + * row — scrape URLs routinely embed tokens there. Keeps scheme/host/path. + */ +export const redactAuditUrl = (raw: string): string => { + if (!URL.canParse(raw)) return "" + const url = new URL(raw) + return `${url.protocol}//${url.host}${url.pathname}` +} + +/** + * Build the `changes` diff for one resource's update handler. + * + * The spec is declared once next to the resource's wire shape and applied per + * request: `fields` are diffed through the wire view, `summarize` replaces a + * config blob's value with a static placeholder, `redact` rewrites a value + * (scrape URLs carry tokens), and `writeOnly` records credentials the response + * never echoes as having rotated. `summarize` and `redact` are keyed by + * `fields`, so a renamed wire key is a type error rather than a silently + * disabled redaction. + * + * Returns undefined when nothing observable changed, so the caller passes the + * result straight through as `changes`. + */ +export const auditDiff = (spec: { + readonly fields: ReadonlyArray + readonly summarize?: Partial> + readonly redact?: Partial string>> + readonly writeOnly?: ReadonlyArray +}) => { + const redactors: Record string) | undefined> = spec.redact ?? {} + + const redactChanges = (changes: AuditChanges): AuditChanges => { + const apply = (values: Record): Record => { + const out = { ...values } + for (const field of changes.fields) { + const redact = redactors[field] + const value = out[field] + if (redact !== undefined && typeof value === "string") out[field] = redact(value) + } + return out + } + return { fields: changes.fields, before: apply(changes.before), after: apply(changes.after) } + } + + return ( + payload: { readonly [P in Field]?: unknown }, + before: { readonly [P in Field]: unknown }, + after: { readonly [P in Field]: unknown }, + ): AuditChanges | undefined => { + const diffed = diffAuditChanges( + pickPresentFields(spec.fields, payload, before), + pickPresentFields(spec.fields, payload, after), + ) + const compacted = diffed === undefined ? undefined : compactAuditChanges(diffed, spec.summarize ?? {}) + const observable = compacted === undefined ? undefined : redactChanges(compacted) + // Write-only fields never appear in a response, so their rotation can only + // be inferred from the request carrying them. + const present: Record = payload + const rotated = (spec.writeOnly ?? []).filter((field) => present[field] !== undefined) + if (rotated.length === 0) return observable + const placeholders = Object.fromEntries(rotated.map((field) => [field, ""])) + return { + fields: [...(observable?.fields ?? []), ...rotated], + before: { ...observable?.before, ...placeholders }, + after: { ...observable?.after, ...placeholders }, + } + } +} diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts new file mode 100644 index 000000000..ec9b351a7 --- /dev/null +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -0,0 +1,149 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { AuditChanges, CurrentTenant } from "@maple/domain/http" +import { ActorId, ApiKeyId, UserId } from "@maple/domain/primitives" +import { + decodePublicId, + encodePublicId, + MapleApiV2, + paginateOffsetQuery, + PublicIdPrefixes, + timestamp, + V2InsufficientPermissions, + V2ParameterInvalid, +} from "@maple/domain/http/v2" +import type { V2AuditLogEntry } from "@maple/domain/http/v2" +import type { AuditLogEntryRow } from "@maple/db" +import { Effect, Option, Schema } from "effect" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { requireAdmin } from "@/services/auth/auth" +import type { AuditLogListFilters } from "@/services/audit/AuditLogService" + +const adminOnly = () => V2InsufficientPermissions.make("Only org admins can read the audit log") + +const decodeApiKeyIdOption = Schema.decodeUnknownOption(ApiKeyId) +const decodeActorIdOption = Schema.decodeUnknownOption(ActorId) +const decodeUserIdOption = Schema.decodeUnknownOption(UserId) + +type ActorIdentityFilter = Pick + +/** + * Resolve the public `actor_id` filter to the column it identifies: `key_…` → + * the API key, `actor_…` → the agent, anything else → a (Clerk-issued, already + * public) user ID. + */ +const actorIdentityFilter = (publicActorId: string) => { + const invalid = V2ParameterInvalid.make("Invalid actor_id.", { param: "actor_id" }) + const succeed = (filter: ActorIdentityFilter) => Effect.succeed(filter) + const asApiKey = decodePublicId(PublicIdPrefixes.apiKey, publicActorId) + if (asApiKey !== null) { + return Option.match(decodeApiKeyIdOption(asApiKey), { + onNone: () => Effect.fail(invalid), + onSome: (apiKeyId) => succeed({ apiKeyId }), + }) + } + const asActor = decodePublicId(PublicIdPrefixes.actor, publicActorId) + if (asActor !== null) { + return Option.match(decodeActorIdOption(asActor), { + onNone: () => Effect.fail(invalid), + onSome: (actorId) => succeed({ actorId }), + }) + } + return Option.match(decodeUserIdOption(publicActorId), { + onNone: () => Effect.fail(invalid), + onSome: (userId) => succeed({ userId }), + }) +} + +const isJsonRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const decodeChangesOption = Schema.decodeUnknownOption(AuditChanges) + +/** The actor's public identifier, matching the ID style of its own resource. */ +const publicActorId = (row: AuditLogEntryRow): string | null => { + switch (row.actorType) { + case "api_key": + return row.apiKeyId === null ? null : encodePublicId(PublicIdPrefixes.apiKey, row.apiKeyId) + case "agent": + return row.actorId === null ? null : encodePublicId(PublicIdPrefixes.actor, row.actorId) + case "user": + // Clerk user IDs are already prefixed public IDs — passed through as-is. + return row.userId + case "system": + return null + } +} + +const toV2AuditLogEntry = (row: AuditLogEntryRow): V2AuditLogEntry => ({ + id: row.id, + object: "audit_log_entry", + action: row.action, + outcome: row.outcome, + denial_reason: row.denialReason, + actor_type: row.actorType, + actor_id: publicActorId(row), + actor_name: row.actorLabel, + affected_user: row.affectedUserId, + source: row.source, + resource_type: row.resourceType, + resource_id: row.resourceId, + changes: Option.getOrNull(decodeChangesOption(row.changesJson)), + metadata: isJsonRecord(row.metadataJson) ? row.metadataJson : null, + request_id: row.requestId, + origin_ip: row.originIp, + origin_country: row.originCountry, + occurred_at: timestamp(row.occurredAt.toISOString()), + recorded_at: timestamp(row.recordedAt.toISOString()), +}) + +export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", (handlers) => + Effect.gen(function* () { + const audit = yield* AuditLogService + + return handlers.handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // The log carries every member's activity, denial history, and origin + // IP for the whole retention window — org admins only. Scoped API keys + // are additionally gated by `audit_log:read`. + yield* requireAdmin(tenant.roles, adminOnly) + const identity = + query.actor_id !== undefined ? yield* actorIdentityFilter(query.actor_id) : undefined + const affectedUser = + query.affected_user !== undefined + ? yield* Option.match(decodeUserIdOption(query.affected_user), { + onNone: () => + Effect.fail( + V2ParameterInvalid.make("Invalid affected_user.", { param: "affected_user" }), + ), + onSome: (userId) => Effect.succeed(userId), + }) + : undefined + const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => + audit + .list(tenant.orgId, { + ...(query.actor_type !== undefined ? { actorType: query.actor_type } : undefined), + ...identity, + ...(affectedUser !== undefined ? { affectedUserId: affectedUser } : undefined), + ...(query.action !== undefined ? { action: query.action } : undefined), + ...(query.outcome !== undefined ? { outcome: query.outcome } : undefined), + ...(query.resource_type !== undefined + ? { resourceType: query.resource_type } + : undefined), + ...(query.resource_id !== undefined + ? { resourceId: query.resource_id } + : undefined), + ...(query.changed !== undefined ? { changedField: query.changed } : undefined), + ...(query.request_id !== undefined ? { requestId: query.request_id } : undefined), + ...(query.since !== undefined ? { sinceMs: Date.parse(query.since) } : undefined), + ...(query.until !== undefined ? { untilMs: Date.parse(query.until) } : undefined), + limit, + offset, + }) + .pipe(Effect.map((rows) => rows.map(toV2AuditLogEntry))), + ) + return { object: "list" as const, ...page } + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index dcdd10091..e5d3f47a2 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -9,6 +9,7 @@ import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQue import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { Env } from "@/platform/Env" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -115,6 +116,7 @@ const makeHarness = () => { // session_replays (in AllV2GroupLayersLive) needs the warehouse at the routes level. Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index 025d06de8..1418b1fa3 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -11,6 +11,7 @@ import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, @@ -65,6 +66,7 @@ const makeHarness = () => { Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/dashboards.http.ts b/apps/api/src/routes/v2/dashboards.http.ts index efc99098a..1a9420477 100644 --- a/apps/api/src/routes/v2/dashboards.http.ts +++ b/apps/api/src/routes/v2/dashboards.http.ts @@ -9,9 +9,11 @@ import { PortableDashboardDocument, } from "@maple/domain/http" import { + encodePublicId, MapleApiV2, LIST_LIMIT_DEFAULT, paginateArray, + PublicIdPrefixes, V2ParameterInvalid, V2ParameterMissing, } from "@maple/domain/http/v2" @@ -30,6 +32,8 @@ import type { DashboardId } from "@maple/domain/primitives" import { Clock, Effect, Option, Schema } from "effect" import { getTemplateById, listTemplateMetadata } from "@/dashboard-templates" import type { TemplateParameterValues } from "@/dashboard-templates" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" import { convertPersesDashboardToPortable } from "@/services/dashboards/perses-dashboard-import" @@ -174,6 +178,22 @@ const applyUpdate = ( }) } +/** Update-payload fields diffable through the wire shape; layout blobs get summarized. */ +const dashboardAuditDiff = auditDiff({ + fields: [ + "name", + "description", + "tags", + "timeRange", + "widgets", + "sections", + "variables", + "refreshIntervalSeconds", + ], + // Layout arrays are config blobs — audit that they changed, not their bodies. + summarize: { widgets: "", sections: "", variables: "" }, +}) + const encodeVersionCursor = (versionNumber: number): string => `ver_${versionNumber.toString(36)}` const decodeVersionCursor = (cursor: string): number | null => { @@ -270,6 +290,16 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards "maple.share.id": created.id, mode: created.mode, }) + yield* recordHttpAudit("dashboard_share.created", { + resourceId: created.id, + metadata: { + mode: created.mode, + dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, context.scope.dashboardId), + ...(context.scope.widgetId === null + ? undefined + : { widget_id: context.scope.widgetId }), + }, + }) return toV2DashboardShare(created) }) @@ -284,6 +314,14 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards ) yield* logShare("dashboard share rotated", context, { "maple.share.id": rotated.id }) + // Security event: rotation invalidates the previous public share token. + yield* recordHttpAudit("dashboard_share.rotated", { + resourceId: rotated.id, + metadata: { + dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, dashboardId), + ...(widgetId === null ? undefined : { widget_id: widgetId }), + }, + }) return toV2DashboardShare(rotated) }) @@ -298,6 +336,14 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards ) yield* logShare("dashboard share revoked", context, { hadLiveShare: tombstone.revoked }) + if (tombstone.revoked) { + yield* recordHttpAudit("dashboard_share.deleted", { + metadata: { + dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, dashboardId), + ...(widgetId === null ? undefined : { widget_id: widgetId }), + }, + }) + } // `deleted: true` regardless of whether a live share existed: "stop // sharing" is a statement about the end state, and the dialog must be @@ -336,6 +382,10 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards tenant.userId, toPortable(payload), ) + yield* recordHttpAudit("dashboard.created", { + resourceId: dashboard.id, + metadata: { name: dashboard.name }, + }) return toV2DashboardMutation(dashboard) }), @@ -346,12 +396,26 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const updatedAt = asIsoDateTime( new Date(yield* Clock.currentTimeMillis).toISOString(), ) + // Capture the pre-state the mutate callback already reads, for the diff. + let previous: DashboardDocument | undefined const dashboard = yield* persistence.mutate( tenant.orgId, tenant.userId, params.id, - (current) => Effect.succeed(applyUpdate(current, payload, updatedAt)), + (current) => { + previous = current + return Effect.succeed(applyUpdate(current, payload, updatedAt)) + }, ) + const changes = + previous === undefined + ? undefined + : dashboardAuditDiff(payload, toV2Dashboard(previous), toV2Dashboard(dashboard)) + yield* recordHttpAudit("dashboard.updated", { + resourceId: dashboard.id, + changes, + metadata: { name: dashboard.name }, + }) return toV2DashboardMutation(dashboard) }), @@ -360,6 +424,9 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const deleted = yield* persistence.delete(tenant.orgId, params.id) + yield* recordHttpAudit("dashboard.deleted", { + resourceId: deleted.id, + }) return { id: deleted.id, @@ -378,6 +445,10 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards tenant.userId, converted.dashboard, ) + yield* recordHttpAudit("dashboard.created", { + resourceId: dashboard.id, + metadata: { name: dashboard.name, source: "perses_import" }, + }) return { object: "dashboard_import" as const, @@ -436,6 +507,16 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards params.id, params.version_id, ) + yield* recordHttpAudit("dashboard.version_restored", { + resourceId: dashboard.id, + metadata: { + name: dashboard.name, + version_id: encodePublicId( + PublicIdPrefixes.dashboardVersion, + params.version_id, + ), + }, + }) return toV2DashboardMutation(dashboard) }), @@ -528,6 +609,14 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards }) const tenant = yield* CurrentTenant.Context const dashboard = yield* persistence.create(tenant.orgId, tenant.userId, portable) + yield* recordHttpAudit("dashboard.created", { + resourceId: dashboard.id, + metadata: { + name: dashboard.name, + source: "template", + template_id: params.template_id, + }, + }) return toV2DashboardMutation(dashboard) }), diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index cddf23be2..28fa08c31 100644 --- a/apps/api/src/routes/v2/ingest-keys.http.ts +++ b/apps/api/src/routes/v2/ingest-keys.http.ts @@ -4,6 +4,7 @@ import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, V2InsufficientPermissions } from "@maple/domain/http/v2" import type { V2IngestKeys } from "@maple/domain/http/v2" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" import { requireAdmin } from "@/services/auth/auth" @@ -37,6 +38,9 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("roll")) const keys = yield* ingestKeys.rerollPublic(tenant.orgId, tenant.userId) + yield* recordHttpAudit("ingest_key.rolled", { + metadata: { key_type: "public" }, + }) return toV2IngestKeys(keys) }), @@ -46,6 +50,9 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly("roll")) const keys = yield* ingestKeys.rerollPrivate(tenant.orgId, tenant.userId) + yield* recordHttpAudit("ingest_key.rolled", { + metadata: { key_type: "private" }, + }) return toV2IngestKeys(keys) }), diff --git a/apps/api/src/routes/v2/integrations.http.test.ts b/apps/api/src/routes/v2/integrations.http.test.ts index a21d9b9b0..ea54c4b3a 100644 --- a/apps/api/src/routes/v2/integrations.http.test.ts +++ b/apps/api/src/routes/v2/integrations.http.test.ts @@ -23,6 +23,7 @@ import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { SLACK_CALLBACK_PATH, SlackIntegrationService, @@ -170,6 +171,7 @@ const makeHarness = (slack: Partial = {}, planetscal Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index 22d2f41dd..f5fb66e7c 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -31,6 +31,7 @@ import { V2TimeRangeInvalid, } from "@maple/domain/http/v2" import { Array as Arr, Effect, Option } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { requireAdmin } from "@/services/auth/auth" import { Env } from "@/platform/Env" import { EdgeCacheService } from "@maple/cache" @@ -258,6 +259,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla const result = yield* slack .startInstall(tenant.orgId, tenant.userId, callbackUrl) .pipe(tapHttpErrors("Slack install failed")) + yield* recordHttpAudit("slack_integration.install_started") return { object: "slack_integration.install" as const, url: result.url, @@ -273,6 +275,7 @@ export const HttpV2SlackIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "sla yield* slack .uninstall(tenant.orgId) .pipe(tapHttpErrors("Slack integration uninstall failed")) + yield* recordHttpAudit("slack_integration.uninstalled") return { object: "slack_integration" as const, installed: false as const, @@ -353,6 +356,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( returnTo: payload.return_to, }) .pipe(tapHttpErrors("PlanetScale connect failed")) + yield* recordHttpAudit("planetscale_integration.connect_started") return { object: "planetscale_integration.connect" as const, redirect_url: result.redirectUrl, @@ -397,6 +401,13 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( excludeBranches: payload.exclude_branches, }) .pipe(tapHttpErrors("PlanetScale organization selection failed")) + yield* recordHttpAudit("planetscale_integration.organization_selected", { + metadata: { + organization: payload.organization, + include_branches: payload.include_branches, + exclude_branches: payload.exclude_branches, + }, + }) return toPlanetScaleStatus(status) }), ) @@ -414,6 +425,11 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( tokenSecret: payload.token_secret, }) .pipe(tapHttpErrors("PlanetScale metrics token update failed")) + // The token id names which credential was installed; its secret + // is write-only and never reaches an audit row. + yield* recordHttpAudit("planetscale_integration.metrics_token_set", { + metadata: { token_id: payload.token_id }, + }) return toPlanetScaleStatus(status) }), ) @@ -426,6 +442,7 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( yield* planetscale .disconnect(tenant.orgId) .pipe(tapHttpErrors("PlanetScale disconnect failed")) + yield* recordHttpAudit("planetscale_integration.disconnected") return { object: "planetscale_integration" as const, connected: false as const, diff --git a/apps/api/src/routes/v2/investigations.http.ts b/apps/api/src/routes/v2/investigations.http.ts index 54388f0d7..31fed9372 100644 --- a/apps/api/src/routes/v2/investigations.http.ts +++ b/apps/api/src/routes/v2/investigations.http.ts @@ -21,6 +21,7 @@ import type { V2InvestigationSubject, } from "@maple/domain/http/v2" import { Effect, Match, Schema } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { InvestigationService } from "@/services/errors/InvestigationService" const toWireSubject = Effect.fn("HttpV2Investigations.toWireSubject")(function* ( @@ -265,6 +266,10 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest : undefined), }), ) + yield* recordHttpAudit("investigation.created", { + resourceId: doc.id, + metadata: { subject_type: payload.subject.type }, + }) return yield* serializeInvestigation(doc) }), @@ -273,6 +278,7 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const doc = yield* service.restartInvestigation(tenant.orgId, params.id) + yield* recordHttpAudit("investigation.restarted", { resourceId: doc.id }) return yield* serializeInvestigation(doc) }), @@ -281,6 +287,10 @@ export const HttpV2InvestigationsLive = HttpApiBuilder.group(MapleApiV2, "invest Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const doc = yield* service.updateStatus(tenant.orgId, params.id, payload.status) + yield* recordHttpAudit("investigation.status_changed", { + resourceId: doc.id, + metadata: { to_status: payload.status }, + }) return yield* serializeInvestigation(doc) }), diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 416e0826a..c88e67c07 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -7,6 +7,7 @@ import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { Env } from "@/platform/Env" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -79,6 +80,7 @@ const makeHarness = () => { Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index f273bce19..fa9a51b64 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -50,6 +50,7 @@ import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryServic import { Env } from "@/platform/Env" import { AnomalyDetectionService } from "@/services/alerts/AnomalyDetectionService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -572,6 +573,7 @@ const makeHarness = ( Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/scrape-targets.http.ts b/apps/api/src/routes/v2/scrape-targets.http.ts index e8916bd5c..755d0ba12 100644 --- a/apps/api/src/routes/v2/scrape-targets.http.ts +++ b/apps/api/src/routes/v2/scrape-targets.http.ts @@ -4,6 +4,8 @@ import { CreateScrapeTargetRequest, CurrentTenant, UpdateScrapeTargetRequest } f import { MapleApiV2, paginateArray, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { Effect } from "effect" +import { auditDiff, redactAuditUrl } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ @@ -28,6 +30,27 @@ const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ updated_at: target.updatedAt, }) +/** Update-payload fields diffable through the wire shape; credentials never appear. */ +const targetAuditDiff = auditDiff({ + fields: [ + "name", + "url", + "organization", + "include_branches", + "exclude_branches", + "scrape_interval_seconds", + "labels_json", + "auth_type", + "service_name", + "enabled", + ], + // Scrape URLs may carry tokens in userinfo/query — audit only scheme/host/path. + // Identical redacted values still mean the URL changed within the stripped part. + redact: { url: redactAuditUrl }, + // Credentials are write-only: audit that they rotated, never their value. + writeOnly: ["auth_credentials"], +}) + export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeTargets", (handlers) => Effect.gen(function* () { const service = yield* ScrapeTargetsService @@ -96,12 +119,18 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT }), ) + yield* recordHttpAudit("scrape_target.created", { + resourceId: created.id, + metadata: { name: created.name }, + }) + return toV2ScrapeTarget(created) }), ) .handle("update", ({ params, payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context + const current = yield* service.get(tenant.orgId, params.id) const updated = yield* service.update( tenant.orgId, params.id, @@ -144,6 +173,14 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT }), ) + // Read-then-write with no CAS: a concurrent update can make `before` + // reflect a state this update never saw. Accepted for audit purposes. + yield* recordHttpAudit("scrape_target.updated", { + resourceId: updated.id, + changes: targetAuditDiff(payload, toV2ScrapeTarget(current), toV2ScrapeTarget(updated)), + metadata: { name: updated.name }, + }) + return toV2ScrapeTarget(updated) }), ) @@ -151,6 +188,7 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const deleted = yield* service.delete(tenant.orgId, params.id) + yield* recordHttpAudit("scrape_target.deleted", { resourceId: deleted.id }) return { id: deleted.id, object: "scrape_target" as const, deleted: true as const } }), diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index 79698faa6..ad678e8a4 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -10,6 +10,7 @@ import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryServic import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -142,6 +143,7 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi = warehouseStub()) => { Layer.provide(TelemetryServiceStubsLayer), Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 90d158721..868835b9b 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -12,6 +12,7 @@ import { type WarehouseQueryServiceApi, } from "@/services/warehouse/WarehouseQueryService" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -275,6 +276,7 @@ const makeHarness = ( Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 6ad42815e..43b548a6d 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -41,6 +41,8 @@ import { HttpV2InvestigationsLive } from "./investigations.http" import { HttpV2MobileDevicesLive } from "./mobile-devices.http" import { HttpV2OrganizationLive } from "./organization.http" import { HttpV2InstrumentationRecommendationsLive } from "./recommendations.http" +import { HttpV2AuditLogLive } from "./audit-log.http" +import { AuditLogService } from "@/services/audit/AuditLogService" import { HttpV2ScrapeTargetsLive } from "./scrape-targets.http" import { HttpV2SessionReplaysLive } from "./session-replays.http" import { HttpV2InstrumentationAuditLive } from "./setup-audit.http" @@ -76,6 +78,8 @@ export const AllV2GroupLayersLive = Layer.mergeAll( HttpV2IngestKeysLive, HttpV2ErrorIssuesLive, HttpV2AttributeMappingsLive, + // Real service, no stub: it needs only the Database every harness already provides. + HttpV2AuditLogLive.pipe(Layer.provide(AuditLogService.layer)), HttpV2ScrapeTargetsLive, HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, @@ -118,6 +122,10 @@ export const AllV2GroupLayersLive = Layer.mergeAll( }), ), ), +).pipe( + // Mutation handlers across the groups record audit entries; the real service + // needs only the Database every harness already provides. + Layer.provide(AuditLogService.layer), ) export const ApiV2RateLimiterAllowAllLayer = Layer.succeed(ApiV2RateLimiter, { diff --git a/apps/api/src/routes/v2/widget-credentials.http.test.ts b/apps/api/src/routes/v2/widget-credentials.http.test.ts index 05b784def..c09ec82be 100644 --- a/apps/api/src/routes/v2/widget-credentials.http.test.ts +++ b/apps/api/src/routes/v2/widget-credentials.http.test.ts @@ -7,6 +7,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -77,6 +78,7 @@ const makeHarness = () => { Layer.provide(PlanetScaleServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/v2/widget-credentials.http.ts b/apps/api/src/routes/v2/widget-credentials.http.ts index 12cc2a84c..4cd9a00ca 100644 --- a/apps/api/src/routes/v2/widget-credentials.http.ts +++ b/apps/api/src/routes/v2/widget-credentials.http.ts @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, isoTimestamp } from "@maple/domain/http/v2" import { Effect } from "effect" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" /** @@ -49,6 +50,14 @@ export const HttpV2WidgetCredentialsLive = HttpApiBuilder.group(MapleApiV2, "wid // letting it resolve with the API-key default — is `root`. roles: tenant.roles, }) + // A credential mint is the security event; the secret itself never + // reaches the row, only which installation it was issued to. + yield* recordHttpAudit("widget_credential.minted", { + metadata: { + installation_id: params.installation_id, + scopes: credential.scopes ?? WIDGET_CREDENTIAL_SCOPES, + }, + }) return { object: "widget_credential" as const, secret: credential.secret, @@ -68,6 +77,9 @@ export const HttpV2WidgetCredentialsLive = HttpApiBuilder.group(MapleApiV2, "wid // to revoke: this is the sign-out path, and an error the app // cannot act on while signing out anyway is worse than silence. yield* apiKeys.revokeDeviceKeys(tenant.orgId, params.installation_id) + yield* recordHttpAudit("widget_credential.revoked", { + metadata: { installation_id: params.installation_id }, + }) return { object: "widget_credential" as const, deleted: true as const } }), ) diff --git a/apps/api/src/routes/v2/widget-summary.http.test.ts b/apps/api/src/routes/v2/widget-summary.http.test.ts index 6113aeb70..d7ca174f3 100644 --- a/apps/api/src/routes/v2/widget-summary.http.test.ts +++ b/apps/api/src/routes/v2/widget-summary.http.test.ts @@ -15,6 +15,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "@/services/auth/AuthService" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" @@ -190,6 +191,7 @@ const makeHarness = (options: { Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layer), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), Layer.provideMerge(servicesLive), ) diff --git a/apps/api/src/routes/webhooks/clerk.http.ts b/apps/api/src/routes/webhooks/clerk.http.ts index e84f5543d..4f27fafca 100644 --- a/apps/api/src/routes/webhooks/clerk.http.ts +++ b/apps/api/src/routes/webhooks/clerk.http.ts @@ -1,25 +1,51 @@ -import { Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import { HttpRouter, type HttpServerRequest } from "effect/unstable/http" import { Env } from "@/platform/Env" import { + CLERK_MEMBERSHIP_EVENTS, decodeClerkEnvelope, + decodeClerkOrganizationMembership, decodeClerkUserCreated, + isClerkMembershipEvent, signupCompletedEvent, } from "@/services/product-events/clerk-events" +import type { ClerkOrganizationMembershipData } from "@/services/product-events/clerk-events" import { ProductEventsService } from "@/services/product-events/ProductEventsService" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { OrgId, UserId } from "@maple/domain/primitives" import { receiveSvixWebhook, webhookText } from "./svix-receiver" /** - * Clerk webhook receiver: `user.created` → `signup_completed` product event. - * Public route; authenticity is the Svix signature (`CLERK_WEBHOOK_SECRET`). - * Any other event type is acknowledged with 200 so Clerk does not retry it. + * Clerk webhook receiver: `user.created` → `signup_completed` product event, + * and `organizationMembership.*` → an org audit entry. Public route; + * authenticity is the Svix signature (`CLERK_WEBHOOK_SECRET`). Any other event + * type is acknowledged with 200 so Clerk does not retry it. + * + * Membership is the one org change the web app makes in Clerk rather than + * through Maple's API, so this receiver is the only writer of `affected_user`. + * Enabling the three `organizationMembership.*` events in the Clerk dashboard + * is what turns it on — until then Clerk simply never delivers them. */ const ROUTE = "/webhooks/clerk" +const decodeOrgId = Schema.decodeUnknownEffect(OrgId) +const decodeUserId = Schema.decodeUnknownEffect(UserId) + +/** + * Brand the two Clerk IDs together so a payload with either one malformed is + * dropped whole, rather than recording an entry against a half-known subject. + */ +const decodeMembershipIds = (data: ClerkOrganizationMembershipData) => + Effect.all({ + orgId: decodeOrgId(data.organization.id), + userId: decodeUserId(data.public_user_data.user_id), + }) + export const ClerkWebhookRouter = HttpRouter.use((router) => Effect.gen(function* () { const env = yield* Env const productEvents = yield* ProductEventsService + const audit = yield* AuditLogService const handle = Effect.fn("ClerkWebhook.receive")(function* ( req: HttpServerRequest.HttpServerRequest, @@ -59,6 +85,38 @@ export const ClerkWebhookRouter = HttpRouter.use((router) => } else { yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "parse_rejected" }) } + } else if (isClerkMembershipEvent(envelope.value.type)) { + const membership = yield* decodeClerkOrganizationMembership(envelope.value.data).pipe( + Effect.tapError((error) => + Effect.logInfo("Clerk membership payload failed to decode").pipe( + Effect.annotateLogs({ event: envelope.value.type, error: String(error) }), + ), + ), + Effect.option, + ) + if (Option.isSome(membership)) { + const ids = yield* decodeMembershipIds(membership.value).pipe(Effect.option) + if (Option.isSome(ids)) { + // Clerk's payload names the member, never the admin who acted, so + // attributing this to a user would be a guess. `system` says + // truthfully that Maple learned of the change rather than made it. + yield* audit.record({ + orgId: ids.value.orgId, + actor: { type: "system" }, + source: "system", + action: `member.${CLERK_MEMBERSHIP_EVENTS[envelope.value.type]}`, + affectedUserId: ids.value.userId, + ...(membership.value.role !== undefined + ? { metadata: { role: membership.value.role } } + : undefined), + }) + yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "handled" }) + } else { + yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "parse_rejected" }) + } + } else { + yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "parse_rejected" }) + } } else { yield* Effect.annotateCurrentSpan({ "maple.webhook.outcome": "ignored" }) } diff --git a/apps/api/src/routes/webhooks/webhooks.http.test.ts b/apps/api/src/routes/webhooks/webhooks.http.test.ts index f130cfb53..cd825d920 100644 --- a/apps/api/src/routes/webhooks/webhooks.http.test.ts +++ b/apps/api/src/routes/webhooks/webhooks.http.test.ts @@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http" import { Env } from "@/platform/Env" import { ProductEventsService, type ProductEventInput } from "@/services/product-events/ProductEventsService" import { signSvix } from "@/services/product-events/svix" +import { AuditLogService, type AuditLogRecordInput } from "@/services/audit/AuditLogService" import { AutumnWebhookRouter } from "./autumn.http" import { ClerkWebhookRouter } from "./clerk.http" @@ -34,11 +35,27 @@ const recordingProductEvents = () => { return { tracked, layer } } +const recordingAudit = () => { + const recorded: Array = [] + const layer = Layer.succeed(AuditLogService, { + record: (input) => Effect.sync(() => void recorded.push(input)), + list: () => Effect.succeed([]), + }) + return { recorded, layer } +} + const makeRouterLayer = ( router: typeof ClerkWebhookRouter, config: Record, productEvents: Layer.Layer, -) => router.pipe(Layer.provide(productEvents), Layer.provide(Env.layer), Layer.provide(makeConfig(config))) + audit: Layer.Layer = recordingAudit().layer, +) => + router.pipe( + Layer.provide(productEvents), + Layer.provide(audit), + Layer.provide(Env.layer), + Layer.provide(makeConfig(config)), + ) const signedHeaders = (secret: string, body: string, nowMs: number, id = "msg_test") => Effect.gen(function* () { @@ -118,7 +135,55 @@ const AUTUMN_BILLING_UPDATED = JSON.stringify({ }, }) +const CLERK_MEMBERSHIP_CREATED = JSON.stringify({ + type: "organizationMembership.created", + timestamp: 1_700_000_000_000, + data: { + organization: { id: "org_42" }, + public_user_data: { user_id: "user_2abc" }, + role: "org:admin", + }, +}) + describe("ClerkWebhookRouter", () => { + // Membership is changed in Clerk, never through Maple's API, so this receiver + // is the only writer of `affected_user`. + it.effect("audits an organizationMembership.created delivery against the member", () => + Effect.gen(function* () { + const events = recordingProductEvents() + const audit = recordingAudit() + const configured = HttpRouter.toWebHandler( + makeRouterLayer( + ClerkWebhookRouter, + { CLERK_WEBHOOK_SECRET: CLERK_SECRET }, + events.layer, + audit.layer, + ), + { disableLogger: true }, + ) + yield* Effect.gen(function* () { + const now = Date.now() + const headers = yield* signedHeaders(CLERK_SECRET, CLERK_MEMBERSHIP_CREATED, now) + const response = yield* post( + configured.handler, + "/webhooks/clerk", + CLERK_MEMBERSHIP_CREATED, + headers, + ) + assert.strictEqual(response.status, 200) + assert.strictEqual(audit.recorded.length, 1) + const entry = audit.recorded[0]! + assert.strictEqual(entry.action, "member.added") + assert.strictEqual(entry.affectedUserId, "user_2abc") + assert.strictEqual(entry.orgId, "org_42") + // Clerk's payload never names the admin who acted; claiming a user + // here would be a guess, so the entry is Maple recording what it learned. + assert.strictEqual(entry.actor.type, "system") + assert.strictEqual(entry.source, "system") + }).pipe(Effect.ensuring(Effect.promise(() => configured.dispose()))) + }), + ) + it.effect( "503s while unconfigured, 401s a bad signature, and emits signup_completed for user.created", () => diff --git a/apps/api/src/runtime/graph-boundaries.test.ts b/apps/api/src/runtime/graph-boundaries.test.ts index cadd8f9eb..70f3a3051 100644 --- a/apps/api/src/runtime/graph-boundaries.test.ts +++ b/apps/api/src/runtime/graph-boundaries.test.ts @@ -71,6 +71,8 @@ describe("API runtime graph boundaries", () => { "AlertReadModelsServiceLive", "AlertRulesServiceLive", "AlertsServiceLive", + // Lets `register_agent` (and issue-workflow mutations) write org audit entries. + "AuditLogService.layer", "DashboardPersistenceService.layer", "ErrorActorsServiceLive", "ErrorIssueReadModelsServiceLive", diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 40adb9d09..d8dd32b35 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -49,6 +49,8 @@ import { HttpV2InvestigationsLive } from "@/routes/v2/investigations.http" import { HttpV2MobileDevicesLive } from "@/routes/v2/mobile-devices.http" import { HttpV2OrganizationLive } from "@/routes/v2/organization.http" import { HttpV2InstrumentationRecommendationsLive } from "@/routes/v2/recommendations.http" +import { HttpV2AuditLogLive } from "@/routes/v2/audit-log.http" +import { AuditLogService } from "@/services/audit/AuditLogService" import { HttpV2ScrapeTargetsLive } from "@/routes/v2/scrape-targets.http" import { HttpV2InstrumentationAuditLive } from "@/routes/v2/setup-audit.http" import { HttpV2SessionReplaysLive } from "@/routes/v2/session-replays.http" @@ -130,6 +132,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2PlanetScaleIntegrationsLive, HttpV2ErrorIssuesLive, HttpV2AttributeMappingsLive, + HttpV2AuditLogLive, HttpV2ScrapeTargetsLive, HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, @@ -184,6 +187,8 @@ export const ApiAuthLive = Layer.mergeAll( ).pipe( Layer.provideMerge(ApiV2RateLimiter.layer), Layer.provideMerge(ApiKeysService.layer), + // Denied attempts are audited from inside the auth layers themselves. + Layer.provideMerge(AuditLogService.layer), // Membership verification for `x-maple-org-id`. Only the v2 layer asks for // it; without it that layer cannot build, which is deliberate — the header // must never end up silently ignored in a runtime that forgot to wire this. diff --git a/apps/api/src/runtime/mcp-service-graph.ts b/apps/api/src/runtime/mcp-service-graph.ts index 883798415..4adf4dda6 100644 --- a/apps/api/src/runtime/mcp-service-graph.ts +++ b/apps/api/src/runtime/mcp-service-graph.ts @@ -2,6 +2,7 @@ import { EdgeCacheService } from "@maple/cache" import { BucketCacheService } from "@maple/query-engine/caching" import { Layer } from "effect" import { McpToolExecutor } from "@/mcp/dispatcher" +import { AuditLogService } from "@/services/audit/AuditLogService" import { CacheBackendLive } from "@/platform/CacheBackendLive" import { EmailService } from "@/platform/EmailService" import { Env } from "@/platform/Env" @@ -104,7 +105,7 @@ const NotificationDispatcherLive = NotificationDispatcher.layer.pipe( const ErrorActorsServiceLive = ErrorActorsService.layer const ErrorIssueWorkflowServiceLive = ErrorIssueWorkflowService.layer.pipe( - Layer.provide(ErrorActorsServiceLive), + Layer.provide(Layer.mergeAll(ErrorActorsServiceLive, AuditLogService.layer)), ) const ErrorPolicyServiceLive = ErrorPolicyService.layer const ErrorIssueReadModelsServiceLive = ErrorIssueReadModelsService.layer.pipe( @@ -165,6 +166,7 @@ const McpRuntimeServicesLive = Layer.mergeAll( AlertReadModelsServiceLive, AlertRulesServiceLive, AlertsServiceLive, + AuditLogService.layer, DashboardPersistenceService.layer, ErrorActorsServiceLive, ErrorIssueReadModelsServiceLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 3c2a278de..8f263e7d4 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -53,6 +53,7 @@ import { GithubConnectService } from "@/services/integrations/vcs/vendor/github/ import { GithubHttp } from "@/services/integrations/vcs/vendor/github/GithubHttp" import { GithubProvider } from "@/services/integrations/vcs/vendor/github/GithubProvider" import { ApiKeysService } from "@/services/org/ApiKeysService" +import { AuditLogService } from "@/services/audit/AuditLogService" import { DemoService } from "@/services/org/DemoService" import { IngestAttributeMappingService } from "@/services/org/IngestAttributeMappingService" import { OnboardingService } from "@/services/org/OnboardingService" @@ -84,6 +85,7 @@ const EdgeCacheServiceLive = EdgeCacheService.layer.pipe(Layer.provide(CacheBack const CoreServicesLive = Layer.mergeAll( AuthService.layer, ApiKeysService.layer, + AuditLogService.layer, CliDeviceAuthService.layer, McpOAuthService.layer, CloudflareOAuthService.layer, @@ -176,6 +178,7 @@ const NotificationDispatcherLive = NotificationDispatcher.layer.pipe( const ErrorActorsServiceLive = ErrorActorsService.layer const ErrorIssueWorkflowServiceLive = ErrorIssueWorkflowService.layer.pipe( + Layer.provide(AuditLogService.layer), Layer.provideMerge(ErrorActorsServiceLive), ) const ErrorPolicyServiceLive = ErrorPolicyService.layer diff --git a/apps/api/src/services/audit/AuditLogService.test.ts b/apps/api/src/services/audit/AuditLogService.test.ts new file mode 100644 index 000000000..ddd3a8ca2 --- /dev/null +++ b/apps/api/src/services/audit/AuditLogService.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { OrgId, UserId } from "@maple/domain/primitives" +import { Effect, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuditLogService, recordHttpAudit } from "./AuditLogService" +import { CurrentTenant } from "@maple/domain/http" +import { ApiKeyId } from "@maple/domain/primitives" +import { type AuditActorInfo, CurrentAuditActor } from "@/services/auth/audit-actor" + +const asOrgId = Schema.decodeUnknownSync(OrgId) +const asUserId = Schema.decodeUnknownSync(UserId) + +const ORG = asOrgId("org_audit_log_test") +const USER = asUserId("user_audit_log_test") +const createdDbs: TestDb[] = [] + +afterEach(() => cleanupTestDbs(createdDbs)) + +const DASHBOARD_ID = "3f1b7c02-9a44-4d1e-8b2f-0c5d6e7a8b91" +const API_KEY = Schema.decodeUnknownSync(ApiKeyId)("7b2e4c10-55aa-4d3e-9f21-1a2b3c4d5e6f") + +const makeLayer = () => AuditLogService.layer.pipe(Layer.provide(createTestDb(createdDbs).layer)) + +/** Three entries with distinct timestamps: user, then api_key, then agent. */ +const seedThree = Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + // Internal ID in, public `dash_…` ID out — the service owns the encoding. + resourceId: DASHBOARD_ID, + metadata: { name: "First" }, + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "api_key" }, + source: "api", + action: "alert_rule.updated", + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent", label: "triage-bot" }, + source: "mcp", + action: "error_issue.state_change", + }) +}) + +describe("AuditLogService", () => { + it.effect("round-trips a recorded entry and lists newest first", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* seedThree + + const rows = yield* audit.list(ORG, { limit: 10, offset: 0 }) + expect(rows.map((row) => row.action)).toEqual([ + "error_issue.state_change", + "alert_rule.updated", + "dashboard.created", + ]) + + const oldest = rows[2]! + expect(oldest.actorType).toBe("user") + expect(oldest.userId).toBe(USER) + expect(oldest.source).toBe("dashboard") + expect(oldest.resourceType).toBe("dashboard") + expect(oldest.resourceId).toBe(encodePublicId(PublicIdPrefixes.dashboard, DASHBOARD_ID)) + expect(oldest.metadataJson).toEqual({ name: "First" }) + + const newest = rows[0]! + expect(newest.actorType).toBe("agent") + expect(newest.actorLabel).toBe("triage-bot") + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("filters by actor type", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* seedThree + + const apiKeyRows = yield* audit.list(ORG, { actorType: "api_key", limit: 10, offset: 0 }) + expect(apiKeyRows.map((row) => row.action)).toEqual(["alert_rule.updated"]) + + const systemRows = yield* audit.list(ORG, { actorType: "system", limit: 10, offset: 0 }) + expect(systemRows).toEqual([]) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("pages with offset and limit in newest-first order", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* seedThree + + const firstPage = yield* audit.list(ORG, { limit: 2, offset: 0 }) + expect(firstPage.map((row) => row.action)).toEqual([ + "error_issue.state_change", + "alert_rule.updated", + ]) + + const secondPage = yield* audit.list(ORG, { limit: 2, offset: 2 }) + expect(secondPage.map((row) => row.action)).toEqual(["dashboard.created"]) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("records denied outcomes and filters by outcome", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* seedThree + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "alert_rule.deleted", + outcome: "denied", + denialReason: "missing role: admin", + }) + + const denied = yield* audit.list(ORG, { outcome: "denied", limit: 10, offset: 0 }) + expect(denied.map((row) => row.action)).toEqual(["alert_rule.deleted"]) + expect(denied[0]!.outcome).toBe("denied") + expect(denied[0]!.denialReason).toBe("missing role: admin") + + const allowed = yield* audit.list(ORG, { outcome: "allowed", limit: 10, offset: 0 }) + expect(allowed).toHaveLength(3) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("stores update diffs and filters by changed field", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* seedThree + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.updated", + changes: { fields: ["name"], before: { name: "a" }, after: { name: "b" } }, + }) + + const rows = yield* audit.list(ORG, { changedField: "name", limit: 10, offset: 0 }) + expect(rows.map((row) => row.action)).toEqual(["dashboard.updated"]) + expect(rows[0]!.changedFields).toEqual(["name"]) + expect(rows[0]!.changesJson).toEqual({ + fields: ["name"], + before: { name: "a" }, + after: { name: "b" }, + }) + + const none = yield* audit.list(ORG, { changedField: "description", limit: 10, offset: 0 }) + expect(none).toEqual([]) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("publishes to the audit queue instead of writing when the binding is present", () => { + const sent: unknown[] = [] + return Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + }) + + expect(sent).toHaveLength(1) + expect(sent[0]).toMatchObject({ orgId: ORG, action: "dashboard.created" }) + // The consumer performs the insert; nothing lands in the DB directly. + const rows = yield* audit.list(ORG, { limit: 10, offset: 0 }) + expect(rows).toEqual([]) + }).pipe( + Effect.provide( + makeLayer().pipe( + Layer.provide( + Layer.succeed(WorkerEnvironment, { + AUDIT_EVENTS_QUEUE: { + send: async (message: unknown) => { + sent.push(message) + }, + }, + }), + ), + ), + ), + ) + }) + + // The credential and the surface are the two facts a mutation handler cannot + // re-derive, and getting them wrong is what made API-key and MCP actions read + // back as dashboard sessions. + describe("recordHttpAudit attribution", () => { + const tenant = new CurrentTenant.TenantSchema({ + orgId: ORG, + userId: USER, + roles: [], + authMode: "self_hosted", + }) + + const recordAs = (info: AuditActorInfo | undefined) => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* recordHttpAudit("dashboard.created", { resourceId: DASHBOARD_ID }) + const rows = yield* audit.list(ORG, { limit: 1, offset: 0 }) + return rows[0]! + }).pipe( + Effect.provideService(CurrentTenant.Context, tenant), + Effect.provideService(CurrentAuditActor, info), + Effect.provide(makeLayer().pipe(Layer.provide(Layer.succeed(WorkerEnvironment, {})))), + ) + + it.effect("attributes an API-key request to the key, not the dashboard", () => + Effect.gen(function* () { + const row = yield* recordAs({ type: "api_key", apiKeyId: API_KEY, source: "api" }) + expect(row.actorType).toBe("api_key") + expect(row.source).toBe("api") + expect(row.apiKeyId).toBe(API_KEY) + }), + ) + + it.effect("records the MCP surface rather than assuming a dashboard session", () => + Effect.gen(function* () { + const row = yield* recordAs({ type: "api_key", source: "mcp" }) + expect(row.source).toBe("mcp") + expect(row.actorType).toBe("api_key") + }), + ) + + it.effect("records Maple's own internal-token actions as system", () => + Effect.gen(function* () { + const row = yield* recordAs({ type: "system", source: "system" }) + expect(row.actorType).toBe("system") + expect(row.source).toBe("system") + }), + ) + + // Requests that skipped every auth middleware still have a tenant; the + // fallback must not invent a credential it did not see. + it.effect("falls back to the tenant user when no middleware set the reference", () => + Effect.gen(function* () { + const row = yield* recordAs(undefined) + expect(row.actorType).toBe("user") + expect(row.source).toBe("dashboard") + expect(row.userId).toBe(USER) + expect(row.apiKeyId).toBeNull() + }), + ) + }) + + it.effect("writes directly when the queue binding is absent from the worker environment", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + }) + + const rows = yield* audit.list(ORG, { limit: 10, offset: 0 }) + expect(rows.map((row) => row.action)).toEqual(["dashboard.created"]) + }).pipe( + Effect.provide(makeLayer().pipe(Layer.provide(Layer.succeed(WorkerEnvironment, {})))), + ), + ) +}) diff --git a/apps/api/src/services/audit/AuditLogService.ts b/apps/api/src/services/audit/AuditLogService.ts new file mode 100644 index 000000000..fbce6c231 --- /dev/null +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -0,0 +1,323 @@ +import { randomUUID } from "node:crypto" +import { HttpServerRequest } from "effect/unstable/http" +import { AuditLogPersistenceError, CurrentTenant } from "@maple/domain/http" +import type { AuditActorType, AuditChanges, AuditLogSource, AuditOutcome } from "@maple/domain/http" +import type { ActorId, ApiKeyId, OrgId, UserId } from "@maple/domain/primitives" +import { AuditLogEntryId as AuditLogEntryIdSchema } from "@maple/domain/primitives" +import { auditLogEntries, type AuditLogEntryRow } from "@maple/db" +import { and, arrayContains, desc, eq, gte, lte } from "drizzle-orm" +import { Clock, Context, Effect, Layer, Option, Schema } from "effect" +import type { Queue } from "@cloudflare/workers-types" +import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" +import { Database, type DatabaseError } from "@/platform/DatabaseLive" +import { msToDate } from "@/platform/time" +import { CurrentAuditActor } from "@/services/auth/audit-actor" +import { type AuditAction, auditResourceFields, type AuditResourceIdOption } from "./audit-actions" +import { AuditLogEvent, auditEventToInsert, encodeAuditLogEventSync } from "./audit-event" + +const decodeAuditLogEntryIdSync = Schema.decodeUnknownSync(AuditLogEntryIdSchema) + +class AuditQueueSendError extends Schema.TaggedError()( + "@maple/api/services/audit/AuditQueueSendError", + { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} + +/** Producer binding name; the paired `*_NAME` var drives consumer dispatch. */ +export const AUDIT_EVENTS_QUEUE_BINDING = "AUDIT_EVENTS_QUEUE" + +/** + * `queue.send` sits on the response path of every mutation and denial. A + * healthy send is tens of ms; 2s bounds a stalling broker before the entry + * degrades to a direct Postgres write. + */ +export const AUDIT_QUEUE_SEND_TIMEOUT = "2 seconds" + +const toPersistenceError = (error: DatabaseError) => + new AuditLogPersistenceError({ message: error.message, cause: error }) + +/** The credential-holder behind an audited action, as known at the call site. */ +export interface AuditActorRef { + readonly type: AuditActorType + readonly userId?: UserId + readonly apiKeyId?: ApiKeyId + readonly actorId?: ActorId + readonly label?: string +} + +export type AuditLogRecordInput = { + readonly orgId: OrgId + readonly actor: AuditActorRef + readonly source: AuditLogSource + /** Declared in `AuditResources`; the row's `resource_type` is derived from it. */ + readonly action: A + /** Defaults to `"allowed"`; denied attempts pass `"denied"` + `denialReason`. */ + readonly outcome?: AuditOutcome + readonly denialReason?: string + readonly affectedUserId?: UserId + readonly changes?: AuditChanges | undefined + readonly metadata?: Record + readonly requestId?: string + readonly originIp?: string + readonly originCountry?: string +} & AuditResourceIdOption + +export interface AuditLogListFilters { + readonly actorType?: AuditActorType + /** At most one of the three actor-identity filters is set per request. */ + readonly userId?: UserId + readonly apiKeyId?: ApiKeyId + readonly actorId?: ActorId + readonly affectedUserId?: UserId + readonly action?: string + readonly outcome?: AuditOutcome + readonly resourceType?: string + /** Matches the stored public form (e.g. `dash_…`). */ + readonly resourceId?: string + /** Field name that an update's diff must have touched. */ + readonly changedField?: string + readonly requestId?: string + readonly sinceMs?: number + readonly untilMs?: number + readonly limit: number + readonly offset: number +} + +export interface AuditLogServiceApi { + /** + * Append one entry, durably: published to the audit events queue when the + * binding is present (the consumer performs the insert, retried by the + * queue), written straight to Postgres otherwise (tests, local dev, crons). + * Never fails: a mutation that succeeded must not 500 because its audit + * write did not — terminal failures are logged and swallowed. + */ + readonly record: (input: AuditLogRecordInput) => Effect.Effect + readonly list: ( + orgId: OrgId, + filters: AuditLogListFilters, + ) => Effect.Effect, AuditLogPersistenceError> +} + +export class AuditLogService extends Context.Service()( + "@maple/api/services/AuditLogService", + { + make: Effect.gen(function* () { + const database = yield* Database + // Optional so PGlite tests and non-Worker runtimes fall back to direct + // writes without providing a WorkerEnvironment. + const workerEnv = yield* Effect.serviceOption(WorkerEnvironment) + const queue = Option.match(workerEnv, { + onNone: () => undefined, + onSome: (env) => { + const binding = env[AUDIT_EVENTS_QUEUE_BINDING] + // SAFETY: the binding slot is owned by this service; anything present is the queue. + return binding === undefined ? undefined : (binding as Queue) + }, + }) + + const insertDirect = (event: AuditLogEvent) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + yield* database.execute((db) => + db.insert(auditLogEntries).values(auditEventToInsert(event, now)).onConflictDoNothing(), + ) + }) + + // Queue unavailability must not lose the entry: degrade to a direct + // write before giving up. Only typed send failures land here — an + // interrupt must propagate, not spawn a Postgres insert mid-teardown. + const fallbackToDirect = (event: AuditLogEvent, error: AuditQueueSendError) => + Effect.logWarning("Audit queue send failed; writing directly", { cause: error }).pipe( + Effect.andThen(insertDirect(event)), + ) + + const publish = (event: AuditLogEvent) => + queue === undefined + ? insertDirect(event) + : Effect.tryPromise({ + try: () => queue.send(encodeAuditLogEventSync(event)), + catch: (cause) => + new AuditQueueSendError({ message: "Audit queue send failed", cause }), + }).pipe( + // A Queues brown-out that stalls (rather than rejects) must not + // hang the mutation's response: 2s is far above a healthy send's + // latency yet bounds the worst case before the direct-write fallback. + Effect.timeout(AUDIT_QUEUE_SEND_TIMEOUT), + Effect.catchTag("TimeoutError", (error) => + Effect.fail(new AuditQueueSendError({ message: "Audit queue send timed out", cause: error })), + ), + Effect.catchTag("@maple/api/services/audit/AuditQueueSendError", (error) => + fallbackToDirect(event, error), + ), + ) + + const record: AuditLogServiceApi["record"] = Effect.fn("AuditLogService.record")(function* ( + input, + ) { + const now = yield* Clock.currentTimeMillis + const resource = auditResourceFields(input.action, input.resourceId) + const event = new AuditLogEvent({ + orgId: input.orgId, + id: decodeAuditLogEntryIdSync(randomUUID()), + actorType: input.actor.type, + ...(input.actor.userId !== undefined ? { userId: input.actor.userId } : undefined), + ...(input.actor.apiKeyId !== undefined ? { apiKeyId: input.actor.apiKeyId } : undefined), + ...(input.actor.actorId !== undefined ? { actorId: input.actor.actorId } : undefined), + ...(input.actor.label !== undefined ? { actorLabel: input.actor.label } : undefined), + ...(input.affectedUserId !== undefined + ? { affectedUserId: input.affectedUserId } + : undefined), + source: input.source, + action: input.action, + outcome: input.outcome ?? "allowed", + ...(input.denialReason !== undefined ? { denialReason: input.denialReason } : undefined), + resourceType: resource.resourceType, + ...(resource.resourceId !== undefined ? { resourceId: resource.resourceId } : undefined), + ...(input.changes !== undefined ? { changes: input.changes } : undefined), + ...(input.metadata !== undefined ? { metadata: input.metadata } : undefined), + ...(input.requestId !== undefined ? { requestId: input.requestId } : undefined), + ...(input.originIp !== undefined ? { originIp: input.originIp } : undefined), + ...(input.originCountry !== undefined + ? { originCountry: input.originCountry } + : undefined), + occurredAtMs: now, + }) + // High-signal by definition — surfaced as a warning so Maple's own + // error/log alerting can watch for spikes of refused attempts. + if (event.outcome === "denied") { + yield* Effect.logWarning("Audit: denied action").pipe( + Effect.annotateLogs({ + orgId: event.orgId, + action: event.action, + actorType: event.actorType, + denialReason: event.denialReason ?? "", + }), + ) + } + // Swallow typed failures and defects — a mutation that succeeded must + // not 500 because its audit write did not — but let interrupts + // propagate so fiber teardown never triggers a stray insert. + yield* publish(event).pipe( + Effect.catch((error) => + Effect.logWarning("Audit log write failed", { action: input.action, cause: error }), + ), + Effect.catchDefect((defect) => + Effect.logWarning("Audit log write failed", { action: input.action, cause: defect }), + ), + ) + }) + + const list: AuditLogServiceApi["list"] = Effect.fn("AuditLogService.list")(function* ( + orgId, + filters, + ) { + const conditions = [ + eq(auditLogEntries.orgId, orgId), + ...(filters.actorType !== undefined + ? [eq(auditLogEntries.actorType, filters.actorType)] + : []), + ...(filters.userId !== undefined ? [eq(auditLogEntries.userId, filters.userId)] : []), + ...(filters.apiKeyId !== undefined + ? [eq(auditLogEntries.apiKeyId, filters.apiKeyId)] + : []), + ...(filters.actorId !== undefined ? [eq(auditLogEntries.actorId, filters.actorId)] : []), + ...(filters.affectedUserId !== undefined + ? [eq(auditLogEntries.affectedUserId, filters.affectedUserId)] + : []), + ...(filters.action !== undefined ? [eq(auditLogEntries.action, filters.action)] : []), + ...(filters.outcome !== undefined ? [eq(auditLogEntries.outcome, filters.outcome)] : []), + ...(filters.resourceType !== undefined + ? [eq(auditLogEntries.resourceType, filters.resourceType)] + : []), + ...(filters.resourceId !== undefined + ? [eq(auditLogEntries.resourceId, filters.resourceId)] + : []), + ...(filters.changedField !== undefined + ? [arrayContains(auditLogEntries.changedFields, [filters.changedField])] + : []), + ...(filters.requestId !== undefined + ? [eq(auditLogEntries.requestId, filters.requestId)] + : []), + ...(filters.sinceMs !== undefined + ? [gte(auditLogEntries.occurredAt, msToDate(filters.sinceMs))] + : []), + ...(filters.untilMs !== undefined + ? [lte(auditLogEntries.occurredAt, msToDate(filters.untilMs))] + : []), + ] + return yield* database + .execute((db) => + db + .select() + .from(auditLogEntries) + .where(and(...conditions)) + .orderBy(desc(auditLogEntries.occurredAt), desc(auditLogEntries.id)) + .limit(filters.limit) + .offset(filters.offset), + ) + .pipe(Effect.mapError(toPersistenceError)) + }) + + return { record, list } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} + +/** Request forensics for an audit entry, read off the Cloudflare request headers. */ +const requestContext = Effect.gen(function* () { + const request = yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest) + return Option.match(request, { + onNone: () => ({}), + onSome: (req) => ({ + ...(req.headers["cf-ray"] !== undefined ? { requestId: req.headers["cf-ray"] } : undefined), + ...(req.headers["cf-connecting-ip"] !== undefined + ? { originIp: req.headers["cf-connecting-ip"] } + : undefined), + ...(req.headers["cf-ipcountry"] !== undefined + ? { originCountry: req.headers["cf-ipcountry"] } + : undefined), + }), + }) +}) + +/** + * Record an audit entry for the current authenticated HTTP request, deriving + * the actor and surface from the tenant plus the auth middleware's + * `CurrentAuditActor`, and request forensics (request id, origin) from the + * Cloudflare headers. The credential kind and the surface both come from the + * reference — an API-key or MCP request must not read back as a dashboard + * session. + */ +export const recordHttpAudit = ( + action: A, + opts?: { + readonly changes?: AuditChanges | undefined + readonly affectedUserId?: UserId + readonly metadata?: Record + } & AuditResourceIdOption, +) => + Effect.gen(function* () { + const audit = yield* AuditLogService + const tenant = yield* CurrentTenant.Context + const info = yield* CurrentAuditActor + const context = yield* requestContext + // No reference means the request bypassed every auth middleware (internal + // tokens, tests). Attribute to the tenant's user rather than inventing a + // credential, but do not claim a surface the request may not have used. + yield* audit.record({ + orgId: tenant.orgId, + actor: { + type: info?.type ?? "user", + userId: tenant.userId, + ...(info?.apiKeyId !== undefined ? { apiKeyId: info.apiKeyId } : undefined), + }, + source: info?.source ?? "dashboard", + action, + ...context, + ...opts, + }) + }) diff --git a/apps/api/src/services/audit/audit-actions.test.ts b/apps/api/src/services/audit/audit-actions.test.ts new file mode 100644 index 000000000..b72044316 --- /dev/null +++ b/apps/api/src/services/audit/audit-actions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" +import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { ErrorIssueEventType } from "@maple/domain/http" +import { AuditResources, auditResourceFields } from "./audit-actions" + +describe("AuditResources", () => { + it("names every resource in the `.` snake_case shape the rows store", () => { + for (const [resource, { verbs }] of Object.entries(AuditResources)) { + expect(resource).toMatch(/^[a-z][a-z0-9_]*$/) + for (const verb of verbs) expect(verb).toMatch(/^[a-z][a-z0-9_]*$/) + } + }) + + // The issue workflow audits `error_issue.${type}` for every event type it + // attributes, so a new event type must not silently produce an undeclared action. + it("declares an `error_issue` verb for every issue event type", () => { + expect([...AuditResources.error_issue.verbs]).toEqual([...ErrorIssueEventType.literals]) + }) +}) + +describe("auditResourceFields", () => { + it("derives the resource type from the action", () => { + expect(auditResourceFields("alert_rule.created").resourceType).toBe("alert_rule") + expect(auditResourceFields("dashboard_share.rotated").resourceType).toBe("dashboard_share") + expect(auditResourceFields("dashboard.version_restored").resourceType).toBe("dashboard") + }) + + it("encodes the internal ID with the resource's own public prefix", () => { + const internal = "3f1b7c02-9a44-4d1e-8b2f-0c5d6e7a8b91" + const { resourceId } = auditResourceFields("alert_rule.created", internal) + expect(resourceId).toMatch(/^alrt_/) + expect(decodePublicId(PublicIdPrefixes.alertRule, resourceId!)).toBe(internal) + }) + + it("omits the resource ID for org-singleton resources", () => { + expect(auditResourceFields("ingest_key.rolled")).toEqual({ resourceType: "ingest_key" }) + expect(auditResourceFields("api.request")).toEqual({ resourceType: "api" }) + }) +}) diff --git a/apps/api/src/services/audit/audit-actions.ts b/apps/api/src/services/audit/audit-actions.ts new file mode 100644 index 000000000..444284d52 --- /dev/null +++ b/apps/api/src/services/audit/audit-actions.ts @@ -0,0 +1,119 @@ +import { encodePublicId, type PublicIdPrefix, PublicIdPrefixes } from "@maple/domain/http/v2" +import { ErrorIssueEventType } from "@maple/domain/http" + +/** + * Every audited action in Maple, grouped by the resource it acts on. + * + * The key is both the `resource_type` stored on the row and the `` + * half of the `.` action string, so the two can never disagree. + * `prefix` is the public-ID prefix the resource's internal ID is encoded with; + * resources that are org-singletons (`ingest_key`, `anomaly_settings`) or carry + * no resource at all (`api`) omit it, and passing a `resourceId` for one of + * those is a type error. + * + * Adding an entry here is what makes `record({ action: "." })` + * compile — a typo, or an action recorded before it is declared, fails the build. + */ +export const AuditResources = { + agent: { prefix: PublicIdPrefixes.actor, verbs: ["registered"] }, + alert_destination: { + prefix: PublicIdPrefixes.alertDestination, + verbs: ["created", "updated", "deleted"], + }, + alert_rule: { prefix: PublicIdPrefixes.alertRule, verbs: ["created", "updated", "deleted"] }, + anomaly_incident: { prefix: PublicIdPrefixes.anomalyIncident, verbs: ["resolved"] }, + /** Org-singleton settings — no resource id. */ + anomaly_settings: { verbs: ["updated"] }, + /** Refused requests, recorded by the auth layers; the route is in `metadata`. */ + api: { verbs: ["request"] }, + api_key: { prefix: PublicIdPrefixes.apiKey, verbs: ["created", "rolled", "revoked"] }, + attribute_mapping: { + prefix: PublicIdPrefixes.attributeMapping, + verbs: ["created", "updated", "deleted"], + }, + dashboard: { + prefix: PublicIdPrefixes.dashboard, + verbs: ["created", "updated", "deleted", "version_restored"], + }, + dashboard_share: { prefix: PublicIdPrefixes.dashboardShare, verbs: ["created", "rotated", "deleted"] }, + /** Verbs mirror the issue event types — `recordEvent` audits every one it attributes. */ + error_issue: { prefix: PublicIdPrefixes.errorIssue, verbs: ErrorIssueEventType.literals }, + /** Org-singleton public/private pair; which one rolled is in `metadata`. */ + ingest_key: { verbs: ["rolled"] }, + investigation: { prefix: PublicIdPrefixes.investigation, verbs: ["created", "restarted", "status_changed"] }, + /** + * Org-singleton connections. `*_started` is the admin action Maple sees; the + * OAuth round trip completes at the provider's callback. + */ + planetscale_integration: { + verbs: ["connect_started", "organization_selected", "metrics_token_set", "disconnected"], + }, + slack_integration: { verbs: ["install_started", "uninstalled"] }, + /** + * Org membership, learned from Clerk's webhook — the web app changes members + * in Clerk directly, so nothing reaches Maple's own API. The member is the + * entry's `affected_user`; no prefix, since Clerk IDs are already public. + */ + member: { verbs: ["added", "role_changed", "removed"] }, + /** + * The org itself. No prefix: every row already carries `org_id`, and a + * deleted org has no public ID left to resolve. + */ + organization: { verbs: ["deleted"] }, + scrape_target: { prefix: PublicIdPrefixes.scrapeTarget, verbs: ["created", "updated", "deleted"] }, + /** Org-singleton BYO-ClickHouse connection; holds warehouse credentials. */ + warehouse_settings: { verbs: ["updated", "deleted", "schema_applied"] }, + /** Short-lived device credentials for the mobile widget; keyed by installation. */ + widget_credential: { verbs: ["minted", "revoked"] }, +} as const satisfies Record + +interface AuditResourceDefinition { + readonly prefix?: PublicIdPrefix + readonly verbs: ReadonlyArray +} + +export type AuditResourceType = keyof typeof AuditResources + +/** `.` for every declared pair — the closed set of audit actions. */ +export type AuditAction = { + [K in AuditResourceType]: `${K}.${(typeof AuditResources)[K]["verbs"][number]}` +}[AuditResourceType] + +type ResourceOf = A extends `${infer R}.${string}` + ? R extends AuditResourceType + ? R + : never + : never + +/** + * The `resourceId` option for an action: the resource's *internal* ID, encoded + * to its public `_…` form on the way to the row. Resources that declare + * no prefix (org-singletons) accept no `resourceId` at all. + */ +export type AuditResourceIdOption = (typeof AuditResources)[ResourceOf] extends { + readonly prefix: PublicIdPrefix +} + ? { readonly resourceId?: string } + : { readonly resourceId?: never } + +/** + * Derive the row's `resource_type` from the action and encode the internal + * resource ID into its public form, so no call site restates either. + */ +export const auditResourceFields = ( + action: AuditAction, + resourceId?: string, +): { readonly resourceType: AuditResourceType; readonly resourceId?: string } => { + // SAFETY: every `AuditAction` is built as `${resource}.${verb}` from the keys + // of `AuditResources`, so the segment before the dot is always one of them. + const resourceType = action.slice(0, action.indexOf(".")) as AuditResourceType + const resource = AuditResources[resourceType] + // Narrow rather than widen: org-singleton resources declare no `prefix` at all. + const prefix = "prefix" in resource ? resource.prefix : undefined + return { + resourceType, + ...(resourceId !== undefined && prefix !== undefined + ? { resourceId: encodePublicId(prefix, resourceId) } + : undefined), + } +} diff --git a/apps/api/src/services/audit/audit-event.ts b/apps/api/src/services/audit/audit-event.ts new file mode 100644 index 000000000..30e79b5c5 --- /dev/null +++ b/apps/api/src/services/audit/audit-event.ts @@ -0,0 +1,62 @@ +import { AuditActorType, AuditChanges, AuditLogSource, AuditOutcome } from "@maple/domain/http" +import { ActorId, ApiKeyId, AuditLogEntryId, OrgId, UserId } from "@maple/domain/primitives" +import type { AuditLogEntryInsert } from "@maple/db" +import { Schema } from "effect" +import { msToDate } from "@/platform/time" + +/** + * The serialized audit event as it travels the audit queue. `occurredAtMs` is + * stamped by the producer; `recordedAt` exists only on the table row, stamped + * by whichever writer performs the insert. + */ +export class AuditLogEvent extends Schema.Class("AuditLogEvent")({ + orgId: OrgId, + id: AuditLogEntryId, + actorType: AuditActorType, + userId: Schema.optionalKey(UserId), + apiKeyId: Schema.optionalKey(ApiKeyId), + actorId: Schema.optionalKey(ActorId), + actorLabel: Schema.optionalKey(Schema.String), + affectedUserId: Schema.optionalKey(UserId), + source: AuditLogSource, + action: Schema.String, + outcome: AuditOutcome, + denialReason: Schema.optionalKey(Schema.String), + resourceType: Schema.optionalKey(Schema.String), + resourceId: Schema.optionalKey(Schema.String), + changes: Schema.optionalKey(AuditChanges), + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + requestId: Schema.optionalKey(Schema.String), + originIp: Schema.optionalKey(Schema.String), + originCountry: Schema.optionalKey(Schema.String), + occurredAtMs: Schema.Number, +}) {} + +export const decodeAuditLogEvent = Schema.decodeUnknownEffect(AuditLogEvent) +export const encodeAuditLogEventSync = Schema.encodeSync(AuditLogEvent) + +/** Lower a queue event to its table row; `recordedAtMs` is the insert time. */ +export const auditEventToInsert = (event: AuditLogEvent, recordedAtMs: number): AuditLogEntryInsert => ({ + orgId: event.orgId, + id: event.id, + actorType: event.actorType, + userId: event.userId ?? null, + apiKeyId: event.apiKeyId ?? null, + actorId: event.actorId ?? null, + actorLabel: event.actorLabel ?? null, + affectedUserId: event.affectedUserId ?? null, + source: event.source, + action: event.action, + outcome: event.outcome, + denialReason: event.denialReason ?? null, + resourceType: event.resourceType ?? null, + resourceId: event.resourceId ?? null, + changedFields: event.changes === undefined ? null : [...event.changes.fields], + changesJson: event.changes ?? null, + metadataJson: event.metadata ?? null, + requestId: event.requestId ?? null, + originIp: event.originIp ?? null, + originCountry: event.originCountry ?? null, + occurredAt: msToDate(event.occurredAtMs), + recordedAt: msToDate(recordedAtMs), +}) diff --git a/apps/api/src/services/audit/audit-log-retention.ts b/apps/api/src/services/audit/audit-log-retention.ts new file mode 100644 index 000000000..a1c176f05 --- /dev/null +++ b/apps/api/src/services/audit/audit-log-retention.ts @@ -0,0 +1,79 @@ +import { auditLogEntries } from "@maple/db" +import { sql } from "drizzle-orm" +import { Clock, Config, Effect } from "effect" +import { Database } from "@/platform/DatabaseLive" +import { msToSqlTimestamp } from "@/platform/time" + +/** + * Retention for the org audit log (`audit_log_entries`). + * + * Entries older than `AUDIT_LOG_RETENTION_DAYS` (default 400 — a spec-friendly + * 13 months) are swept in bounded batches so one tick never holds its Postgres + * connection for minutes. Runs from the API worker's existing hourly retention + * cron rather than its own schedule — every new cron string costs an entry in + * both `wrangler.jsonc` and `alchemy.run.ts`, and a horizon this wide has no + * reason to tick on a different beat. + */ + +const DEFAULT_RETENTION_DAYS = 400 +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Rows per DELETE, and a per-tick ceiling; the hourly cadence drains any backlog. */ +const RETENTION_BATCH_ROWS = 5_000 +const RETENTION_MAX_BATCHES = 20 + +const retentionDaysConfig = Config.number("AUDIT_LOG_RETENTION_DAYS").pipe( + Config.withDefault(DEFAULT_RETENTION_DAYS), +) + +/** + * Apply retention. Every batch runs inside ONE `execute`: under `DatabasePgLive` + * each call dials and tears down its own postgres.js client, so the handshake + * count is what costs, not the statement count. + */ +export const runAuditLogRetention = Effect.gen(function* () { + const retentionDays = yield* retentionDaysConfig + const now = yield* Clock.currentTimeMillis + // Raw-fragment param: bind an ISO string, not a Date — see msToSqlTimestamp. + const cutoff = msToSqlTimestamp(now - retentionDays * DAY_MS) + const database = yield* Database + + const deleted = yield* database.execute(async (db) => { + let total = 0 + for (let batch = 0; batch < RETENTION_MAX_BATCHES; batch++) { + // ctid-addressed delete: one scan of the standalone occurred_at index + // finds the batch, and the DELETE fetches those exact tuples directly — + // no second lookup by a key the PK index (org_id, id) cannot serve. + const rows = await db + .delete(auditLogEntries) + .where( + sql`ctid IN (SELECT ctid FROM ${auditLogEntries} WHERE ${auditLogEntries.occurredAt} < ${cutoff}::timestamptz LIMIT ${RETENTION_BATCH_ROWS})`, + ) + .returning({ id: auditLogEntries.id }) + total += rows.length + if (rows.length < RETENTION_BATCH_ROWS) break + } + return total + }) + + yield* Effect.annotateCurrentSpan({ + "audit.retention.deleted": deleted, + "audit.retention.days": retentionDays, + "audit.retention.outcome": "completed", + }) + yield* Effect.logInfo("[audit] log retention tick complete").pipe( + Effect.annotateLogs({ deleted, retentionDays }), + ) +}).pipe( + // tapCause lets the cause propagate so `withSpan` marks the tick as Error. + Effect.tapCause((cause) => + Effect.annotateCurrentSpan({ "audit.retention.outcome": "failed" }).pipe( + Effect.flatMap(() => + Effect.logError("[audit] log retention tick failed").pipe( + Effect.annotateLogs({ error: String(cause) }), + ), + ), + ), + ), + Effect.withSpan("AuditLogRetention.tick"), +) diff --git a/apps/api/src/services/auth/ApiAuthorizationLayer.ts b/apps/api/src/services/auth/ApiAuthorizationLayer.ts index 8db63c1ac..6c57bf3ac 100644 --- a/apps/api/src/services/auth/ApiAuthorizationLayer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationLayer.ts @@ -4,6 +4,9 @@ import { Effect, Layer, Option, Schema } from "effect" import { ApiKeysService } from "@/services/org/ApiKeysService" import { makeResolveTenant } from "./AuthService" import { annotateAuthSpan } from "@/services/auth/auth-span" +import { CurrentAuditActor } from "@/services/auth/audit-actor" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { recordApiDenial } from "@/services/auth/audit-denial" import { Env } from "@/platform/Env" const decodeRoleNameSync = Schema.decodeUnknownSync(RoleName) @@ -22,6 +25,7 @@ export const ApiAuthorizationLayer = Layer.effect( Effect.gen(function* () { const env = yield* Env const apiKeys = yield* ApiKeysService + const audit = yield* AuditLogService const resolveTenant = makeResolveTenant(env) return CurrentTenant.Authorization.of({ @@ -42,12 +46,25 @@ export const ApiAuthorizationLayer = Layer.effect( if (Option.isSome(apiKeyResolved)) { const resolved = apiKeyResolved.value + // Denied attempts are audited with the same attribution as + // successes — a key probing a surface it is not valid for is + // exactly what the audit log exists to surface. This layer has + // no rate limiter, so coalescing is what bounds the volume. + const recordDenied = (denialReason: string) => + recordApiDenial(audit, request, { + orgId: resolved.orgId, + userId: resolved.userId, + apiKeyId: resolved.keyId, + denialReason, + }) if (resolved.kind !== "standard") { + yield* recordDenied("This API key is only valid for the MCP server") return yield* new UnauthorizedError({ message: "This API key is only valid for the MCP server", }) } if (resolved.scopes !== null) { + yield* recordDenied("Restricted API keys must use the /v2 API") return yield* new UnauthorizedError({ message: "Restricted API keys must use the /v2 API", }) @@ -63,15 +80,21 @@ export const ApiAuthorizationLayer = Layer.effect( roles: resolved.roles ?? apiKeyDefaultRoles, authMode: "self_hosted", }) - return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) + return yield* httpEffect.pipe( + Effect.provideService(CurrentTenant.Context, tenant), + Effect.provideService(CurrentAuditActor, { + type: "api_key", + apiKeyId: resolved.keyId, + source: "api", + }), + ) } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - return yield* Effect.provideService( - httpEffect, - CurrentTenant.Context, - new CurrentTenant.TenantSchema(tenant), + return yield* httpEffect.pipe( + Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), + Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), ) }), }) diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 10010fa08..b818b8429 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -15,6 +15,9 @@ import { ORG_SELECTION_HEADER } from "@maple/auth" import { makeResolveTenant } from "./AuthService" import { OrgMembershipService } from "@/services/auth/OrgMembershipService" import { annotateAuthSpan } from "@/services/auth/auth-span" +import { CurrentAuditActor } from "@/services/auth/audit-actor" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { recordApiDenial } from "@/services/auth/audit-denial" import { Env } from "@/platform/Env" import { API_V2_RATE_LIMIT_PERIOD_SECONDS, @@ -57,6 +60,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( const env = yield* Env const apiKeys = yield* ApiKeysService const rateLimiter = yield* ApiV2RateLimiter + const audit = yield* AuditLogService // The one resolver wired for organization selection: `x-maple-org-id` is // a v2-client affordance (the iOS app publishing a widget snapshot per // organization), and every other resolver rejects the header instead. @@ -82,6 +86,17 @@ export const ApiAuthorizationV2Layer = Layer.effect( if (Option.isSome(apiKeyResolved)) { const resolved = apiKeyResolved.value + // A refused attempt is the highest-signal audit row there is — + // denials carry the same actor attribution as successes, tagged + // `outcome: "denied"`, coalesced so a looping client cannot + // amplify into unbounded rows. + const recordDenied = (denialReason: string) => + recordApiDenial(audit, request, { + orgId: resolved.orgId, + userId: resolved.userId, + apiKeyId: resolved.keyId, + denialReason, + }) // Deny-list, not an allow-list: `mcp` keys are minted through a // path that does not gate on organization admin, so they must // never reach the public API. `device` keys are admitted @@ -89,9 +104,9 @@ export const ApiAuthorizationV2Layer = Layer.effect( // pinned roles below — is chosen by the server that minted // them, not by whatever is holding them. if (resolved.kind === "mcp") { - return yield* Effect.fail( - V2InvalidCredentials.make("This API key is only valid for the MCP server."), - ) + const message = "This API key is only valid for the MCP server." + yield* recordDenied(message) + return yield* Effect.fail(V2InvalidCredentials.make(message)) } // A device credential's authority is entirely its pinned @@ -100,9 +115,9 @@ export const ApiAuthorizationV2Layer = Layer.effect( // permissive default — it is a key whose defining property // is missing, so it is rejected rather than promoted. if (resolved.kind === "device" && resolved.roles === null) { - return yield* Effect.fail( - V2InvalidCredentials.make("This device credential is not valid."), - ) + const message = "This device credential is not valid." + yield* recordDenied(message) + return yield* Effect.fail(V2InvalidCredentials.make(message)) } // Attribute before the scope check so scope-rejected @@ -130,11 +145,9 @@ export const ApiAuthorizationV2Layer = Layer.effect( const required = requiredScopeForRequest(request.method, requestPath(request.url)) if (required !== null && !scopeAllows(resolved.scopes, required)) { - return yield* Effect.fail( - V2InsufficientScope.make( - `This API key does not have the "${required.family}:${required.access}" scope required for this request.`, - ), - ) + const message = `This API key does not have the "${required.family}:${required.access}" scope required for this request.` + yield* recordDenied(message) + return yield* Effect.fail(V2InsufficientScope.make(message)) } // An API key is already organization-bound, so a selection could @@ -143,11 +156,9 @@ export const ApiAuthorizationV2Layer = Layer.effect( // check has to be here too. const requestedOrg = getOrgSelectionHeader(request.headers) if (requestedOrg !== undefined && requestedOrg !== resolved.orgId) { - return yield* Effect.fail( - V2OrganizationAccessDenied.make( - "An API key cannot select a different organization.", - ), - ) + const message = "An API key cannot select a different organization." + yield* recordDenied(message) + return yield* Effect.fail(V2OrganizationAccessDenied.make(message)) } const tenant = new CurrentTenant.TenantSchema({ @@ -157,7 +168,14 @@ export const ApiAuthorizationV2Layer = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) - return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) + return yield* httpEffect.pipe( + Effect.provideService(CurrentTenant.Context, tenant), + Effect.provideService(CurrentAuditActor, { + type: "api_key", + apiKeyId: resolved.keyId, + source: "api", + }), + ) } const tenant = yield* resolveTenant(request.headers).pipe( @@ -166,10 +184,9 @@ export const ApiAuthorizationV2Layer = Layer.effect( ), ) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - return yield* Effect.provideService( - httpEffect, - CurrentTenant.Context, - new CurrentTenant.TenantSchema(tenant), + return yield* httpEffect.pipe( + Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), + Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), ) }), }) diff --git a/apps/api/src/services/auth/SessionAuthorizationLayer.ts b/apps/api/src/services/auth/SessionAuthorizationLayer.ts index 3917091d7..6c2df1fce 100644 --- a/apps/api/src/services/auth/SessionAuthorizationLayer.ts +++ b/apps/api/src/services/auth/SessionAuthorizationLayer.ts @@ -4,6 +4,7 @@ import { CurrentTenant } from "@maple/domain/http" import { Effect, Layer } from "effect" import { makeResolveTenant } from "./AuthService" import { annotateAuthSpan } from "@/services/auth/auth-span" +import { CurrentAuditActor } from "@/services/auth/audit-actor" import { Env } from "@/platform/Env" const getBearerToken = (headers: Record): string | undefined => { @@ -47,10 +48,9 @@ export const SessionAuthorizationLayer = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - return yield* Effect.provideService( - httpEffect, - CurrentTenant.Context, - new CurrentTenant.TenantSchema(tenant), + return yield* httpEffect.pipe( + Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), + Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), ) }), }) diff --git a/apps/api/src/services/auth/audit-actor.ts b/apps/api/src/services/auth/audit-actor.ts new file mode 100644 index 000000000..43147417e --- /dev/null +++ b/apps/api/src/services/auth/audit-actor.ts @@ -0,0 +1,30 @@ +import { Context } from "effect" +import type { AuditLogSource } from "@maple/domain/http" +import type { ApiKeyId } from "@maple/domain/primitives" + +/** + * How the current request authenticated, for audit attribution. The tenant + * context deliberately does not say whether a request came from a dashboard + * session, an API key, or MCP — this reference carries those two facts, which + * nothing downstream can re-derive. + */ +export interface AuditActorInfo { + /** `system` is Maple itself acting through an internal service token. */ + readonly type: "user" | "api_key" | "system" + readonly apiKeyId?: ApiKeyId + /** The surface the request arrived through, recorded as the entry's `source`. */ + readonly source: AuditLogSource +} + +/** + * A reference (typed default, no handler requirement) rather than a service: + * the auth middlewares override it per request, and handlers that never record + * audit entries are unaffected. `undefined` means the request skipped the + * standard auth middlewares (internal tokens, queue consumers, crons) — callers + * must then fall back to whatever attribution they can establish themselves, + * never assume a dashboard session. + */ +export class CurrentAuditActor extends Context.Reference( + "@maple/api/services/auth/CurrentAuditActor", + { defaultValue: () => undefined }, +) {} diff --git a/apps/api/src/services/auth/audit-denial.ts b/apps/api/src/services/auth/audit-denial.ts new file mode 100644 index 000000000..f8dd9352e --- /dev/null +++ b/apps/api/src/services/auth/audit-denial.ts @@ -0,0 +1,94 @@ +import { Clock, Effect } from "effect" +import type { HttpServerRequest } from "effect/unstable/http" +import type { ApiKeyId, OrgId, UserId } from "@maple/domain/primitives" +import type { AuditLogServiceApi } from "@/services/audit/AuditLogService" + +/** Suppress duplicate denial rows for the same key/reason within this window. */ +export const AUDIT_DENIAL_COALESCE_WINDOW_MS = 60_000 + +/** Bound on distinct in-flight denial signatures kept per isolate. */ +const MAX_TRACKED_DENIALS = 10_000 + +/** + * Isolate-local coalescing cache: last-recorded time per denial signature. + * Tradeoff: Workers isolates multiply and recycle, so suppression is + * best-effort — each isolate still records the first denial it sees, which is + * the forensic signal; only the repeat volume is shed, with no network hop. + */ +const recentDenials = new Map() + +/** Test-only: clear the isolate-local coalescing state between cases. */ +export const resetAuditDenialCoalescing = (): void => { + recentDenials.clear() +} + +/** + * True when this signature has not been recorded within the window; marks it + * recorded. The timestamp is not refreshed on suppression, so a sustained loop + * still lands one row per window rather than going silent forever. + */ +const shouldRecordDenial = (signature: string, now: number): boolean => { + const last = recentDenials.get(signature) + if (last !== undefined && now - last < AUDIT_DENIAL_COALESCE_WINDOW_MS) return false + // Delete-then-set keeps insertion order ≈ recency, so the bound evicts the stalest signature. + recentDenials.delete(signature) + if (recentDenials.size >= MAX_TRACKED_DENIALS) { + const oldest = recentDenials.keys().next() + if (!oldest.done) recentDenials.delete(oldest.value) + } + recentDenials.set(signature, now) + return true +} + +export interface ApiDenialInput { + readonly orgId: OrgId + readonly userId: UserId + readonly apiKeyId: ApiKeyId + readonly denialReason: string +} + +const requestPath = (url: string): string => { + const queryStart = url.indexOf("?") + return queryStart === -1 ? url : url.slice(0, queryStart) +} + +/** + * Record a denied public-API request with full forensics (method+path plus the + * `cf-ray`/`cf-connecting-ip`/`cf-ipcountry` headers), coalescing duplicates: + * the same (org, key, method+path, reason) is written at most once per window + * so a client looping mis-scoped requests cannot amplify into unbounded queue + * messages, rows, and warn logs. Never fails — same contract as `record`. + */ +export const recordApiDenial = ( + audit: AuditLogServiceApi, + request: HttpServerRequest.HttpServerRequest, + input: ApiDenialInput, +): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const path = requestPath(request.url) + const signature = `${input.orgId}|${input.apiKeyId}|${request.method} ${path}|${input.denialReason}` + if (!shouldRecordDenial(signature, now)) return + yield* audit.record({ + orgId: input.orgId, + actor: { + type: "api_key", + userId: input.userId, + apiKeyId: input.apiKeyId, + }, + source: "api", + action: "api.request", + outcome: "denied", + denialReason: input.denialReason, + metadata: { method: request.method, path }, + ...(request.headers["cf-ray"] !== undefined + ? { requestId: request.headers["cf-ray"] } + : undefined), + ...(request.headers["cf-connecting-ip"] !== undefined + ? { originIp: request.headers["cf-connecting-ip"] } + : undefined), + ...(request.headers["cf-ipcountry"] !== undefined + ? { originCountry: request.headers["cf-ipcountry"] } + : undefined), + }) + }) diff --git a/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts b/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts index 8e6120f40..e2002a232 100644 --- a/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts +++ b/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts @@ -7,6 +7,7 @@ import { Clock, Effect, Layer, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { msToDate } from "@/platform/time" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuditLogService } from "@/services/audit/AuditLogService" import { WarehouseQueryService, type WarehouseQueryServiceApi, @@ -66,7 +67,11 @@ const makeWarehouseStub = (contexts: Array): WarehouseQueryServiceApi => const makeLayer = (contexts: Array) => { const database = createTestDb(createdDbs).layer const actors = ErrorActorsService.layer.pipe(Layer.provide(database)) - const workflow = ErrorIssueWorkflowService.layer.pipe(Layer.provide(database), Layer.provide(actors)) + const workflow = ErrorIssueWorkflowService.layer.pipe( + Layer.provide(AuditLogService.layer), + Layer.provide(database), + Layer.provide(actors), + ) const warehouse = Layer.succeed(WarehouseQueryService, makeWarehouseStub(contexts)) const readModels = readRequirements.pipe( Layer.provide(database), diff --git a/apps/api/src/services/errors/ErrorIssueWorkflowService.test.ts b/apps/api/src/services/errors/ErrorIssueWorkflowService.test.ts index a4ca5ae30..b3cbb77dd 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.test.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.test.ts @@ -18,13 +18,17 @@ import { import { and, eq } from "drizzle-orm" import { Database } from "@/platform/DatabaseLive" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ErrorActorsService } from "./ErrorActorsService" import { ErrorIssueWorkflowService } from "./ErrorIssueWorkflowService" // Compile-time guard: broadening this service to warehouse, cache, Env, // notifications, or WorkerEnvironment makes this assignment fail. -const databaseAndActorsOnly: Layer.Layer = - ErrorIssueWorkflowService.layer +const databaseAndActorsOnly: Layer.Layer< + ErrorIssueWorkflowService, + never, + Database | ErrorActorsService | AuditLogService +> = ErrorIssueWorkflowService.layer const asOrgId = Schema.decodeUnknownSync(OrgId) const asPullRequestId = Schema.decodeUnknownSync(ErrorIssuePullRequestId) @@ -42,7 +46,8 @@ afterEach(() => cleanupTestDbs(createdDbs)) const makeLayer = () => { const database = createTestDb(createdDbs).layer const actors = ErrorActorsService.layer.pipe(Layer.provide(database)) - const workflow = databaseAndActorsOnly.pipe(Layer.provide(Layer.mergeAll(database, actors))) + const audit = AuditLogService.layer.pipe(Layer.provide(database)) + const workflow = databaseAndActorsOnly.pipe(Layer.provide(Layer.mergeAll(database, actors, audit))) return Layer.mergeAll(workflow, actors).pipe(Layer.provideMerge(database)) } diff --git a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts index 875d91b8e..27b4c837f 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts @@ -23,6 +23,7 @@ import { MACHINE_OWNED_WORKFLOW_STATES, } from "@maple/domain/http" import { + actors, alertIncidents, errorIncidents, errorIssues, @@ -37,6 +38,9 @@ import { import { and, desc, eq, inArray, sql } from "drizzle-orm" import { Clock, Context, Effect, Layer, Option, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { CurrentAuditActor } from "@/services/auth/audit-actor" +import { SYSTEM_ERRORS_AGENT_NAME } from "@/services/auth/system-actors" import { readTxid, txidColumn } from "@/platform/electric-txid" import { dateToMs, msToDate } from "@/platform/time" import { ErrorActorsService } from "./ErrorActorsService" @@ -169,10 +173,15 @@ export interface ErrorIssueWorkflowServiceApi extends ErrorIssueWorkflowPublicAp > } -const make: Effect.Effect = Effect.gen( +const make: Effect.Effect< + ErrorIssueWorkflowServiceApi, + never, + Database | ErrorActorsService | AuditLogService +> = Effect.gen( function* () { const database = yield* Database - const actors = yield* ErrorActorsService + const actorsService = yield* ErrorActorsService + const audit = yield* AuditLogService const dbExecute = makeErrorDatabaseExecute(database, "ErrorIssueWorkflowService") const newEventId = () => decodeEventIdSync(randomUUID()) @@ -376,7 +385,7 @@ const make: Effect.Effect row.id) const openSet = yield* issuesWithOpenIncidents(orgId, issueIds) const activityMap = yield* issueActivityRollups(orgId, issueIds) - const actorMap = yield* actors.collectActorDocs( + const actorMap = yield* actorsService.collectActorDocs( orgId, rows.flatMap((row) => [row.assignedActorId ?? null, row.leaseHolderActorId ?? null]), ) @@ -392,6 +401,78 @@ const make: Effect.Effect + Effect.gen(function* () { + const rows = yield* dbExecute((db) => + db + .select() + .from(actors) + .where(and(eq(actors.orgId, orgId), eq(actors.id, actorId))) + .limit(1), + ) + const actor = rows[0] + if (actor === undefined || (actor.type !== "agent" && actor.type !== "user")) return + // Maple's own sweeps run as an agent actor (`ensureSystemActor` mints + // one), so without this check auto-close, lease expiry and fix + // verification all read as a third-party agent acting over MCP. + const isSystemActor = actor.type === "agent" && actor.agentName === SYSTEM_ERRORS_AGENT_NAME + // The actors row knows *who*, never *how*: it is the same row whether + // the mutation arrived from the dashboard, an API key, or MCP. The + // request's `CurrentAuditActor` is the only thing that knows the + // credential and surface, so a human actor is attributed through it and + // falls back to a dashboard session only when nothing set it (queue + // consumers, crons). + const request = yield* CurrentAuditActor + yield* audit.record({ + orgId, + actor: isSystemActor + ? { type: "system", actorId, label: SYSTEM_ERRORS_AGENT_NAME } + : actor.type === "agent" + ? { + type: "agent", + actorId, + ...(actor.agentName === null ? undefined : { label: actor.agentName }), + // On-behalf-of: the human who registered the agent, the + // closest authority the actor registry records. + ...(actor.createdBy === null ? undefined : { userId: actor.createdBy }), + } + : { + type: request?.type ?? "user", + ...(actor.userId === null ? undefined : { userId: actor.userId }), + ...(request?.apiKeyId === undefined + ? undefined + : { apiKeyId: request.apiKeyId }), + actorId, + }, + source: isSystemActor + ? "system" + : actor.type === "agent" + ? "mcp" + : (request?.source ?? "dashboard"), + action: `error_issue.${type}`, + resourceId: issueId, + metadata: { + ...(opts.fromState != null ? { from_state: opts.fromState } : undefined), + ...(opts.toState != null ? { to_state: opts.toState } : undefined), + }, + }) + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Issue event audit write failed", { issueId, cause }), + ), + ) + const recordEvent: ErrorIssueWorkflowServiceApi["recordEvent"] = Effect.fn( "ErrorsService.recordEvent", )(function* (orgId, issueId, actorId, type, opts = {}) { @@ -407,7 +488,12 @@ const make: Effect.Effect db.insert(errorIssueEvents).values(insert)) + const inserted = yield* dbExecute((db) => db.insert(errorIssueEvents).values(insert)) + // System/sweep events carry no actor and stay out of the audit log. + if (actorId !== null) { + yield* recordEventAudit(orgId, issueId, actorId, type, opts) + } + return inserted }) /** @@ -525,7 +611,7 @@ const make: Effect.Effect db.insert(errorIssueEvents).values(row)) - yield* actors.touchActor(orgId, actorId, timestamp) - const actorMap = yield* actors.collectActorDocs(orgId, [actorId]) + // This path writes the event row itself rather than going through + // `recordEvent`, so the audit mirror has to be invoked explicitly. The + // comment body stays out of the row — the audit records that a comment + // was made, not what it said. + yield* recordEventAudit(orgId, issueId, actorId, type, {}) + yield* actorsService.touchActor(orgId, actorId, timestamp) + const actorMap = yield* actorsService.collectActorDocs(orgId, [actorId]) return rowToEvent(row, actorMap) }) @@ -811,7 +902,7 @@ const make: Effect.Effect row.actorId ?? null), ) diff --git a/apps/api/src/services/errors/ErrorsService.test.ts b/apps/api/src/services/errors/ErrorsService.test.ts index d85917369..bb816e014 100644 --- a/apps/api/src/services/errors/ErrorsService.test.ts +++ b/apps/api/src/services/errors/ErrorsService.test.ts @@ -38,6 +38,7 @@ import { Database, DatabaseError } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { isRetryablePostgresContention } from "@/platform/postgres-errors" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuditLogService } from "@/services/audit/AuditLogService" import type { SqlQueryOptions, WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { ErrorActorsService } from "./ErrorActorsService" @@ -209,6 +210,7 @@ const makeErrorsLayer = ( const databaseLive = testDb.layer const errorActorsLive = ErrorActorsService.layer.pipe(Layer.provide(databaseLive)) const errorIssueWorkflowLive = ErrorIssueWorkflowService.layer.pipe( + Layer.provide(AuditLogService.layer), Layer.provide(databaseLive), Layer.provide(errorActorsLive), ) @@ -299,6 +301,7 @@ const makeGatingLayer = (opts: { const databaseLive = testDb.layer const errorActorsLive = ErrorActorsService.layer.pipe(Layer.provide(databaseLive)) const errorIssueWorkflowLive = ErrorIssueWorkflowService.layer.pipe( + Layer.provide(AuditLogService.layer), Layer.provide(databaseLive), Layer.provide(errorActorsLive), ) diff --git a/apps/api/src/services/errors/IssueFixVerificationService.test.ts b/apps/api/src/services/errors/IssueFixVerificationService.test.ts index 6383d30c5..43799cfc8 100644 --- a/apps/api/src/services/errors/IssueFixVerificationService.test.ts +++ b/apps/api/src/services/errors/IssueFixVerificationService.test.ts @@ -8,6 +8,7 @@ import { eq } from "drizzle-orm" import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuditLogService } from "@/services/audit/AuditLogService" import { ErrorActorsService } from "./ErrorActorsService" import { ErrorIssueWorkflowService } from "./ErrorIssueWorkflowService" import { PullRequestLookup } from "./PullRequestLookup" @@ -64,6 +65,7 @@ const makeLayer = (lookup?: { const envLive = Env.layer.pipe(Layer.provide(testConfig())) const actorsLive = ErrorActorsService.layer.pipe(Layer.provide(databaseLive)) const workflowLive = ErrorIssueWorkflowService.layer.pipe( + Layer.provide(AuditLogService.layer), Layer.provide(databaseLive), Layer.provide(actorsLive), ) diff --git a/apps/api/src/services/product-events/clerk-events.ts b/apps/api/src/services/product-events/clerk-events.ts index c193820ef..b6f61bdab 100644 --- a/apps/api/src/services/product-events/clerk-events.ts +++ b/apps/api/src/services/product-events/clerk-events.ts @@ -29,6 +29,38 @@ export const ClerkUserCreatedData = Schema.Struct({ created_at: Schema.optionalKey(Schema.Number), }) +/** + * `organizationMembership.*` payload. Membership is managed in Clerk directly — + * the web app never asks Maple's API to add or remove a member — so this + * webhook is the only place those changes can be audited. Clerk does not name + * the admin who made the change in this payload, only the member it happened + * to, which is why the resulting entries are attributed to `system`. + */ +export const ClerkOrganizationMembershipData = Schema.Struct({ + organization: Schema.Struct({ id: Schema.String }), + public_user_data: Schema.Struct({ user_id: Schema.String }), + role: Schema.optionalKey(Schema.String), +}) +export type ClerkOrganizationMembershipData = Schema.Schema.Type< + typeof ClerkOrganizationMembershipData +> + +export const decodeClerkOrganizationMembership = Schema.decodeUnknownEffect( + ClerkOrganizationMembershipData, +) + +/** The membership verbs Maple audits, keyed by Clerk's event type. */ +export const CLERK_MEMBERSHIP_EVENTS = { + "organizationMembership.created": "added", + "organizationMembership.updated": "role_changed", + "organizationMembership.deleted": "removed", +} as const satisfies Record + +export type ClerkMembershipEventType = keyof typeof CLERK_MEMBERSHIP_EVENTS + +export const isClerkMembershipEvent = (type: string): type is ClerkMembershipEventType => + Object.hasOwn(CLERK_MEMBERSHIP_EVENTS, type) + export const ClerkWebhookEnvelope = Schema.Struct({ type: Schema.String, data: Schema.Unknown, diff --git a/apps/api/src/vcs-sync-runtime.ts b/apps/api/src/vcs-sync-runtime.ts index 1094eddf1..fbe65b871 100644 --- a/apps/api/src/vcs-sync-runtime.ts +++ b/apps/api/src/vcs-sync-runtime.ts @@ -4,6 +4,7 @@ import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors" import { WorkerConfigProviderLayer, WorkerEnvironment } from "@maple/effect-cloudflare" import { Cause, Effect, Layer, Option } from "effect" import { layerPg } from "@/platform/DatabasePgLive" +import { AuditLogService } from "@/services/audit/AuditLogService" import { Env } from "@/platform/Env" import { GithubAppClient } from "./services/integrations/vcs/vendor/github/GithubAppClient" import { GithubHttp } from "./services/integrations/vcs/vendor/github/GithubHttp" @@ -56,7 +57,9 @@ export const buildVcsSyncLayer = (_env: Record) => { // rather than in `Base`, keeping the cron layer as light as it was. const ErrorActorsServiceLive = ErrorActorsService.layer.pipe(Layer.provide(Base)) const ErrorIssueWorkflowServiceLive = ErrorIssueWorkflowService.layer.pipe( - Layer.provide(Layer.mergeAll(Base, ErrorActorsServiceLive)), + Layer.provide( + Layer.mergeAll(Base, ErrorActorsServiceLive, AuditLogService.layer.pipe(Layer.provide(Base))), + ), ) const IssueFixVerificationServiceLive = IssueFixVerificationService.layer.pipe( Layer.provide( diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index b296a4bb3..523c11ce0 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -439,6 +439,17 @@ const handleQueue = async ( } return } + if (queueKind === "audit-events") { + const { buildAuditEventsLayer, processAuditEventsBatch, flushAuditEventsTelemetry } = await import( + "./audit-events-runtime" + ) + try { + await runScheduledEffect(buildAuditEventsLayer(env), await scoped(processAuditEventsBatch(batch)), ctx) + } finally { + ctx.waitUntil(flushAuditEventsTelemetry(env)) + } + return + } if (queueKind === "unknown") { throw new Error(`No queue consumer configured for "${batch.queue}"`) } @@ -475,14 +486,20 @@ const handleScheduled = async ( const { runScrapeCheckRetention } = await import("@/services/integrations/scrape-check-retention") const { runPlanetScaleEventRetention } = await import("@/services/integrations/planetscale-event-retention") + const { runAuditLogRetention } = await import("@/services/audit/audit-log-retention") try { - // Both sweeps ride this one cron: each new cron string costs an entry in - // wrangler.jsonc and alchemy.run.ts, and neither needs its own beat. + // All three sweeps ride this one cron: each new cron string costs an entry + // in wrangler.jsonc and alchemy.run.ts, and none needs its own beat. // Sequential, not concurrent — they share one Postgres socket for the // whole tick, so running them concurrently would only queue on it. await runScheduledEffect( buildScrapeRetentionLayer(env), - await scoped(Effect.andThen(runScrapeCheckRetention, runPlanetScaleEventRetention)), + await scoped( + Effect.andThen( + runScrapeCheckRetention, + Effect.andThen(runPlanetScaleEventRetention, runAuditLogRetention), + ), + ), ctx, { onInterrupt: "graceful" }, ) diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index cb25adbaf..17462b4de 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -16,6 +16,7 @@ "API_V2_RATE_LIMIT_PARTITION": "local", "PLANETSCALE_WEBHOOK_QUEUE_NAME": "maple-planetscale-webhooks-local", "VCS_SYNC_QUEUE_NAME": "maple-vcs-sync-local", + "AUDIT_EVENTS_QUEUE_NAME": "maple-audit-events-local", }, "ratelimits": [ { @@ -99,6 +100,7 @@ "binding": "PLANETSCALE_WEBHOOK_QUEUE", "queue": "maple-planetscale-webhooks-local", }, + { "binding": "AUDIT_EVENTS_QUEUE", "queue": "maple-audit-events-local" }, ], "consumers": [ { @@ -113,6 +115,16 @@ "max_batch_timeout": 5, "max_retries": 3, }, + { + "queue": "maple-audit-events-local", + "max_batch_size": 25, + "max_batch_timeout": 5, + "max_retries": 5, + // Mirrors alchemy.run.ts: exhausted audit entries park here rather + // than being dropped. Keep `max_retries` in sync with + // AUDIT_EVENTS_MAX_RETRIES in audit-events-runtime.ts. + "dead_letter_queue": "maple-audit-events-dlq-local", + }, ], }, } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 7b31b3aa8..c68b2076c 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7335,6 +7335,10 @@ "description": "Ingest-time attribute rewrite rules. Move or copy span/resource attribute values to new keys as telemetry arrives, normalizing naming across services without redeploying them.", "name": "Attribute Mappings" }, + { + "description": "The organization's append-only audit trail — allowed and denied actions performed through the dashboard, the public API, and MCP, attributed to the user, API key, or agent that performed them, with before/after diffs for updates. Reading it requires organization-administrator access (or the `audit_log:read` scope for API keys).", + "name": "Audit Log" + }, { "description": "Metrics endpoints Maple scrapes on a schedule — self-hosted Prometheus endpoints and PlanetScale branch metrics. Manage targets, probe them on demand, and inspect recent scrape checks. Credentials are write-only.", "name": "Scrape Targets" diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx new file mode 100644 index 000000000..6c6aab5d0 --- /dev/null +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -0,0 +1,367 @@ +import type { AuditActorType, AuditOutcome } from "@maple/domain/http" +import type { V2AuditChanges, V2AuditLogEntry } from "@maple/domain/http/v2" +import { useState, type ReactNode } from "react" + +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import { auditLogPageAtom } from "@/lib/services/atoms/audit-log-atoms" + +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { cn } from "@maple/ui/lib/utils" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { AlertWarningIcon, HistoryIcon } from "@/components/icons" + +type ActorFilter = AuditActorType | "all" +type OutcomeFilter = AuditOutcome | "all" + +const ACTOR_FILTERS: ReadonlyArray<{ value: ActorFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "user", label: "Users" }, + { value: "api_key", label: "API keys" }, + { value: "agent", label: "Agents" }, + { value: "system", label: "System" }, +] + +const OUTCOME_FILTERS: ReadonlyArray<{ value: OutcomeFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "allowed", label: "Allowed" }, + { value: "denied", label: "Denied" }, +] + +const ACTOR_BADGES: Record = { + user: { label: "User", variant: "secondary" }, + api_key: { label: "API key", variant: "success" }, + agent: { label: "Agent", variant: "info" }, + system: { label: "System", variant: "outline" }, +} satisfies Record + +// Shared column lanes so the header row and entry rows stay aligned. Resource and +// source collapse on narrower viewports; time + actor + action always stay visible. +const COL = { + time: "w-[96px] shrink-0", + actor: "w-[200px] min-w-0 shrink-0", + action: "min-w-0 flex-1", + resource: "hidden w-[220px] min-w-0 shrink-0 md:block", + source: "hidden w-[80px] shrink-0 lg:block", +} +const COL_HEADER = "text-muted-foreground/70 font-mono text-[10px] uppercase tracking-[0.12em]" + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString(undefined, { + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +// JSON.stringify(undefined) is undefined — surface it as text in tooltips. +function formatChangeValue(value: unknown): string { + return JSON.stringify(value) ?? "undefined" +} + +function formatChangesTooltip(changes: V2AuditChanges): string { + return changes.fields + .map( + (field) => + `${field}: ${formatChangeValue(changes.before[field])} → ${formatChangeValue(changes.after[field])}`, + ) + .join("\n") +} + +function formatSourceTooltip(entry: V2AuditLogEntry): string | undefined { + const lines = [ + entry.origin_ip !== null || entry.origin_country !== null + ? `From ${entry.origin_ip ?? "unknown IP"}${entry.origin_country !== null ? ` (${entry.origin_country})` : ""}` + : null, + entry.request_id !== null ? `Request ${entry.request_id}` : null, + ].filter((line) => line !== null) + return lines.length > 0 ? lines.join("\n") : undefined +} + +/** + * Append the next page, dropping any entry already shown. The pinned `until` + * ceiling makes overlap rare, but a filter re-fetch or a refresh mid-scroll can + * still repeat one — and a duplicated React key corrupts the list either way. + */ +function dedupeById( + existing: ReadonlyArray, + next: ReadonlyArray, +): V2AuditLogEntry[] { + const seen = new Set(existing.map((entry) => entry.id)) + return [...existing, ...next.filter((entry) => !seen.has(entry.id))] +} + +interface AuditLogView { + source: { data: ReadonlyArray } + entries: V2AuditLogEntry[] + hasMore: boolean + nextCursor: string | null +} + +export function AuditLogSection() { + const [actorFilter, setActorFilter] = useState("all") + const [outcomeFilter, setOutcomeFilter] = useState("all") + const [cursor, setCursor] = useState(undefined) + // Frozen on the first Load more, and cleared whenever the list restarts. The + // log is append-only and paginated by offset, so entries written mid-scroll + // would otherwise shift later pages and make them repeat and skip rows. + const [until, setUntil] = useState(undefined) + + const pageAtom = auditLogPageAtom({ + ...(cursor !== undefined ? { cursor } : undefined), + ...(until !== undefined ? { until } : undefined), + ...(actorFilter !== "all" ? { actorType: actorFilter } : undefined), + ...(outcomeFilter !== "all" ? { outcome: outcomeFilter } : undefined), + }) + const pageResult = useAtomValue(pageAtom) + const refreshPage = useAtomRefresh(pageAtom) + + // Each Load more / filter change swaps to a new page atom, which starts in its + // initial state. Keep the accumulated entries so the table stays rendered + // (dimmed) while the next page loads; a fresh (cursor-less) page replaces them. + const [view, setView] = useState(null) + if (Result.isSuccess(pageResult) && view?.source !== pageResult.value) { + setView({ + source: pageResult.value, + entries: + cursor === undefined + ? [...pageResult.value.data] + : dedupeById(view?.entries ?? [], pageResult.value.data), + hasMore: pageResult.value.has_more, + nextCursor: pageResult.value.next_cursor, + }) + } + + function handleFilterSelect(value: ActorFilter) { + if (value === actorFilter) return + setActorFilter(value) + setCursor(undefined) + setUntil(undefined) + } + + function handleOutcomeSelect(value: OutcomeFilter) { + if (value === outcomeFilter) return + setOutcomeFilter(value) + setCursor(undefined) + setUntil(undefined) + } + + const waiting = !Result.isSuccess(pageResult) || pageResult.waiting + + return ( +
+
+
+ {ACTOR_FILTERS.map((filter) => ( + handleFilterSelect(filter.value)} + > + {filter.label} + + ))} +
+
+ {OUTCOME_FILTERS.map((filter) => ( + handleOutcomeSelect(filter.value)} + > + {filter.label} + + ))} +
+
+ {/* Deliberately not "every change": this records configuration and + access changes plus refused attempts, not reads or telemetry. */} +

+ Configuration and access changes, from the dashboard, API, and MCP. +

+
+ +
+ {view === null && Result.isFailure(pageResult) ? ( + + + + + + Couldn't load the audit log + + Something went wrong while loading audit log entries. + + + + + ) : view === null ? ( +
+ + + +
+ ) : view.entries.length === 0 ? ( + + + + + + No audit log entries + + Actions performed by users, API keys, and agents will appear here. + + + + ) : ( +
+
+ Time + Actor + Action + Resource + Source +
+ {view.entries.map((entry) => ( + + ))} +
+ )} +
+ + {view !== null && view.hasMore && view.nextCursor !== null && ( +
+ Showing {view.entries.length} entries — more available + +
+ )} +
+ ) +} + +function FilterTab({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { + const badge = ACTOR_BADGES[entry.actor_type] + const actorLabel = entry.actor_name ?? entry.actor_id ?? "—" + + return ( +
+ + {formatRelativeTime(entry.occurred_at)} + +
+ + {badge.label} + + + {actorLabel} + +
+
+
+ + {entry.action} + + {entry.outcome === "denied" && ( + + Denied + + )} +
+ {entry.outcome === "denied" && entry.denial_reason !== null && ( +

+ {entry.denial_reason} +

+ )} + {entry.changes !== null && entry.changes.fields.length > 0 && ( +

+ {entry.changes.fields.join(", ")} +

+ )} +
+
+ {entry.resource_type !== null || entry.resource_id !== null ? ( +
+ {entry.resource_type !== null && ( + + {entry.resource_type} + + )} + {entry.resource_id !== null && ( + + {entry.resource_id} + + )} +
+ ) : ( + + )} +
+ + {entry.source} + {entry.origin_country !== null && ( + · {entry.origin_country} + )} + +
+ ) +} diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index 1efc11c9b..db51b7bf2 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -14,6 +14,7 @@ import { DatabaseIcon, GearIcon, GridIcon, + HistoryIcon, KeyIcon, ServerIcon, ShieldIcon, @@ -26,6 +27,7 @@ import { SettingsNavShell } from "@/components/settings/settings-nav-shell" export const settingsTabValues = [ "organization", "members", + "audit-log", "setup-audit", "ingestion", "api-keys", @@ -41,6 +43,7 @@ export type SettingsTab = (typeof settingsTabValues)[number] export const settingsTabLabels: Record = { organization: "Organization", members: "Members", + "audit-log": "Audit Log", "setup-audit": "Setup Audit", ingestion: "Ingestion", "api-keys": "API Keys", @@ -108,6 +111,7 @@ const navSections: SettingsNavSection[] = [ items: [ { id: "organization", label: "Organization", icon: GearIcon }, { id: "members", label: "Members", icon: UserIcon }, + { id: "audit-log", label: "Audit Log", icon: HistoryIcon }, // Spans alerting, ingestion and integrations, so it sits at workspace level rather than // under any one of them. { id: "setup-audit", label: "Setup Audit", icon: CircleCheckIcon }, @@ -194,6 +198,9 @@ export function useVisibleSettingsSections() { ...section, items: section.items.filter((item) => { if (item.id === "data-platform") return canAccessDataPlatform + // `GET /v2/audit_log` is admin-only; hide the tab rather than let a + // member open it into a 403. + if (item.id === "audit-log") return isAdmin return true }), })) diff --git a/apps/web/src/lib/services/atoms/audit-log-atoms.ts b/apps/web/src/lib/services/atoms/audit-log-atoms.ts new file mode 100644 index 000000000..1e1ccf353 --- /dev/null +++ b/apps/web/src/lib/services/atoms/audit-log-atoms.ts @@ -0,0 +1,51 @@ +import type { AuditActorType, AuditOutcome } from "@maple/domain/http" +import { Effect } from "effect" +import { Atom } from "@/lib/effect-atom" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" + +export const AUDIT_LOG_PAGE_LIMIT = 50 + +const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] +const OUTCOMES: ReadonlyArray = ["allowed", "denied"] + +export interface AuditLogPageInput { + readonly cursor?: string + readonly actorType?: AuditActorType + readonly outcome?: AuditOutcome + /** + * Upper bound on `occurred_at`, pinned by the caller when it takes the first + * page. Pagination here is offset-based over a newest-first, append-only + * table, so an entry written mid-scroll shifts every later row down by one: + * without a frozen ceiling the next page repeats a row and skips another. + */ + readonly until?: string +} + +// Actor types and outcomes never contain "|", nor does an ISO timestamp, and the +// cursor is the trailing segment — so splitting on the first three separators +// stays unambiguous even for exotic cursors. +const family = Atom.family((key: string) => { + const [actorRaw = "", outcomeRaw = "", until = ""] = key.split("|", 3) + const cursor = key.slice(actorRaw.length + outcomeRaw.length + until.length + 3) + const actorType = ACTOR_TYPES.find((type) => type === actorRaw) + const outcome = OUTCOMES.find((value) => value === outcomeRaw) + + return MapleApiV2AtomClient.runtime.atom( + Effect.gen(function* () { + const client = yield* MapleApiV2AtomClient + return yield* client.auditLog.list({ + query: { + limit: AUDIT_LOG_PAGE_LIMIT, + ...(cursor !== "" ? { cursor } : undefined), + ...(actorType !== undefined ? { actor_type: actorType } : undefined), + ...(outcome !== undefined ? { outcome } : undefined), + ...(until !== "" ? { until } : undefined), + }, + }) + }), + ) +}) + +/** One page of the org's audit log, keyed by cursor + filters + the pinned ceiling. */ +export const auditLogPageAtom = (input: AuditLogPageInput) => + family(`${input.actorType ?? ""}|${input.outcome ?? ""}|${input.until ?? ""}|${input.cursor ?? ""}`) diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 91ecda7a9..e2442ade8 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -8,6 +8,7 @@ import { BillingSection } from "@/components/settings/billing-section" import { MembersSection } from "@/components/settings/members-section" import { IngestionSection } from "@/components/settings/ingestion-section" import { ApiKeysSection } from "@/components/settings/api-keys-section" +import { AuditLogSection } from "@/components/settings/audit-log-section" import { DeveloperSection } from "@/components/settings/developer-section" import { McpSection } from "@/components/settings/mcp-section" import { NotificationsSection } from "@/components/settings/notifications-section" @@ -135,6 +136,7 @@ function SettingsPage() { {activeTab === "organization" && } {activeTab === "members" && } + {activeTab === "audit-log" && } {activeTab === "setup-audit" && } {activeTab === "ingestion" && } {activeTab === "api-keys" && } diff --git a/packages/db/drizzle/0050_audit_log_entries.sql b/packages/db/drizzle/0050_audit_log_entries.sql new file mode 100644 index 000000000..8247ac3a6 --- /dev/null +++ b/packages/db/drizzle/0050_audit_log_entries.sql @@ -0,0 +1,38 @@ +CREATE TABLE "audit_log_entries" ( + "org_id" text NOT NULL, + "id" text NOT NULL, + "actor_type" text NOT NULL, + "user_id" text, + "api_key_id" text, + "actor_id" text, + "actor_label" text, + "affected_user_id" text, + "source" text NOT NULL, + "action" text NOT NULL, + "outcome" text NOT NULL, + "denial_reason" text, + "resource_type" text, + "resource_id" text, + "changed_fields" text[], + "changes_json" jsonb, + "metadata_json" jsonb, + "request_id" text, + "origin_ip" text, + "origin_country" text, + "occurred_at" timestamp with time zone NOT NULL, + "recorded_at" timestamp with time zone NOT NULL, + CONSTRAINT "audit_log_entries_org_id_id_pk" PRIMARY KEY("org_id","id") +); +--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_occurred_idx" ON "audit_log_entries" USING btree ("org_id","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_actor_type_occurred_idx" ON "audit_log_entries" USING btree ("org_id","actor_type","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_resource_idx" ON "audit_log_entries" USING btree ("org_id","resource_type","resource_id");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_request_idx" ON "audit_log_entries" USING btree ("org_id","request_id");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_outcome_occurred_idx" ON "audit_log_entries" USING btree ("org_id","outcome","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_occurred_idx" ON "audit_log_entries" USING btree ("occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_user_occurred_idx" ON "audit_log_entries" USING btree ("org_id","user_id","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_api_key_occurred_idx" ON "audit_log_entries" USING btree ("org_id","api_key_id","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_actor_occurred_idx" ON "audit_log_entries" USING btree ("org_id","actor_id","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_affected_user_occurred_idx" ON "audit_log_entries" USING btree ("org_id","affected_user_id","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_org_action_occurred_idx" ON "audit_log_entries" USING btree ("org_id","action","occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entries_changed_fields_gin_idx" ON "audit_log_entries" USING gin ("changed_fields"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0050_snapshot.json b/packages/db/drizzle/meta/0050_snapshot.json new file mode 100644 index 000000000..caa784ce0 --- /dev/null +++ b/packages/db/drizzle/meta/0050_snapshot.json @@ -0,0 +1,9164 @@ +{ + "id": "5224691f-23a3-4172-b65b-be481ae93ad6", + "prevId": "d60d7088-c27b-48cd-94b6-6d9fd59a02ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_triage_settings": { + "name": "ai_triage_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_runs_per_day": { + "name": "max_runs_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "max_passes_per_day": { + "name": "max_passes_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_delivery_events": { + "name": "alert_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message": { + "name": "provider_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_delivery_events_org_idx": { + "name": "alert_delivery_events_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_org_incident_idx": { + "name": "alert_delivery_events_org_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_due_idx": { + "name": "alert_delivery_events_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_claim_idx": { + "name": "alert_delivery_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_delivery_attempt_idx": { + "name": "alert_delivery_events_delivery_attempt_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_destinations": { + "name": "alert_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_destinations_org_idx": { + "name": "alert_destinations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_enabled_idx": { + "name": "alert_destinations_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_name_idx": { + "name": "alert_destinations_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_incidents": { + "name": "alert_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_key": { + "name": "incident_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_delivered_event_type": { + "name": "last_delivered_event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_incidents_org_idx": { + "name": "alert_incidents_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_status_idx": { + "name": "alert_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_rule_idx": { + "name": "alert_incidents_org_rule_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_issue_idx": { + "name": "alert_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_incident_key_idx": { + "name": "alert_incidents_incident_key_idx", + "columns": [ + { + "expression": "incident_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_claims": { + "name": "alert_rule_claims", + "schema": "", + "columns": { + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_claims_org_idx": { + "name": "alert_rule_claims_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_states": { + "name": "alert_rule_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'__total__'" + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_states_org_idx": { + "name": "alert_rule_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alert_rule_states_org_id_rule_id_group_key_pk": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_template_json": { + "name": "notification_template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_names_json": { + "name": "service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_service_names_json": { + "name": "exclude_service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "environments_json": { + "name": "environments_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags_json": { + "name": "tags_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "window_minutes": { + "name": "window_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minimum_sample_count": { + "name": "minimum_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_breaches_required": { + "name": "consecutive_breaches_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "consecutive_healthy_required": { + "name": "consecutive_healthy_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "renotify_interval_minutes": { + "name": "renotify_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "apdex_threshold_ms": { + "name": "apdex_threshold_ms", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "query_builder_draft_json": { + "name": "query_builder_draft_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_query_sql": { + "name": "raw_query_sql", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_by": { + "name": "group_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "query_spec_json": { + "name": "query_spec_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reducer": { + "name": "reducer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_count_strategy": { + "name": "sample_count_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_data_behavior": { + "name": "no_data_behavior", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rules_org_idx": { + "name": "alert_rules_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_enabled_idx": { + "name": "alert_rules_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_name_idx": { + "name": "alert_rules_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_settings": { + "name": "anomaly_detector_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "muted_signals_json": { + "name": "muted_signals_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_states": { + "name": "anomaly_detector_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_incident_id": { + "name": "last_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_detector_states_open_incident_idx": { + "name": "anomaly_detector_states_open_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"anomaly_detector_states\".\"open_incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_evaluated_idx": { + "name": "anomaly_detector_states_evaluated_idx", + "columns": [ + { + "expression": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "anomaly_detector_states_org_id_detector_key_pk": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_incidents": { + "name": "anomaly_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_value": { + "name": "opened_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_sigma": { + "name": "baseline_sigma", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolve_reason": { + "name": "resolve_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprints_json": { + "name": "fingerprints_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reopen_count": { + "name": "reopen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reopened_at": { + "name": "last_reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_incidents_org_status_triggered_idx": { + "name": "anomaly_incidents_org_status_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_triggered_idx": { + "name": "anomaly_incidents_org_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_detector_idx": { + "name": "anomaly_incidents_org_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_issue_idx": { + "name": "anomaly_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_email": { + "name": "created_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_org_id_idx": { + "name": "api_keys_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log_entries": { + "name": "audit_log_entries", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "affected_user_id": { + "name": "affected_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "denial_reason": { + "name": "denial_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "changes_json": { + "name": "changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_ip": { + "name": "origin_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_country": { + "name": "origin_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_entries_org_occurred_idx": { + "name": "audit_log_entries_org_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_actor_type_occurred_idx": { + "name": "audit_log_entries_org_actor_type_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_resource_idx": { + "name": "audit_log_entries_org_resource_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_request_idx": { + "name": "audit_log_entries_org_request_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_outcome_occurred_idx": { + "name": "audit_log_entries_org_outcome_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "outcome", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_occurred_idx": { + "name": "audit_log_entries_occurred_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_user_occurred_idx": { + "name": "audit_log_entries_org_user_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_api_key_occurred_idx": { + "name": "audit_log_entries_org_api_key_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_actor_occurred_idx": { + "name": "audit_log_entries_org_actor_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_affected_user_occurred_idx": { + "name": "audit_log_entries_org_affected_user_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "affected_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_org_action_occurred_idx": { + "name": "audit_log_entries_org_action_occurred_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entries_changed_fields_gin_idx": { + "name": "audit_log_entries_changed_fields_gin_idx", + "columns": [ + { + "expression": "changed_fields", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "audit_log_entries_org_id_id_pk": { + "name": "audit_log_entries_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_analytics_state": { + "name": "cloudflare_analytics_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_at": { + "name": "backfill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_fetched_at": { + "name": "settings_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quantiles_available": { + "name": "quantiles_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "live_scripts_json": { + "name": "live_scripts_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cf_analytics_state_org_dataset_zone_idx": { + "name": "cf_analytics_state_org_dataset_zone_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cf_analytics_state_org_idx": { + "name": "cf_analytics_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_hyperdrive_configs": { + "name": "cloudflare_hyperdrive_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_port": { + "name": "origin_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "origin_scheme": { + "name": "origin_scheme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_database": { + "name": "origin_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_user": { + "name": "origin_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_hyperdrive_configs_org_config_idx": { + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_hyperdrive_configs_org_idx": { + "name": "cloudflare_hyperdrive_configs_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_logpush_connectors": { + "name": "cloudflare_logpush_connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http_requests'" + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_logpush_connectors_org_idx": { + "name": "cloudflare_logpush_connectors_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_org_enabled_idx": { + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_secret_hash_unique": { + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_device_authorizations": { + "name": "cli_device_authorizations", + "schema": "", + "columns": { + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_code_hash": { + "name": "user_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_org_id": { + "name": "approved_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_user_id": { + "name": "approved_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_roles": { + "name": "approved_roles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_user_email": { + "name": "approved_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_tag": { + "name": "token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "denied_at": { + "name": "denied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cli_device_authorizations_user_code_unique": { + "name": "cli_device_authorizations_user_code_unique", + "columns": [ + { + "expression": "user_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_device_authorizations_expires_idx": { + "name": "cli_device_authorizations_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_authorizations": { + "name": "mcp_oauth_authorizations", + "schema": "", + "columns": { + "request_id_hash": { + "name": "request_id_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_code_hash": { + "name": "authorization_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_org_id": { + "name": "approved_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_user_id": { + "name": "approved_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_roles": { + "name": "approved_roles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_user_email": { + "name": "approved_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "denied_at": { + "name": "denied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_authorizations_code_unique": { + "name": "mcp_oauth_authorizations_code_unique", + "columns": [ + { + "expression": "authorization_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_authorizations_expires_idx": { + "name": "mcp_oauth_authorizations_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "client_uri": { + "name": "client_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_refresh_tokens": { + "name": "mcp_oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roles": { + "name": "roles", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_refresh_tokens_hash_unique": { + "name": "mcp_oauth_refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_refresh_tokens_family_idx": { + "name": "mcp_oauth_refresh_tokens_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_refresh_tokens_expires_idx": { + "name": "mcp_oauth_refresh_tokens_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_devices": { + "name": "mobile_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bundle_id": { + "name": "bundle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_activity_start_token": { + "name": "live_activity_start_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mobile_devices_org_platform_token_unique": { + "name": "mobile_devices_org_platform_token_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_devices_org_idx": { + "name": "mobile_devices_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_devices_user_idx": { + "name": "mobile_devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_shares": { + "name": "dashboard_shares", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "widget_id": { + "name": "widget_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_tag": { + "name": "token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dashboard_shares_token_hash_unq": { + "name": "dashboard_shares_token_hash_unq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_live_unq": { + "name": "dashboard_shares_live_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(widget_id, '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "revoked_at is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_org_dashboard_idx": { + "name": "dashboard_shares_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_id_idx": { + "name": "dashboard_shares_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_shares_dashboard_fk": { + "name": "dashboard_shares_dashboard_fk", + "tableFrom": "dashboard_shares", + "tableTo": "dashboards", + "columnsFrom": [ + "org_id", + "dashboard_id" + ], + "columnsTo": [ + "org_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_shares_org_id_id_pk": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_versions": { + "name": "dashboard_versions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_kind": { + "name": "change_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_version_id": { + "name": "source_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dashboard_versions_org_dashboard_idx": { + "name": "dashboard_versions_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_versions_org_dashboard_version_unq": { + "name": "dashboard_versions_org_dashboard_version_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboard_versions_org_id_id_pk": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "dashboards_org_updated_idx": { + "name": "dashboards_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboards_org_name_idx": { + "name": "dashboards_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboards_org_id_id_pk": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.digest_subscriptions": { + "name": "digest_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "digest_subscriptions_org_user_idx": { + "name": "digest_subscriptions_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "digest_subscriptions_org_enabled_idx": { + "name": "digest_subscriptions_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "actors_org_user_idx": { + "name": "actors_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_agent_name_idx": { + "name": "actors_org_agent_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_type_idx": { + "name": "actors_org_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_fingerprint_candidates": { + "name": "error_fingerprint_candidates", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_versions_json": { + "name": "service_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_fingerprint_candidates_last_seen_idx": { + "name": "error_fingerprint_candidates_last_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_fingerprint_candidates_org_id_fingerprint_hash_pk": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_incidents": { + "name": "error_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_incidents_org_issue_idx": { + "name": "error_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_incidents_org_status_idx": { + "name": "error_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_events": { + "name": "error_issue_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_events_issue_idx": { + "name": "error_issue_events_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_actor_idx": { + "name": "error_issue_events_actor_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_type_idx": { + "name": "error_issue_events_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_pull_requests": { + "name": "error_issue_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "merge_commit_sha": { + "name": "merge_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_source": { + "name": "link_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_by_actor_id": { + "name": "linked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_pull_requests_issue_pr_idx": { + "name": "error_issue_pull_requests_issue_pr_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_pull_requests_repo_number_idx": { + "name": "error_issue_pull_requests_repo_number_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_pull_requests_issue_idx": { + "name": "error_issue_pull_requests_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_states": { + "name": "error_issue_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_observed_occurrence_at": { + "name": "last_observed_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_issue_states_org_id_issue_id_pk": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_verifications": { + "name": "error_issue_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "verify_after": { + "name": "verify_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "baseline_versions_json": { + "name": "baseline_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "baseline_occurrence_count": { + "name": "baseline_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "baseline_rate_per_hour": { + "name": "baseline_rate_per_hour", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_note": { + "name": "verdict_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_merge_occurrence_count": { + "name": "post_merge_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_verifications_due_idx": { + "name": "error_issue_verifications_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verify_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_verifications_issue_idx": { + "name": "error_issue_verifications_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_verifications_open_idx": { + "name": "error_issue_verifications_open_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"error_issue_verifications\".\"status\" in ('waiting', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issues": { + "name": "error_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source_ref_json": { + "name": "source_ref_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_version": { + "name": "fingerprint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_state": { + "name": "workflow_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'triage'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity_source": { + "name": "severity_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_actor_id": { + "name": "assigned_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_holder_actor_id": { + "name": "lease_holder_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_actor_id": { + "name": "resolved_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_regressed_at": { + "name": "last_regressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "regression_count": { + "name": "regression_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seen_versions_json": { + "name": "seen_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "resolved_versions_json": { + "name": "resolved_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "snooze_until": { + "name": "snooze_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issues_org_fp_idx": { + "name": "error_issues_org_fp_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_workflow_idx": { + "name": "error_issues_org_workflow_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_severity_idx": { + "name": "error_issues_org_severity_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_live_seen_idx": { + "name": "error_issues_org_live_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"error_issues\".\"archived_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_fp_version_idx": { + "name": "error_issues_org_fp_version_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_assignee_idx": { + "name": "error_issues_org_assignee_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_lease_expiry_idx": { + "name": "error_issues_lease_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_archived_idx": { + "name": "error_issues_org_archived_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"error_issues\".\"archived_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_deliveries": { + "name": "error_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_notification_deliveries_due_idx": { + "name": "error_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_notification_deliveries_org_idx": { + "name": "error_notification_deliveries_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_notification_deliveries_key_destination_idx": { + "name": "error_notification_deliveries_key_destination_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_policies": { + "name": "error_notification_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_first_seen": { + "name": "notify_on_first_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_regression": { + "name": "notify_on_regression", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_resolve": { + "name": "notify_on_resolve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_in_review": { + "name": "notify_on_transition_in_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_done": { + "name": "notify_on_transition_done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_claim": { + "name": "notify_on_claim", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "min_occurrence_count": { + "name": "min_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_tick_states": { + "name": "error_tick_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "processed_through": { + "name": "processed_through", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "bootstrap_completed": { + "name": "bootstrap_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_tick_states_claim_idx": { + "name": "error_tick_states_claim_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalation_policies": { + "name": "issue_escalation_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rules_json": { + "name": "rules_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalations": { + "name": "issue_escalations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "delivery_results_json": { + "name": "delivery_results_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_escalations_dedupe_idx": { + "name": "issue_escalations_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_due_idx": { + "name": "issue_escalations_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_org_issue_idx": { + "name": "issue_escalations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigation_lens_runs": { + "name": "investigation_lens_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lens_id": { + "name": "lens_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claim": { + "name": "claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress_note": { + "name": "progress_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_ms": { + "name": "elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lens_name": { + "name": "lens_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lens_question": { + "name": "lens_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deadline_hit": { + "name": "deadline_hit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hypothesis_json": { + "name": "hypothesis_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mechanism": { + "name": "mechanism", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "self_doubt": { + "name": "self_doubt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_actions_json": { + "name": "suggested_actions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ranked_at": { + "name": "ranked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigation_lens_runs_lens_idx": { + "name": "investigation_lens_runs_lens_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lens_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigation_lens_runs_org_inv_idx": { + "name": "investigation_lens_runs_org_inv_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "investigation_lens_runs_investigation_id_investigations_id_fk": { + "name": "investigation_lens_runs_investigation_id_investigations_id_fk", + "tableFrom": "investigation_lens_runs", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'investigating'" + }, + "seeded_by": { + "name": "seeded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "subject_json": { + "name": "subject_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fanout_state": { + "name": "fanout_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "fanout_size": { + "name": "fanout_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plan_json": { + "name": "plan_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "planner_model": { + "name": "planner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_elapsed_ms": { + "name": "planner_elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "validator_note": { + "name": "validator_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validator_elapsed_ms": { + "name": "validator_elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fanout_deadline_at": { + "name": "fanout_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fanout_attempt": { + "name": "fanout_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "autonomous_turns": { + "name": "autonomous_turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigations_incident_idx": { + "name": "investigations_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"investigations\".\"incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_created_idx": { + "name": "investigations_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_issue_idx": { + "name": "investigations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_status_idx": { + "name": "investigations_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.live_activities": { + "name": "live_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_reason": { + "name": "ended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "live_activities_device_incident_unique": { + "name": "live_activities_device_incident_unique", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "live_activities_incident_idx": { + "name": "live_activities_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_states": { + "name": "oauth_auth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_states_expires_idx": { + "name": "oauth_auth_states_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_connections": { + "name": "oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_email": { + "name": "external_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_account_name": { + "name": "external_account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_connections_org_provider_idx": { + "name": "oauth_connections_org_provider_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_connections_org_idx": { + "name": "oauth_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_onboarding_state": { + "name": "org_onboarding_state", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_data_requested": { + "name": "demo_data_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "checklist_dismissed_at": { + "name": "checklist_dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_data_received_at": { + "name": "first_data_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "welcome_email_sent_at": { + "name": "welcome_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connect_nudge_email_sent_at": { + "name": "connect_nudge_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stalled_email_sent_at": { + "name": "stalled_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activation_email_sent_at": { + "name": "activation_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_attribute_mappings": { + "name": "org_ingest_attribute_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_ingest_attribute_mappings_org_idx": { + "name": "org_ingest_attribute_mappings_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_recommendation_issues": { + "name": "org_recommendation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_key": { + "name": "canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_recommendation_issues_org_idx": { + "name": "org_recommendation_issues_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_recommendation_issues_org_key_idx": { + "name": "org_recommendation_issues_org_key_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_keys": { + "name": "org_ingest_keys", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hash": { + "name": "public_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_ciphertext": { + "name": "private_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_tag": { + "name": "private_key_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_hash": { + "name": "private_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_rotated_at": { + "name": "public_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "private_rotated_at": { + "name": "private_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_ingest_keys_public_key_unique": { + "name": "org_ingest_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_public_key_hash_unique": { + "name": "org_ingest_keys_public_key_hash_unique", + "columns": [ + { + "expression": "public_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_private_key_hash_unique": { + "name": "org_ingest_keys_private_key_hash_unique", + "columns": [ + { + "expression": "private_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_ingest_keys_org_id_pk": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_sampling_policies": { + "name": "org_ingest_sampling_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_sample_ratio": { + "name": "trace_sample_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "always_keep_error_spans": { + "name": "always_keep_error_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "always_keep_slow_spans_ms": { + "name": "always_keep_slow_spans_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_settings": { + "name": "org_clickhouse_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_url": { + "name": "ch_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_user": { + "name": "ch_user", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_password_ciphertext": { + "name": "ch_password_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_iv": { + "name": "ch_password_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_tag": { + "name": "ch_password_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_database": { + "name": "ch_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_settings_org_id_pk": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_schema_apply_runs": { + "name": "org_clickhouse_schema_apply_runs", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_migration": { + "name": "current_migration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_total": { + "name": "steps_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_done": { + "name": "steps_done", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applied_versions": { + "name": "applied_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_schema_apply_runs_org_id_pk": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_connections": { + "name": "planetscale_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ps_organization": { + "name": "ps_organization", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scrape_target_id": { + "name": "scrape_target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_ciphertext": { + "name": "webhook_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_iv": { + "name": "webhook_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_tag": { + "name": "webhook_secret_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_permissions_json": { + "name": "detected_permissions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_inventory_at": { + "name": "last_inventory_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_inventory_error": { + "name": "last_inventory_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_connections_org_idx": { + "name": "planetscale_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_databases": { + "name": "planetscale_databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mysql'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branches_json": { + "name": "branches_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_databases_org_db_idx": { + "name": "planetscale_databases_org_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_databases_org_idx": { + "name": "planetscale_databases_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_events": { + "name": "planetscale_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "database_name": { + "name": "database_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_events_dedupe_idx": { + "name": "planetscale_events_dedupe_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_events_org_db_time_idx": { + "name": "planetscale_events_org_db_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_events_org_time_idx": { + "name": "planetscale_events_org_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_poll_state": { + "name": "planetscale_poll_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_poll_state_org_dataset_db_idx": { + "name": "planetscale_poll_state_org_dataset_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_poll_state_org_idx": { + "name": "planetscale_poll_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_target_checks": { + "name": "scrape_target_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_target_key": { + "name": "sub_target_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_scraped": { + "name": "samples_scraped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_post_relabel": { + "name": "samples_post_relabel", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scrape_target_checks_target_checked_idx": { + "name": "scrape_target_checks_target_checked_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrape_target_checks_target_id_scrape_targets_id_fk": { + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "tableFrom": "scrape_target_checks", + "tableTo": "scrape_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_targets": { + "name": "scrape_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prometheus'" + }, + "discovery_config_json": { + "name": "discovery_config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scrape_interval_seconds": { + "name": "scrape_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "labels_json": { + "name": "labels_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_ciphertext": { + "name": "auth_credentials_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_iv": { + "name": "auth_credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_tag": { + "name": "auth_credentials_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scrape_error": { + "name": "last_scrape_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scrape_targets_org_idx": { + "name": "scrape_targets_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scrape_targets_org_enabled_idx": { + "name": "scrape_targets_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_workspaces": { + "name": "slack_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_ciphertext": { + "name": "bot_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_iv": { + "name": "bot_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_tag": { + "name": "bot_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_ciphertext": { + "name": "api_key_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_iv": { + "name": "api_key_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_tag": { + "name": "api_key_secret_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slack_workspaces_team_id_idx": { + "name": "slack_workspaces_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_workspaces_org_idx": { + "name": "slack_workspaces_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_workspaces_active_org_idx": { + "name": "slack_workspaces_active_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_workspaces\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_commits": { + "name": "vcs_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authored_at": { + "name": "authored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_commits_repo_sha_idx": { + "name": "vcs_commits_repo_sha_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_commits_org_sha_idx": { + "name": "vcs_commits_org_sha_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_installations": { + "name": "vcs_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_installation_id": { + "name": "external_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_installations_provider_external_idx": { + "name": "vcs_installations_provider_external_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_installations_org_idx": { + "name": "vcs_installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repositories": { + "name": "vcs_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "tracked_branch": { + "name": "tracked_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repositories_org_repo_idx": { + "name": "vcs_repositories_org_repo_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_org_idx": { + "name": "vcs_repositories_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_installation_idx": { + "name": "vcs_repositories_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repository_branches": { + "name": "vcs_repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repository_branches_repo_name_idx": { + "name": "vcs_repository_branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repository_branches_org_idx": { + "name": "vcs_repository_branches_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 826446dba..477f454e6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1787920777688, "tag": "0049_home_list_indexes", "breakpoints": true + }, + { + "idx": 49, + "version": "7", + "when": 1788007454701, + "tag": "0050_audit_log_entries", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/audit-log.ts b/packages/db/src/schema/audit-log.ts new file mode 100644 index 000000000..ca7e463ed --- /dev/null +++ b/packages/db/src/schema/audit-log.ts @@ -0,0 +1,80 @@ +import { index, jsonb, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core" +import type { ActorId, ApiKeyId, AuditLogEntryId, OrgId, UserId } from "@maple/domain/primitives" +import type { AuditActorType, AuditLogSource, AuditOutcome } from "@maple/domain/http" + +/** + * Append-only org-wide audit trail: every allowed or denied action an + * identified actor performs against Maple, whether it arrived from the + * dashboard, the public API, or MCP. `userId`/`apiKeyId`/`actorId` identify the + * credential-holder per `actorType` — for `agent` rows `userId` is the human + * the agent acted on behalf of. `actorLabel` freezes a display name at write + * time so entries stay readable after keys are rolled or agents renamed. + * Rows arrive through the audit events queue; `occurredAt` is stamped by the + * producer, `recordedAt` by the consumer at insert. + */ +export const auditLogEntries = pgTable( + "audit_log_entries", + { + orgId: text("org_id").$type().notNull(), + id: text("id").$type().notNull(), + actorType: text("actor_type").$type().notNull(), + userId: text("user_id").$type(), + apiKeyId: text("api_key_id").$type(), + actorId: text("actor_id").$type(), + actorLabel: text("actor_label"), + affectedUserId: text("affected_user_id").$type(), + source: text("source").$type().notNull(), + action: text("action").notNull(), + outcome: text("outcome").$type().notNull(), + denialReason: text("denial_reason"), + resourceType: text("resource_type"), + resourceId: text("resource_id"), + // Field names touched by an update, queryable without parsing changesJson. + changedFields: text("changed_fields").array(), + changesJson: jsonb("changes_json").$type(), + metadataJson: jsonb("metadata_json").$type(), + requestId: text("request_id"), + originIp: text("origin_ip"), + originCountry: text("origin_country"), + occurredAt: timestamp("occurred_at", { withTimezone: true, mode: "date" }).notNull(), + recordedAt: timestamp("recorded_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.orgId, table.id] }), + index("audit_log_entries_org_occurred_idx").on(table.orgId, table.occurredAt), + index("audit_log_entries_org_actor_type_occurred_idx").on( + table.orgId, + table.actorType, + table.occurredAt, + ), + index("audit_log_entries_org_resource_idx").on(table.orgId, table.resourceType, table.resourceId), + index("audit_log_entries_org_request_idx").on(table.orgId, table.requestId), + index("audit_log_entries_org_outcome_occurred_idx").on( + table.orgId, + table.outcome, + table.occurredAt, + ), + // Retention sweep scans `occurred_at < cutoff` across ALL orgs; every other + // index leads with org_id and cannot serve that predicate. + index("audit_log_entries_occurred_idx").on(table.occurredAt), + // "What did this credential do" — the primary read-endpoint filters. + index("audit_log_entries_org_user_occurred_idx").on(table.orgId, table.userId, table.occurredAt), + index("audit_log_entries_org_api_key_occurred_idx").on( + table.orgId, + table.apiKeyId, + table.occurredAt, + ), + index("audit_log_entries_org_actor_occurred_idx").on(table.orgId, table.actorId, table.occurredAt), + index("audit_log_entries_org_affected_user_occurred_idx").on( + table.orgId, + table.affectedUserId, + table.occurredAt, + ), + index("audit_log_entries_org_action_occurred_idx").on(table.orgId, table.action, table.occurredAt), + // drizzle `arrayContains` compiles to `@>`, which only GIN can serve on text[]. + index("audit_log_entries_changed_fields_gin_idx").using("gin", table.changedFields), + ], +) + +export type AuditLogEntryRow = typeof auditLogEntries.$inferSelect +export type AuditLogEntryInsert = typeof auditLogEntries.$inferInsert diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 9699888fd..93181338c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,6 +2,7 @@ export * from "./ai-triage" export * from "./alerts" export * from "./anomalies" export * from "./api-keys" +export * from "./audit-log" export * from "./cloudflare-analytics-state" export * from "./cloudflare-hyperdrive-configs" export * from "./cloudflare-logpush-connectors" diff --git a/packages/domain/src/http/audit-log.ts b/packages/domain/src/http/audit-log.ts new file mode 100644 index 000000000..bafd703a8 --- /dev/null +++ b/packages/domain/src/http/audit-log.ts @@ -0,0 +1,56 @@ +import { Schema } from "effect" +import { HttpTaggedError } from "./error-policy" + +/** + * Who performed an audited action. `user` is a dashboard session, `api_key` a + * v1/v2 public-API credential, `agent` a registered LLM agent acting over MCP, + * and `system` Maple itself (crons, sweeps, lifecycle automation). + */ +export const AuditActorType = Schema.Literals(["user", "api_key", "agent", "system"]).annotate({ + identifier: "@maple/AuditActorType", + title: "Audit Actor Type", +}) +export type AuditActorType = Schema.Schema.Type + +/** Which surface the audited request arrived through. */ +export const AuditLogSource = Schema.Literals(["dashboard", "api", "mcp", "system"]).annotate({ + identifier: "@maple/AuditLogSource", + title: "Audit Log Source", +}) +export type AuditLogSource = Schema.Schema.Type + +/** Whether the action was performed or refused — denied attempts are logged too. */ +export const AuditOutcome = Schema.Literals(["allowed", "denied"]).annotate({ + identifier: "@maple/AuditOutcome", + title: "Audit Outcome", +}) +export type AuditOutcome = Schema.Schema.Type + +/** Before/after diff of an update, with the touched field names queryable on their own. */ +export const AuditChanges = Schema.Struct({ + fields: Schema.Array(Schema.String), + before: Schema.Record(Schema.String, Schema.Unknown), + after: Schema.Record(Schema.String, Schema.Unknown), +}).annotate({ + identifier: "@maple/AuditChanges", + title: "Audit Changes", +}) +export type AuditChanges = Schema.Schema.Type + +export class AuditLogPersistenceError extends HttpTaggedError()( + "@maple/http/errors/AuditLogPersistenceError", + { + message: Schema.String, + // Diagnostic only — `exposure: "redacted"` keeps it off the wire. + cause: Schema.optionalKey(Schema.Defect()), + }, + { + status: 503, + code: "audit_log_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", + }, +) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 9bcacd800..5d9d6e87f 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -5,6 +5,7 @@ export * from "./ai-triage" export * from "./investigations" export * from "./anomalies" export * from "./api-keys" +export * from "./audit-log" export * from "./alerts" export * from "./mobile-devices" export * from "./auth" diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 53ec8e161..e0814e060 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -6,6 +6,7 @@ import { V2AlertIncidentsApiGroup } from "./alert-incidents" import { V2AlertRulesApiGroup } from "./alert-rules" import { V2ApiKeysApiGroup } from "./api-keys" import { V2AttributeMappingsApiGroup } from "./attribute-mappings" +import { V2AuditLogApiGroup } from "./audit-log" import { V2DashboardsApiGroup } from "./dashboards" import { V2IngestKeysApiGroup } from "./ingest-keys" import { V2SlackIntegrationsApiGroup } from "./integrations" @@ -94,6 +95,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2PlanetScaleIntegrationsApiGroup) .add(V2ErrorIssuesApiGroup) .add(V2AttributeMappingsApiGroup) + .add(V2AuditLogApiGroup) .add(V2ScrapeTargetsApiGroup) .add(V2InstrumentationRecommendationsApiGroup) .add(V2InstrumentationAuditApiGroup) diff --git a/packages/domain/src/http/v2/audit-log.ts b/packages/domain/src/http/v2/audit-log.ts new file mode 100644 index 000000000..d4091f716 --- /dev/null +++ b/packages/domain/src/http/v2/audit-log.ts @@ -0,0 +1,246 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { AuditLogEntryId } from "../../primitives" +import { + AuditActorType, + AuditLogPersistenceError, + AuditLogSource, + AuditOutcome, +} from "../audit-log" +import { AuthorizationV2 } from "./auth" +import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" +import { V2InsufficientPermissions, V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { PublicId, PublicIdPrefixes } from "./public-id" + +/** `alog_…` public ID ⇄ internal `AuditLogEntryId` (raw UUID). */ +export const AuditLogEntryPublicId = PublicId(PublicIdPrefixes.auditLogEntry, AuditLogEntryId) + +const actorTypeField = AuditActorType.annotate({ + description: + "Who performed the action: `user` (a dashboard session), `api_key` (a public-API credential), `agent` (a registered LLM agent acting over MCP), or `system` (Maple automation).", + examples: ["user"], +}) + +const sourceField = AuditLogSource.annotate({ + description: + "The surface the request arrived through: `dashboard`, `api` (the public v1/v2 API), `mcp`, or `system`.", + examples: ["dashboard"], +}) + +const outcomeField = AuditOutcome.annotate({ + description: + "Whether the action was performed (`allowed`) or refused (`denied`). Denied attempts — e.g. an API key lacking the required scope — are logged too.", + examples: ["allowed"], +}) + +export const V2AuditChanges = Schema.Struct({ + fields: Schema.Array(Schema.String).annotate({ + description: "Names of the fields the update touched.", + examples: [["name"]], + }), + before: Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: "Prior values of the touched fields.", + }), + after: Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: "New values of the touched fields.", + }), +}).annotate({ + identifier: "AuditLogChanges", + title: "Audit Log Changes", + description: "The before/after diff an update applied, keyed by field name.", +}) +export type V2AuditChanges = Schema.Schema.Type + +const auditLogEntryExample = { + id: "alog_4CzLmR1pTxWvYbNhQd82Kf", + object: "audit_log_entry", + action: "alert_rule.updated", + outcome: "allowed", + denial_reason: null, + actor_type: "user", + actor_id: "user_2fj3K9dLqWm8xYbT", + actor_name: "David", + affected_user: null, + source: "dashboard", + resource_type: "alert_rule", + resource_id: "alrt_YofPTrK9782DWwcnXhpcCw", + changes: { fields: ["name"], before: { name: "Errors" }, after: { name: "High error rate" } }, + metadata: null, + request_id: "8f2c1a9d4b7e3f60", + origin_ip: "203.0.113.7", + origin_country: "DE", + occurred_at: "2026-08-29T09:12:00.000Z", + recorded_at: "2026-08-29T09:12:00.412Z", +} as const + +// v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`) — see the +// note in api-keys.ts. +export const V2AuditLogEntry = Schema.Struct({ + id: AuditLogEntryPublicId, + object: Schema.Literal("audit_log_entry").annotate({ + description: 'The object type — always `"audit_log_entry"`.', + examples: ["audit_log_entry"], + }), + action: Schema.String.annotate({ + description: "What happened, as `.` (e.g. `alert_rule.created`, `api_key.rolled`).", + examples: ["alert_rule.created"], + }), + outcome: outcomeField, + denial_reason: Schema.NullOr(Schema.String).annotate({ + description: "Why the action was refused, when `outcome` is `denied`; otherwise `null`.", + }), + actor_type: actorTypeField, + actor_id: Schema.NullOr(Schema.String).annotate({ + description: + "Public identifier of the actor: a `user_…` ID for users, a `key_…` ID for API keys, an `actor_…` ID for agents, or `null` for system actions.", + examples: ["user_2fj3K9dLqWm8xYbT"], + }), + actor_name: Schema.NullOr(Schema.String).annotate({ + description: + "Display name of the actor at the time of the action (agent name, API key name, …), or `null` when none was recorded.", + examples: ["David"], + }), + affected_user: Schema.NullOr(Schema.String).annotate({ + description: + "The `user_…` ID of the user the action was performed on (e.g. a removed member), when different from the actor; otherwise `null`.", + }), + source: sourceField, + resource_type: Schema.NullOr(Schema.String).annotate({ + description: "The kind of resource acted on (e.g. `alert_rule`, `dashboard`), or `null`.", + examples: ["alert_rule"], + }), + resource_id: Schema.NullOr(Schema.String).annotate({ + description: "Public ID of the resource acted on, or `null`.", + examples: ["alrt_YofPTrK9782DWwcnXhpcCw"], + }), + changes: Schema.NullOr(V2AuditChanges).annotate({ + description: "The before/after diff for updates, or `null` when the action carries no diff.", + }), + metadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "Action-specific context recorded with the entry, or `null`.", + }), + request_id: Schema.NullOr(Schema.String).annotate({ + description: + "Identifier of the HTTP request that performed the action, shared by every entry the request produced; or `null`.", + }), + origin_ip: Schema.NullOr(Schema.String).annotate({ + description: "Client IP the request originated from, or `null`.", + }), + origin_country: Schema.NullOr(Schema.String).annotate({ + description: "ISO 3166-1 country code the request originated from, or `null`.", + }), + occurred_at: Timestamp.annotate({ description: "When the action happened." }), + recorded_at: Timestamp.annotate({ + description: + "When the entry was durably recorded. Trails `occurred_at` by the audit pipeline's delivery latency.", + }), +}).annotate({ + identifier: "AuditLogEntry", + title: "Audit Log Entry", + description: + "One entry in the organization's append-only audit log: an allowed or denied action performed by a user, API key, or agent against a Maple resource.", + examples: [wireExample(auditLogEntryExample)], +}) +export type V2AuditLogEntry = Schema.Schema.Type + +/** Audit-log list query: standard pagination plus actor/action/resource/outcome/time filters. */ +export const V2AuditLogQuery = Schema.Struct({ + ...ListQuery.fields, + actor_type: Schema.optional( + AuditActorType.annotate({ + description: "Only return entries performed by this kind of actor.", + }), + ), + actor_id: Schema.optional( + Schema.String.annotate({ + description: + "Only return entries performed by this specific actor: a `user_…` user ID, `key_…` API key ID, or `actor_…` agent ID.", + }), + ), + affected_user: Schema.optional( + Schema.String.annotate({ + description: "Only return entries that acted on this `user_…` user.", + }), + ), + action: Schema.optional( + Schema.String.annotate({ + description: "Only return entries with exactly this action (e.g. `alert_rule.created`).", + }), + ), + outcome: Schema.optional( + AuditOutcome.annotate({ + description: "Only return entries with this outcome.", + }), + ), + resource_type: Schema.optional( + Schema.String.annotate({ + description: "Only return entries acting on this kind of resource (e.g. `dashboard`).", + }), + ), + resource_id: Schema.optional( + Schema.String.annotate({ + description: + "Only return entries acting on this exact resource, by its public ID (e.g. `dash_…`).", + }), + ), + changed: Schema.optional( + Schema.String.annotate({ + description: "Only return entries whose update touched this field name (e.g. `scopes`).", + }), + ), + request_id: Schema.optional( + Schema.String.annotate({ + description: "Only return entries produced by this HTTP request.", + }), + ), + since: Schema.optional( + Timestamp.annotate({ + description: "Only return entries that occurred at or after this time.", + }), + ), + until: Schema.optional( + Timestamp.annotate({ + description: "Only return entries that occurred at or before this time.", + }), + ), +}).annotate({ + identifier: "AuditLogQuery", + title: "Audit log query", + description: + "Pagination plus optional actor, action, outcome, resource, changed-field, request, and time-window filters.", +}) +export type V2AuditLogQuery = Schema.Schema.Type + +const [auditLogPersistence] = publicErrors(AuditLogPersistenceError) + +const AuditLogEntryList = ListOf(V2AuditLogEntry).annotate({ + identifier: "AuditLogEntryList", + title: "Audit log entry list", + description: "A cursor-paginated page of audit log entries, newest first.", +}) + +export class V2AuditLogApiGroup extends HttpApiGroup.make("auditLog") + .add( + HttpApiEndpoint.get("list", "/", { + query: V2AuditLogQuery, + success: AuditLogEntryList, + error: [V2ParameterInvalid.schema, V2InsufficientPermissions.schema, auditLogPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listAuditLogEntries", + summary: "List audit log entries", + description: + "Returns your organization's audit log, newest first, optionally filtered by actor, action, outcome, resource, changed field, request, and time window. Cursor-paginated. Session callers must be organization administrators; API keys require the `audit_log:read` scope.", + }), + ), + ) + .prefix("/v2/audit_log") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Audit Log", + description: + "The organization's append-only audit trail — allowed and denied actions performed through the dashboard, the public API, and MCP, attributed to the user, API key, or agent that performed them, with before/after diffs for updates. Reading it requires organization-administrator access (or the `audit_log:read` scope for API keys).", + }), + ) {} diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index e8b7d3ab1..b04facc3b 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -6,6 +6,7 @@ export * from "./anomalies" export * from "./api" export * from "./api-keys" export * from "./attribute-mappings" +export * from "./audit-log" export * from "./auth" export * from "./dashboards" export * from "./envelopes" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 9d0dfa744..e0ee0573c 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -118,6 +118,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/api_keys/{id}", "GET /v2/attribute_mappings", "GET /v2/attribute_mappings/{id}", + "GET /v2/audit_log", "GET /v2/dashboards", "GET /v2/dashboards/templates", "GET /v2/dashboards/{id}", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 6136f9d06..52f80200f 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -28,6 +28,7 @@ export const PublicIdPrefixes = { alertDestination: "dest", alertIncident: "inc", actor: "actor", + auditLogEntry: "alog", errorIssue: "iss", errorIncident: "einc", investigation: "inv", diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 4b8467d80..3c909ff7e 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -128,6 +128,9 @@ export type ActorId = Schema.Schema.Type export const ErrorIssueEventId = MapleUuidId("@maple/ErrorIssueEventId", "Error Issue Event ID") export type ErrorIssueEventId = Schema.Schema.Type +export const AuditLogEntryId = MapleUuidId("@maple/AuditLogEntryId", "Audit Log Entry ID") +export type AuditLogEntryId = Schema.Schema.Type + export const ErrorIssuePullRequestId = MapleUuidId( "@maple/ErrorIssuePullRequestId", "Error Issue Pull Request ID",