From 59e39a7ccbb063ad902e6e7c3500eb4fe0261444 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 29 Aug 2026 15:02:11 +0200 Subject: [PATCH 1/6] feat(audit): org-wide audit log with actor attribution and durable queue delivery Adds an append-only audit trail distinguishing users, API keys, agents, and system automation, following the auditlog.dev spec: - audit_log_entries table (migration 0050): actor snapshot + credential refs, outcome (allowed/denied) + denial_reason, before/after change diffs with queryable changed_fields, affected_user, request forensics (request id, origin IP/country), and occurred_at/recorded_at. - Durable delivery through a Cloudflare Queue (audit-events): producers enqueue, the api worker consumes and inserts idempotently; direct DB write as fallback when the binding is absent or the send fails. - CurrentAuditActor context from all three auth layers distinguishes session vs API-key requests; denied attempts (scope/org/surface rejections) are recorded from inside the auth layers. - Recording wired into every v2 mutation handler (with diffs and secret redaction), the issue-workflow choke point (agent vs user attribution with on-behalf-of), and register_agent. - GET /v2/audit_log: cursor-paginated, filterable by actor, outcome, action, resource, changed field, request id, and time window; new audit_log:read scope and alog_ public IDs. - Settings > Audit Log tab with actor/outcome filters, denied badges, change summaries, and load-more pagination. - Hourly retention sweep (AUDIT_LOG_RETENTION_DAYS, default 400) in the api worker's existing retention cron. --- apps/alerting/src/worker.ts | 5 +- apps/api/alchemy.run.ts | 20 + apps/api/src/alerting.ts | 1 + apps/api/src/audit-events-runtime.ts | 70 + apps/api/src/mcp/tools/register-agent.ts | 13 + .../api/src/mcp/tools/runtime-requirements.ts | 2 + apps/api/src/queue-dispatch.ts | 5 +- .../v2/alchemy-provider.integration.test.ts | 2 + .../src/routes/v2/alert-destinations.http.ts | 83 +- apps/api/src/routes/v2/alert-rules.http.ts | 67 +- apps/api/src/routes/v2/alerts.http.test.ts | 2 + apps/api/src/routes/v2/anomalies.http.ts | 16 +- apps/api/src/routes/v2/api-keys.http.test.ts | 2 + apps/api/src/routes/v2/api-keys.http.ts | 18 + .../src/routes/v2/attribute-mappings.http.ts | 31 +- apps/api/src/routes/v2/audit-changes.ts | 60 + apps/api/src/routes/v2/audit-log.http.ts | 141 + .../routes/v2/config-resources.http.test.ts | 2 + .../api/src/routes/v2/dashboards.http.test.ts | 2 + apps/api/src/routes/v2/dashboards.http.ts | 76 +- apps/api/src/routes/v2/ingest-keys.http.ts | 9 + .../src/routes/v2/integrations.http.test.ts | 2 + .../src/routes/v2/mobile-devices.http.test.ts | 2 + .../routes/v2/phase1-resources.http.test.ts | 2 + apps/api/src/routes/v2/scrape-targets.http.ts | 67 +- .../src/routes/v2/setup-audit.http.test.ts | 2 + apps/api/src/routes/v2/telemetry.http.test.ts | 2 + apps/api/src/routes/v2/v2-test-support.ts | 8 + .../routes/v2/widget-credentials.http.test.ts | 2 + .../src/routes/v2/widget-summary.http.test.ts | 2 + apps/api/src/runtime/graph-boundaries.test.ts | 2 + apps/api/src/runtime/http-graph.ts | 5 + apps/api/src/runtime/mcp-service-graph.ts | 4 +- apps/api/src/runtime/service-graph.ts | 3 + .../services/audit/AuditLogService.test.ts | 202 + .../api/src/services/audit/AuditLogService.ts | 291 + apps/api/src/services/audit/audit-event.ts | 62 + .../src/services/audit/audit-log-retention.ts | 78 + .../services/auth/ApiAuthorizationLayer.ts | 36 +- .../services/auth/ApiAuthorizationV2Layer.ts | 61 +- .../auth/SessionAuthorizationLayer.ts | 8 +- apps/api/src/services/auth/audit-actor.ts | 23 + .../ErrorIssueReadModelsService.test.ts | 7 +- .../errors/ErrorIssueWorkflowService.test.ts | 11 +- .../errors/ErrorIssueWorkflowService.ts | 95 +- .../src/services/errors/ErrorsService.test.ts | 3 + .../IssueFixVerificationService.test.ts | 2 + apps/api/src/vcs-sync-runtime.ts | 5 +- apps/api/src/worker.ts | 23 +- apps/api/wrangler.jsonc | 8 + .../components/settings/audit-log-section.tsx | 337 + .../src/components/settings/settings-nav.tsx | 4 + .../src/lib/services/atoms/audit-log-atoms.ts | 46 + apps/web/src/routes/settings.tsx | 2 + .../db/drizzle/0050_audit_log_entries.sql | 31 + packages/db/drizzle/meta/0050_snapshot.json | 8999 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema/audit-log.ts | 61 + packages/db/src/schema/index.ts | 1 + packages/domain/src/http/audit-log.ts | 54 + packages/domain/src/http/index.ts | 1 + packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/audit-log.ts | 246 + packages/domain/src/http/v2/index.ts | 1 + packages/domain/src/http/v2/openapi.test.ts | 1 + packages/domain/src/http/v2/public-id.ts | 1 + packages/primitives/src/index.ts | 3 + 67 files changed, 11387 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/audit-events-runtime.ts create mode 100644 apps/api/src/routes/v2/audit-changes.ts create mode 100644 apps/api/src/routes/v2/audit-log.http.ts create mode 100644 apps/api/src/services/audit/AuditLogService.test.ts create mode 100644 apps/api/src/services/audit/AuditLogService.ts create mode 100644 apps/api/src/services/audit/audit-event.ts create mode 100644 apps/api/src/services/audit/audit-log-retention.ts create mode 100644 apps/api/src/services/auth/audit-actor.ts create mode 100644 apps/web/src/components/settings/audit-log-section.tsx create mode 100644 apps/web/src/lib/services/atoms/audit-log-atoms.ts create mode 100644 packages/db/drizzle/0050_audit_log_entries.sql create mode 100644 packages/db/drizzle/meta/0050_snapshot.json create mode 100644 packages/db/src/schema/audit-log.ts create mode 100644 packages/domain/src/http/audit-log.ts create mode 100644 packages/domain/src/http/v2/audit-log.ts 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..162b5862a 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,10 @@ 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, + }) const worker = (yield* Cloudflare.Worker("api", { name: resolveWorkerName("api", stage), @@ -369,6 +375,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 +436,18 @@ 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, + 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.ts b/apps/api/src/audit-events-runtime.ts new file mode 100644 index 000000000..b7b18d063 --- /dev/null +++ b/apps/api/src/audit-events-runtime.ts @@ -0,0 +1,70 @@ +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) + +/** + * 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. + */ +export const processAuditEventsBatch = (batch: MessageBatch) => + Effect.gen(function* () { + const database = yield* Database + yield* Effect.forEach( + batch.messages, + (message) => + decodeAuditLogEvent(message.body).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning("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) => + 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/tools/register-agent.ts b/apps/api/src/mcp/tools/register-agent.ts index be1f258cb..1e887c067 100644 --- a/apps/api/src/mcp/tools/register-agent.ts +++ b/apps/api/src/mcp/tools/register-agent.ts @@ -5,9 +5,11 @@ import { validationError, type McpToolRegistrar, } from "./types" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" 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 +60,17 @@ 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", + resourceType: "agent", + resourceId: encodePublicId(PublicIdPrefixes.actor, 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 { 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..73238c668 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, @@ -18,8 +18,9 @@ import type { V2AlertDestinationUpdateParams, V2TelegramChatList, } from "@maple/domain/http/v2" -import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" +import { encodePublicId, MapleApiV2, paginateArray, PublicIdPrefixes } 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,61 @@ 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): unknown => { + 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 +297,37 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale toCreateRequest(payload), ) + yield* recordHttpAudit("alert_destination.created", { + resourceType: "alert_destination", + resourceId: encodePublicId(PublicIdPrefixes.alertDestination, 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", { + resourceType: "alert_destination", + resourceId: encodePublicId(PublicIdPrefixes.alertDestination, updated.id), + ...(changes !== undefined ? { changes } : undefined), + metadata: { name: updated.name, type: updated.type }, + }) + return toV2DestinationMutation(updated) }), ) @@ -266,6 +339,10 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale tenant.roles, params.id, ) + yield* recordHttpAudit("alert_destination.deleted", { + resourceType: "alert_destination", + resourceId: encodePublicId(PublicIdPrefixes.alertDestination, 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..54c61591c 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -16,9 +16,19 @@ import type { V2AlertRulePreviewResult, V2AlertRuleUpdateParams, } from "@maple/domain/http/v2" -import { MapleApiV2, paginateArray, scopeAllows, timestamp, V2ParameterInvalid } from "@maple/domain/http/v2" +import { + encodePublicId, + MapleApiV2, + paginateArray, + PublicIdPrefixes, + scopeAllows, + timestamp, + V2ParameterInvalid, +} from "@maple/domain/http/v2" import { AlertForbiddenError } from "@maple/domain/http" import { Effect, Encoding, Result, Schema } from "effect" +import { compactAuditChanges, diffAuditChanges, pickPresentFields } 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 +104,37 @@ const toV2Rule = (doc: AlertRuleDocument): V2AlertRule => ({ updated_by: doc.updatedBy, }) +/** Update-payload fields diffable through the wire shape (drafts get summarized). */ +const ruleAuditKeys: ReadonlyArray = [ + "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. */ +const summarizeRuleBlob = (value: unknown) => (value === null ? null : "") + const toV2RuleMutationResponse = (doc: AlertRuleDocument): V2AlertRuleMutationResponse => ({ ...toV2Rule(doc), ...(doc.txid !== undefined ? { txid: doc.txid } : undefined), @@ -345,6 +386,12 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules request, ) + yield* recordHttpAudit("alert_rule.created", { + resourceType: "alert_rule", + resourceId: encodePublicId(PublicIdPrefixes.alertRule, created.id), + metadata: { name: created.name }, + }) + return toV2RuleMutationResponse(created) }), ) @@ -361,6 +408,20 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules request, ) + const changes = compactAuditChanges( + diffAuditChanges( + pickPresentFields(ruleAuditKeys, payload, toV2Rule(current)), + pickPresentFields(ruleAuditKeys, payload, toV2Rule(updated)), + ), + { query_builder_draft: summarizeRuleBlob, raw_query_sql: summarizeRuleBlob }, + ) + yield* recordHttpAudit("alert_rule.updated", { + resourceType: "alert_rule", + resourceId: encodePublicId(PublicIdPrefixes.alertRule, updated.id), + ...(changes !== undefined ? { changes } : undefined), + metadata: { name: updated.name }, + }) + return toV2RuleMutationResponse(updated) }), ) @@ -368,6 +429,10 @@ 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", { + resourceType: "alert_rule", + resourceId: encodePublicId(PublicIdPrefixes.alertRule, 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..1cfc5dad2 100644 --- a/apps/api/src/routes/v2/anomalies.http.ts +++ b/apps/api/src/routes/v2/anomalies.http.ts @@ -12,9 +12,10 @@ import { AnomalyForbiddenError, CurrentTenant, } from "@maple/domain/http" -import { MapleApiV2, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" +import { encodePublicId, MapleApiV2, paginateOffsetQuery, PublicIdPrefixes, 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,14 @@ 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", { + resourceType: "anomaly_incident", + resourceId: encodePublicId(PublicIdPrefixes.anomalyIncident, incident.id), + metadata: { + signal_type: incident.signalType, + service_name: incident.serviceName, + }, + }) return toV2Incident(incident) }), @@ -244,6 +253,11 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", }), ) + yield* recordHttpAudit("anomaly_settings.updated", { + resourceType: "anomaly_settings", + 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..152742d7d 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -2,14 +2,17 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ApiKeyCreatedResponse, ApiKeyResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { + encodePublicId, MapleApiV2, isoTimestamp, isoTimestampOrNull, paginateArray, + PublicIdPrefixes, V2InsufficientPermissions, } 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 +109,11 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha ? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } } : undefined), }) + yield* recordHttpAudit("api_key.created", { + resourceType: "api_key", + resourceId: encodePublicId(PublicIdPrefixes.apiKey, created.id), + metadata: { name: created.name, kind: created.kind, scopes: created.scopes }, + }) return toV2ApiKeyWithSecret(created) }), ) @@ -117,6 +125,11 @@ 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", { + resourceType: "api_key", + resourceId: encodePublicId(PublicIdPrefixes.apiKey, rolled.id), + metadata: { name: rolled.name, scopes: rolled.scopes }, + }) return toV2ApiKeyWithSecret(rolled) }), ) @@ -132,6 +145,11 @@ 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", { + resourceType: "api_key", + resourceId: encodePublicId(PublicIdPrefixes.apiKey, 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..4557de176 100644 --- a/apps/api/src/routes/v2/attribute-mappings.http.ts +++ b/apps/api/src/routes/v2/attribute-mappings.http.ts @@ -6,9 +6,11 @@ import { IngestAttributeMappingNotFoundError, UpdateIngestAttributeMappingRequest, } from "@maple/domain/http" -import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" +import { encodePublicId, MapleApiV2, paginateArray, PublicIdPrefixes } 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,19 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att }), ) + yield* recordHttpAudit("attribute_mapping.created", { + resourceType: "attribute_mapping", + resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, 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 +123,17 @@ 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", { + resourceType: "attribute_mapping", + resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, updated.id), + ...(changes !== undefined ? { changes } : undefined), + metadata: { name: updated.name }, + }) + return toV2AttributeMapping(updated) }), ) @@ -116,6 +141,10 @@ 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", { + resourceType: "attribute_mapping", + resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, 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.ts b/apps/api/src/routes/v2/audit-changes.ts new file mode 100644 index 000000000..afa070f61 --- /dev/null +++ b/apps/api/src/routes/v2/audit-changes.ts @@ -0,0 +1,60 @@ +import type { AuditChanges } from "@maple/domain/http" + +/** + * 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 (JSON.stringify(prev) === JSON.stringify(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 compact summary so large + * config blobs (dashboard widgets, query drafts) don't bloat the audit row. + */ +export const compactAuditChanges = ( + changes: AuditChanges | undefined, + summarize: Record unknown>, +): AuditChanges | undefined => { + if (changes === undefined) return undefined + const before: Record = { ...changes.before } + const after: Record = { ...changes.after } + for (const field of changes.fields) { + const summary = summarize[field] + if (summary === undefined) continue + if (field in before) before[field] = summary(before[field]) + if (field in after) after[field] = summary(after[field]) + } + return { fields: changes.fields, before, after } +} 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..a1a6e6e02 --- /dev/null +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -0,0 +1,141 @@ +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, + 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 type { AuditLogListFilters } from "@/services/audit/AuditLogService" + +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 + 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..604285f25 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 { compactAuditChanges, diffAuditChanges, pickPresentFields } 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 dashboardAuditKeys: ReadonlyArray = [ + "name", + "description", + "tags", + "timeRange", + "widgets", + "sections", + "variables", + "refreshIntervalSeconds", +] + +/** Layout arrays are config blobs — audit their size, not their bodies. */ +const summarizeListBlob = (label: string) => (value: unknown) => + Array.isArray(value) ? `<${value.length} ${label}>` : "" + const encodeVersionCursor = (versionNumber: number): string => `ver_${versionNumber.toString(36)}` const decodeVersionCursor = (cursor: string): number | null => { @@ -270,6 +290,17 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards "maple.share.id": created.id, mode: created.mode, }) + yield* recordHttpAudit("dashboard_share.created", { + resourceType: "dashboard_share", + resourceId: encodePublicId(PublicIdPrefixes.dashboardShare, 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) }) @@ -298,6 +329,15 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards ) yield* logShare("dashboard share revoked", context, { hadLiveShare: tombstone.revoked }) + if (tombstone.revoked) { + yield* recordHttpAudit("dashboard_share.deleted", { + resourceType: "dashboard_share", + 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 +376,11 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards tenant.userId, toPortable(payload), ) + yield* recordHttpAudit("dashboard.created", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + metadata: { name: dashboard.name }, + }) return toV2DashboardMutation(dashboard) }), @@ -346,12 +391,37 @@ 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 + : compactAuditChanges( + diffAuditChanges( + pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(previous)), + pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(dashboard)), + ), + { + widgets: summarizeListBlob("widgets"), + sections: summarizeListBlob("sections"), + variables: summarizeListBlob("variables"), + }, + ) + yield* recordHttpAudit("dashboard.updated", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + ...(changes !== undefined ? { changes } : undefined), + metadata: { name: dashboard.name }, + }) return toV2DashboardMutation(dashboard) }), @@ -360,6 +430,10 @@ 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", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, deleted.id), + }) return { id: deleted.id, diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index cddf23be2..b337fd79c 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,10 @@ 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", { + resourceType: "ingest_key", + metadata: { key_type: "public" }, + }) return toV2IngestKeys(keys) }), @@ -46,6 +51,10 @@ 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", { + resourceType: "ingest_key", + 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/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..0c9d1651b 100644 --- a/apps/api/src/routes/v2/scrape-targets.http.ts +++ b/apps/api/src/routes/v2/scrape-targets.http.ts @@ -1,9 +1,18 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ScrapeTargetResponse } from "@maple/domain/http" import { CreateScrapeTargetRequest, CurrentTenant, UpdateScrapeTargetRequest } from "@maple/domain/http" -import { MapleApiV2, paginateArray, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" +import { + encodePublicId, + MapleApiV2, + paginateArray, + paginateOffsetQuery, + PublicIdPrefixes, + timestamp, +} from "@maple/domain/http/v2" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { Effect } from "effect" +import { diffAuditChanges, pickPresentFields } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ @@ -28,6 +37,31 @@ const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ updated_at: target.updatedAt, }) +/** Update-payload fields diffable through the wire shape; credentials never appear. */ +const targetAuditKeys: ReadonlyArray< + | "name" + | "url" + | "organization" + | "include_branches" + | "exclude_branches" + | "scrape_interval_seconds" + | "labels_json" + | "auth_type" + | "service_name" + | "enabled" +> = [ + "name", + "url", + "organization", + "include_branches", + "exclude_branches", + "scrape_interval_seconds", + "labels_json", + "auth_type", + "service_name", + "enabled", +] + export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeTargets", (handlers) => Effect.gen(function* () { const service = yield* ScrapeTargetsService @@ -96,12 +130,19 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT }), ) + yield* recordHttpAudit("scrape_target.created", { + resourceType: "scrape_target", + resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, 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 +185,26 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT }), ) + const observable = diffAuditChanges( + pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(current)), + pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(updated)), + ) + // Credentials are write-only: audit that they rotated, never their value. + const changes = + payload.auth_credentials !== undefined + ? { + fields: [...(observable?.fields ?? []), "auth_credentials"], + before: { ...observable?.before, auth_credentials: "" }, + after: { ...observable?.after, auth_credentials: "" }, + } + : observable + yield* recordHttpAudit("scrape_target.updated", { + resourceType: "scrape_target", + resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, updated.id), + ...(changes !== undefined ? { changes } : undefined), + metadata: { name: updated.name }, + }) + return toV2ScrapeTarget(updated) }), ) @@ -151,6 +212,10 @@ 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", { + resourceType: "scrape_target", + resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, 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-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/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..267d965a4 --- /dev/null +++ b/apps/api/src/services/audit/AuditLogService.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" +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 } from "./AuditLogService" + +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 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", + resourceType: "dashboard", + resourceId: "dash_first", + 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("dash_first") + 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.delete", + 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.delete"]) + 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) + }, + }, + }), + ), + ), + ), + ) + }) + + 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..4472dcb68 --- /dev/null +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -0,0 +1,291 @@ +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 } from "@/platform/DatabaseLive" +import { msToDate } from "@/platform/time" +import { CurrentAuditActor } from "@/services/auth/audit-actor" +import { AuditLogEvent, auditEventToInsert, encodeAuditLogEventSync } from "./audit-event" + +const decodeAuditLogEntryIdSync = Schema.decodeUnknownSync(AuditLogEntryIdSchema) + +/** Producer binding name; the paired `*_NAME` var drives consumer dispatch. */ +export const AUDIT_EVENTS_QUEUE_BINDING = "AUDIT_EVENTS_QUEUE" + +const toPersistenceError = (error: unknown) => + new AuditLogPersistenceError({ + message: error instanceof Error ? error.message : "Audit log query failed", + }) + +/** 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 interface AuditLogRecordInput { + readonly orgId: OrgId + readonly actor: AuditActorRef + readonly source: AuditLogSource + /** `.`, e.g. `alert_rule.created`. */ + readonly action: string + /** Defaults to `"allowed"`; denied attempts pass `"denied"` + `denialReason`. */ + readonly outcome?: AuditOutcome + readonly denialReason?: string + readonly affectedUserId?: UserId + readonly resourceType?: string + readonly resourceId?: string + readonly changes?: AuditChanges + readonly metadata?: Record + readonly requestId?: string + readonly originIp?: string + readonly originCountry?: string +} + +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(), + ) + }) + + const publish = (event: AuditLogEvent) => + queue === undefined + ? insertDirect(event) + : Effect.tryPromise({ + try: () => queue.send(encodeAuditLogEventSync(event)), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + // Queue unavailability must not lose the entry: degrade to a + // direct write before giving up. + Effect.catchCause((cause) => + Effect.logWarning("Audit queue send failed; writing directly", { cause }).pipe( + Effect.andThen(insertDirect(event)), + ), + ), + ) + + const record: AuditLogServiceApi["record"] = Effect.fn("AuditLogService.record")(function* ( + input, + ) { + const now = yield* Clock.currentTimeMillis + 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), + ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : undefined), + ...(input.resourceId !== undefined ? { resourceId: input.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 ?? "", + }), + ) + } + yield* publish(event).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Audit log write failed", { action: input.action, cause }), + ), + ) + }) + + 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 from the tenant plus the auth middleware's `CurrentAuditActor`, + * and request forensics (request id, origin) from the Cloudflare headers. + * Session requests (and requests that bypassed the standard middlewares) + * attribute to the user; API-key requests attribute to the key. + */ +export const recordHttpAudit = ( + action: string, + opts?: { + readonly resourceType?: string + readonly resourceId?: string + readonly changes?: AuditChanges + readonly affectedUserId?: UserId + readonly metadata?: Record + }, +) => + Effect.gen(function* () { + const audit = yield* AuditLogService + const tenant = yield* CurrentTenant.Context + const info = yield* CurrentAuditActor + const context = yield* requestContext + const isApiKey = info?.type === "api_key" + yield* audit.record({ + orgId: tenant.orgId, + actor: { + type: isApiKey ? "api_key" : "user", + userId: tenant.userId, + ...(isApiKey && info.apiKeyId !== undefined ? { apiKeyId: info.apiKeyId } : undefined), + }, + source: isApiKey ? "api" : "dashboard", + action, + ...context, + ...opts, + }) + }) 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..ec19b59ff --- /dev/null +++ b/apps/api/src/services/audit/audit-log-retention.ts @@ -0,0 +1,78 @@ +import { auditLogEntries } from "@maple/db" +import { inArray, lt } from "drizzle-orm" +import { Clock, Config, Effect } from "effect" +import { Database } from "@/platform/DatabaseLive" +import { msToDate } 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 + const cutoff = msToDate(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++) { + const staleIds = db + .select({ id: auditLogEntries.id }) + .from(auditLogEntries) + .where(lt(auditLogEntries.occurredAt, cutoff)) + .limit(RETENTION_BATCH_ROWS) + const rows = await db + .delete(auditLogEntries) + .where(inArray(auditLogEntries.id, staleIds)) + .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..37f45cfde 100644 --- a/apps/api/src/services/auth/ApiAuthorizationLayer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationLayer.ts @@ -4,6 +4,8 @@ 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 { Env } from "@/platform/Env" const decodeRoleNameSync = Schema.decodeUnknownSync(RoleName) @@ -22,6 +24,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 +45,30 @@ 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. + const recordDenied = (denialReason: string) => + audit.record({ + orgId: resolved.orgId, + actor: { + type: "api_key", + userId: resolved.userId, + apiKeyId: resolved.keyId, + }, + source: "api", + action: "api.request", + outcome: "denied", + 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 +84,20 @@ 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, + }), + ) } 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" }), ) }), }) diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 10010fa08..83117610d 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -15,6 +15,8 @@ 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 { Env } from "@/platform/Env" import { API_V2_RATE_LIMIT_PERIOD_SECONDS, @@ -57,6 +59,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. @@ -128,13 +131,38 @@ export const ApiAuthorizationV2Layer = Layer.effect( ) } + // A refused attempt is the highest-signal audit row there is — + // denials are recorded with the same actor attribution as + // successes, tagged `outcome: "denied"`. + const recordDenied = (denialReason: string) => + audit.record({ + orgId: resolved.orgId, + actor: { + type: "api_key", + userId: resolved.userId, + apiKeyId: resolved.keyId, + }, + source: "api", + action: "api.request", + outcome: "denied", + denialReason, + metadata: { method: request.method, path: requestPath(request.url) }, + ...(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), + }) + 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 +171,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 +183,13 @@ 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, + }), + ) } const tenant = yield* resolveTenant(request.headers).pipe( @@ -166,10 +198,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" }), ) }), }) diff --git a/apps/api/src/services/auth/SessionAuthorizationLayer.ts b/apps/api/src/services/auth/SessionAuthorizationLayer.ts index 3917091d7..82a706283 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" }), ) }), }) 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..5258a336b --- /dev/null +++ b/apps/api/src/services/auth/audit-actor.ts @@ -0,0 +1,23 @@ +import { Context } from "effect" +import type { ApiKeyId } from "@maple/domain/primitives" + +/** + * How the current HTTP request authenticated, for audit attribution. The + * tenant context deliberately does not say whether a request came from a + * dashboard session or an API key — this reference carries that one fact. + */ +export interface AuditActorInfo { + readonly type: "user" | "api_key" + readonly apiKeyId?: ApiKeyId +} + +/** + * 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, tests). + */ +export class CurrentAuditActor extends Context.Reference( + "@maple/api/services/auth/CurrentAuditActor", + { defaultValue: () => 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..2e97446c7 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts @@ -22,7 +22,9 @@ import { CLOSED_WORKFLOW_STATES, MACHINE_OWNED_WORKFLOW_STATES, } from "@maple/domain/http" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { + actors, alertIncidents, errorIncidents, errorIssues, @@ -37,6 +39,7 @@ 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 { readTxid, txidColumn } from "@/platform/electric-txid" import { dateToMs, msToDate } from "@/platform/time" import { ErrorActorsService } from "./ErrorActorsService" @@ -169,10 +172,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 +384,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 +400,62 @@ 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 + yield* audit.record({ + orgId, + // A human actor at this layer may have acted from the dashboard or + // over MCP — the issue event does not say which. + actor: + 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: "user", + ...(actor.userId === null ? undefined : { userId: actor.userId }), + actorId, + }, + source: actor.type === "agent" ? "mcp" : "dashboard", + action: `error_issue.${type}`, + resourceType: "error_issue", + resourceId: encodePublicId(PublicIdPrefixes.errorIssue, 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 +471,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 +594,7 @@ const make: Effect.Effect db.insert(errorIssueEvents).values(row)) - yield* actors.touchActor(orgId, actorId, timestamp) - const actorMap = yield* actors.collectActorDocs(orgId, [actorId]) + yield* actorsService.touchActor(orgId, actorId, timestamp) + const actorMap = yield* actorsService.collectActorDocs(orgId, [actorId]) return rowToEvent(row, actorMap) }) @@ -811,7 +880,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/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..998325d98 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,12 @@ "max_batch_timeout": 5, "max_retries": 3, }, + { + "queue": "maple-audit-events-local", + "max_batch_size": 25, + "max_batch_timeout": 5, + "max_retries": 5, + }, ], }, } 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..967e28e92 --- /dev/null +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -0,0 +1,337 @@ +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 +} + +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) + + const pageAtom = auditLogPageAtom({ + ...(cursor !== undefined ? { cursor } : 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] + : [...(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) + } + + function handleOutcomeSelect(value: OutcomeFilter) { + if (value === outcomeFilter) return + setOutcomeFilter(value) + setCursor(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} + + ))} +
+
+

+ Every change made through 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..c6c8446c7 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 }, 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..72772d2cf --- /dev/null +++ b/apps/web/src/lib/services/atoms/audit-log-atoms.ts @@ -0,0 +1,46 @@ +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 +} + +// Actor types and outcomes never contain "|", and the cursor is the trailing +// segment, so splitting on the first two separators stays unambiguous even for +// exotic cursors. +const family = Atom.family((key: string) => { + const firstSeparator = key.indexOf("|") + const secondSeparator = key.indexOf("|", firstSeparator + 1) + const actorRaw = key.slice(0, firstSeparator) + const outcomeRaw = key.slice(firstSeparator + 1, secondSeparator) + const cursor = key.slice(secondSeparator + 1) + 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), + }, + }) + }), + ) +}) + +/** One page of the org's audit log, keyed by cursor + actor-type/outcome filters. */ +export const auditLogPageAtom = (input: AuditLogPageInput) => + family(`${input.actorType ?? ""}|${input.outcome ?? ""}|${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..fedc989f5 --- /dev/null +++ b/packages/db/drizzle/0050_audit_log_entries.sql @@ -0,0 +1,31 @@ +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"); \ 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..46076f621 --- /dev/null +++ b/packages/db/drizzle/meta/0050_snapshot.json @@ -0,0 +1,8999 @@ +{ + "id": "2b89a081-0fdd-4565-9366-89077aa29ec5", + "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": {} + } + }, + "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..c3990b67e --- /dev/null +++ b/packages/db/src/schema/audit-log.ts @@ -0,0 +1,61 @@ +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, + ), + ], +) + +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..19d572532 --- /dev/null +++ b/packages/domain/src/http/audit-log.ts @@ -0,0 +1,54 @@ +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, + }, + { + 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..74bae4dc4 --- /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 { 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, 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. Requires 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 — every allowed or denied action performed through the dashboard, the public API, or MCP, attributed to the user, API key, or agent that performed it, with before/after diffs for updates.", + }), + ) {} 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", From b21b3a39a1fb93b7ac903935ef324ac724fdaf7f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 29 Aug 2026 15:09:41 +0200 Subject: [PATCH 2/6] fix(audit): satisfy effect-boundary lint and regenerate iOS OpenAPI spec - Tagged AuditQueueSendError instead of a global Error in the queue send failure channel. - compactAuditChanges takes static placeholder strings instead of unknown-typed summarizer functions; null still survives as null. - destinationObservableValue returns a concrete union, not unknown. - iOS OpenAPI spec regenerated for the new /v2/audit_log path. --- .../src/routes/v2/alert-destinations.http.ts | 5 ++++- apps/api/src/routes/v2/alert-rules.http.ts | 5 ++--- apps/api/src/routes/v2/audit-changes.ts | 19 ++++++++++--------- apps/api/src/routes/v2/dashboards.http.ts | 10 ++++------ .../api/src/services/audit/AuditLogService.ts | 11 ++++++++++- .../MapleAPI/Sources/MapleAPI/openapi.json | 4 ++++ 6 files changed, 34 insertions(+), 20 deletions(-) diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 73238c668..4f586d98b 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -196,7 +196,10 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati 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): unknown => { +const destinationObservableValue = ( + doc: AlertDestinationDocument, + key: string, +): string | boolean | ReadonlyArray | null | undefined => { switch (key) { case "name": return doc.name diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 54c61591c..45e53cabf 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -132,8 +132,6 @@ const ruleAuditKeys: ReadonlyArray (value === null ? null : "") const toV2RuleMutationResponse = (doc: AlertRuleDocument): V2AlertRuleMutationResponse => ({ ...toV2Rule(doc), @@ -413,7 +411,8 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules pickPresentFields(ruleAuditKeys, payload, toV2Rule(current)), pickPresentFields(ruleAuditKeys, payload, toV2Rule(updated)), ), - { query_builder_draft: summarizeRuleBlob, raw_query_sql: summarizeRuleBlob }, + // Query drafts and raw SQL are config blobs — audit that they changed, not their bodies. + { query_builder_draft: "", raw_query_sql: "" }, ) yield* recordHttpAudit("alert_rule.updated", { resourceType: "alert_rule", diff --git a/apps/api/src/routes/v2/audit-changes.ts b/apps/api/src/routes/v2/audit-changes.ts index afa070f61..444a9fed4 100644 --- a/apps/api/src/routes/v2/audit-changes.ts +++ b/apps/api/src/routes/v2/audit-changes.ts @@ -40,21 +40,22 @@ export const pickPresentFields = ( } /** - * Replace selected fields' before/after values with a compact summary so large - * config blobs (dashboard widgets, query drafts) don't bloat the audit row. + * 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, - summarize: Record unknown>, + placeholders: Record, ): AuditChanges | undefined => { if (changes === undefined) return undefined - const before: Record = { ...changes.before } - const after: Record = { ...changes.after } + const before = { ...changes.before } + const after = { ...changes.after } for (const field of changes.fields) { - const summary = summarize[field] - if (summary === undefined) continue - if (field in before) before[field] = summary(before[field]) - if (field in after) after[field] = summary(after[field]) + 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 } } diff --git a/apps/api/src/routes/v2/dashboards.http.ts b/apps/api/src/routes/v2/dashboards.http.ts index 604285f25..a1c0ef931 100644 --- a/apps/api/src/routes/v2/dashboards.http.ts +++ b/apps/api/src/routes/v2/dashboards.http.ts @@ -190,9 +190,6 @@ const dashboardAuditKeys: ReadonlyArray (value: unknown) => - Array.isArray(value) ? `<${value.length} ${label}>` : "" const encodeVersionCursor = (versionNumber: number): string => `ver_${versionNumber.toString(36)}` @@ -410,10 +407,11 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(previous)), pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(dashboard)), ), + // Layout arrays are config blobs — audit that they changed, not their bodies. { - widgets: summarizeListBlob("widgets"), - sections: summarizeListBlob("sections"), - variables: summarizeListBlob("variables"), + widgets: "", + sections: "", + variables: "", }, ) yield* recordHttpAudit("dashboard.updated", { diff --git a/apps/api/src/services/audit/AuditLogService.ts b/apps/api/src/services/audit/AuditLogService.ts index 4472dcb68..16a514cef 100644 --- a/apps/api/src/services/audit/AuditLogService.ts +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -16,6 +16,14 @@ import { AuditLogEvent, auditEventToInsert, encodeAuditLogEventSync } from "./au 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" @@ -118,7 +126,8 @@ export class AuditLogService extends Context.Service queue.send(encodeAuditLogEventSync(event)), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => + new AuditQueueSendError({ message: "Audit queue send failed", cause }), }).pipe( // Queue unavailability must not lose the entry: degrade to a // direct write before giving up. diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 7b31b3aa8..f1155e639 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 — every allowed or denied action performed through the dashboard, the public API, or MCP, attributed to the user, API key, or agent that performed it, with before/after diffs for updates.", + "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" From 416ee4c20d4db0f148ef4b495f001cc61185b415 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 29 Aug 2026 17:28:38 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(audit):=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?admin=20gate,=20denial=20coalescing,=20diff=20safety,=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From four review passes over the audit log: - Admin-gate GET /v2/audit_log: entries carry every member's activity, denial history and origin IP for the retention window. The settings tab hides for non-admins to match. - Coalesce denied api.request records per (org, key, method+path, reason) in a 60s isolate-local window. The v1 auth layer has no rate limiter, so a client looping mis-scoped requests could otherwise amplify into unbounded queue messages, rows and warn logs. Both auth layers now share one helper, so v1 records the same forensics as v2. - Audit the two v2 denial branches that returned early (MCP-only key, invalid device credential) — the credential-probing case the feature exists to surface. - Bound queue.send with a 2s timeout: a stalling broker must not hang the mutation's response before the direct-write fallback. - Replace blanket catchCause with catchTag/catchDefect so interruption propagates instead of spawning a Postgres insert mid-teardown. - Structural (key-order insensitive) diff comparison; redact userinfo and query strings from audited scrape-target URLs; carry the cause on AuditLogPersistenceError. - Index occurred_at for the retention sweep, the actor-identity columns for the primary 'what did this credential do' query, and a GIN index for changed-field lookups. --- apps/api/src/routes/v2/alert-rules.http.ts | 4 +- apps/api/src/routes/v2/audit-changes.ts | 32 +++- apps/api/src/routes/v2/audit-log.http.ts | 8 + apps/api/src/routes/v2/dashboards.http.ts | 36 +++- apps/api/src/routes/v2/scrape-targets.http.ts | 26 ++- .../api/src/services/audit/AuditLogService.ts | 48 +++-- .../src/services/audit/audit-log-retention.ts | 19 +- .../services/auth/ApiAuthorizationLayer.ts | 16 +- .../services/auth/ApiAuthorizationV2Layer.ts | 51 ++---- apps/api/src/services/auth/audit-denial.ts | 94 ++++++++++ .../src/components/settings/settings-nav.tsx | 3 + .../db/drizzle/0050_audit_log_entries.sql | 9 +- packages/db/drizzle/meta/0050_snapshot.json | 167 +++++++++++++++++- packages/db/src/schema/audit-log.ts | 19 ++ packages/domain/src/http/audit-log.ts | 2 + packages/domain/src/http/v2/audit-log.ts | 8 +- 16 files changed, 466 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/services/auth/audit-denial.ts diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index 45e53cabf..fe1f5122d 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -412,7 +412,9 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules pickPresentFields(ruleAuditKeys, payload, toV2Rule(updated)), ), // Query drafts and raw SQL are config blobs — audit that they changed, not their bodies. - { query_builder_draft: "", raw_query_sql: "" }, + { query_builder_draft: "", raw_query_sql: "" } satisfies Partial< + Record<(typeof ruleAuditKeys)[number], string> + >, ) yield* recordHttpAudit("alert_rule.updated", { resourceType: "alert_rule", diff --git a/apps/api/src/routes/v2/audit-changes.ts b/apps/api/src/routes/v2/audit-changes.ts index 444a9fed4..40853fd03 100644 --- a/apps/api/src/routes/v2/audit-changes.ts +++ b/apps/api/src/routes/v2/audit-changes.ts @@ -1,5 +1,23 @@ 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 @@ -15,7 +33,7 @@ export const diffAuditChanges = ( for (const key of Object.keys(after)) { const prev = before[key] const next = after[key] - if (JSON.stringify(prev) === JSON.stringify(next)) continue + if (structuralEqual(prev, next)) continue fields.push(key) beforeOut[key] = prev afterOut[key] = next @@ -46,6 +64,8 @@ export const pickPresentFields = ( */ export const compactAuditChanges = ( changes: AuditChanges | undefined, + // Call sites `satisfies Partial>` + // so a wire-key rename cannot silently disable a redaction placeholder. placeholders: Record, ): AuditChanges | undefined => { if (changes === undefined) return undefined @@ -59,3 +79,13 @@ export const compactAuditChanges = ( } 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}` +} diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts index a1a6e6e02..ec9b351a7 100644 --- a/apps/api/src/routes/v2/audit-log.http.ts +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -8,14 +8,18 @@ import { 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) @@ -99,6 +103,10 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", ( 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 = diff --git a/apps/api/src/routes/v2/dashboards.http.ts b/apps/api/src/routes/v2/dashboards.http.ts index a1c0ef931..6ba9b87c0 100644 --- a/apps/api/src/routes/v2/dashboards.http.ts +++ b/apps/api/src/routes/v2/dashboards.http.ts @@ -312,6 +312,15 @@ 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", { + resourceType: "dashboard_share", + resourceId: encodePublicId(PublicIdPrefixes.dashboardShare, rotated.id), + metadata: { + dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, dashboardId), + ...(widgetId === null ? undefined : { widget_id: widgetId }), + }, + }) return toV2DashboardShare(rotated) }) @@ -412,7 +421,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards widgets: "", sections: "", variables: "", - }, + } satisfies Partial>, ) yield* recordHttpAudit("dashboard.updated", { resourceType: "dashboard", @@ -450,6 +459,11 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards tenant.userId, converted.dashboard, ) + yield* recordHttpAudit("dashboard.created", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + metadata: { name: dashboard.name, source: "perses_import" }, + }) return { object: "dashboard_import" as const, @@ -508,6 +522,17 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards params.id, params.version_id, ) + yield* recordHttpAudit("dashboard.version_restored", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + metadata: { + name: dashboard.name, + version_id: encodePublicId( + PublicIdPrefixes.dashboardVersion, + params.version_id, + ), + }, + }) return toV2DashboardMutation(dashboard) }), @@ -600,6 +625,15 @@ 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", { + resourceType: "dashboard", + resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + metadata: { + name: dashboard.name, + source: "template", + template_id: params.template_id, + }, + }) return toV2DashboardMutation(dashboard) }), diff --git a/apps/api/src/routes/v2/scrape-targets.http.ts b/apps/api/src/routes/v2/scrape-targets.http.ts index 0c9d1651b..71fee0fd6 100644 --- a/apps/api/src/routes/v2/scrape-targets.http.ts +++ b/apps/api/src/routes/v2/scrape-targets.http.ts @@ -11,7 +11,7 @@ import { } from "@maple/domain/http/v2" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { Effect } from "effect" -import { diffAuditChanges, pickPresentFields } from "@/routes/v2/audit-changes" +import { diffAuditChanges, pickPresentFields, redactAuditUrl } from "@/routes/v2/audit-changes" import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" @@ -185,10 +185,32 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT }), ) - const observable = diffAuditChanges( + // Read-then-write with no CAS: a concurrent update can make `before` + // reflect a state this update never saw. Accepted for audit purposes. + const diffed = diffAuditChanges( pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(current)), pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(updated)), ) + // 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. + const observable = + diffed === undefined || !diffed.fields.includes("url") + ? diffed + : { + fields: diffed.fields, + before: { + ...diffed.before, + ...(typeof diffed.before["url"] === "string" + ? { url: redactAuditUrl(diffed.before["url"]) } + : undefined), + }, + after: { + ...diffed.after, + ...(typeof diffed.after["url"] === "string" + ? { url: redactAuditUrl(diffed.after["url"]) } + : undefined), + }, + } // Credentials are write-only: audit that they rotated, never their value. const changes = payload.auth_credentials !== undefined diff --git a/apps/api/src/services/audit/AuditLogService.ts b/apps/api/src/services/audit/AuditLogService.ts index 16a514cef..fcf2cf5ce 100644 --- a/apps/api/src/services/audit/AuditLogService.ts +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -9,7 +9,7 @@ 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 } from "@/platform/DatabaseLive" +import { Database, type DatabaseError } from "@/platform/DatabaseLive" import { msToDate } from "@/platform/time" import { CurrentAuditActor } from "@/services/auth/audit-actor" import { AuditLogEvent, auditEventToInsert, encodeAuditLogEventSync } from "./audit-event" @@ -27,10 +27,15 @@ class AuditQueueSendError extends Schema.TaggedError()( /** Producer binding name; the paired `*_NAME` var drives consumer dispatch. */ export const AUDIT_EVENTS_QUEUE_BINDING = "AUDIT_EVENTS_QUEUE" -const toPersistenceError = (error: unknown) => - new AuditLogPersistenceError({ - message: error instanceof Error ? error.message : "Audit log query failed", - }) +/** + * `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 { @@ -121,6 +126,14 @@ export class AuditLogService extends Context.Service + Effect.logWarning("Audit queue send failed; writing directly", { cause: error }).pipe( + Effect.andThen(insertDirect(event)), + ) + const publish = (event: AuditLogEvent) => queue === undefined ? insertDirect(event) @@ -129,12 +142,15 @@ export class AuditLogService extends Context.Service new AuditQueueSendError({ message: "Audit queue send failed", cause }), }).pipe( - // Queue unavailability must not lose the entry: degrade to a - // direct write before giving up. - Effect.catchCause((cause) => - Effect.logWarning("Audit queue send failed; writing directly", { cause }).pipe( - Effect.andThen(insertDirect(event)), - ), + // 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), ), ) @@ -180,9 +196,15 @@ export class AuditLogService extends Context.Service - Effect.logWarning("Audit log write failed", { action: input.action, cause }), + 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 }), ), ) }) diff --git a/apps/api/src/services/audit/audit-log-retention.ts b/apps/api/src/services/audit/audit-log-retention.ts index ec19b59ff..a1c176f05 100644 --- a/apps/api/src/services/audit/audit-log-retention.ts +++ b/apps/api/src/services/audit/audit-log-retention.ts @@ -1,8 +1,8 @@ import { auditLogEntries } from "@maple/db" -import { inArray, lt } from "drizzle-orm" +import { sql } from "drizzle-orm" import { Clock, Config, Effect } from "effect" import { Database } from "@/platform/DatabaseLive" -import { msToDate } from "@/platform/time" +import { msToSqlTimestamp } from "@/platform/time" /** * Retention for the org audit log (`audit_log_entries`). @@ -34,20 +34,21 @@ const retentionDaysConfig = Config.number("AUDIT_LOG_RETENTION_DAYS").pipe( export const runAuditLogRetention = Effect.gen(function* () { const retentionDays = yield* retentionDaysConfig const now = yield* Clock.currentTimeMillis - const cutoff = msToDate(now - retentionDays * DAY_MS) + // 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++) { - const staleIds = db - .select({ id: auditLogEntries.id }) - .from(auditLogEntries) - .where(lt(auditLogEntries.occurredAt, cutoff)) - .limit(RETENTION_BATCH_ROWS) + // 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(inArray(auditLogEntries.id, staleIds)) + .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 diff --git a/apps/api/src/services/auth/ApiAuthorizationLayer.ts b/apps/api/src/services/auth/ApiAuthorizationLayer.ts index 37f45cfde..27334c2be 100644 --- a/apps/api/src/services/auth/ApiAuthorizationLayer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationLayer.ts @@ -6,6 +6,7 @@ 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) @@ -47,18 +48,13 @@ export const ApiAuthorizationLayer = Layer.effect( 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. + // 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) => - audit.record({ + recordApiDenial(audit, request, { orgId: resolved.orgId, - actor: { - type: "api_key", - userId: resolved.userId, - apiKeyId: resolved.keyId, - }, - source: "api", - action: "api.request", - outcome: "denied", + userId: resolved.userId, + apiKeyId: resolved.keyId, denialReason, }) if (resolved.kind !== "standard") { diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 83117610d..b203095a1 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -17,6 +17,7 @@ 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, @@ -85,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 @@ -92,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 @@ -103,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 @@ -131,33 +143,6 @@ export const ApiAuthorizationV2Layer = Layer.effect( ) } - // A refused attempt is the highest-signal audit row there is — - // denials are recorded with the same actor attribution as - // successes, tagged `outcome: "denied"`. - const recordDenied = (denialReason: string) => - audit.record({ - orgId: resolved.orgId, - actor: { - type: "api_key", - userId: resolved.userId, - apiKeyId: resolved.keyId, - }, - source: "api", - action: "api.request", - outcome: "denied", - denialReason, - metadata: { method: request.method, path: requestPath(request.url) }, - ...(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), - }) - const required = requiredScopeForRequest(request.method, requestPath(request.url)) if (required !== null && !scopeAllows(resolved.scopes, required)) { const message = `This API key does not have the "${required.family}:${required.access}" scope required for this request.` 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/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index c6c8446c7..db51b7bf2 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -198,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/packages/db/drizzle/0050_audit_log_entries.sql b/packages/db/drizzle/0050_audit_log_entries.sql index fedc989f5..8247ac3a6 100644 --- a/packages/db/drizzle/0050_audit_log_entries.sql +++ b/packages/db/drizzle/0050_audit_log_entries.sql @@ -28,4 +28,11 @@ CREATE INDEX "audit_log_entries_org_occurred_idx" ON "audit_log_entries" USING b 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"); \ No newline at end of file +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 index 46076f621..caa784ce0 100644 --- a/packages/db/drizzle/meta/0050_snapshot.json +++ b/packages/db/drizzle/meta/0050_snapshot.json @@ -1,5 +1,5 @@ { - "id": "2b89a081-0fdd-4565-9366-89077aa29ec5", + "id": "5224691f-23a3-4172-b65b-be481ae93ad6", "prevId": "d60d7088-c27b-48cd-94b6-6d9fd59a02ff", "version": "7", "dialect": "postgresql", @@ -2080,6 +2080,171 @@ "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": {}, diff --git a/packages/db/src/schema/audit-log.ts b/packages/db/src/schema/audit-log.ts index c3990b67e..ca7e463ed 100644 --- a/packages/db/src/schema/audit-log.ts +++ b/packages/db/src/schema/audit-log.ts @@ -54,6 +54,25 @@ export const auditLogEntries = pgTable( 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), ], ) diff --git a/packages/domain/src/http/audit-log.ts b/packages/domain/src/http/audit-log.ts index 19d572532..bafd703a8 100644 --- a/packages/domain/src/http/audit-log.ts +++ b/packages/domain/src/http/audit-log.ts @@ -41,6 +41,8 @@ export class AuditLogPersistenceError extends HttpTaggedError Date: Sat, 29 Aug 2026 17:34:08 +0200 Subject: [PATCH 4/6] chore(ios): regenerate OpenAPI spec for the audit log admin gate --- apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index f1155e639..c68b2076c 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7336,7 +7336,7 @@ "name": "Attribute Mappings" }, { - "description": "The organization's append-only audit trail — every allowed or denied action performed through the dashboard, the public API, or MCP, attributed to the user, API key, or agent that performed it, with before/after diffs for updates.", + "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" }, { From dbf2f50f68ea2ecb38d0aad4b970477696064595 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 30 Aug 2026 00:55:20 +0200 Subject: [PATCH 5/6] refactor(audit): close the action namespace and derive the resource fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an audited action meant restating what the action already implied: a free-string `resourceType` echoing the action's own prefix, an inline `encodePublicId(PublicIdPrefixes.x, id)`, and — for updates — a hand-assembled diff pipeline. Across 28 call sites the resource pair was mechanically derivable every time, and nothing checked it: the service's own test recorded `alert_rule.delete`, a verb that does not exist. `AuditResources` now declares each resource with its public-ID prefix and verbs, and `AuditAction` is the derived `${resource}.${verb}` union. `record`/`recordHttpAudit` take the internal ID and derive `resourceType` plus the public encoding themselves, so a typo fails the build, a `resourceId` on an org-singleton resource fails the build, and the prefix can no longer disagree with the resource. `error_issue` verbs come from `ErrorIssueEventType.literals` so a new issue event type cannot produce an undeclared action. `auditDiff({ fields, summarize, redact, writeOnly })` replaces the per-handler diff assembly; scrape-targets' update handler goes from ~40 lines of object surgery to one call. Keying `summarize`/`redact` by `fields` makes the old "remember to `satisfies`" rule structural. --- apps/api/src/mcp/tools/register-agent.ts | 4 +- .../src/routes/v2/alert-destinations.http.ts | 13 +-- apps/api/src/routes/v2/alert-rules.http.ts | 92 +++++++---------- apps/api/src/routes/v2/anomalies.http.ts | 6 +- apps/api/src/routes/v2/api-keys.http.ts | 11 +-- .../src/routes/v2/attribute-mappings.http.ts | 13 +-- apps/api/src/routes/v2/audit-changes.test.ts | 83 ++++++++++++++++ apps/api/src/routes/v2/audit-changes.ts | 66 ++++++++++++- apps/api/src/routes/v2/dashboards.http.ts | 67 +++++-------- apps/api/src/routes/v2/ingest-keys.http.ts | 2 - apps/api/src/routes/v2/scrape-targets.http.ts | 99 +++++-------------- .../services/audit/AuditLogService.test.ts | 13 ++- .../api/src/services/audit/AuditLogService.ts | 30 +++--- .../src/services/audit/audit-actions.test.ts | 39 ++++++++ apps/api/src/services/audit/audit-actions.ts | 95 ++++++++++++++++++ .../errors/ErrorIssueWorkflowService.ts | 4 +- 16 files changed, 405 insertions(+), 232 deletions(-) create mode 100644 apps/api/src/routes/v2/audit-changes.test.ts create mode 100644 apps/api/src/services/audit/audit-actions.test.ts create mode 100644 apps/api/src/services/audit/audit-actions.ts diff --git a/apps/api/src/mcp/tools/register-agent.ts b/apps/api/src/mcp/tools/register-agent.ts index 1e887c067..5e0006543 100644 --- a/apps/api/src/mcp/tools/register-agent.ts +++ b/apps/api/src/mcp/tools/register-agent.ts @@ -5,7 +5,6 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { Effect, Option, Schema } from "effect" import { createDualContent } from "@/mcp/lib/structured-output" import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" @@ -66,8 +65,7 @@ export function registerRegisterAgentTool(server: McpToolRegistrar) { actor: { type: "user", userId: tenant.userId }, source: "mcp", action: "agent.registered", - resourceType: "agent", - resourceId: encodePublicId(PublicIdPrefixes.actor, actor.id), + resourceId: actor.id, metadata: { name: actor.agentName ?? name }, }) diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 4f586d98b..9f8325bba 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -18,7 +18,7 @@ import type { V2AlertDestinationUpdateParams, V2TelegramChatList, } from "@maple/domain/http/v2" -import { encodePublicId, MapleApiV2, paginateArray, PublicIdPrefixes } 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" @@ -301,8 +301,7 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale ) yield* recordHttpAudit("alert_destination.created", { - resourceType: "alert_destination", - resourceId: encodePublicId(PublicIdPrefixes.alertDestination, created.id), + resourceId: created.id, metadata: { name: created.name, type: created.type }, }) @@ -325,9 +324,8 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale const changes = buildDestinationChanges(request, current, updated) yield* recordHttpAudit("alert_destination.updated", { - resourceType: "alert_destination", - resourceId: encodePublicId(PublicIdPrefixes.alertDestination, updated.id), - ...(changes !== undefined ? { changes } : undefined), + resourceId: updated.id, + changes, metadata: { name: updated.name, type: updated.type }, }) @@ -343,8 +341,7 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale params.id, ) yield* recordHttpAudit("alert_destination.deleted", { - resourceType: "alert_destination", - resourceId: encodePublicId(PublicIdPrefixes.alertDestination, deleted.id), + resourceId: deleted.id, }) return { diff --git a/apps/api/src/routes/v2/alert-rules.http.ts b/apps/api/src/routes/v2/alert-rules.http.ts index fe1f5122d..abfb40c5b 100644 --- a/apps/api/src/routes/v2/alert-rules.http.ts +++ b/apps/api/src/routes/v2/alert-rules.http.ts @@ -16,18 +16,10 @@ import type { V2AlertRulePreviewResult, V2AlertRuleUpdateParams, } from "@maple/domain/http/v2" -import { - encodePublicId, - MapleApiV2, - paginateArray, - PublicIdPrefixes, - scopeAllows, - timestamp, - V2ParameterInvalid, -} from "@maple/domain/http/v2" +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 { compactAuditChanges, diffAuditChanges, pickPresentFields } from "@/routes/v2/audit-changes" +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" @@ -105,33 +97,36 @@ const toV2Rule = (doc: AlertRuleDocument): V2AlertRule => ({ }) /** Update-payload fields diffable through the wire shape (drafts get summarized). */ -const ruleAuditKeys: ReadonlyArray = [ - "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", -] - +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), @@ -385,8 +380,7 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules ) yield* recordHttpAudit("alert_rule.created", { - resourceType: "alert_rule", - resourceId: encodePublicId(PublicIdPrefixes.alertRule, created.id), + resourceId: created.id, metadata: { name: created.name }, }) @@ -406,20 +400,9 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules request, ) - const changes = compactAuditChanges( - diffAuditChanges( - pickPresentFields(ruleAuditKeys, payload, toV2Rule(current)), - pickPresentFields(ruleAuditKeys, payload, toV2Rule(updated)), - ), - // Query drafts and raw SQL are config blobs — audit that they changed, not their bodies. - { query_builder_draft: "", raw_query_sql: "" } satisfies Partial< - Record<(typeof ruleAuditKeys)[number], string> - >, - ) yield* recordHttpAudit("alert_rule.updated", { - resourceType: "alert_rule", - resourceId: encodePublicId(PublicIdPrefixes.alertRule, updated.id), - ...(changes !== undefined ? { changes } : undefined), + resourceId: updated.id, + changes: ruleAuditDiff(payload, toV2Rule(current), toV2Rule(updated)), metadata: { name: updated.name }, }) @@ -430,10 +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", { - resourceType: "alert_rule", - resourceId: encodePublicId(PublicIdPrefixes.alertRule, deleted.id), - }) + yield* recordHttpAudit("alert_rule.deleted", { resourceId: deleted.id }) return { id: deleted.id, diff --git a/apps/api/src/routes/v2/anomalies.http.ts b/apps/api/src/routes/v2/anomalies.http.ts index 1cfc5dad2..62049a999 100644 --- a/apps/api/src/routes/v2/anomalies.http.ts +++ b/apps/api/src/routes/v2/anomalies.http.ts @@ -12,7 +12,7 @@ import { AnomalyForbiddenError, CurrentTenant, } from "@maple/domain/http" -import { encodePublicId, MapleApiV2, paginateOffsetQuery, PublicIdPrefixes, timestamp } from "@maple/domain/http/v2" +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" @@ -188,8 +188,7 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", const tenant = yield* CurrentTenant.Context const incident = yield* anomalies.resolveIncidentManually(tenant.orgId, params.id) yield* recordHttpAudit("anomaly_incident.resolved", { - resourceType: "anomaly_incident", - resourceId: encodePublicId(PublicIdPrefixes.anomalyIncident, incident.id), + resourceId: incident.id, metadata: { signal_type: incident.signalType, service_name: incident.serviceName, @@ -254,7 +253,6 @@ export const HttpV2AnomaliesLive = HttpApiBuilder.group(MapleApiV2, "anomalies", ) yield* recordHttpAudit("anomaly_settings.updated", { - resourceType: "anomaly_settings", metadata: { enabled: settings.enabled, sensitivity: settings.sensitivity }, }) diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts index 152742d7d..0e737a3e5 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -2,12 +2,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ApiKeyCreatedResponse, ApiKeyResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { - encodePublicId, MapleApiV2, isoTimestamp, isoTimestampOrNull, paginateArray, - PublicIdPrefixes, V2InsufficientPermissions, } from "@maple/domain/http/v2" import type { V2ApiKey, V2ApiKeyMutationResponse, V2ApiKeyWithSecret } from "@maple/domain/http/v2" @@ -110,8 +108,7 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha : undefined), }) yield* recordHttpAudit("api_key.created", { - resourceType: "api_key", - resourceId: encodePublicId(PublicIdPrefixes.apiKey, created.id), + resourceId: created.id, metadata: { name: created.name, kind: created.kind, scopes: created.scopes }, }) return toV2ApiKeyWithSecret(created) @@ -126,8 +123,7 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha createdByEmail, }) yield* recordHttpAudit("api_key.rolled", { - resourceType: "api_key", - resourceId: encodePublicId(PublicIdPrefixes.apiKey, rolled.id), + resourceId: rolled.id, metadata: { name: rolled.name, scopes: rolled.scopes }, }) return toV2ApiKeyWithSecret(rolled) @@ -146,8 +142,7 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha } const revoked = yield* apiKeysService.revoke(tenant.orgId, params.id) yield* recordHttpAudit("api_key.revoked", { - resourceType: "api_key", - resourceId: encodePublicId(PublicIdPrefixes.apiKey, revoked.id), + 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 4557de176..948e70b90 100644 --- a/apps/api/src/routes/v2/attribute-mappings.http.ts +++ b/apps/api/src/routes/v2/attribute-mappings.http.ts @@ -6,7 +6,7 @@ import { IngestAttributeMappingNotFoundError, UpdateIngestAttributeMappingRequest, } from "@maple/domain/http" -import { encodePublicId, MapleApiV2, paginateArray, PublicIdPrefixes } from "@maple/domain/http/v2" +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" @@ -88,8 +88,7 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att ) yield* recordHttpAudit("attribute_mapping.created", { - resourceType: "attribute_mapping", - resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, created.id), + resourceId: created.id, metadata: { name: created.name }, }) @@ -128,9 +127,8 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att pickPresentFields(mappingAuditKeys, payload, toV2AttributeMapping(updated)), ) yield* recordHttpAudit("attribute_mapping.updated", { - resourceType: "attribute_mapping", - resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, updated.id), - ...(changes !== undefined ? { changes } : undefined), + resourceId: updated.id, + changes, metadata: { name: updated.name }, }) @@ -142,8 +140,7 @@ export const HttpV2AttributeMappingsLive = HttpApiBuilder.group(MapleApiV2, "att const tenant = yield* CurrentTenant.Context const deleted = yield* service.delete(tenant.orgId, params.id) yield* recordHttpAudit("attribute_mapping.deleted", { - resourceType: "attribute_mapping", - resourceId: encodePublicId(PublicIdPrefixes.attributeMapping, deleted.id), + 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 index 40853fd03..4986d06dc 100644 --- a/apps/api/src/routes/v2/audit-changes.ts +++ b/apps/api/src/routes/v2/audit-changes.ts @@ -64,9 +64,9 @@ export const pickPresentFields = ( */ export const compactAuditChanges = ( changes: AuditChanges | undefined, - // Call sites `satisfies Partial>` - // so a wire-key rename cannot silently disable a redaction placeholder. - placeholders: Record, + // 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 } @@ -89,3 +89,63 @@ export const redactAuditUrl = (raw: string): string => { 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/dashboards.http.ts b/apps/api/src/routes/v2/dashboards.http.ts index 6ba9b87c0..1a9420477 100644 --- a/apps/api/src/routes/v2/dashboards.http.ts +++ b/apps/api/src/routes/v2/dashboards.http.ts @@ -32,7 +32,7 @@ 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 { compactAuditChanges, diffAuditChanges, pickPresentFields } from "@/routes/v2/audit-changes" +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" @@ -179,17 +179,20 @@ const applyUpdate = ( } /** Update-payload fields diffable through the wire shape; layout blobs get summarized. */ -const dashboardAuditKeys: ReadonlyArray = [ - "name", - "description", - "tags", - "timeRange", - "widgets", - "sections", - "variables", - "refreshIntervalSeconds", -] - +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)}` @@ -288,8 +291,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards mode: created.mode, }) yield* recordHttpAudit("dashboard_share.created", { - resourceType: "dashboard_share", - resourceId: encodePublicId(PublicIdPrefixes.dashboardShare, created.id), + resourceId: created.id, metadata: { mode: created.mode, dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, context.scope.dashboardId), @@ -314,8 +316,7 @@ 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", { - resourceType: "dashboard_share", - resourceId: encodePublicId(PublicIdPrefixes.dashboardShare, rotated.id), + resourceId: rotated.id, metadata: { dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, dashboardId), ...(widgetId === null ? undefined : { widget_id: widgetId }), @@ -337,7 +338,6 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards yield* logShare("dashboard share revoked", context, { hadLiveShare: tombstone.revoked }) if (tombstone.revoked) { yield* recordHttpAudit("dashboard_share.deleted", { - resourceType: "dashboard_share", metadata: { dashboard_id: encodePublicId(PublicIdPrefixes.dashboard, dashboardId), ...(widgetId === null ? undefined : { widget_id: widgetId }), @@ -383,8 +383,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards toPortable(payload), ) yield* recordHttpAudit("dashboard.created", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + resourceId: dashboard.id, metadata: { name: dashboard.name }, }) @@ -411,22 +410,10 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const changes = previous === undefined ? undefined - : compactAuditChanges( - diffAuditChanges( - pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(previous)), - pickPresentFields(dashboardAuditKeys, payload, toV2Dashboard(dashboard)), - ), - // Layout arrays are config blobs — audit that they changed, not their bodies. - { - widgets: "", - sections: "", - variables: "", - } satisfies Partial>, - ) + : dashboardAuditDiff(payload, toV2Dashboard(previous), toV2Dashboard(dashboard)) yield* recordHttpAudit("dashboard.updated", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), - ...(changes !== undefined ? { changes } : undefined), + resourceId: dashboard.id, + changes, metadata: { name: dashboard.name }, }) @@ -438,8 +425,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards const tenant = yield* CurrentTenant.Context const deleted = yield* persistence.delete(tenant.orgId, params.id) yield* recordHttpAudit("dashboard.deleted", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, deleted.id), + resourceId: deleted.id, }) return { @@ -460,8 +446,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards converted.dashboard, ) yield* recordHttpAudit("dashboard.created", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + resourceId: dashboard.id, metadata: { name: dashboard.name, source: "perses_import" }, }) @@ -523,8 +508,7 @@ export const HttpV2DashboardsLive = HttpApiBuilder.group(MapleApiV2, "dashboards params.version_id, ) yield* recordHttpAudit("dashboard.version_restored", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + resourceId: dashboard.id, metadata: { name: dashboard.name, version_id: encodePublicId( @@ -626,8 +610,7 @@ 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", { - resourceType: "dashboard", - resourceId: encodePublicId(PublicIdPrefixes.dashboard, dashboard.id), + resourceId: dashboard.id, metadata: { name: dashboard.name, source: "template", diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index b337fd79c..28fa08c31 100644 --- a/apps/api/src/routes/v2/ingest-keys.http.ts +++ b/apps/api/src/routes/v2/ingest-keys.http.ts @@ -39,7 +39,6 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys yield* requireAdmin(tenant.roles, adminOnly("roll")) const keys = yield* ingestKeys.rerollPublic(tenant.orgId, tenant.userId) yield* recordHttpAudit("ingest_key.rolled", { - resourceType: "ingest_key", metadata: { key_type: "public" }, }) @@ -52,7 +51,6 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys yield* requireAdmin(tenant.roles, adminOnly("roll")) const keys = yield* ingestKeys.rerollPrivate(tenant.orgId, tenant.userId) yield* recordHttpAudit("ingest_key.rolled", { - resourceType: "ingest_key", metadata: { key_type: "private" }, }) diff --git a/apps/api/src/routes/v2/scrape-targets.http.ts b/apps/api/src/routes/v2/scrape-targets.http.ts index 71fee0fd6..755d0ba12 100644 --- a/apps/api/src/routes/v2/scrape-targets.http.ts +++ b/apps/api/src/routes/v2/scrape-targets.http.ts @@ -1,17 +1,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import type { ScrapeTargetResponse } from "@maple/domain/http" import { CreateScrapeTargetRequest, CurrentTenant, UpdateScrapeTargetRequest } from "@maple/domain/http" -import { - encodePublicId, - MapleApiV2, - paginateArray, - paginateOffsetQuery, - PublicIdPrefixes, - timestamp, -} from "@maple/domain/http/v2" +import { MapleApiV2, paginateArray, paginateOffsetQuery, timestamp } from "@maple/domain/http/v2" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" import { Effect } from "effect" -import { diffAuditChanges, pickPresentFields, redactAuditUrl } from "@/routes/v2/audit-changes" +import { auditDiff, redactAuditUrl } from "@/routes/v2/audit-changes" import { recordHttpAudit } from "@/services/audit/AuditLogService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" @@ -38,29 +31,25 @@ const toV2ScrapeTarget = (target: ScrapeTargetResponse): V2ScrapeTarget => ({ }) /** Update-payload fields diffable through the wire shape; credentials never appear. */ -const targetAuditKeys: ReadonlyArray< - | "name" - | "url" - | "organization" - | "include_branches" - | "exclude_branches" - | "scrape_interval_seconds" - | "labels_json" - | "auth_type" - | "service_name" - | "enabled" -> = [ - "name", - "url", - "organization", - "include_branches", - "exclude_branches", - "scrape_interval_seconds", - "labels_json", - "auth_type", - "service_name", - "enabled", -] +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* () { @@ -131,8 +120,7 @@ export const HttpV2ScrapeTargetsLive = HttpApiBuilder.group(MapleApiV2, "scrapeT ) yield* recordHttpAudit("scrape_target.created", { - resourceType: "scrape_target", - resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, created.id), + resourceId: created.id, metadata: { name: created.name }, }) @@ -187,43 +175,9 @@ 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. - const diffed = diffAuditChanges( - pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(current)), - pickPresentFields(targetAuditKeys, payload, toV2ScrapeTarget(updated)), - ) - // 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. - const observable = - diffed === undefined || !diffed.fields.includes("url") - ? diffed - : { - fields: diffed.fields, - before: { - ...diffed.before, - ...(typeof diffed.before["url"] === "string" - ? { url: redactAuditUrl(diffed.before["url"]) } - : undefined), - }, - after: { - ...diffed.after, - ...(typeof diffed.after["url"] === "string" - ? { url: redactAuditUrl(diffed.after["url"]) } - : undefined), - }, - } - // Credentials are write-only: audit that they rotated, never their value. - const changes = - payload.auth_credentials !== undefined - ? { - fields: [...(observable?.fields ?? []), "auth_credentials"], - before: { ...observable?.before, auth_credentials: "" }, - after: { ...observable?.after, auth_credentials: "" }, - } - : observable yield* recordHttpAudit("scrape_target.updated", { - resourceType: "scrape_target", - resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, updated.id), - ...(changes !== undefined ? { changes } : undefined), + resourceId: updated.id, + changes: targetAuditDiff(payload, toV2ScrapeTarget(current), toV2ScrapeTarget(updated)), metadata: { name: updated.name }, }) @@ -234,10 +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", { - resourceType: "scrape_target", - resourceId: encodePublicId(PublicIdPrefixes.scrapeTarget, deleted.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/services/audit/AuditLogService.test.ts b/apps/api/src/services/audit/AuditLogService.test.ts index 267d965a4..543d16948 100644 --- a/apps/api/src/services/audit/AuditLogService.test.ts +++ b/apps/api/src/services/audit/AuditLogService.test.ts @@ -1,5 +1,6 @@ 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" @@ -15,6 +16,8 @@ const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) +const DASHBOARD_ID = "3f1b7c02-9a44-4d1e-8b2f-0c5d6e7a8b91" + const makeLayer = () => AuditLogService.layer.pipe(Layer.provide(createTestDb(createdDbs).layer)) /** Three entries with distinct timestamps: user, then api_key, then agent. */ @@ -25,8 +28,8 @@ const seedThree = Effect.gen(function* () { actor: { type: "user", userId: USER }, source: "dashboard", action: "dashboard.created", - resourceType: "dashboard", - resourceId: "dash_first", + // Internal ID in, public `dash_…` ID out — the service owns the encoding. + resourceId: DASHBOARD_ID, metadata: { name: "First" }, }) yield* TestClock.adjust("1 second") @@ -63,7 +66,7 @@ describe("AuditLogService", () => { expect(oldest.userId).toBe(USER) expect(oldest.source).toBe("dashboard") expect(oldest.resourceType).toBe("dashboard") - expect(oldest.resourceId).toBe("dash_first") + expect(oldest.resourceId).toBe(encodePublicId(PublicIdPrefixes.dashboard, DASHBOARD_ID)) expect(oldest.metadataJson).toEqual({ name: "First" }) const newest = rows[0]! @@ -109,13 +112,13 @@ describe("AuditLogService", () => { orgId: ORG, actor: { type: "user", userId: USER }, source: "dashboard", - action: "alert_rule.delete", + 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.delete"]) + expect(denied.map((row) => row.action)).toEqual(["alert_rule.deleted"]) expect(denied[0]!.outcome).toBe("denied") expect(denied[0]!.denialReason).toBe("missing role: admin") diff --git a/apps/api/src/services/audit/AuditLogService.ts b/apps/api/src/services/audit/AuditLogService.ts index fcf2cf5ce..8ebc4110d 100644 --- a/apps/api/src/services/audit/AuditLogService.ts +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -12,6 +12,7 @@ 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) @@ -46,24 +47,22 @@ export interface AuditActorRef { readonly label?: string } -export interface AuditLogRecordInput { +export type AuditLogRecordInput = { readonly orgId: OrgId readonly actor: AuditActorRef readonly source: AuditLogSource - /** `.`, e.g. `alert_rule.created`. */ - readonly action: string + /** 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 resourceType?: string - readonly resourceId?: string - readonly changes?: AuditChanges + readonly changes?: AuditChanges | undefined readonly metadata?: Record readonly requestId?: string readonly originIp?: string readonly originCountry?: string -} +} & AuditResourceIdOption export interface AuditLogListFilters { readonly actorType?: AuditActorType @@ -94,7 +93,7 @@ export interface AuditLogServiceApi { * 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 record: (input: AuditLogRecordInput) => Effect.Effect readonly list: ( orgId: OrgId, filters: AuditLogListFilters, @@ -158,6 +157,7 @@ export class AuditLogService extends Context.Service( + action: A, opts?: { - readonly resourceType?: string - readonly resourceId?: string - readonly changes?: AuditChanges + readonly changes?: AuditChanges | undefined readonly affectedUserId?: UserId readonly metadata?: Record - }, + } & AuditResourceIdOption, ) => Effect.gen(function* () { const audit = yield* AuditLogService 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..47d3303a5 --- /dev/null +++ b/apps/api/src/services/audit/audit-actions.ts @@ -0,0 +1,95 @@ +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"] }, + scrape_target: { prefix: PublicIdPrefixes.scrapeTarget, verbs: ["created", "updated", "deleted"] }, +} 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/errors/ErrorIssueWorkflowService.ts b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts index 2e97446c7..81521e52b 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts @@ -22,7 +22,6 @@ import { CLOSED_WORKFLOW_STATES, MACHINE_OWNED_WORKFLOW_STATES, } from "@maple/domain/http" -import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { actors, alertIncidents, @@ -443,8 +442,7 @@ const make: Effect.Effect< }, source: actor.type === "agent" ? "mcp" : "dashboard", action: `error_issue.${type}`, - resourceType: "error_issue", - resourceId: encodePublicId(PublicIdPrefixes.errorIssue, issueId), + resourceId: issueId, metadata: { ...(opts.fromState != null ? { from_state: opts.fromState } : undefined), ...(opts.toState != null ? { to_state: opts.toState } : undefined), From f83c29bfbac3fd47d7fc6593b772be436398bc7f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 30 Aug 2026 01:11:07 +0200 Subject: [PATCH 6/6] fix(audit): close the open follow-ups from the audit log review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durability. The audit-events consumer had no dead letter queue and no final-attempt branch, so after five retries Cloudflare dropped the entry with nothing in the logs at the moment it happened. There is now an `audit-events-dlq` queue with no consumer — an entry landing there is a lost record and the point is that it survives — and the consumer logs the hand-off at Error, with the org and action read defensively off the body. It keeps retrying on the final attempt, because acking is what would discard the message instead of routing it. Attribution. The actors row knows who acted, never how, so every mutation reached through an API key or over MCP was recorded as a dashboard session — the MCP middleware set no audit reference at all. `CurrentAuditActor` now carries the surface alongside the credential, all four auth layers stamp it, and the issue-workflow mirror consults it instead of assuming. Maple's own sweeps run as an agent actor, which made auto-close and lease expiry read as a third-party agent over MCP; they are now recorded as `system`, which until today had no writer at all. Coverage. Audited org deletion, warehouse settings (updated, deleted, schema applied), the Slack and PlanetScale integration lifecycles including the metrics-token install, widget credential mint/revoke, investigations, and issue comments — which wrote their event row directly and so bypassed the audit mirror entirely. Secrets stay out: the entries record which credential was installed, never its value. Membership. Members are changed in Clerk, never through Maple's API, which is why `affected_user` had no writers. The Clerk receiver now audits `organizationMembership.*` against the member. Clerk's payload does not name the admin who acted, so the entry is attributed to `system` rather than guessing a user. Enabling the three events in the Clerk dashboard is what turns this on. UI. The list paginates by offset over a newest-first append-only table, so an entry written mid-scroll shifted later pages and made them repeat one row and skip another. The first Load more now pins `until` to the newest entry on screen, freezing the window, and pages are deduped by id on append. The header no longer claims to record "every change". The retention sweep's ctid-addressed delete already landed with the review fixes; verified rather than changed. --- apps/api/alchemy.run.ts | 8 ++ apps/api/src/audit-events-runtime.test.ts | 127 ++++++++++++++++++ apps/api/src/audit-events-runtime.ts | 64 ++++++++- apps/api/src/mcp/app.ts | 25 ++++ apps/api/src/mcp/lib/resolve-tenant.ts | 3 +- .../routes/v1/org-clickhouse-settings.http.ts | 22 ++- apps/api/src/routes/v1/organizations.http.ts | 8 +- apps/api/src/routes/v2/integrations.http.ts | 17 +++ apps/api/src/routes/v2/investigations.http.ts | 10 ++ .../src/routes/v2/widget-credentials.http.ts | 12 ++ apps/api/src/routes/webhooks/clerk.http.ts | 66 ++++++++- .../src/routes/webhooks/webhooks.http.test.ts | 67 ++++++++- .../services/audit/AuditLogService.test.ts | 67 ++++++++- .../api/src/services/audit/AuditLogService.ts | 19 +-- apps/api/src/services/audit/audit-actions.ts | 24 ++++ .../services/auth/ApiAuthorizationLayer.ts | 3 +- .../services/auth/ApiAuthorizationV2Layer.ts | 3 +- .../auth/SessionAuthorizationLayer.ts | 2 +- apps/api/src/services/auth/audit-actor.ts | 17 ++- .../errors/ErrorIssueWorkflowService.ts | 36 ++++- .../services/product-events/clerk-events.ts | 32 +++++ apps/api/wrangler.jsonc | 4 + .../components/settings/audit-log-section.tsx | 36 ++++- .../src/lib/services/atoms/audit-log-atoms.ts | 25 ++-- 24 files changed, 644 insertions(+), 53 deletions(-) create mode 100644 apps/api/src/audit-events-runtime.test.ts diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index 162b5862a..e6b943f64 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -324,6 +324,11 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp 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), @@ -441,6 +446,9 @@ export const createMapleApi = ({ stage, domains, replayBlobs }: CreateMapleApiOp 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, 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 index b7b18d063..8e6c4a926 100644 --- a/apps/api/src/audit-events-runtime.ts +++ b/apps/api/src/audit-events-runtime.ts @@ -26,10 +26,33 @@ export const buildAuditEventsLayer = (_env: Record) => { 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. + * 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* () { @@ -39,8 +62,11 @@ export const processAuditEventsBatch = (batch: MessageBatch) => (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.logWarning("Discarding malformed audit event queue message").pipe( + Effect.logError("Discarding malformed audit event queue message").pipe( Effect.annotateLogs({ attempt: message.attempts, error: String(error) }), Effect.flatMap(() => Effect.sync(() => message.ack())), ), @@ -56,12 +82,36 @@ export const processAuditEventsBatch = (batch: MessageBatch) => yield* Effect.sync(() => message.ack()) }).pipe( Effect.withSpan("auditEvents.processMessage"), - Effect.catchCause((cause) => - Effect.logWarning("Audit event insert failed; retrying").pipe( - Effect.annotateLogs({ attempt: message.attempts, error: String(cause) }), + 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())), - ), - ), + ) + }), ), }), ), 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/routes/v1/org-clickhouse-settings.http.ts b/apps/api/src/routes/v1/org-clickhouse-settings.http.ts index 79e1f2857..6d43e4b8f 100644 --- a/apps/api/src/routes/v1/org-clickhouse-settings.http.ts +++ b/apps/api/src/routes/v1/org-clickhouse-settings.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 { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" export const HttpOrgClickHouseSettingsLive = HttpApiBuilder.group( @@ -20,7 +21,18 @@ export const HttpOrgClickHouseSettingsLive = HttpApiBuilder.group( .handle("upsert", ({ payload }) => 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/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/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/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/services/audit/AuditLogService.test.ts b/apps/api/src/services/audit/AuditLogService.test.ts index 543d16948..ddd3a8ca2 100644 --- a/apps/api/src/services/audit/AuditLogService.test.ts +++ b/apps/api/src/services/audit/AuditLogService.test.ts @@ -5,7 +5,10 @@ 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 } from "./AuditLogService" +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) @@ -17,6 +20,7 @@ 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)) @@ -186,6 +190,67 @@ describe("AuditLogService", () => { ) }) + // 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 diff --git a/apps/api/src/services/audit/AuditLogService.ts b/apps/api/src/services/audit/AuditLogService.ts index 8ebc4110d..fbce6c231 100644 --- a/apps/api/src/services/audit/AuditLogService.ts +++ b/apps/api/src/services/audit/AuditLogService.ts @@ -286,10 +286,11 @@ const requestContext = Effect.gen(function* () { /** * Record an audit entry for the current authenticated HTTP request, deriving - * the actor from the tenant plus the auth middleware's `CurrentAuditActor`, - * and request forensics (request id, origin) from the Cloudflare headers. - * Session requests (and requests that bypassed the standard middlewares) - * attribute to the user; API-key requests attribute to the key. + * 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, @@ -304,15 +305,17 @@ export const recordHttpAudit = ( const tenant = yield* CurrentTenant.Context const info = yield* CurrentAuditActor const context = yield* requestContext - const isApiKey = info?.type === "api_key" + // 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: isApiKey ? "api_key" : "user", + type: info?.type ?? "user", userId: tenant.userId, - ...(isApiKey && info.apiKeyId !== undefined ? { apiKeyId: info.apiKeyId } : undefined), + ...(info?.apiKeyId !== undefined ? { apiKeyId: info.apiKeyId } : undefined), }, - source: isApiKey ? "api" : "dashboard", + source: info?.source ?? "dashboard", action, ...context, ...opts, diff --git a/apps/api/src/services/audit/audit-actions.ts b/apps/api/src/services/audit/audit-actions.ts index 47d3303a5..444284d52 100644 --- a/apps/api/src/services/audit/audit-actions.ts +++ b/apps/api/src/services/audit/audit-actions.ts @@ -40,7 +40,31 @@ export const AuditResources = { 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 { diff --git a/apps/api/src/services/auth/ApiAuthorizationLayer.ts b/apps/api/src/services/auth/ApiAuthorizationLayer.ts index 27334c2be..6c57bf3ac 100644 --- a/apps/api/src/services/auth/ApiAuthorizationLayer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationLayer.ts @@ -85,6 +85,7 @@ export const ApiAuthorizationLayer = Layer.effect( Effect.provideService(CurrentAuditActor, { type: "api_key", apiKeyId: resolved.keyId, + source: "api", }), ) } @@ -93,7 +94,7 @@ export const ApiAuthorizationLayer = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), - Effect.provideService(CurrentAuditActor, { type: "user" }), + 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 b203095a1..b818b8429 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -173,6 +173,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( Effect.provideService(CurrentAuditActor, { type: "api_key", apiKeyId: resolved.keyId, + source: "api", }), ) } @@ -185,7 +186,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), - Effect.provideService(CurrentAuditActor, { type: "user" }), + 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 82a706283..6c2df1fce 100644 --- a/apps/api/src/services/auth/SessionAuthorizationLayer.ts +++ b/apps/api/src/services/auth/SessionAuthorizationLayer.ts @@ -50,7 +50,7 @@ export const SessionAuthorizationLayer = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), - Effect.provideService(CurrentAuditActor, { type: "user" }), + 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 index 5258a336b..43147417e 100644 --- a/apps/api/src/services/auth/audit-actor.ts +++ b/apps/api/src/services/auth/audit-actor.ts @@ -1,21 +1,28 @@ import { Context } from "effect" +import type { AuditLogSource } from "@maple/domain/http" import type { ApiKeyId } from "@maple/domain/primitives" /** - * How the current HTTP request authenticated, for audit attribution. The - * tenant context deliberately does not say whether a request came from a - * dashboard session or an API key — this reference carries that one fact. + * 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 { - readonly type: "user" | "api_key" + /** `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, tests). + * 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", diff --git a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts index 81521e52b..27b4c837f 100644 --- a/apps/api/src/services/errors/ErrorIssueWorkflowService.ts +++ b/apps/api/src/services/errors/ErrorIssueWorkflowService.ts @@ -39,6 +39,8 @@ 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" @@ -421,12 +423,22 @@ const make: Effect.Effect< ) 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, - // A human actor at this layer may have acted from the dashboard or - // over MCP — the issue event does not say which. - actor: - actor.type === "agent" + actor: isSystemActor + ? { type: "system", actorId, label: SYSTEM_ERRORS_AGENT_NAME } + : actor.type === "agent" ? { type: "agent", actorId, @@ -436,11 +448,18 @@ const make: Effect.Effect< ...(actor.createdBy === null ? undefined : { userId: actor.createdBy }), } : { - type: "user", + type: request?.type ?? "user", ...(actor.userId === null ? undefined : { userId: actor.userId }), + ...(request?.apiKeyId === undefined + ? undefined + : { apiKeyId: request.apiKeyId }), actorId, }, - source: actor.type === "agent" ? "mcp" : "dashboard", + source: isSystemActor + ? "system" + : actor.type === "agent" + ? "mcp" + : (request?.source ?? "dashboard"), action: `error_issue.${type}`, resourceId: issueId, metadata: { @@ -859,6 +878,11 @@ const make: Effect.Effect< createdAt: msToDate(timestamp), } yield* dbExecute((db) => db.insert(errorIssueEvents).values(row)) + // 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) 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/wrangler.jsonc b/apps/api/wrangler.jsonc index 998325d98..17462b4de 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -120,6 +120,10 @@ "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/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index 967e28e92..6c6aab5d0 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -82,6 +82,19 @@ function formatSourceTooltip(entry: V2AuditLogEntry): string | undefined { 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[] @@ -93,9 +106,14 @@ 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), }) @@ -112,7 +130,7 @@ export function AuditLogSection() { entries: cursor === undefined ? [...pageResult.value.data] - : [...(view?.entries ?? []), ...pageResult.value.data], + : dedupeById(view?.entries ?? [], pageResult.value.data), hasMore: pageResult.value.has_more, nextCursor: pageResult.value.next_cursor, }) @@ -122,12 +140,14 @@ export function AuditLogSection() { 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 @@ -158,8 +178,10 @@ export function AuditLogSection() { ))}
+ {/* Deliberately not "every change": this records configuration and + access changes plus refused attempts, not reads or telemetry. */}

- Every change made through the dashboard, API, and MCP. + Configuration and access changes, from the dashboard, API, and MCP.

@@ -221,7 +243,15 @@ export function AuditLogSection() { size="sm" disabled={waiting} onClick={() => { - if (view.nextCursor !== null) setCursor(view.nextCursor) + if (view.nextCursor === null) return + // Pin the window to the newest entry already on screen before + // the first Load more, so later offsets address a list that + // cannot grow underneath them. + if (until === undefined) { + const newest = view.entries[0] + if (newest !== undefined) setUntil(newest.occurred_at) + } + setCursor(view.nextCursor) }} > {waiting ? "Loading…" : "Load more"} diff --git a/apps/web/src/lib/services/atoms/audit-log-atoms.ts b/apps/web/src/lib/services/atoms/audit-log-atoms.ts index 72772d2cf..1e1ccf353 100644 --- a/apps/web/src/lib/services/atoms/audit-log-atoms.ts +++ b/apps/web/src/lib/services/atoms/audit-log-atoms.ts @@ -12,17 +12,21 @@ 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 "|", and the cursor is the trailing -// segment, so splitting on the first two separators stays unambiguous even for -// exotic cursors. +// 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 firstSeparator = key.indexOf("|") - const secondSeparator = key.indexOf("|", firstSeparator + 1) - const actorRaw = key.slice(0, firstSeparator) - const outcomeRaw = key.slice(firstSeparator + 1, secondSeparator) - const cursor = key.slice(secondSeparator + 1) + 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) @@ -35,12 +39,13 @@ const family = Atom.family((key: string) => { ...(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 + actor-type/outcome filters. */ +/** 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.cursor ?? ""}`) + family(`${input.actorType ?? ""}|${input.outcome ?? ""}|${input.until ?? ""}|${input.cursor ?? ""}`)