fix(slack-plugin): scope interactive surface credentials per delivering company (BLO-21083) - #1090
Open
allyblockcast[bot] wants to merge 1 commit into
Open
Conversation
…ng company (BLO-21083) Every Slack-originated request path — reaction events, slash commands, view_submission, and block_actions — read a module-level `pluginToken` and guessed a companyId from `ctx.companies.list()[0]` instead of the delivery's own `input.companyId`/`event.companyId`. On a multi-company install the host always hands the worker an empty bootstrap config (plugin-loader.ts), so `pluginToken` could never be populated and the guessed companyId could name a different tenant than the one that actually sent the request. Adds resolveInteractionScope(), mirroring resolveCompanyJobScope() (BLO-20959) and paperclip-plugin-alertmanager's config-scope.ts (BLO-20467): derive the company from the inbound payload, load that company's own config and token, and fail closed with one explanatory warn when the tenant can't be established or its credential can't be resolved — never fall back to the bootstrap snapshot or another tenant's token. Also fixes a second instance of the same root cause discovered while verifying the fix end-to-end: `canProcessMutatingApprovalWebhook`'s signing secret was resolved once from the bootstrap snapshot too, so on any multi-company install it stayed null forever and every mutating approval interaction was unconditionally rejected regardless of what a given company's own config said. Resolved per delivery now via resolveCompanySigningSecret(), same pattern. The in-process thread-message router (freeform revision replies, !approve thread commands) still reads its bootstrap-snapshot token/config — same limitation as before, tracked as a follow-up rather than folded in here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Author
1 similar comment
Author
Author
|
@ally please review this PR. Focus areas:
Referenced: paperclip issue BLO-21083. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Fixes the interactive-surface half of BLO-20959, split out as its own ticket in Paperclip: BLO-21083 (canonical tracker is Paperclip, not GitHub — no existing GitHub issue).
What happened
Every Slack-originated interactive request path read a module-level
pluginTokenthat is only ever populated when thesetup()bootstrap snapshot happens to carry a resolvableslackTokenRef— which the host never provides once more than one company has the plugin configured. Company resolution usedresolveTargetCompanyId(), which fell back toctx.companies.list()[0]— an arbitrary guess, not the delivery's actual tenant.Repro (pre-fix)
paperclip-plugin-slackfor two companies, each with its own Slack app/token./clip approve <id>, in either company.pluginTokenundefined) or is evaluated against the wrong company's data, depending on which companyctx.companies.list()happens to return first.A second instance of the same root cause was found while writing an end-to-end verification test:
canProcessMutatingApprovalWebhook's Slack signing secret was also resolved once from the bootstrap snapshot, so on any multi-company install it stayednullforever and every mutating interaction (reactions, buttons,/clip approve) was unconditionally rejected — independent of whether the specific company's own signing secret was configured correctly. Without this second fix, the token fix alone would still leave the interactive surface fully dead on a multi-company install.What Changed
resolveInteractionScope(ctx, companyId, label)inworker.ts: loads the delivering company's own config viactx.config.get(companyId), resolves itsslackTokenRefviactx.secrets.resolve(ref, { companyId }), and fails closed (returnsnull, logs one warn) for a missing companyId, an unreadable/empty config, a missing token ref, or a secret that fails to resolve. Never falls back to the bootstrap snapshot or another company's credential.resolveCompanySigningSecret(ctx, companyId): same per-company resolution for the Slack signing secret used byverifySlackSignatureandcanProcessMutatingApprovalWebhook.onWebhooknow resolves the signing secret and (for events/slash/interactivity dispatch) the full company scope frominput.companyId— the host-attached, per-delivery tenant — instead of guessing.handleReactionEvent,handleInboundMessageEvent, andhandleSlashCommandnow take an explicitcompanyIdparameter instead of calling the removedresolveTargetCompanyId().isAuthorizedReactornow takes the resolved per-company config instead of reading the module-levelpluginConfigsnapshot — fixes a related bug where the approval allowlist was always empty on multi-company installs, which would have silently blocked every approval even after the token fix.pluginTokenand the single-resolutionslackSigningSecretglobal.!approvethread commands) still reads itssetup()-time bootstrap token/config/signing-secret — same pre-existing limitation, explicitly out of scope here (documented inline) and left as a natural follow-up given it has a different, lower-risk blast radius (it only fires for events already scoped to a real company via the emitted event'scompanyId).Verification
New cases in
src/__tests__/interaction-tenant-scope.test.ts, following the discipline used injob-registration.test.ts: every case was confirmed to fail against the pre-fix worker (git stashthe fix, rerun) before being accepted as covering the production path — 6 of 7 fail pre-fix; the 7th is a fail-closed regression guard that (correctly) also holds pre-fix for an unrelated reason, documented inline as such.companyIdis dropped with one warn/clip acpworks on a multi-company install after cold startAlso mocked
ctx.companies.list()to throw across the new suite, so any surviving guess-the-tenant code path fails loudly instead of silently.Post-deploy (cannot be observed pre-merge, per the ticket's stated verifying signal): after a
kubectl delete pod paperclip-0with no config touch, a ✅ reaction on an approval card in a configured company should commit the decision, and plugin interaction logs should show noundefined-token Slack API failures over 1h.Risks
ctx.config.get(companyId)plus (for mutating paths) onectx.secrets.resolveper delivery, plus a separate signing-secret resolution per webhook delivery. This is human-driven, low-frequency traffic (reactions, button clicks, slash commands), not a hot loop — comparable in shape to the per-tick job resolution already landed in BLO-20959.!approve/!rejecttyped in an approval thread) still uses thesetup()-time bootstrap token/config, same as before this PR. Filing as a fast follow.Model Used
Claude Sonnet 5 (
claude-sonnet-5, 1M context), extended reasoning, with tool use — running as the Paperclip Platform/SRE agent.Checklist