Repository navigation
Resolve custom-alert incidents on delete via a SECURITY DEFINER function (#3334) - #3347
Conversation
…ion (#3334) The resolve-on-delete recovery row (#3305) is written to config_alert_log, but only admin/owner may INSERT there; viewer and mcp get SELECT + narrow per-table writes, not config_alert_log. So on the two least-privilege delete paths -- the mcp delete_custom_alert_rule tool and the viewer DELETE /api/alerts -- the resolve INSERT was permission-denied, failure-isolated (swallowed), and the row silently dropped, leaving a deleted firing rule showing "open" in history. The #3305 tests missed it because they connect as the owner. Fix (Erik greenlit the security-definer approach over a blanket INSERT grant, which would let those roles fabricate arbitrary history): - DarlingManagedRoles provisioning gains a SECURITY DEFINER function config.record_custom_alert_resolution(server_id, server_name, metric_name, detail_text). Definer-safe: owned by the store owner (the creating provisioning role), an explicit pinned SET search_path = config, pg_catalog (no injected path can redirect the unqualified config_alert_log or now()), and a fully parameterized INSERT with NO dynamic SQL. It is a thin wrapper: it writes ONE row and HARDCODES the no-channel resolution shape (alert_sent false, notification_type 'none', current/threshold 0, unmuted, no send_error/context) -- byte-identical to what RecordAlertAsync writes for a BuildResolutionRecord -- so a grantee can page nothing and forge no fire; only the four inputs vary. REVOKE ALL FROM PUBLIC then GRANT EXECUTE to viewer + mcp. CREATE OR REPLACE in provisioning (idempotent, owner-run), NOT a migration -- no SchemaVersion bump. The CREATE + REVOKE are emitted by a shared builder so the live proof test creates the identical function. - WriteTeardownResolutionAsync (the delete AND sweep-reconcile teardown writer) now routes its recovery-row write through PgAlertHistoryStore. RecordCustomAlertResolutionAsync -> SELECT config.record_custom_alert_resolution on the caller's pool (runs as owner via the definer). Normal fire/resolve history writes (the owner service path) stay on RecordAlertAsync unchanged. Tests: - Provisioning pin: the function's DDL, SECURITY DEFINER, pinned search_path, the PUBLIC revoke before the narrow EXECUTE grant to viewer/mcp, the locked resolution shape, and the ABSENCE of any blanket config_alert_log grant. - THE proof #3305 lacked (gated live, verified against a local PG 18.4 + TimescaleDB rig): connect as a disposable LOW-PRIVILEGE role (viewer/mcp-shaped, no direct config_alert_log INSERT -- asserted denied 42501), delete a firing rule via ResolveAndDeleteRuleAsync, and assert the "<name> Resolved" row IS present and is a proper no-channel resolution. Same for the sweep-reconcile teardown with a low-privilege history pool. - CustomAlertTeardownLiveTests now provisions the function in its fixture (the teardown path depends on it; those tests migrate but do not provision). No MCP tool added, so the tool-inventory pins do not apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
|
Reviewed the diff ( Parity: Custom alert rules are Darling-only (no Security definer function: Checked the definer-safety properties claimed in the PR description against the actual SQL in Correctness: Verified Minor/non-blocking nit: Tests ( |
…'s no-channel docs Two documentation-only follow-ups to the custom-alert feature, no logic change. - CHANGELOG: fold the starter templates + list_custom_alert_templates (erikdarlingdata#3325) into the erikdarlingdata#3285 [Unreleased] "Added" bullet -- they were the one user-facing capability the bullet omitted. Absorbed into the existing bullet rather than a new one, like every other custom-alert follow-up, because the feature has not shipped yet. - Correct three comments (erikdarlingdata#3347) that called the SECURITY DEFINER resolve row "identical to BuildResolutionRecord". It maps the same way (title -> metric_name, message -> detail_text, zeroed/unmuted) but is deliberately pinned to a no-channel row (alert_sent false, notification_type 'none') rather than a natural clear's 'tray': a teardown surfaces no operator notification, and the definer function must never let a grantee write a row claiming a delivery. The old wording risked a maintainer "aligning" the hardcoded shape back to 'tray' and weakening the guarantee. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
Part of #3285. Fixes #3334 (Fixes only auto-closes on dev->main, so close it manually on dev-merge).
The last correctness item before the client nightly: the resolve-on-delete grant gap flagged in #3305/#3333.
The gap
CustomAlertEvaluator.WriteTeardownResolutionAsyncwrites the "<name> Resolved" recovery row toconfig.config_alert_log, but only admin/owner may INSERT there. On the two least-privilege delete paths -- the mcpdelete_custom_alert_ruletool and the viewerDELETE /api/alerts-- the INSERT was permission-denied, failure-isolated (swallowed), and the row silently dropped, leaving a deleted firing rule showing "open" in history forever. #3305's tests missed it because they connect as the owner.Fix — a scoped SECURITY DEFINER function (Erik's greenlit approach, over a blanket INSERT grant)
DarlingManagedRolesprovisioning gains, via a shared builderBuildCustomAlertResolveFunctionSql:Definer-safety (first SECURITY DEFINER function in the codebase, so each property is deliberate):
config_alert_logINSERT the body runs with;CREATE OR REPLACEby the owner keeps ownership.config, pg_catalog: no caller search_path can redirect the unqualifiedconfig_alert_logornow().alert_sentfalse,notification_type'none', current/threshold 0, unmuted, no send_error/context) -- byte-identical to whatRecordAlertAsyncwrites for aBuildResolutionRecord. A grantee can page nothing and forge no fire; only server_id/name and the already-sanitized title/detail vary.GRANT EXECUTE TO viewer, mcp(a fresh function is PUBLIC-executable by default).CREATE OR REPLACEis idempotent + owner-run every start with no probeable schema footprint, so noSchemaVersionbump (which would drag in the probe rung + ladder fixture + version pins).Routing:
WriteTeardownResolutionAsync(the delete AND sweep-reconcile teardown writer) now callsPgAlertHistoryStore.RecordCustomAlertResolutionAsync→SELECT config.record_custom_alert_resolution(...)on the caller's pool (runs as owner via the definer). Normal fire/resolve history writes (the owner service path,DeliverFireAsync/DeliverResolveAsync) stay onRecordAlertAsync, unchanged.Provisioning-pin test updates
Added
BuildProvisioningSql_CreatesResolveDefinerFunction_ScopedAndGrantedToViewerAndMcptoDarlingManagedRolesTests: pins the CREATE,SECURITY DEFINER, the pinnedsearch_path, the PUBLIC-revoke-before-EXECUTE-grant order, the locked resolution shape, and the ABSENCE of any blanketconfig_alert_loggrant. The existing pins are unaffected (all substringAssert.Contains/DoesNotContain; the new section is additive).The proof test #3305 lacked (gated live, run green on a local PG 18.4 + TimescaleDB rig)
CustomAlertResolveGrantLiveTestsconnects as a disposable low-privilege role (viewer/mcp-shaped: SELECT + narrowcustom_alert_ruleswrite + EXECUTE on the function, and no directconfig_alert_logINSERT):config_alert_logINSERT by the role is asserted denied (42501); thenResolveAndDeleteRuleAsyncon the low-priv pool writes the<name> Resolvedrow (asserted present + a proper no-channel resolution shape) and deletes the rule. A present row can only have come through the definer function.CustomAlertTeardownLiveTestsnow provisions the function in its fixture (the teardown path depends on this provisioning artifact; those tests migrate but do not provision). All 9 custom-alert live tests pass on a fresh DB.Out of scope (flagged, not changed)
The BYO
Darling/tools/provision-roles.sql(bring-your-own-Postgres, viewer-only, no mcp) is a separate hand-maintained surface with its own drift guard. A BYO viewer'sDELETE /api/alertswould still hit the gap. It was not in the stated scope (which namedDarlingManagedRoles), and adding a SECURITY DEFINER function to the shipped BYO script is a security-surface decision for the operator -- worth a follow-up decision.Build & full-suite results (post-rebase onto current dev)
FleetIdentifierScrubTestsx2 "scanned 0 files",NpgsqlRootCertificateValidationTestsx1 TLS). The fourth,IncidentAttachmentDeliveryTests.TheFourNonEmailChannels_PostIdenticalBytes_WithAndWithoutIncidentAttachments, is a test-ISOLATION issue in dev's newly-merged [BUG] AttachmentXml is per-alert but the cards are per-incident: per-event mode emails N cards that all attach the FIRST graph in the window #3330 class: it passes in isolation and fails only in the full parallel run, and has no code path to this change (it exercises delivery-channel bytes; this touches config_alert_log resolution provisioning). It affects any branch off current dev -- flagging for a dev-side look, not caused here.AuroraOnlySqlIsGatedTestsworktree source-scan baseline; no Lite files touched.No MCP tool added, so the tool-inventory pins do not apply.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4