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
2 changes: 1 addition & 1 deletion apps/api/src/routes/alerts.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const HttpAlertsLive = HttpApiBuilder.group(MapleApi, "alerts", (handlers
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
return yield* alerts.previewRule(tenant.orgId, payload)
return yield* alerts.previewRule(tenant.orgId, tenant.roles, payload)
}).pipe(Effect.withSpan("alerts.previewRule")),
)
.handle("listIncidents", () =>
Expand Down
25 changes: 24 additions & 1 deletion apps/api/src/routes/v2/alert-rules.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ import type {
V2AlertRuleUpdateParams,
V2InvalidRequestError,
} from "@maple/domain/http/v2"
import { MapleApiV2, invalidRequest, paginateArray, resourceNotFound, timestamp } from "@maple/domain/http/v2"
import {
MapleApiV2,
invalidRequest,
paginateArray,
resourceNotFound,
scopeAllows,
timestamp,
} from "@maple/domain/http/v2"
import { AlertForbiddenError } from "@maple/domain/http"
import { Effect, Encoding, Result, Schema } from "effect"
import { AlertsService } from "../../services/AlertsService"
import { mapAlertError } from "./alerts-error-map"
Expand Down Expand Up @@ -381,9 +389,24 @@ export const HttpV2AlertRulesLive = HttpApiBuilder.group(MapleApiV2, "alertRules
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rule = yield* toUpsertRequest(payload.rule)
// Preview is otherwise an `alerts:read` endpoint, but replaying a
// raw_query rule executes user-authored ClickHouse against the org's
// warehouse — the capability creating one requires. API keys carry
// root roles, so scope (not role) is what separates a read-only key
// here; the role check inside previewRule covers the session path.
if (
payload.rule.signal_type === "raw_query" &&
!scopeAllows(tenant.scopes, { family: "alerts", access: "write" })
) {
return yield* new AlertForbiddenError({
message:
'Previewing a raw SQL alert requires the "alerts:write" scope, because it executes your query against the warehouse.',
}).pipe(mapAlertError("rule_preview"))
}
const preview = yield* alerts
.previewRule(
tenant.orgId,
tenant.roles,
new AlertRulePreviewRequest({
rule,
startTime: decodeIsoDateTime(payload.start_time),
Expand Down
36 changes: 33 additions & 3 deletions apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ describe("v2 alerts over HTTP", () => {
await harness.dispose()
})

it("blocks raw SQL preview for alerts:read keys without querying the warehouse", async () => {
it("blocks raw SQL preview for non-admin keys without querying the warehouse", async () => {
let warehouseCalls = 0
const harness = makeHarness({
...warehouseStub,
Expand All @@ -338,6 +338,9 @@ describe("v2 alerts over HTTP", () => {
return Effect.succeed([{ value: 42 }])
},
})
// Preview needs only `alerts:read`, but replaying a raw_query rule executes
// user-authored ClickHouse — the same capability creating one requires. A
// read-only key must not get there via preview.
const key = await harness.bootstrapKey(["alerts:read"])
const response = await harness.request("POST", "/v2/alerts/rules/preview", key.secret, {
rule: {
Expand All @@ -355,12 +358,39 @@ describe("v2 alerts over HTTP", () => {
start_time: "2026-01-01T00:00:00.000Z",
end_time: "2026-01-01T00:30:00.000Z",
})
expect(response.status).toBe(400)
expect(JSON.stringify(response.body)).toContain("cannot be previewed")
expect(response.status).toBe(403)
expect(warehouseCalls).toBe(0)
await harness.dispose()
})

it("previews a raw SQL alert for alerts:write keys", async () => {
const harness = makeHarness({
...warehouseStub,
rawSqlQuery: () => Effect.succeed([{ value: 42, samples: 20 }]),
})
const key = await harness.bootstrapKey(["alerts:write"])
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(200)
expect(response.body.object).toBe("alert_rule.preview")
expect(response.body.series.length).toBeGreaterThan(0)
await harness.dispose()
})

it("paginates alert checks beyond the former 2,000-row window", async () => {
const checkRows = Array.from({ length: 2_005 }, (_, index) => {
const timestamp = new Date(Date.UTC(2026, 0, 1) + (2_005 - index) * 1_000).toISOString()
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/routes/v2/telemetry.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ const queryEngineStub = {
)
},
evaluate: () => Effect.die(new Error("not used")),
evaluateRawSql: () => Effect.die(new Error("not used")),
evaluateSeries: () => Effect.die(new Error("not used")),
cachedDirect: (_tenant, _route, _payload, effect) => effect,
} satisfies QueryEngineServiceShape
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ export const TelemetryServiceStubsLayer = Layer.mergeAll(
Layer.succeed(QueryEngineService, {
execute: die,
evaluate: die,
evaluateRawSql: die,
evaluateSeries: die,
cachedDirect: die,
}),
Expand Down
18 changes: 10 additions & 8 deletions apps/api/src/services/AlertsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3038,7 +3038,7 @@ describe("AlertsService.previewRule", () => {
endTime: "2026-01-01T00:30:00.000Z",
})

const response = yield* alerts.previewRule(orgId, request)
const response = yield* alerts.previewRule(orgId, adminRoles, request)

assert.strictEqual(response.bucketSeconds, 300)
assert.strictEqual(response.windowMinutes, 5)
Expand Down Expand Up @@ -3097,7 +3097,7 @@ describe("AlertsService.previewRule", () => {
endTime: "2026-01-01T00:32:30.000Z",
})

const response = yield* alerts.previewRule(orgId, request)
const response = yield* alerts.previewRule(orgId, adminRoles, request)

assert.lengthOf(response.series, 1)
const points = response.series[0]!.points
Expand All @@ -3119,7 +3119,7 @@ describe("AlertsService.previewRule", () => {
}).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: okFetch })))
})

it.effect("rejects raw-SQL previews before warehouse execution", () => {
it.effect("previews a raw-SQL rule through the same path as every other kind", () => {
const testDb = createTestDb(trackedDbs)
const state = { rawQueryRows: [{ value: 42, samples: 20 }] }

Expand Down Expand Up @@ -3149,11 +3149,13 @@ describe("AlertsService.previewRule", () => {
endTime: "2026-01-01T00:30:00.000Z",
})

const exit = yield* alerts.previewRule(orgId, request).pipe(Effect.exit)
assert.isTrue(Exit.isFailure(exit))
const failure = getError(exit)
assert.instanceOf(failure, AlertValidationError)
assert.include((failure as AlertValidationError).message, "cannot be previewed")
// Raw SQL used to be rejected here even though the UI offers the chart.
// It is now just another evaluate source, so the preview renders.
const preview = yield* alerts.previewRule(orgId, adminRoles, request)
assert.isAbove(preview.series.length, 0)
const points = preview.series[0]?.points ?? []
assert.isAbove(points.length, 0)
assert.isTrue(points.some((p) => p.value === 42))
}).pipe(Effect.provide(makeLayer(testDb, makeWarehouseStub(state), { fetch: okFetch })))
})
})
Loading
Loading