Skip to content

Custom-alert resolve-on-delete silently skips the recovery row on mcp/viewer paths (config_alert_log grant gap in #3305) #3334

Closed

Description

@erikdarlingdata

The bug

CustomAlertEvaluator.ResolveAndDeleteRuleAsync (#3305) force-resolves an open incident on rule delete by writing a "Custom: Resolved" recovery row via INSERT INTO config.config_alert_log. But DarlingManagedRoles grants INSERT/UPDATE/DELETE ON ALL TABLES IN SCHEMA config to admin only; viewer and mcp get SELECT-everywhere plus narrow write grants on specific tables (custom_views, custom_alert_rules, config_mute_rules, config_monitored_servers, config_alert_settings UPDATE, analysis_* INSERT) — NOT config_alert_log.

So the recovery INSERT fails permission-denied on both least-privilege delete paths:

The write is failure-isolated (caught + logged), so the delete still succeeds (rule + state cascade away, alert stops firing) — but the resolution row #3305 intends is silently skipped. A deleted firing rule leaves a dangling "open" incident in alert history / the open-incidents view forever, which is exactly what #3305 set out to prevent.

Why the #3305 tests missed it

CustomAlertTeardownLiveTests connect as the owner/superuser (which has INSERT on all config tables), so the least-privilege write path was never exercised. Any test that connects as viewer/mcp would have caught it.

Scope

Affects the deployed #3305 (MCP path) and #3333 (web path). Low impact on DARLING01 today (no custom rules exist there yet), but wrong on any real fleet. The ReconcileStateAsync disable/out-of-scope sweep runs as the service (owner) so it is NOT affected — only the caller-initiated DELETE paths.

Fix options (security-surface decision — flagging rather than unilaterally granting)

  • A. GRANT INSERT ON config.config_alert_log TO viewer, mcp — simplest, but broad: it lets these roles inject arbitrary alert-history rows (fabricated fires/resolutions), an integrity expansion of the deliberately-restricted role model.
  • B (recommended). A SECURITY DEFINER function owned by the store owner, e.g. config.resolve_custom_alert_incident(rule_id), that writes only the scoped resolution row(s) with owner privilege; GRANT EXECUTE to viewer + mcp; ResolveAndDeleteRuleAsync calls it. Confines the privileged write to exactly its purpose (resolving a rule's own incident as it is deleted) without a blanket INSERT grant.
  • C. Don't write the resolve on delete; make the open-incidents consumers filter out incidents whose rule no longer exists. Avoids the write but leaves history literally incomplete and relies on every consumer.

Also

Add a resolve-on-delete test that connects as viewer/mcp (not owner) so this class of grant gap can't hide again.

Part of #3285; regression in #3305 (surfaced reviewing #3333).

Activity

  1. added a commit that references this issue on Sep 12, 2026
  2. erikdarlingdata commented on Sep 12, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed in #3347 (merged to dev). The resolve-on-delete recovery row is now written through a SECURITY DEFINER function config.record_custom_alert_resolution (owner-privileged, EXECUTE-granted to viewer/mcp, shape-locked to a no-channel resolution row so no fire can be forged), so the teardown resolution lands even when the delete runs on the least-privilege mcp/viewer pool. A gated live proof test connects as a disposable low-privilege role, asserts the direct config_alert_log INSERT is denied (42501), then confirms both teardown paths (caller delete + sweep reconcile) write the row via the function. Will auto-close-verify again at the next dev->main promotion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions