feat(security): per-tool approver allowlist for DEFER approvals (email-based, fail-closed) (#313)#315
Conversation
…l-based, fail-closed) (closes #313) Native DEFER approvals (#310) recorded who clicked but never authorized them — channel membership was the entire approval ACL. Add an optional email-based allowlist, enforced in the shared runtime, fail-closed. - config: security.defer.tools.<tool>.approvers []string (+ default_approvers). Empty → no allowlist (channel membership is the ACL, as before). - deferengine: Spec.Approvers (normalized lowercase emails); Handle.IsApprover(email) — empty list authorizes anyone, non-empty requires a listed email (case-insensitive), empty email fails closed. Resolution.ApproverEmail threads the resolved email through. - decisions endpoint (the shared chokepoint for Slack + direct curl): accepts approver_email; when the deferral has an allowlist, an unlisted/empty email is refused 403 and the deferral STAYS PENDING; the refusal is audited (task_deferred_decision, authorized:false). This keeps enforcement adapter-agnostic. - channels.ApprovalDecision.ApproverEmail; adapters populate it. - Slack: resolveUserEmail via users.info (cached; needs users:read.email) populates ApproverEmail on a click. Best-effort — on failure the email stays empty and the runtime fails closed against a configured allowlist. - run.go: postDeferDecision forwards approver_email. - docs: defer-decisions.md documents the allowlist, the users:read.email scope, fail-closed behavior, and the softened membership-ACL warning. Tests: IsApprover unit; decisions-endpoint allowlist (listed resolves case-insensitive / unlisted 403 stays pending / empty fails closed / no allowlist allows anyone); resolveDeferSpec normalization + default fallback; Slack users.info resolve+cache+error and handleInteractive email attachment. Out of scope (per issue): group/role policies, four-eyes gate, reject reason modal.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: per-tool approver allowlist for DEFER (email-based, fail-closed)
This is the actual authorization gate that closes the "channel membership was the entire ACL" hole from #311 — exactly the design #313 specified: email-keyed (portable, matches OIDC), enforced in the shared runtime (decisions endpoint, not the adapter), fail-closed. I traced the security-critical paths and they're all correct:
IsApproveris sound: empty allowlist → anyone (preserves pre-#313); non-empty → trim+lowercase input, empty email →false(fail-closed), elseslices.Contains. Case-insensitivity is properly backed —normalizeApproverslowercases/trims/drops-blanks the config list, so both sides of the compare are normalized.- Single chokepoint: the endpoint checks
IsApproverAFTERPeekand BEFOREResolve, and on failure auditsauthorized: false/unauthorized_approver, returns 403, and leaves the deferral pending so a real approver can still act. That stays-pending behavior is exactly right. - The endpoint IS authenticated — verified the auth middleware wraps the whole mux (
handler = s.authMiddleware(handler)), and the REST handlers register on that same mux, so a direct POST needs the runtime bearer token. The allowlist is a second authorization layer on top of auth. - Slack resolution fails closed:
resolveUserEmailerrors on missing scope / no-email / API failure → caller leavesApproverEmailempty → runtime refuses against a configured allowlist. Cached, correct.
Test coverage is thorough (the IsApprover table incl. empty-email fail-closed; endpoint listed-resolves / unlisted-403-stays-pending / empty-email-fail-closed / no-allowlist; resolveDeferSpec normalization + default_approvers fallback; Slack resolve+cache+no-email). CI fully green.
One docs finding (inline) + two optional minors:
2. Minor: no forge validate hygiene on approver entries
Approver emails are free strings; a typo (alice@crop.com) silently never matches, locking that person out — fail-closed so not insecure, just a confusing 2 a.m. lockout. A forge validate warning for an approvers/default_approvers entry lacking @ would mirror the config-hygiene precedent from #303/#297. Optional.
3. Nit (audit consistency): the denial event uses plain Emit
The 403 denial audits via auditLogger.Emit (no seq/trace/correlation), while the approve/reject events go through the correlation-carrying path. Defensible — the decision POST is its own HTTP request outside the deferred task's invocation scope (same rationale as the schedule-fire events that intentionally use plain Emit), and both event types carry TaskID, the real join key for a cross-request deferral. Noting the asymmetry is intentional, not a gap.
Verdict
Merge-ready. The gate is correct where it counts: fail-closed IsApprover, normalized both-sides comparison, single authenticated chokepoint, stays-pending on refusal, Slack binding the email to a verified identity, audit recording both allow and deny. Finding 1 (the caller-asserted-email docs paragraph) is the one I'd fold in. Cleanly resolves #313 and completes the #310/#311 approval story.
| - **Email, not platform ids** — a portable identity that works the same | ||
| across Slack / Teams / a web console and matches Forge's OIDC model. | ||
| - **The Slack adapter resolves the clicker's email** via `users.info` | ||
| (cached) — this needs the bot's **`users:read.email`** scope. |
There was a problem hiding this comment.
Minor (threat-model clarity): state that the email is caller-asserted — its strength differs by path.
The allowlist authorizes an asserted email, and its trustworthiness comes entirely from who asserts it:
- Slack path: the runtime resolves the real email from
users.info, bound to the authenticated Slack identity. This is the strong path — the one that was vulnerable in feat(channels): native Slack interactive approvals for DEFER (R4c) (#310) #311 — so the gate genuinely restricts which channel members can approve. - Direct-POST path: whoever holds the runtime bearer token can POST any
approver_email, including a listed one. Acceptable (the token holder is already trusted), but it means the direct path's real gate is the endpoint's authentication, and the allowlist is advisory there.
This section documents the config, the users:read.email scope, and fail-closed, but never states this distinction — an operator could assume the email is always verified. Suggest a paragraph: "The Slack adapter resolves the approver's email from their authenticated Slack identity; a direct POST /tasks/{id}/decisions trusts the approver_email the (authenticated) caller supplies — so the endpoint's bearer auth is the gate for direct posts." Worth a corollary line: don't run --no-auth with an allowlist and expect it to hold (no auth ⇒ anyone reaching the port asserts any email).
| // email against a non-empty allowlist is refused). The deferral stays | ||
| // pending so a real approver can still act. No-op when no allowlist is | ||
| // configured (empty Approvers → IsApprover always true). | ||
| if !handle.IsApprover(body.ApproverEmail) { |
There was a problem hiding this comment.
Verified this enforcement is correct: IsApprover is checked after Peek (deferral exists) and before Resolve, a refusal returns 403 + audits authorized:false + leaves the deferral pending (no Resolve call) so a real approver can still act, and the endpoint is behind the auth middleware (the whole mux is wrapped). No change requested here — flagging it as the load-bearing line so a future refactor doesn't reorder the check past Resolve or drop the stays-pending property. The caller-asserted-email nuance is the docs comment on defer-decisions.md.
A Slack button click carries no free text, so a native `reject` used to record an empty `note`. Clicking **Reject** now opens a modal (`views.open`) that prompts for a justification; the `view_submission` resolves the deferral as a reject carrying the typed reason as the decision `note` — so it flows into the `task_deferred_decision` audit event and the `defer: rejected by …` tool error. - Reject click → `openRejectModal` (trigger_id → views.open); the deferral stays pending until the modal is submitted. Approve still resolves immediately. - `view_submission` (callback_id `forge_defer_reject_modal`) → parse the reason + round-tripped `private_metadata` (task id + message locator) → resolve via the shared `resolveDecision` path (email resolution + allowlist enforcement + message update), same as Approve. - Fallback: if `views.open` fails (transient), reject without a reason so the decision is never lost (logged). - `parseApprovalInteraction` now also returns the `trigger_id`. Tests: buildRejectModal shape; parseRejectSubmission (reason+meta extract, foreign-callback/bad-metadata ignore); reject-opens-modal (no resolve yet); submission resolves with note + approver email. Docs: defer-decisions.md reject-reason behavior (replaces the "no reason captured" note). No new Slack scope required (views.open uses the existing bot token).
1380fb8 to
2cad19a
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review: reject-reason modal (2cad19a)
The modal flow is correct and well-built. I verified the three things that matter for a new interactive path:
- The submission path is NOT an allowlist bypass (top concern):
handleRejectSubmission→parseRejectSubmission→ the sharedresolveDecision, which resolves the submitter's email viausers.infoand routes through the same decisions-endpointIsApprovergate as Approve. A reject-with-reason is authorized identically — the modal can't sidestep #313's allowlist. The email is resolved from the submitter'sin.User.ID, and Slack only shows the modal to the clicker, so submitter = rejecter; fail-closed either way. private_metadataround-trip is trustworthy: task id + message locator are Slack-held betweenviews.openandview_submission(the user can't tamper), and even if they could, the allowlist gate + the endpoint'sPeek(task-must-exist) still apply. No harmful spoofing vector.- The 3s
view_submissionack budget is met: the envelope is acked up front in the readLoop (conn.WriteJSON(ack)) BEFOREhandleInteractive, so the modal closes on that ack and theusers.info+ decisions-POST +chat.updatecalls run after — they don't race the deadline. The reason input is a requiredinputblock, so Slack enforces non-empty client-side, sidestepping the need for aresponse_action: errorsack.
The reason-less fallback (if views.open fails, reject without a reason rather than lose it) is the right failure direction, and the shared resolveDecision refactor cleanly unifies approve / reject-with-reason / fallback. Tests cover the new paths including the fail-closed parse cases (foreign callback_id, bad metadata → ignore). This also closes the empty-note gap flagged back in #311 — as a proper feature rather than a caveat.
Minor observation (not a finding): handleInteractive runs synchronously in the readLoop, so its up-to-three HTTP calls block the next envelope's ack. Negligible for approval frequency, but under a burst a later interaction's own 3s ack could be delayed — a goroutine dispatch would decouple it if that ever matters.
Still open from the #315 review
This commit is additive scope, not a response to the earlier findings (no replies on those threads):
- Finding 1 (the one I'd fold in): the caller-asserted-email docs paragraph — that Slack binds the email to a verified identity while a direct authenticated POST trusts the caller's asserted
approver_email, so the endpoint's bearer auth is the gate for direct posts. Still not in the docs. - Finding 2 (optional):
forge validatehygiene for typo'd approver emails (entry lacking@).
Verdict
The reject-modal is sound and merge-ready on its own merits — allowlist-enforced, ack-safe, fail-closed. The PR overall is still lightly gated on finding 1's docs paragraph, which this commit didn't touch. (CI: all pass except Integration Tests, still running at review time.)
…ing (#315 review) Addresses the two open findings from the #315 review (finding 3 was noted as intentional, not a gap): Finding 1 (docs) — the approver allowlist authorizes an *asserted* email, and its strength differs by path: the Slack path resolves the real email from an authenticated Slack identity (the strong gate #311 lacked), while a direct POST /tasks/{id}/decisions trusts the caller-supplied approver_email — so for direct posts the real gate is the endpoint's bearer auth and the allowlist is advisory. Documents this, incl. the corollary that `--no-auth` + an allowlist does not hold. Finding 2 (validate hygiene) — approver emails are free strings, so a typo (`alice@crop.com`) or a bare handle silently never matches and fail-closed locks that person out. `deferApproverWarnings` warns at startup for any `approvers`/`default_approvers` entry that isn't email-shaped (`looksLikeEmail`: one `@`, non-empty local, dotted domain), mirroring the existing channel-target / timeout startup warnings. Tests: TestDeferApproverWarnings (per-tool + default, valid skipped, disabled → none) + looksLikeEmail edge cases.
|
Addressed the two open review findings in Finding 1 — caller-asserted-email threat-model note (docs). Added a callout to the Approver allowlist section in
Finding 2 — Finding 3 — plain CI green. |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review of eb2bf44 — both #315 findings resolved; one small prose inaccuracy the fix introduces
Finding 1 (caller-asserted-email docs) — resolved excellently. The callout cleanly splits the two paths: Slack resolves the real email from an authenticated identity (the strong gate #311 lacked), while a direct POST trusts the caller-supplied approver_email so its real gate is bearer auth with the allowlist "advisory on top." It includes the --no-auth-doesn't-hold corollary and ties in the typo-lockout note. Comprehensive — clearer than the suggestion.
Finding 2 (validate hygiene) — correct guard, well-tested, consistent. deferApproverWarnings + looksLikeEmail warns at startup for non-email approver entries, mirroring the deferChannelTargetWarnings/deferTimeoutWarnings precedent. The looksLikeEmail edge-case test is solid.
One minor inaccuracy (prose, not code)
The illustrative example alice@crop.com — used in the commit message, the run.go comment, and the user-facing docs — describes something the guard does NOT catch. alice@crop.com is structurally a valid email (one @, dotted domain), so looksLikeEmail returns true and no warning fires. The test author clearly understood this: the actual warn cases are bob (bare handle), dave@corp (no dot), @handle (empty local) — all structural non-emails — and the test explicitly asserts alice@corp.com doesn't warn. So the code and tests are correct and self-consistent; only the prose example is wrong.
The honest framing is that the guard catches malformed/non-email entries (missing @, bare handle, no-dot domain), not domain typos — and it can't catch crop↔corp without a directory. The alice@crop.com example oversells it as "typo detection." Suggest swapping to a structural example (alice, or alicecorp.com) in the three prose spots so operators don't expect domain-typo detection the guard doesn't provide. See the inline note on the docs line.
Verdict
Both #315 findings resolved, the code is correct and well-tested — merge-ready. The only residual is that three-place prose example: a wording fix, not a code change, but worth correcting since it's in operator-facing docs and slightly misrepresents the guard's coverage. (CI: Lint + doc-link pass; Test running at review time.)
| > reach the port can assert any email. Never rely on the allowlist without | ||
| > the runner's auth middleware enabled. | ||
| > | ||
| > A non-email approver entry (a typo like `alice@crop.com`, or a bare handle) |
There was a problem hiding this comment.
Minor (accuracy): alice@crop.com is used here (and in the run.go comment + commit message) as the example of what the startup warning catches — but it doesn't. alice@crop.com is structurally a valid email (one @, dotted domain), so looksLikeEmail returns true and no warning fires (your own test asserts alice@corp.com — same shape — produces no warning).
The guard catches malformed entries — missing @, a bare handle, a no-dot domain (bob, @handle, dave@corp, the actual warn cases in the test) — not intra-email domain typos, which it can't distinguish without a directory. Swap the example to a structural one (e.g. alice, or alicecorp.com) so the docs don't imply domain-typo detection. Same edit in the run.go comment and, ideally, note it in the commit trailer.
There was a problem hiding this comment.
Fixed in 2783b66. You're right — alice@crop.com is structurally valid, so it's exactly what the guard does not fire on (the test even asserts alice@corp.com produces no warning). Reframed all three spots (docs callout, run.go comment, warning message) around malformed / not email-shaped entries — bare handle (alice), missing @ (alicecorp.com), no-dot domain (alice@corp) — and added an explicit note that a valid-but-wrong domain typo can't be caught structurally. Code and tests were already correct; this was prose-only.
… domain-typo detection (#315 review) Re-review caught a prose inaccuracy: the illustrative `alice@crop.com` oversold the guard. That address is structurally valid (one `@`, dotted domain), so `looksLikeEmail` returns true and no warning fires — the guard catches MALFORMED entries (missing `@`, bare handle, no-dot domain), not a valid-but-wrong domain typo, which it can't distinguish without a directory. Code + tests were already correct and self-consistent (the test asserts `alice@corp.com` produces no warning); only the example was wrong. Swapped the three-place example to structural non-emails (`alice`, `alicecorp.com`, `alice@corp`) and reframed as "malformed / not email-shaped" in the docs callout, the run.go comment, and the warning message, with an explicit note that domain typos are out of scope.
initializ-mk
left a comment
There was a problem hiding this comment.
Closing confirmation — 2783b66 corrects the example in all three places
The prose-accuracy finding is fully resolved:
- Docs callout —
alice@crop.comswapped for three genuinely-structural non-emails (alice,alicecorp.com,alice@corp), reframed as "malformed," plus the scope boundary I'd recommended: "a structural check only — it can't catch a valid-but-wrong address like acorp↔cropdomain typo, which needs a directory to detect." run.gocomment — same correction and the same explicit "structural only" caveat.- The warning string itself — reworded from "is not an email … (check for a typo)" to "is not email-shaped … (missing @, bare handle, or no-dot domain)," so the runtime message an operator sees now names the actual failure modes rather than implying typo-detection.
That last one is the nicest touch — the docs, the code comment, the warning text, and the test cases now all agree on exactly what the guard does and doesn't catch. Pure docs/string change, no logic touched.
Verdict
All #315 findings resolved — merge-ready. Nothing outstanding from my side once Lint/Test go green. This also closes the full #310→#311→#313/#315 approval arc: native Slack delivery → email-based fail-closed authorization gate → reject-with-reason modal → threat-model docs → config-hygiene warning, now accurate down to the warning string. Nice work across the series.
…ing (#315 review) Addresses the two open findings from the #315 review (finding 3 was noted as intentional, not a gap): Finding 1 (docs) — the approver allowlist authorizes an *asserted* email, and its strength differs by path: the Slack path resolves the real email from an authenticated Slack identity (the strong gate #311 lacked), while a direct POST /tasks/{id}/decisions trusts the caller-supplied approver_email — so for direct posts the real gate is the endpoint's bearer auth and the allowlist is advisory. Documents this, incl. the corollary that `--no-auth` + an allowlist does not hold. Finding 2 (validate hygiene) — approver emails are free strings, so a typo (`alice@crop.com`) or a bare handle silently never matches and fail-closed locks that person out. `deferApproverWarnings` warns at startup for any `approvers`/`default_approvers` entry that isn't email-shaped (`looksLikeEmail`: one `@`, non-empty local, dotted domain), mirroring the existing channel-target / timeout startup warnings. Tests: TestDeferApproverWarnings (per-tool + default, valid skipped, disabled → none) + looksLikeEmail edge cases.
Closes #313.
Problem
Native DEFER approvals (#310/#311) record who clicked Approve/Reject but never check whether that user is authorized — so channel membership was the entire approval ACL. Anyone in the target channel (guests, contractors, bots) could resolve a security-sensitive deferred tool call.
Change — email-based, adapter-agnostic, fail-closed
Per the issue's design: key the allowlist on email (portable across Slack/Teams/console, matches the OIDC model), enforce it in the shared runtime (not the adapter), and fail closed.
approvers(+ optionaldefault_approvers):Spec.Approvers(normalized lowercase);Handle.IsApprover(email)(empty list → anyone; non-empty → listed email, case-insensitive; empty email → fail closed).Resolution.ApproverEmailthreads it through.curl. Acceptsapprover_email; when the deferral has an allowlist, an unlisted/empty email is refused 403 and the deferral stays pending for a real approver. The refusal is audited (task_deferred_decision,authorized: false). Enforcement is adapter-agnostic.channels.ApprovalDecision.ApproverEmail— adapters populate it.users.info(cached; needs theusers:read.emailscope) intoApproverEmail. Best-effort: on failure the email stays empty and the runtime fails closed.run.go—postDeferDecisionforwardsapprover_email.Acceptance
approvers(+default_approvers) configurable per DEFER tool.users.info(cached) →ApproverEmail.Tests
Handle.IsApprover(empty allows anyone; listed case/space-insensitive; unlisted refused; empty-email fail-closed).resolveDeferSpecnormalization +default_approversfallback.users.inforesolve + cache + no-email/missing-scope errors;handleInteractiveattaches the resolved email.Full
forge-core/forge-plugins/forge-clisuites pass; lint clean.Docs
defer-decisions.md— new Approver allowlist section (config,users:read.emailscope, fail-closed, adapter-agnostic), and the membership-ACL warning is softened to "unless you setapprovers".Out of scope (per issue)
Group/role-based approver policies; requester≠approver (four-eyes) gate; Slack reject-reason modal.
Update — reject-reason modal (folded in)
Also lands the reject-reason capture that was previously listed as out-of-scope, since it's the same code path.
views.open, via the click'strigger_id) that prompts for a justification. The deferral stays pending until the modal is submitted; Approve still resolves immediately.view_submission(callbackforge_defer_reject_modal) carries the typed reason → resolved as arejectwith the reason as the decisionnote, flowing into thetask_deferred_decisionaudit event and thedefer: rejected by …tool error.private_metadata; resolution reuses the sharedresolveDecisionpath (email resolution → allowlist enforcement → message update).views.openfailure falls back to a reason-less reject (logged) so a decision is never lost.views.openuses the existing bot token).Tests: modal shape,
view_submissionparse (reason + metadata, foreign-callback / bad-metadata ignore), reject-opens-modal-without-resolving, and submission-resolves-with-note+email. Docs updated (the old "no rejection reason captured" note is replaced).Remaining out of scope: group/role approver policies; requester≠approver (four-eyes) gate.