<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged slack</title>
<link>https://coderlegion.com/tag/slack</link>
<description>Powered by Question2Answer</description>
<item>
<title>Building a Slack Approval Workflow That Deletes Cloud Infrastructure</title>
<link>https://coderlegion.com/26692/building-a-slack-approval-workflow-that-deletes-cloud-infrastructure</link>
<description>&lt;p&gt;&lt;em&gt;Block Kit, signature verification, and the design decisions that stop a button click from becoming an incident.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/boazleleina/boaz-portfolio/main/public/blog-images/slack-demo.png&quot; alt=&quot;A Slack interface screenshot showing an interactive bot message asking permission to approve remediation for an EBS volume with primary and deny buttons&quot;&gt;&lt;/p&gt;
&lt;p&gt;That screenshot is a bot asking permission to delete an EBS volume. Clicking&lt;br&gt;
&lt;strong&gt;Approve Remediation&lt;/strong&gt; snapshots the volume, waits for the snapshot to&lt;br&gt;
complete, deletes the volume, and edits the message to say what happened.&lt;/p&gt;
&lt;p&gt;Getting that to work is mostly plumbing. Getting it to work &lt;em&gt;safely&lt;/em&gt;, so a&lt;br&gt;
stale click, a replayed request, or a resource someone protected in the&lt;br&gt;
meantime cannot cause damage, is the interesting part.&lt;/p&gt;
&lt;p&gt;This walks through both, using the Slack adapter from&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;FinOps Sentinel&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-shape-of-the-problem&quot;&gt;The shape of the problem&lt;/h3&gt;
&lt;p&gt;Slack interactivity is two separate channels that only look like a conversation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Your app ──── incoming webhook ────▶ Slack channel
                                          │
                                     user clicks
                                          │
Your app ◀─── HTTP POST ──────────────────┘
             (a completely new request, from Slack's servers)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The click arrives as an &lt;strong&gt;unauthenticated POST from the public internet&lt;/strong&gt; to&lt;br&gt;
whatever URL you registered. Nothing about the request proves it came from&lt;br&gt;
Slack, or that a human clicked anything. That is the security problem in one&lt;br&gt;
sentence, and everything below follows from it.&lt;/p&gt;
&lt;p&gt;It also has a second half that took me a round of review to see. Proving the&lt;br&gt;
request came from Slack is not the same as proving the &lt;em&gt;person&lt;/em&gt; may delete&lt;br&gt;
infrastructure, and neither one is AWS agreeing that they may. Three different&lt;br&gt;
questions, three different places to answer them: the adapter, the domain, and&lt;br&gt;
IAM.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-1-setting-up-the-slack-app&quot;&gt;Part 1: Setting up the Slack app&lt;/h3&gt;
&lt;h4 id=&quot;create-the-app-and-get-a-webhook&quot;&gt;Create the app and get a webhook&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://api.slack.com/apps&quot;&gt;api.slack.com/apps&lt;/a&gt; → &lt;strong&gt;Create New App&lt;/strong&gt; → &lt;strong&gt;From scratch&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Name it, pick your workspace&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incoming Webhooks&lt;/strong&gt; → toggle &lt;strong&gt;On&lt;/strong&gt; → &lt;strong&gt;Add New Webhook to Workspace&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Choose a channel, click &lt;strong&gt;Allow&lt;/strong&gt;, copy the URL&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;SLACK_WEBHOOK_URL=https://hooks.slack.com/services/TXXXXX/BXXXXX/XXXXXXXX
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Webhooks post to exactly one channel and cannot read anything. For a&lt;br&gt;
notification bot that is the right amount of privilege: no OAuth flow, no bot&lt;br&gt;
token, no scopes to review.&lt;/p&gt;
&lt;h4 id=&quot;enable-interactivity&quot;&gt;Enable interactivity&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Interactivity &amp;amp; Shortcuts&lt;/strong&gt; → toggle &lt;strong&gt;On&lt;/strong&gt; → set the Request URL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;https://your-domain.example/callbacks/slack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Locally you need a tunnel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ngrok http 8000
# → https://a1b2c3d4.ngrok.app
# Request URL: https://a1b2c3d4.ngrok.app/callbacks/slack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The free ngrok URL changes on every restart, and you must update Slack each&lt;br&gt;
time. Save yourself the confusion: if buttons &quot;do nothing,&quot; check this first.&lt;/p&gt;
&lt;h4 id=&quot;get-the-signing-secret&quot;&gt;Get the signing secret&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Basic Information&lt;/strong&gt; → &lt;strong&gt;App Credentials&lt;/strong&gt; → &lt;strong&gt;Signing Secret&lt;/strong&gt; → Show.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;SLACK_SIGNING_SECRET=your_signing_secret_here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is what makes callbacks trustworthy. Without it your endpoint will delete&lt;br&gt;
infrastructure for anyone who sends it a well-formed POST.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-2-sending-a-message-worth-acting-on&quot;&gt;Part 2: Sending a message worth acting on&lt;/h3&gt;
&lt;p&gt;Block Kit messages are JSON arrays of blocks. The naive version works&lt;br&gt;
immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from slack_sdk.webhook import WebhookClient

WebhookClient(webhook_url).send(text=f&quot;Idle volume found: {volume_id}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But an alert an engineer has to act on needs to answer &lt;em&gt;what, where, how much,&lt;br&gt;
and what happens if I click this&lt;/em&gt;, in about ten seconds, often on a phone.&lt;/p&gt;
&lt;p&gt;Here is the real implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def send_finding_alert(self, finding: Finding, resource: Resource) -&amp;gt; str | None:
    webhook_url = settings.slack_webhook_url
    if not webhook_url:
        raise RuntimeError(&quot;SLACK_WEBHOOK_URL is not configured&quot;)

    remediable = is_remediable(finding.rule)
    header = (
        &quot;*FinOps Alert: Waste Detected*&quot;
        if remediable
        else &quot;*FinOps Advisory: Possible Idle Resource*&quot;
    )

    blocks: list[dict[str, Any]] = [
        {
            &quot;type&quot;: &quot;section&quot;,
            &quot;text&quot;: {
                &quot;type&quot;: &quot;mrkdwn&quot;,
                &quot;text&quot;: (
                    f&quot;{header}\n\n&quot;
                    f&quot;*Rule:* {finding.rule}\n&quot;
                    f&quot;*Resource:* `{resource.resource_id}` ({resource.resource_type})\n&quot;
                    # Region is not decoration: with several regions scanned, it is
                    # the first thing an approver needs to know where to look, and
                    # remediation runs there.
                    f&quot;*Region:* `{resource.region}`\n&quot;
                    f&quot;*Cost Impact:* ${finding.est_monthly_cost_usd}/mo&quot;
                ),
            },
        }
    ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The region line was added after a real confusion.&lt;/strong&gt; Scanning three regions&lt;br&gt;
produces three near-identical alerts differing only by resource ID. Without the&lt;br&gt;
region an approver cannot tell which account corner they are about to change.&lt;/p&gt;
&lt;h4 id=&quot;the-llm-summary-goes-in-its-own-block&quot;&gt;The LLM summary goes in its own block&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    if finding.llm_summary:
        # Advisor output is untrusted display copy (it summarizes
        # user-controlled tags), so it goes in its own context block and is
        # never used to build action values.
        blocks.append({
            &quot;type&quot;: &quot;context&quot;,
            &quot;elements&quot;: [{&quot;type&quot;: &quot;mrkdwn&quot;, &quot;text&quot;: finding.llm_summary}],
        })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A local LLM writes a plain-language explanation of each finding. The prompt&lt;br&gt;
contains AWS tags, which anyone with tagging permission can write. So model&lt;br&gt;
output is treated as &lt;strong&gt;untrusted display copy&lt;/strong&gt;: rendered, never parsed, and&lt;br&gt;
never used to build anything the system acts on.&lt;/p&gt;
&lt;h4 id=&quot;buttons-are-conditional-and-that-is-the-whole-design&quot;&gt;Buttons are conditional, and that is the whole design&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    if remediable:
        blocks.append({
            &quot;type&quot;: &quot;actions&quot;,
            &quot;elements&quot;: [
                {
                    &quot;type&quot;: &quot;button&quot;,
                    &quot;text&quot;: {&quot;type&quot;: &quot;plain_text&quot;, &quot;text&quot;: &quot;Approve Remediation&quot;},
                    &quot;style&quot;: &quot;primary&quot;,
                    &quot;value&quot;: f&quot;approve_{finding.id}&quot;,
                    &quot;action_id&quot;: &quot;approve_remediation&quot;,
                },
                {
                    &quot;type&quot;: &quot;button&quot;,
                    &quot;text&quot;: {&quot;type&quot;: &quot;plain_text&quot;, &quot;text&quot;: &quot;Deny&quot;},
                    &quot;style&quot;: &quot;danger&quot;,
                    &quot;value&quot;: f&quot;deny_{finding.id}&quot;,
                    &quot;action_id&quot;: &quot;deny_remediation&quot;,
                },
            ],
        })
    else:
        # Metric-inferred: no playbook is allowed to run, so offering an
        # Approve button would promise an action the domain refuses.
        blocks.append({
            &quot;type&quot;: &quot;context&quot;,
            &quot;elements&quot;: [{
                &quot;type&quot;: &quot;mrkdwn&quot;,
                &quot;text&quot;: (&quot;_Advisory only — inferred from CloudWatch metrics. &quot;
                         &quot;No automated remediation is available for this rule._&quot;),
            }],
        })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some findings are advisory by design. An idle EC2 instance might be a warm&lt;br&gt;
standby, a batch worker between runs, or a license server; low CPU is&lt;br&gt;
&lt;em&gt;evidence&lt;/em&gt;, not proof. The domain refuses to remediate those rules.&lt;/p&gt;
&lt;p&gt;The early version rendered buttons on every alert. Clicking Approve on an&lt;br&gt;
advisory finding returned a refusal. &lt;strong&gt;A button that does nothing is worse than&lt;br&gt;
no button&lt;/strong&gt;: it trains people to distrust every button. So the adapter asks the&lt;br&gt;
domain first and renders accordingly.&lt;/p&gt;
&lt;h4 id=&quot;notification-preview-text&quot;&gt;Notification preview text&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    response = WebhookClient(webhook_url).send(
        # Notification preview text — the region belongs here too, since
        # this is all a phone lock screen shows.
        text=(f&quot;FinOps Alert: {finding.rule} on {resource.resource_id} &quot;
              f&quot;in {resource.region}&quot;),
        blocks=blocks,
    )
    if response.status_code != 200:
        raise RuntimeError(f&quot;Slack webhook returned {response.status_code}: {response.body}&quot;)

    logger.info(&quot;Sent Slack alert for finding %s&quot;, finding.id)
    # Incoming webhooks return no message timestamp; edits happen via the
    # interaction payload's response_url instead.
    return None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Easy to forget: when &lt;code&gt;blocks&lt;/code&gt; is present, &lt;code&gt;text&lt;/code&gt; becomes the push-notification&lt;br&gt;
preview. Omit it and phones show &quot;This content can't be displayed.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note the raise.&lt;/strong&gt; A failed send is not swallowed: the caller leaves the&lt;br&gt;
finding unnotified so the next scan retries it. Swallowing would lose the alert&lt;br&gt;
silently, which for a cost alert means the money keeps burning and nobody knows.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-3-receiving-the-click-safely&quot;&gt;Part 3: Receiving the click safely&lt;/h3&gt;
&lt;h4 id=&quot;verify-before-you-parse&quot;&gt;Verify before you parse&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def parse_callback(
    self, raw_body: bytes, headers: Mapping[str, str]
) -&amp;gt; tuple[Decision, dict[str, Any]]:
    self._verify_signature(raw_body, headers)     # ← first line, always
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Signature verification is the &lt;strong&gt;first&lt;/strong&gt; thing that happens. Parse-then-verify&lt;br&gt;
means you've already run a JSON decoder on hostile input.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;CALLBACK_MAX_AGE_SECONDS = 60 * 5

def _verify_signature(self, raw_body: bytes, headers: Mapping[str, str]) -&amp;gt; None:
    secret = settings.slack_signing_secret
    if not secret:
        # No secret configured — verification bypassed (local testing).
        return

    timestamp = headers.get(&quot;x-slack-request-timestamp&quot;) or headers.get(
        &quot;X-Slack-Request-Timestamp&quot;
    )
    signature = headers.get(&quot;x-slack-signature&quot;) or headers.get(&quot;X-Slack-Signature&quot;)
    if not timestamp or not signature:
        raise PermissionError(&quot;Missing Slack signature headers&quot;)

    try:
        age = abs(time.time() - int(timestamp))
    except ValueError as exc:
        raise PermissionError(&quot;Invalid Slack timestamp header&quot;) from exc
    if age &amp;gt; CALLBACK_MAX_AGE_SECONDS:
        raise PermissionError(&quot;Slack request timestamp expired&quot;)

    verifier = SignatureVerifier(secret)
    if not verifier.is_valid(body=raw_body, timestamp=timestamp, signature=signature):
        raise PermissionError(&quot;Invalid Slack signature&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three checks, three different attacks:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Check &lt;/th&gt;&lt;th&gt;Stops&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Headers present &lt;/td&gt;&lt;td&gt;Casual probing of a public endpoint&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timestamp within 5 minutes &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Replay&lt;/strong&gt;: a captured valid request resent later&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HMAC valid &lt;/td&gt;&lt;td&gt;Forgery&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And one attack none of them stops, which took me an embarrassingly long time to&lt;br&gt;
see: &lt;strong&gt;a valid signature from someone who has no business deleting anything.&lt;/strong&gt;&lt;br&gt;
More on that in Part 4, where it turns out to be the more interesting hole.&lt;/p&gt;
&lt;p&gt;The replay check is the one people skip. A signature stays valid forever unless&lt;br&gt;
you bound its age; capture one legitimate approve callback and you can replay it&lt;br&gt;
indefinitely. Slack sends the timestamp precisely so you can reject stale&lt;br&gt;
requests.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SignatureVerifier&lt;/code&gt; comes from &lt;code&gt;slack_sdk&lt;/code&gt; and does constant-time comparison,&lt;br&gt;
worth using rather than hand-rolling HMAC and comparing with &lt;code&gt;==&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &lt;code&gt;if not secret: return&lt;/code&gt; bypass is a deliberate local-testing affordance,&lt;br&gt;
and it is a landmine.&lt;/strong&gt; It means an unconfigured deployment accepts anything.&lt;br&gt;
Fine on a laptop, dangerous anywhere reachable. If I were hardening this for&lt;br&gt;
production I would make it fail closed unless an explicit&lt;br&gt;
&lt;code&gt;ALLOW_UNSIGNED_CALLBACKS=true&lt;/code&gt; were set.&lt;/p&gt;
&lt;h4 id=&quot;the-signature-proves-the-app-not-the-install&quot;&gt;The signature proves the app, not the install&lt;/h4&gt;
&lt;p&gt;A signature says the request went through &lt;em&gt;this app&lt;/em&gt;. It does not say which&lt;br&gt;
install of it. Add the app to a second workspace, or widen the channel, and&lt;br&gt;
well-signed approvals start arriving from a population nobody enumerated,&lt;br&gt;
every one of them passing all three checks above.&lt;/p&gt;
&lt;p&gt;That is Slack-shaped, so it belongs in the Slack adapter, right next to the&lt;br&gt;
signature check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _verify_provenance(self, payload: dict[str, Any]) -&amp;gt; None:
    expected_team = settings.slack_team_id
    if expected_team:
        team_id = (payload.get(&quot;team&quot;) or {}).get(&quot;id&quot;)
        if team_id != expected_team:
            raise PermissionError(f&quot;Callback from unexpected Slack workspace: {team_id!r}&quot;)

    allowed_channels = settings.allowed_slack_channels
    if allowed_channels:
        channel_id = (payload.get(&quot;channel&quot;) or {}).get(&quot;id&quot;)
        if channel_id not in allowed_channels:
            raise PermissionError(f&quot;Callback from unexpected channel: {channel_id!r}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;team.id&lt;/code&gt; means nothing to a Telegram install (its equivalent is a chat id),&lt;br&gt;
so this stays on the adapter side of the port. The question of whether the&lt;br&gt;
&lt;em&gt;person&lt;/em&gt; may delete infrastructure is different, and it goes somewhere else&lt;br&gt;
entirely.&lt;/p&gt;
&lt;h4 id=&quot;raw-bytes-matter&quot;&gt;Raw bytes matter&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@app.post(&quot;/callbacks/{channel}&quot;)
async def notifier_callback(channel: str, request: Request) -&amp;gt; dict[str, Any]:
    raw_body = await request.body()          # ← bytes, not a parsed model
    decision, reply_context = notifier.parse_callback(raw_body, request.headers)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The HMAC is computed over the &lt;strong&gt;exact bytes&lt;/strong&gt; Slack sent. Let FastAPI parse the&lt;br&gt;
body into a Pydantic model first and you cannot reconstruct them: key order,&lt;br&gt;
whitespace, and encoding all shift. Signature verification will then fail&lt;br&gt;
mysteriously and you will lose an afternoon.&lt;/p&gt;
&lt;h4 id=&quot;parsing-the-payload&quot;&gt;Parsing the payload&lt;/h4&gt;
&lt;p&gt;Slack sends &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; with a single &lt;code&gt;payload&lt;/code&gt; field&lt;br&gt;
containing JSON. Yes, really.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    form = urllib.parse.parse_qs(raw_body.decode(&quot;utf-8&quot;))
    payload_values = form.get(&quot;payload&quot;)
    if not payload_values:
        raise ValueError(&quot;No payload found&quot;)

    try:
        payload = json.loads(payload_values[0])
    except json.JSONDecodeError as exc:
        raise ValueError(&quot;Payload is not valid JSON&quot;) from exc

    actions = payload.get(&quot;actions&quot;) or []
    if not actions:
        raise ValueError(&quot;No actions in payload&quot;)

    value = str(actions[0].get(&quot;value&quot;, &quot;&quot;))
    action: Literal[&quot;approve&quot;, &quot;deny&quot;]
    if value.startswith(&quot;approve_&quot;):
        action, finding_id = &quot;approve&quot;, value.removeprefix(&quot;approve_&quot;)
    elif value.startswith(&quot;deny_&quot;):
        action, finding_id = &quot;deny&quot;, value.removeprefix(&quot;deny_&quot;)
    else:
        raise ValueError(f&quot;Unrecognized action value: {value!r}&quot;)

    actor = payload.get(&quot;user&quot;, {}).get(&quot;username&quot;) or payload.get(&quot;user&quot;, {}).get(
        &quot;id&quot;, &quot;unknown&quot;
    )

    decision = Decision(
        finding_id=finding_id,
        actor=actor,
        action=action,
        decided_at=datetime.now(UTC),
        channel=self.channel_name,
    )
    reply_context = {
        &quot;response_url&quot;: payload.get(&quot;response_url&quot;),
        &quot;original_blocks&quot;: payload.get(&quot;message&quot;, {}).get(&quot;blocks&quot;, []),
    }
    return decision, reply_context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output is a &lt;strong&gt;domain object&lt;/strong&gt;. Everything Slack-shaped (form encoding, the&lt;br&gt;
&lt;code&gt;payload&lt;/code&gt; wrapper, &lt;code&gt;response_url&lt;/code&gt;) stops at this boundary. The domain service&lt;br&gt;
receiving this &lt;code&gt;Decision&lt;/code&gt; has no idea Slack exists.&lt;/p&gt;
&lt;p&gt;The button value carries a &lt;strong&gt;system-generated finding ID&lt;/strong&gt;, never model output&lt;br&gt;
and never anything a user typed. The parser refuses anything not matching the&lt;br&gt;
expected prefixes.&lt;/p&gt;
&lt;h4 id=&quot;mapping-exceptions-to-status-codes&quot;&gt;Mapping exceptions to status codes&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    try:
        decision, reply_context = notifier.parse_callback(raw_body, request.headers)
    except PermissionError as exc:
        raise HTTPException(status_code=401, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The port defines the exception contract (&lt;code&gt;PermissionError&lt;/code&gt; for auth failures,&lt;br&gt;
&lt;code&gt;ValueError&lt;/code&gt; for malformed input), so any notifier implementation maps cleanly&lt;br&gt;
to HTTP without the route knowing which one is configured.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-4-what-happens-after-the-click&quot;&gt;Part 4: What happens after the click&lt;/h3&gt;
&lt;p&gt;The route hands off to the domain immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _decide(finding_id: str, action: str, actor: str, channel: str) -&amp;gt; bool:
    repo = get_repository()
    if action == &quot;approve&quot;:
        return approve_finding(
            finding_id,
            repo,
            # A factory, not a gateway: the credentials depend on the approval
            # — its region, and whose role runs it.
            get_approval_gateway_factory(),
            actor=actor,
            channel=channel,
            dry_run=settings.dry_run,
            # Authority, checked in the domain: the notifier already proved the
            # request came through the app, which is a different question.
            authorizer=get_authorizer(),
        )
    return deny_finding(finding_id, repo, actor=actor, channel=channel)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside &lt;code&gt;approve_finding&lt;/code&gt;, guardrails are re-checked &lt;strong&gt;at approval time&lt;/strong&gt;, not&lt;br&gt;
trusted from detection time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    if authorizer is not None and not authorizer.can_approve(actor):
        _audit(repo, &quot;approve_blocked_unauthorized&quot;, finding.id, {&quot;actor&quot;: actor})
        return False

    if resource.lifecycle == ResourceLifecycle.DELETED:
        _audit(repo, &quot;approve_blocked_resource_gone&quot;, finding.id, {...})
        return False

    if finding.protected or rules.is_protected(resource.current_tags):
        _audit(repo, &quot;approve_blocked_protected&quot;, finding.id, {...})
        return False

    if not rules.is_remediable(finding.rule):
        _audit(repo, &quot;approve_blocked_notify_only&quot;, finding.id, {...})
        return False

    playbook = rules.PLAYBOOK_ALLOWLIST.get(resource.resource_type)
    if playbook is None:
        _audit(repo, &quot;approve_blocked_no_playbook&quot;, finding.id, {...})
        return False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An alert can sit in Slack for hours. In that window someone may have tagged the&lt;br&gt;
resource &lt;code&gt;finops:protected=true&lt;/code&gt;, or deleted it out of band. &lt;strong&gt;The newer intent&lt;br&gt;
wins&lt;/strong&gt;, and each refusal gets its own audit event name so &quot;why didn't it act?&quot;&lt;br&gt;
is answerable from the database.&lt;/p&gt;
&lt;h4 id=&quot;authentication-is-not-authorization&quot;&gt;Authentication is not authorization&lt;/h4&gt;
&lt;p&gt;That first check is the one I originally didn't have, and its absence was the&lt;br&gt;
most serious thing in this system.&lt;/p&gt;
&lt;p&gt;Signature verification proves the request came through the app. The signing&lt;br&gt;
secret is &lt;em&gt;app-level&lt;/em&gt;, so that proof is shared by everyone who can see the&lt;br&gt;
message. I captured &lt;code&gt;actor&lt;/code&gt;, stamped it into the &lt;code&gt;Decision&lt;/code&gt;, and printed it in&lt;br&gt;
&quot;Approved by @boaz&quot;, so the audit trail could answer who clicked, while the&lt;br&gt;
system had never formed an opinion on whether that person was permitted to.&lt;/p&gt;
&lt;p&gt;The check lives in the domain, not the adapter. &lt;code&gt;approve_finding&lt;/code&gt; already&lt;br&gt;
receives &lt;code&gt;actor&lt;/code&gt;; putting the decision there means it survives the swap to&lt;br&gt;
Telegram that the whole port arrangement exists to make possible. It gets its&lt;br&gt;
own refusal path and its own audit event, because a denied attempt has to be as&lt;br&gt;
visible in the log as an accepted one; otherwise the trail records only the&lt;br&gt;
approvals that happened to be permitted.&lt;/p&gt;
&lt;p&gt;The list is a config table (&lt;code&gt;SENTINEL_APPROVERS&lt;/code&gt;), keyed on the Slack &lt;strong&gt;user&lt;br&gt;
id&lt;/strong&gt; rather than the username: display names are user-controlled, and this&lt;br&gt;
string decides who gets to delete infrastructure.&lt;/p&gt;
&lt;h4 id=&quot;the-double-click-problem-and-the-one-i-actually-had&quot;&gt;The double-click problem, and the one I actually had&lt;/h4&gt;
&lt;p&gt;Slack buttons are trivially double-clickable, and Slack itself retries on&lt;br&gt;
timeout. Without protection you get two remediations. Here is what I originally&lt;br&gt;
wrote:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    if not repo.transition_finding(finding.id, finding.status, FindingStatus.APPROVED):
        return False  # lost the race — someone else already decided
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Backed by a compare-and-swap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE findings SET status = :new WHERE id = :id AND status = :expected
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Returns &lt;code&gt;True&lt;/code&gt; only when &lt;code&gt;rowcount == 1&lt;/code&gt;. That protects the &lt;em&gt;simultaneous&lt;/em&gt; case:&lt;br&gt;
two requests that both read &lt;code&gt;NOTIFIED&lt;/code&gt;, one of which loses.&lt;/p&gt;
&lt;p&gt;It does not protect a &lt;strong&gt;replay&lt;/strong&gt;, and a double-click is a replay. The second&lt;br&gt;
click arrives after the first one committed, so it loads &lt;code&gt;APPROVED&lt;/code&gt;, passes&lt;br&gt;
&lt;code&gt;expected=APPROVED&lt;/code&gt;, and runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE findings SET status='APPROVED' WHERE id=:id AND status='APPROVED'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That matches. &lt;code&gt;rowcount == 1&lt;/code&gt;. Remediation runs again.&lt;/p&gt;
&lt;p&gt;What saved me was a check one gate above: the transition table has no&lt;br&gt;
&lt;code&gt;APPROVED → APPROVED&lt;/code&gt; edge, so a replay is refused before it reaches the CAS.&lt;br&gt;
But that means the guarantee I was advertising came from a different mechanism&lt;br&gt;
than the one I was pointing at, and it holds only for as long as nobody adds a&lt;br&gt;
self-loop to that table. The fix is to name the pre-decision state literally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    if not repo.transition_finding(finding.id, FindingStatus.NOTIFIED, FindingStatus.APPROVED):
        return False  # already decided — a concurrent click, or a replayed one
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;APPROVED → APPROVED&lt;/code&gt; updates zero rows, which is the behaviour I claimed in&lt;br&gt;
the first place.&lt;/p&gt;
&lt;p&gt;A concurrency test cannot catch this; a sequential one catches it immediately.&lt;br&gt;
Approve once, let it commit, replay the identical payload, assert the fake cloud&lt;br&gt;
gateway saw exactly one delete:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_replayed_approval_executes_once(repository):
    seed(repository)
    gateway = FakeCloudGateway()
    payload = {&quot;actor&quot;: &quot;boaz&quot;, &quot;channel&quot;: &quot;slack&quot;, &quot;dry_run&quot;: False}

    assert approve_finding(&quot;f-mock&quot;, repository, resolver(gateway), **payload) is True
    assert approve_finding(&quot;f-mock&quot;, repository, resolver(gateway), **payload) is False

    assert gateway.executed == [(&quot;snapshot_then_delete_volume&quot;, &quot;vol-123&quot;, False)]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;three-seconds-versus-a-snapshot&quot;&gt;Three seconds versus a snapshot&lt;/h4&gt;
&lt;p&gt;The version of this article that shipped first put the remediation inline in the&lt;br&gt;
callback route, and buried the consequence in a troubleshooting note. The&lt;br&gt;
consequence deserves better than a note.&lt;/p&gt;
&lt;p&gt;The EBS playbook snapshots the volume and &lt;strong&gt;waits for the snapshot&lt;/strong&gt; before&lt;br&gt;
deleting (up to ten minutes). Slack gives up at three seconds and retries the&lt;br&gt;
interaction itself. Across that entire interval the message still carried its&lt;br&gt;
buttons, because the code that removes them ran after the playbook finished. So&lt;br&gt;
the acknowledgement window, the button removal, and the CAS all stopped covering&lt;br&gt;
the same interval, and the interval was as long as the remediation.&lt;/p&gt;
&lt;p&gt;The fix is to split deciding from executing. Deciding is repository reads and&lt;br&gt;
one write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;plan = commit_approval(finding_id, repo, actor=actor, channel=channel,
                       authorizer=get_authorizer())
if plan is None:
    return _rejected(...)          # a guardrail refused, or it was already decided

notifier.confirm_decision(reply_context, f&quot;⏳ *Approved* by @{actor} — running &quot;
                                         f&quot;`{plan.playbook}`{where}.&quot;)
background_tasks.add_task(_run_remediation, plan, notifier, reply_context, actor, where)
return {&quot;message&quot;: &quot;accepted&quot;}     # well inside the 3-second budget
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ordering is the point: the finding is &lt;code&gt;APPROVED&lt;/code&gt; in the database &lt;em&gt;before&lt;/em&gt;&lt;br&gt;
the acknowledgement goes out. The state change that makes a replay a no-op and&lt;br&gt;
the edit that removes the buttons land together, rather than a remediation&lt;br&gt;
apart. The playbook then runs in the background and edits the message a second&lt;br&gt;
time with the outcome.&lt;/p&gt;
&lt;h4 id=&quot;editing-the-message&quot;&gt;Editing the message&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def confirm_decision(self, reply_context: dict[str, Any], text: str) -&amp;gt; None:
    response_url = reply_context.get(&quot;response_url&quot;)
    if not response_url:
        return

    blocks: list[dict[str, Any]] = []
    original_blocks = reply_context.get(&quot;original_blocks&quot;) or []
    if original_blocks:
        blocks.append(original_blocks[0])  # keep the alert text, drop the buttons
    blocks.append({&quot;type&quot;: &quot;section&quot;, &quot;text&quot;: {&quot;type&quot;: &quot;mrkdwn&quot;, &quot;text&quot;: text}})

    response = WebhookClient(response_url).send(
        text=text, blocks=blocks, replace_original=True
    )
    if response.status_code != 200:
        logger.error(&quot;Failed to update Slack message: %s&quot;, response.body)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keeping block &lt;code&gt;[0]&lt;/code&gt; and dropping the rest preserves the context (what the alert&lt;br&gt;
&lt;em&gt;was&lt;/em&gt;) while removing the buttons so a decided finding cannot be clicked again.&lt;br&gt;
&lt;code&gt;response_url&lt;/code&gt; is valid for 30 minutes and 5 uses, which is plenty for one edit.&lt;/p&gt;
&lt;p&gt;The outcome text distinguishes every terminal state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;outcome = f&quot;⏳ *Approved* by @{actor} — running `{plan.playbook}`{where}.&quot;   # the ack
outcome = f&quot;*Approved* by @{decision.actor} — remediation executed{where}.&quot;
outcome = f&quot;*Approved* by @{decision.actor} — DRY RUN, no resources were changed.&quot;
outcome = f&quot;*Denied* by @{decision.actor} — no action taken, finding closed.&quot;
outcome = f&quot;*Remediation failed*{where} after approval by @{decision.actor} — ...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;Approved&quot; alone does not tell an operator whether anything actually changed.&lt;/p&gt;
&lt;h4 id=&quot;the-guardrail-i-couldn-t-write-in-python&quot;&gt;The guardrail I couldn't write in Python&lt;/h4&gt;
&lt;p&gt;Everything above is Sentinel deciding whether &lt;em&gt;Sentinel&lt;/em&gt; should act. None of it&lt;br&gt;
is AWS deciding whether a person may delete a volume.&lt;/p&gt;
&lt;p&gt;That distinction matters because the deletion runs with the app's own&lt;br&gt;
credentials. An approver list is a string comparison; the &lt;code&gt;DeleteVolume&lt;/code&gt; call&lt;br&gt;
behind it is made by a principal that holds that permission permanently, for&lt;br&gt;
every approval, whoever clicked. Someone on the list with zero IAM access still&lt;br&gt;
deletes the volume. Someone with account admin but not on the list is refused.&lt;br&gt;
The two facts are unrelated: a textbook confused deputy, where the click is&lt;br&gt;
only a trigger.&lt;/p&gt;
&lt;p&gt;Making AWS the enforcement point means the approval has to run &lt;em&gt;as&lt;/em&gt; the&lt;br&gt;
approver. &lt;code&gt;SENTINEL_ASSUME_ROLE=true&lt;/code&gt; maps each actor to a role, and every&lt;br&gt;
approval calls &lt;code&gt;sts:AssumeRole&lt;/code&gt; with three things attached:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RoleSessionName=sentinel-U024BE7LH&lt;/code&gt;: CloudTrail records a session naming the&lt;br&gt;
human, not a shared service principal.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;ExternalId&lt;/code&gt; on the trust policy, so the role cannot be assumed by anything&lt;br&gt;
that doesn't hold the secret even if its ARN leaks.&lt;/li&gt;
&lt;li&gt;An inline &lt;strong&gt;session policy&lt;/strong&gt; scoped to this one approval. Sentinel already&lt;br&gt;
knows the playbook, the resource and the region, so the session it runs under&lt;br&gt;
can do &lt;code&gt;ec2:DeleteVolume&lt;/code&gt; on exactly one volume ARN for fifteen minutes.&lt;br&gt;
STS intersects it with the role's own permissions, so it can only narrow: the&lt;br&gt;
approver's role may be broad, and the session that runs this deletion is not.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sentinel's own role then drops to read-only plus &lt;code&gt;sts:AssumeRole&lt;/code&gt;. A leaked&lt;br&gt;
Sentinel credential can inventory the account and nothing else.&lt;/p&gt;
&lt;p&gt;The limit is worth stating rather than glossing: the actor-to-role mapping is&lt;br&gt;
Sentinel &lt;strong&gt;asserting&lt;/strong&gt; an identity from a Slack payload. AWS enforces what the&lt;br&gt;
resulting session may do; it never sees the Slack user. The chain is as strong&lt;br&gt;
as the Slack account and the signature check in front of it. That is a real&lt;br&gt;
improvement over a name list, and it is not the same thing as the approver&lt;br&gt;
authenticating to AWS; &quot;IAM enforces it&quot; implies more than is true.&lt;/p&gt;
&lt;p&gt;One testing note, because it is a trap: LocalStack Community evaluates no IAM&lt;br&gt;
policies. It issues a session for any role ARN and then permits whatever that&lt;br&gt;
session asks. A &quot;denied&quot; assertion there &lt;strong&gt;passes the deletion&lt;/strong&gt;: a green test&lt;br&gt;
for an enforcement that never ran. Test the wiring against LocalStack, test the&lt;br&gt;
refusals against a fake STS, and verify enforcement once by hand in a sandbox&lt;br&gt;
account.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-5-testing-without-a-workspace&quot;&gt;Part 5: Testing without a workspace&lt;/h3&gt;
&lt;p&gt;The whole flow is testable with no Slack account at all, because the notifier is&lt;br&gt;
a port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class FakeNotifier(Notifier):
    &quot;&quot;&quot;Records what was sent. Zero Slack, zero HTTP.&quot;&quot;&quot;

    def __init__(self):
        self.alerts: list[tuple[str, str]] = []
        self.digests: list[tuple[str, list[str]]] = []

    @property
    def channel_name(self):
        return &quot;fake&quot;

    def send_finding_alert(self, finding, resource):
        self.alerts.append((finding.id, resource.resource_id))
        return f&quot;msg-{len(self.alerts)}&quot;

    def send_digest(self, title, sections):
        self.digests.append((title, sections))
        return f&quot;digest-{len(self.digests)}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The approve-and-remediate flow runs against &lt;code&gt;FakeNotifier&lt;/code&gt; + a fake cloud&lt;br&gt;
gateway + an in-memory repository. If that passes, swapping Slack for Telegram&lt;br&gt;
cannot break the approval logic, because the approval logic never knew about&lt;br&gt;
Slack.&lt;/p&gt;
&lt;p&gt;For the adapter itself, mock the webhook client and assert on the blocks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def send_and_capture(finding, resource):
    &quot;&quot;&quot;Send an alert through a mocked webhook and return the blocks sent.&quot;&quot;&quot;
    settings.slack_webhook_url = &quot;https://hooks.slack.test/T/B/X&quot;
    try:
        with patch(&quot;finops_sentinel.adapters.notifications.slack.WebhookClient&quot;) as client_cls:
            client_cls.return_value.send.return_value = MagicMock(status_code=200, body=&quot;ok&quot;)
            SlackAdapter().send_finding_alert(finding, resource)
            return client_cls.return_value.send.call_args.kwargs[&quot;blocks&quot;]
    finally:
        settings.slack_webhook_url = None


def test_advisory_findings_get_no_buttons():
    blocks = send_and_capture(make_finding(&quot;ec2_idle&quot;), resource)
    assert not any(b[&quot;type&quot;] == &quot;actions&quot; for b in blocks)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test encodes a &lt;strong&gt;product decision&lt;/strong&gt;, not an implementation detail. It fails&lt;br&gt;
if someone later adds buttons to advisory alerts, which is exactly when you want&lt;br&gt;
to be interrupted.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;troubleshooting&quot;&gt;Troubleshooting&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Symptom &lt;/th&gt;&lt;th&gt;Cause&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;401 Invalid Slack signature&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Secret mismatch, or the body was parsed before verification&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Buttons do nothing &lt;/td&gt;&lt;td&gt;Request URL doesn't match your current tunnel; ngrok rotates on restart&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;This content can't be displayed&lt;/code&gt; on mobile &lt;/td&gt;&lt;td&gt;Missing &lt;code&gt;text=&lt;/code&gt; fallback alongside &lt;code&gt;blocks=&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timeouts &lt;/td&gt;&lt;td&gt;Slack wants 200 OK within 3 seconds; do slow work after responding&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Message never edits &lt;/td&gt;&lt;td&gt;&lt;code&gt;response_url&lt;/code&gt; expired (30 min / 5 uses)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Duplicate remediations &lt;/td&gt;&lt;td&gt;CAS passing the status you just read instead of the literal pre-decision one&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Well-signed approvals from strangers &lt;/td&gt;&lt;td&gt;No &lt;code&gt;team.id&lt;/code&gt; / channel check, and no approver list&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;On the 3-second rule: the state transition is a single indexed &lt;code&gt;UPDATE&lt;/code&gt; and&lt;br&gt;
finishes well inside the budget, but the &lt;em&gt;remediation&lt;/em&gt; does not, and that is&lt;br&gt;
the part that matters. Commit the decision, acknowledge, remove the buttons,&lt;br&gt;
then do the slow work in the background. Acknowledging late means Slack retries&lt;br&gt;
into a message whose buttons are still live, and now you are relying on&lt;br&gt;
idempotency you may not have.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Fail closed on a missing signing secret.&lt;/strong&gt; The bypass is convenient locally&lt;br&gt;
and dangerous everywhere else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use a bot token instead of an incoming webhook.&lt;/strong&gt; Webhooks return no message&lt;br&gt;
timestamp, so editing depends on &lt;code&gt;response_url&lt;/code&gt; and its 30-minute window. A bot&lt;br&gt;
token gives you &lt;code&gt;chat.update&lt;/code&gt; on any message, any time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add a confirmation dialog for high-impact actions.&lt;/strong&gt; Block Kit supports a&lt;br&gt;
native &lt;code&gt;confirm&lt;/code&gt; object on buttons: one extra tap between a mis-tap and a&lt;br&gt;
deleted database.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Put the remediation on a durable queue.&lt;/strong&gt; Backgrounding it inside the web&lt;br&gt;
process closed the acknowledgement window, but a restart mid-remediation strands&lt;br&gt;
the finding in &lt;code&gt;APPROVED&lt;/code&gt; with no message update. Fine for one process; wrong&lt;br&gt;
for anything that redeploys often.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check authority on deny, too.&lt;/strong&gt; Denying is the safe direction, so I gated&lt;br&gt;
approve first, but denial is terminal, which means anyone who can see the&lt;br&gt;
message can quietly close a finding nobody ever acts on.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h3&gt;
&lt;p&gt;The Slack integration is about 260 lines. Roughly a third is Block Kit&lt;br&gt;
formatting, a third is signature verification, and a third is turning payloads&lt;br&gt;
into domain objects.&lt;/p&gt;
&lt;p&gt;What makes it safe is not in the Slack code at all. It is that &lt;strong&gt;a button click&lt;br&gt;
is a request, not a command&lt;/strong&gt;: the domain re-checks every guardrail, asks&lt;br&gt;
whether this actor may approve at all, uses a compare-and-swap against the&lt;br&gt;
literal pre-decision status so neither a race nor a replay can execute twice,&lt;br&gt;
and refuses outright when the rule was never remediable.&lt;/p&gt;
&lt;p&gt;And the guarantee that matters most isn't in the code either. It is in IAM: the&lt;br&gt;
session that runs the deletion belongs to the approver, is scoped to one&lt;br&gt;
resource, and expires in fifteen minutes.&lt;/p&gt;
&lt;p&gt;Slack is the driving adapter. The safety lives inside and, for the last mile,&lt;br&gt;
in the account.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;corrections-and-thanks&quot;&gt;Corrections and thanks&lt;/h3&gt;
&lt;p&gt;This article has been revised. The original claimed the compare-and-swap made&lt;br&gt;
double-clicks safe, and it did not: passing &lt;code&gt;finding.status&lt;/code&gt; as the expected&lt;br&gt;
value makes the update a tautology on a replay, and a double-click &lt;em&gt;is&lt;/em&gt; a&lt;br&gt;
replay, not a race. The original also treated signature verification as if it&lt;br&gt;
answered a question about the person clicking, which it never did.&lt;/p&gt;
&lt;p&gt;Both were pointed out by&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/anp2network&quot;&gt;&lt;strong&gt;anp2network&lt;/strong&gt;&lt;/a&gt; in the comments: precisely, with&lt;br&gt;
the failing sequence spelled out and the test that would have caught it. That&lt;br&gt;
feedback prompted the fixes described above: the literal CAS precondition and&lt;br&gt;
its sequential replay test, the domain-side authority check with its own audit&lt;br&gt;
event, the workspace and channel provenance checks in the adapter, moving the&lt;br&gt;
remediation off the request path, and ultimately the assume-role work that moved&lt;br&gt;
enforcement from a name list into IAM.&lt;/p&gt;
&lt;p&gt;The best kind of comment is the one that costs you a weekend. Thank you.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Full source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;github.com/boazleleina/finops-sentinel&lt;/a&gt;; see &lt;code&gt;adapters/notifications/slack.py&lt;/code&gt;, &lt;code&gt;docs/iam-policies.md&lt;/code&gt; and &lt;code&gt;SLACK_SETUP.md&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26692/building-a-slack-approval-workflow-that-deletes-cloud-infrastructure</guid>
<pubDate>Thu, 10 Sep 2026 03:50:49 +0000</pubDate>
</item>
</channel>
</rss>