Skip to content

feat(slack): add encrypted slack_oauth_credentials store - #5134

Merged
RSO merged 3 commits into
mainfrom
slack-token-step-2
Aug 7, 2026
Merged

RSO merged 3 commits into
mainfrom
slack-token-step-2

Conversation

@RSO

@RSO RSO commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Step 2 of the Slack bot token remediation. Slack bot tokens are currently stored unencrypted in platform_integrations.metadata.access_token. This adds an encrypted store alongside it and dual-writes both.

Nothing reads the new store yet. This is deliberately reversible — revert the commit and behaviour is unchanged.

Design decisions worth reviewing

Why a separate table instead of columns on platform_oauth_credentials. GitLabOAuthCredentialRowSchema (packages/worker-utils/src/gitlab-credential.ts:106) is .strict(), and gitlab-oauth-credential-refresher.ts:138 selects the whole table row. Adding any column to platform_oauth_credentials therefore fails every GitLab refresh closed to reconnect_required. That's a hard blocker, not a style preference.

Why RSA keyed envelope with the private key in web. The plan called for AAD binding credential_version into the ciphertext. encryptWithSymmetricKey (packages/encryption/src/encryption.ts:186) never calls setAAD and has no parameter for it, and neither does BYOK's encryptApiKey — only encryptWithPublicKey supports AAD. So "symmetric like the Google/BYOK precedent" and "AAD-bound ciphertext" were not both available off the shelf.

We chose the keyed envelope with web holding both halves. This inverts the intent of the GitLab/Bitbucket split (where the private key lives only in git-token-service), but the Slack webhook is handled in apps/web and must decrypt to call Slack, so an RSA split would mean a service hop per Slack event. mcp-gateway already does exactly this (apps/web/src/lib/mcp-gateway/grant-service.ts:9). The threat being defended is a stolen database dump, not a compromise of web's environment — an attacker with web's env already has SLACK_CLIENT_SECRET.

What the AAD buys. Each ciphertext is bound to its row id, integration, Slack team, owner, secret kind, and credential_version. Consequences, each covered by a test:

  • Copying ciphertext to another row, workspace, or owner makes it undecryptable — a dump cannot be reassembled into a working credential.
  • Re-encrypting under a bumped version invalidates the old ciphertext, so a replayed old row fails authentication instead of resurrecting a superseded token.
  • This is why writeSlackCredential must bump credential_version on every write, under an advisory lock and a version compare-and-set.

Why the dual-write is best-effort. The store is write-only, so an unconfigured or failing keyset must not break Slack installs. There is an explicit test for this, with a comment stating the expectation flips when reads move over. At that point the write becomes required and moves into the same transaction as the platform_integrations write.

Changes

Area File
Table + migration packages/db/src/schema.ts, 0208_melodic_matthew_murdock.sql
Scheme, AAD, lock key packages/worker-utils/src/slack-credential.ts
Keyset config apps/web/src/lib/integrations/platforms/slack/credential-keyset.ts
Encrypt/decrypt .../slack/credential-encryption.ts
Store .../slack/credential-store.ts
Dual-write apps/web/src/lib/integrations/slack-service.ts:196-231
Backfill apps/web/src/scripts/d2026-08-07_backfill-slack-oauth-credentials.ts

The migration is purely additive. Rotation bookkeeping columns are included but inert, so enabling Slack token rotation later needs no further migration.

The keyset validator rejects a config that can encrypt but not decrypt its own writes — otherwise that misconfiguration would be silent until the first read.

Verification

  • 28 Slack credential tests, including 9 real-Postgres integration tests covering the advisory lock, version bump, unique constraint, cascade, and encrypt/decrypt roundtrip
  • 785 passing across src/lib/integrations + src/lib/bot; 341 in worker-utils
  • typecheck + lint clean in web, db, worker-utils; pnpm format run
  • Migration applied against a real Postgres via pnpm drizzle migrate
  • packages/db/src/schema.test.ts: 40/41 pass; the failure is pre-existing (see below)

Before merge

pnpm web:env set SLACK_CREDENTIAL_KEYSET_JSON — shape documented in ENVIRONMENT.md:269. Until it's set the dual-write no-ops into Sentry, which is safe but means no tokens are actually being encrypted.

Pre-existing issues found, not fixed here

  1. packages/db/src/schema.test.ts never runs. packages/db has no test script, and although web's jest testMatch lists packages/db/src/**/*.test.ts, jest's default roots is apps/web, so the entry is dead. packages/db/AGENTS.md cites it as the schema migration-consistency test. Its stale reference to 0204_brainy_baron_strucker.sql (renamed to 0204_drop_cost_insight_tables.sql) is the proof it's unwatched — that's the 1 failing assertion above.

  2. installationProvider does not exist in @chat-adapter/slack@4.27.0. Zero references repo-wide and absent from SlackAdapterConfig in the installed types. The follow-up step's plan to wire an installation provider needs re-verifying against real 4.36.0 types.

  3. The install path cannot observe the real OAuth response. slackAdapter.handleOAuthCallback returns only { botToken, botUserId, teamName }, discarding expires_in, refresh_token, enterprise_id, and the granted scopes. So granted_scopes stays null and the existing getMissingSlackScopes bug (it compares against our hardcoded wish-list, so it can never detect a real gap) is not yet fixed. Rotation support will need either an adapter bump or our own oauth.v2.access exchange.

RSO added 3 commits August 7, 2026 15:27
Slack bot tokens are currently stored unencrypted in
platform_integrations.metadata.access_token. This adds an encrypted store
alongside it and dual-writes to both, so the change is reversible: nothing
reads the new store yet.

- New slack_oauth_credentials table, one row per Slack platform_integrations
  row, cascading on delete. Refresh/expiry columns are included but inert
  until Slack token rotation is enabled, so that step needs no migration.
- Scheme constant, AAD builder, and advisory-lock key in worker-utils so the
  write path and a future refresh loop cannot drift.
- RSA keyed-envelope encryption via SLACK_CREDENTIAL_KEYSET_JSON. Web holds
  the private half because the Slack webhook decrypts in-process; the mcp-gateway
  keyset is the precedent. The AAD binds each ciphertext to its row, integration,
  workspace, owner, secret kind, and credential_version, so ciphertext cannot be
  relocated between rows or replayed after a version bump.
- writeSlackCredential re-encrypts under a bumped credential_version on every
  write, serialized by a transaction-scoped advisory lock and fenced by a
  version compare-and-set.
- Backfill script for existing plaintext tokens, dry-run by default and
  idempotent (already-migrated rows are skipped, not re-encrypted).

A separate table rather than columns on platform_oauth_credentials: that row is
validated by a .strict() Zod schema over a whole-table select in
git-token-service, so adding a column there fails every GitLab refresh closed
to reconnect_required.

The dual-write is deliberately best-effort while the store is write-only, so an
unconfigured keyset cannot break Slack installs. It becomes required, and moves
into the same transaction as the platform_integrations write, when the read
paths move over.

granted_scopes stays null for now: the Chat SDK adapter returns only
{ botToken, botUserId, teamName } and discards the raw oauth.v2.access
response, so the real granted scopes are not observable on the install path.
…l write

The backfill computed its already-migrated set per batch, before the
per-integration write, while writeSlackCredential unconditionally replaces an
existing row. A reinstall landing between the two wrote a fresh credential that
the backfill then overwrote with the token from its pre-run metadata snapshot,
which Slack has revoked by that point.

Add createSlackCredentialIfAbsent, which re-checks existence inside the same
advisory-locked transaction as the write, and use it from the backfill. The
batch query stays as a pre-filter so already-migrated rows do not each open a
transaction. writeSlackCredential keeps its replace semantics for the install
path.
Validation only checked that a private key existed under the active key ID, not
that it matched active.publicKeyPem. A mispaired keyset encrypts successfully
and fails every subsequent decryption, while
isSlackCredentialEncryptionConfigured still reports success, so the backfill
would write ciphertext nothing can read and the mismatch would only surface at
read time in a later step.

Compare the SPKI of the declared active public key with the public half derived
from the active private key so the misconfiguration fails at load time.
@RSO
RSO force-pushed the slack-token-step-2 branch from f79ffc5 to 7b3c775 Compare August 7, 2026 13:32
@RSO
RSO marked this pull request as ready for review August 7, 2026 13:32
Comment thread apps/web/src/lib/user/index.ts Resolved
@kilo-code-bot

kilo-code-bot Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

Well-constructed encrypted credential store with solid AAD binding, advisory-lock serialization, and a reversible dual-write; the only finding is a missing softDeleteUser cascade test for the new slack_oauth_credentials table, which packages/db/AGENTS.md requires for user-linked credential data.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
apps/web/src/lib/user/index.ts 981 Documented slack_oauth_credentials cascade behavior lacks softDeleteUser test coverage in apps/web/src/lib/user/index.test.ts
Files Reviewed (20 files)
  • ENVIRONMENT.md - 0 issues
  • apps/web/src/lib/config.server.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-encryption.test.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-encryption.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-keyset.test.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-keyset.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-store.test.ts - 0 issues
  • apps/web/src/lib/integrations/platforms/slack/credential-store.ts - 0 issues
  • apps/web/src/lib/integrations/slack-service.test.ts - 0 issues
  • apps/web/src/lib/integrations/slack-service.ts - 0 issues
  • apps/web/src/lib/user/index.ts - 1 issue
  • apps/web/src/scripts/d2026-08-07_backfill-slack-oauth-credentials.ts - 0 issues
  • packages/db/src/migrations/0208_melodic_matthew_murdock.sql - 0 issues
  • packages/db/src/migrations/meta/0208_snapshot.json - 0 issues (generated)
  • packages/db/src/migrations/meta/_journal.json - 0 issues (generated)
  • packages/db/src/schema.ts - 0 issues
  • packages/worker-utils/package.json - 0 issues
  • packages/worker-utils/src/index.ts - 0 issues
  • packages/worker-utils/src/slack-credential.test.ts - 0 issues
  • packages/worker-utils/src/slack-credential.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 96.4K · Output: 20.5K · Cached: 1.6M

Review guidance: REVIEW.md from base branch main

@RSO
RSO merged commit 6cec534 into main Aug 7, 2026
43 of 45 checks passed
@RSO
RSO deleted the slack-token-step-2 branch August 7, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants