Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/api/src/routes/v2/alert-rule-read-families.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest"
import { ruleReadFamilies } from "./alert-rule-read-families"

const draft = (dataSource: "traces" | "logs" | "metrics" | "product_events") => ({
id: "q",
name: "A",
dataSource,
aggregation: "count",
})

describe("ruleReadFamilies", () => {
it.each(["error_rate", "p95_latency", "p99_latency", "apdex", "throughput"] as const)(
"%s reads traces",
(signalType) => {
expect(ruleReadFamilies({ signalType })).toEqual(["traces"])
},
)

it.each(["traces", "logs", "metrics"] as const)("a %s builder draft reads only its source", (source) => {
expect(ruleReadFamilies({ signalType: "builder_query", queryBuilderDraft: draft(source) })).toEqual([
source,
])
})

it("a builder draft on a source that is not a family needs all three", () => {
expect(
ruleReadFamilies({ signalType: "builder_query", queryBuilderDraft: draft("product_events") }),
).toEqual(["traces", "logs", "metrics"])
})

it("a builder rule with no draft needs all three", () => {
expect(ruleReadFamilies({ signalType: "builder_query", queryBuilderDraft: null })).toEqual([
"traces",
"logs",
"metrics",
])
})

it("raw SQL needs all three", () => {
expect(ruleReadFamilies({ signalType: "raw_query" })).toEqual(["traces", "logs", "metrics"])
})

it.each(["anomaly", "uptime", "slo_burn_rate"] as const)(
"%s runs no query of its own and needs none",
(signalType) => {
expect(ruleReadFamilies({ signalType })).toEqual([])
},
)
})
34 changes: 34 additions & 0 deletions apps/api/src/routes/v2/alert-rule-read-families.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AlertRuleUpsertRequest } from "@maple/domain/http"
import { TELEMETRY_READ_FAMILIES } from "@maple/domain/http/v2"
import { Array as Arr } from "effect"

type TelemetryFamily = (typeof TELEMETRY_READ_FAMILIES)[number]

const isTelemetryFamily = (source: string): source is TelemetryFamily =>
Arr.some(TELEMETRY_READ_FAMILIES, (family) => family === source)

/** The telemetry a rule's query reads. Raw SQL, and a source that is not a family, read all of it. */
export const ruleReadFamilies = (
rule: Pick<AlertRuleUpsertRequest, "signalType" | "queryBuilderDraft">,
): ReadonlyArray<TelemetryFamily> => {
switch (rule.signalType) {
case "error_rate":
case "p95_latency":
case "p99_latency":
case "apdex":
case "throughput":
return ["traces"]
// No query of their own: an anomaly rule reads open incidents, and uptime and SLO rules
// are refused by the generic rule APIs before anything runs.
case "anomaly":
case "uptime":
case "slo_burn_rate":
return []
case "builder_query": {
const source = rule.queryBuilderDraft?.dataSource
return source !== undefined && isTelemetryFamily(source) ? [source] : TELEMETRY_READ_FAMILIES
}
case "raw_query":
return TELEMETRY_READ_FAMILIES
}
}
22 changes: 22 additions & 0 deletions apps/api/src/routes/v2/alert-rules.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
import {
fromV2Threshold,
MapleApiV2,
missingReadScopes,
paginateArray,
scopeAllows,
timestamp,
Expand All @@ -36,6 +37,7 @@ import { AlertForbiddenError } from "@maple/domain/http"
import { Array as Arr, DateTime, Effect, Option, Result, Schema, String as Str } from "effect"
import { Base64Url } from "effect/encoding"
import { auditDiff } from "@/routes/v2/audit-changes"
import { ruleReadFamilies } from "@/routes/v2/alert-rule-read-families"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
import { AlertsService } from "@maple/backend/services/alerts/AlertsService"
import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService"
Expand Down Expand Up @@ -427,6 +429,24 @@ const mergeUpsertRequest = (
})
})

/**
* Preview and test run the rule's query and hand back what it measured, grouped by whatever
* the caller chose: a telemetry read. A scoped key must be able to read that telemetry itself.
*/
const requireRuleReadScopes = (scopes: ReadonlyArray<string> | undefined, rule: AlertRuleUpsertRequest) => {
const missing = missingReadScopes(scopes, ruleReadFamilies(rule))
return missing.length === 0
? Effect.void
: Effect.fail(
new AlertForbiddenError({
message: `Running this rule's query requires ${Arr.join(
Arr.map(missing, (family) => `"${family}:read"`),
", ",
)}, because it returns the telemetry it reads.`,
}),
)
}

const toV2PreviewResult = (preview: AlertRulePreviewResponse): V2AlertRulePreviewResult => ({
object: "alert_rule.preview",
bucket_seconds: preview.bucketSeconds,
Expand Down Expand Up @@ -562,6 +582,7 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rule = yield* toUpsertRequest(payload.rule)
yield* requireRuleReadScopes(tenant.scopes, rule)
const result = yield* alerts.testRule(
tenant.orgId,
tenant.userId,
Expand Down Expand Up @@ -600,6 +621,7 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules
'Previewing a raw SQL alert requires the "alerts:write" scope, because it executes your query against the warehouse.',
})
}
yield* requireRuleReadScopes(tenant.scopes, rule)
const preview = yield* alerts.previewRule(
tenant.orgId,
tenant.roles,
Expand Down
129 changes: 126 additions & 3 deletions apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,12 +1012,135 @@ describe("v2 alerts over HTTP", () => {
await harness.dispose()
})

it("previews a raw SQL alert for alerts:write keys", async () => {
const errorRatePreview = {
rule: {
name: "Error rate preview",
severity: "warning",
signal_type: "error_rate",
comparator: "gt",
threshold: 5,
window_minutes: 5,
destination_ids: [],
},
start_time: "2026-01-01T00:00:00.000Z",
end_time: "2026-01-01T00:30:00.000Z",
}

it("refuses a preview to a key that cannot read the telemetry the rule queries", async () => {
let warehouseCalls = 0
const harness = makeHarness({
...warehouseStub,
rawSqlQuery: () => Effect.succeed([{ bucket: "2026-01-01 00:10:00", value: 42, samples: 20 }]),
compiledQuery: (tenant, compiled) => {
warehouseCalls += 1
return warehouseStub.compiledQuery(tenant, compiled)
},
})
// The preview returns what the query measured, grouped however the caller chose,
// so an alerts-only key would otherwise read traces it was never granted.
const key = await harness.bootstrapKey(["alerts:write"])
const response = await harness.request(
"POST",
"/v2/alerts/rules/preview",
key.secret,
errorRatePreview,
)
expect(response.status).toBe(403)
expect(warehouseCalls).toBe(0)
await harness.dispose()
})

const logsBuilderPreview = {
rule: {
name: "Logs builder preview",
severity: "warning",
signal_type: "builder_query",
query_builder_draft: { id: "q", name: "A", dataSource: "logs", aggregation: "count" },
comparator: "gt",
threshold: 10,
window_minutes: 5,
destination_ids: [],
},
start_time: "2026-01-01T00:00:00.000Z",
end_time: "2026-01-01T00:30:00.000Z",
}

it("refuses a logs builder preview to a key that reads only traces", async () => {
let warehouseCalls = 0
const harness = makeHarness({
...warehouseStub,
compiledQuery: (tenant, compiled) => {
warehouseCalls += 1
return warehouseStub.compiledQuery(tenant, compiled)
},
})
const key = await harness.bootstrapKey(["alerts:write", "traces:read"])
const response = await harness.request(
"POST",
"/v2/alerts/rules/preview",
key.secret,
logsBuilderPreview,
)
expect(response.status).toBe(403)
expect(warehouseCalls).toBe(0)
await harness.dispose()
})

it("refuses a rule test to a key that cannot read the telemetry the rule queries", async () => {
let warehouseCalls = 0
const harness = makeHarness({
...warehouseStub,
compiledQuery: (tenant, compiled) => {
warehouseCalls += 1
return warehouseStub.compiledQuery(tenant, compiled)
},
})
const key = await harness.bootstrapKey(["alerts:write", "traces:read"])
const response = await harness.request("POST", "/v2/alerts/rules/test", key.secret, {
rule: logsBuilderPreview.rule,
send_notification: false,
})
expect(response.status).toBe(403)
expect(warehouseCalls).toBe(0)
await harness.dispose()
})

it("refuses a raw SQL preview to a key missing one telemetry family", async () => {
let warehouseCalls = 0
const harness = makeHarness({
...warehouseStub,
rawSqlQuery: () => {
warehouseCalls += 1
return Effect.succeed([])
},
})
const key = await harness.bootstrapKey(["alerts:write", "traces:read", "logs:read"])
const response = await harness.request("POST", "/v2/alerts/rules/preview", key.secret, {
rule: {
name: "Raw preview",
severity: "warning",
signal_type: "raw_query",
raw_query_sql:
"SELECT count() AS value FROM traces WHERE $__orgFilter AND $__timeFilter(Timestamp)",
raw_query_reducer: "max",
comparator: "gt",
threshold: 10,
window_minutes: 5,
destination_ids: [],
},
start_time: "2026-01-01T00:00:00.000Z",
end_time: "2026-01-01T00:30:00.000Z",
})
expect(response.status).toBe(403)
expect(warehouseCalls).toBe(0)
await harness.dispose()
})

it("previews a raw SQL alert for keys that can write alerts and read all telemetry", async () => {
const harness = makeHarness({
...warehouseStub,
rawSqlQuery: () => Effect.succeed([{ bucket: "2026-01-01 00:10:00", value: 42, samples: 20 }]),
})
const key = await harness.bootstrapKey(["alerts:write", "traces:read", "logs:read", "metrics:read"])
const response = await harness.request("POST", "/v2/alerts/rules/preview", key.secret, {
rule: {
name: "Raw preview",
Expand Down Expand Up @@ -1063,7 +1186,7 @@ describe("v2 alerts over HTTP", () => {
)
: Effect.succeed([{ value: 1, samples: 1 }]),
})
const key = await harness.bootstrapKey(["alerts:write"])
const key = await harness.bootstrapKey(["alerts:write", "traces:read", "logs:read", "metrics:read"])
const response = await harness.request("POST", "/v2/alerts/rules/preview", key.secret, {
rule: {
name: "Heavy raw preview",
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8151,7 +8151,7 @@
},
"/v2/alerts/rules/preview": {
"post": {
"description": "Replays a rule definition over a historical range and returns the per-window observations and would-have-fired spans. Read-only (sends nothing). Requires the `alerts:read` scope.",
"description": "Replays a rule definition over a historical range and returns the per-window observations and would-have-fired spans. Read-only (sends nothing). Requires the `alerts:read` scope (`alerts:write` for `raw_query`) plus read scopes for the telemetry the rule queries (`traces:read` for the built-in signals, the draft's source for `builder_query`, and all of `traces:read`, `logs:read`, `metrics:read` for `raw_query`).",
"operationId": "previewAlertRule",
"parameters": [],
"requestBody": {
Expand Down
4 changes: 2 additions & 2 deletions packages/domain/src/http/v2/alert-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules")
identifier: "testAlertRule",
summary: "Test an alert rule",
description:
"Evaluates a rule definition once against live data without saving it, optionally delivering a test notification. Requires an org-admin role and the `alerts:write` scope.",
"Evaluates a rule definition once against live data without saving it, optionally delivering a test notification. Requires an org-admin role, the `alerts:write` scope, and read scopes for the telemetry the rule queries (`traces:read` for the built-in signals, the draft's source for `builder_query`, and all of `traces:read`, `logs:read`, `metrics:read` for `raw_query`).",
}),
),
)
Expand All @@ -1062,7 +1062,7 @@ export class V2AlertRulesApiGroup extends HttpApiGroup.make("alertRules")
identifier: "previewAlertRule",
summary: "Preview an alert rule",
description:
"Replays a rule definition over a historical range and returns the per-window observations and would-have-fired spans. Read-only (sends nothing). Requires the `alerts:read` scope.",
"Replays a rule definition over a historical range and returns the per-window observations and would-have-fired spans. Read-only (sends nothing). Requires the `alerts:read` scope (`alerts:write` for `raw_query`) plus read scopes for the telemetry the rule queries (`traces:read` for the built-in signals, the draft's source for `builder_query`, and all of `traces:read`, `logs:read`, `metrics:read` for `raw_query`).",
}),
),
)
Expand Down
Loading