Skip to content

refactor(security): hash workspace API tokens and support multiple permissioned tokens - #1066

Open
realcodesiman wants to merge 8 commits into
mainfrom
feat/workspace-token-hash
Open

refactor(security): hash workspace API tokens and support multiple permissioned tokens#1066
realcodesiman wants to merge 8 commits into
mainfrom
feat/workspace-token-hash

Conversation

@realcodesiman

@realcodesiman realcodesiman commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Introduces a dedicated WorkspaceApiToken table storing only a SHA-256 hash (tokenHash) of each workspace API token, and drops the plaintext Workspace.token column in the same migration.
  • workspaceTokenAuthMidddleware now looks up solely by tokenHash — there is no plaintext fallback, since the drop ships in this same PR/migration and no plaintext token is ever persisted after it runs.
  • Migration backfills WorkspaceApiToken from every non-null Workspace.token (hashed) and drops the column in one transaction, so no existing token is left orphaned or exposed in a DB dump afterward.
  • Generalizes channel-api-rate-limit.ts into api-rate-limit.ts (adds a scope param) so the workspace-token middleware can reuse the same rate limiter the channel-API path already used, and adds a pre-auth IP rate limit (PREAUTH_REQUEST_LIMIT = 600) closing an unthrottled invalid-token lookup gap.
  • Pulls authorize-workspace-access.ts (the owner-quota/trial gate) forward as shared infrastructure — the middleware needs it to enforce that a trial-expired workspace's owner stays read/delete-only rather than fully locked out (mirrors the existing safe-action.ts behavior for server actions).
  • Fixes token generation to use a CSPRNG: apps/builder/src/features/integration-api/lib/generate-credentials.ts moved from remeda's Math.random()-backed randomString(32) to randomUrlSafeString.

Latest: multiple named, permissioned tokens

The single replace-write token (one per workspace, regenerating invalidates the old one) is now a multi-token store:

  • WorkspaceApiToken gains name, permission (full | read_only enum), and tokenPrefix (first 12 chars, shown in the UI so a token can be identified without ever re-displaying the secret).
  • New tokens are minted as cbx_ws_<random> (generateWorkspaceToken, alongside the existing cbx_api_ channel-token format) via a shared generateBearerToken helper. Legacy tokens predate the prefix column (tokenPrefix is null) and keep authenticating — verification is hash-lookup-only and never parses the format.
  • workspaceTokenAuthMidddleware now rejects any mutating request from a read_only token with FORBIDDEN, checked before the owner-quota gate so it costs no extra DB call.
  • workspaceApiTokenService.findWorkspaceByTokenHash is now cached (withCache, 300s TTL, workspace-scoped tag) — caching is skipped whenever the caller passes its own tx, since a caller-owned transaction may still roll back. deleteToken invalidates the tag on every successful delete; permission is immutable post-creation (no update path exists), so there's no stale-permission read path.
  • Per-workspace quota of MAX_WORKSPACE_API_TOKENS = 10, enforced in createToken.
  • Builder UI: update-workspace-token-action/manage-access-token.tsx (single-token regenerate) replaced by create-workspace-token-action/delete-workspace-token-action/manage-workspace-tokens.tsx (list, create-with-name-and-permission, delete-by-id). The Make integration panel no longer needs hasWorkspaceToken since the new UI has its own empty state.
  • Renamed chatbotResourceworkspaceResource (apps/builder/src/features/workspaces/schema/resource.ts) for clarity; fully propagated, no remaining references to the old name.

Why the plaintext fallback was dropped

An earlier iteration of this PR kept Workspace.token around with a plaintext-fallback read path, deferring the column drop to a follow-up. That's no longer necessary or desirable:

  • Nothing has shipped yet. All commits are unmerged in this PR, so the hashing code, backfill, and column drop all deploy together in one release — the "gap between deploying hashing code and running the backfill" the fallback protected against never exists.
  • Keeping the column defeats the point of the PR. As long as Workspace.token exists with values, every DB dump/backup reveals every workspace API token in plaintext.
  • Repo precedent already accepts one-shot backfill+drop migrations (e.g. 20260610084543_drop_contact_last_activity_at, 20260522174527_remove_platform_setting_fields).
  • A repo-wide sweep found zero remaining reads/writes/type refs to Workspace.token. token was removed from WorkspaceWhere (packages/business/src/workspace/service.ts), and getWorkspacePublicResource's .omit({ token: true }) was deleted since the DTO now derives from the token-less schema.

Deploy / rollback notes (please read before merging)

  • Deploy ordering: the migration must run before new code serves traffic — existing tokens will 401 under the new code until the backfill runs. This is a normal DROP COLUMN deploy: old pods will error on any workspace query once the column is gone, so self-hosters should migrate-then-restart.
  • Rollback: once the migration has run, rolling back to pre-PR code breaks workspace-token auth entirely (the plaintext column is gone). Recovery path is regenerating tokens or restoring from backup — this is an accepted risk given tokens are user-regenerable, low-friction secrets.
  • {{api_key}} system field now resolves to null (packages/variables/src/utils.ts) since there's no plaintext value to interpolate — silent, user-visible behavior change for any existing flow that references it. Worth a changelog line.
  • Migration file was edited in place (not a new migration directory) since it had only been applied to local dev DBs, never to a shared/staging/prod environment. The latest commit adds name/permission/tokenPrefix columns to the same in-place migration for the same reason.

Test plan

  • pnpm lint — clean
  • pnpm --filter builder check-types / @chatbotx.io/business check-types / @chatbotx.io/database check-types — clean
  • pnpm --filter @chatbotx.io/database test — 29 files / 533 tests passing
  • pnpm --filter @chatbotx.io/business test — 140 files / 1495 tests passing (incl. updated workspace-api-token.service.test.ts)
  • pnpm --filter builder vitest run (token auth middleware, create/delete token actions, manage-workspace-tokens component) — 4 files / 20 tests passing
  • invariant-guard agent review — PASS (triple-d middleware naming, i18n key parity across locales, no direct db in app layer, .bind()/bindArgsSchemas usage, chatbotResource rename fully propagated, no stale-permission cache path)
  • Manual: create a read_only token in Settings → Integrations → Workspace Token page; confirm a mutating /v1/* call 403s and a read call succeeds; confirm delete removes it from the list and it stops authenticating immediately.
  • Manual: confirm the 10-token-per-workspace limit surfaces a clear error once reached.
  • Migration not yet applied beyond a local dev DB — needs a second reviewer read of packages/database/drizzle/20260828025915_create_workspace_api_token/migration.sql before db:migrate runs anywhere shared.

Adds a tokenHash column and moves workspace bearer-token lookup to hash-first
with a plaintext fallback for the deploy-to-migration gap. The fallback and
the token column removal are deliberately deferred to a follow-up once the
legacy-plaintext warning log has been silent for a release.

Also generalizes channel-api-rate-limit.ts into api-rate-limit.ts (adds a
scope param) and pulls authorize-workspace-access.ts forward as shared
owner-quota/trial-gate infrastructure, since the token-auth middleware needs
both.
@realcodesiman
realcodesiman force-pushed the feat/workspace-token-hash branch from 690d202 to 455cc07 Compare August 31, 2026 02:35
…ta gates

Extracts the duplicated rate-limit-then-throw logic (channel API vs
workspace-token auth) into `assertApiNotRateLimited`, and merges the
owner-quota gate in safe-action.ts with the shared
`checkWorkspaceOwnerAccess` helper, so both call sites can't drift.
…able

Replaces the Workspace.tokenHash column with a WorkspaceApiToken table so a
future scoped-tokens feature only needs additive columns, not a second
backfill. Token regeneration replace-writes the workspace's row (delete +
insert in one transaction) to keep the current single-token invariant.

The middleware's hash-first lookup with a plaintext fallback for the
deploy-to-migration gap is unchanged in behavior, just re-pointed at the new
table via workspaceApiTokenService.
WorkspaceApiToken is now the sole token store. The prior fallback lookup
on Workspace.token existed only to bridge the gap between deploying the
hashing code and running the backfill migration — since the migration
backfills and drops the column in one transaction within this same
release, that gap never occurs, so the fallback and cache layer around
it are removed.

Also fixes token generation to use a CSPRNG (randomUrlSafeString)
instead of remeda's Math.random()-backed randomString, and adds a
pre-auth IP rate limit to close the unthrottled invalid-token lookup
gap the per-workspace limiter can't see.
…ken flows

Move the three workspace API token queries out of the service and into a
new workspaceApiTokenRepository, matching the repository/service split used
by integrationApiRepository. Also fixes an unbounded memory-counter leak in
the in-memory rate-limit fallback, a lost-draft bug when saving a new token
fails, and a migration backfill that generated non-deterministic ids instead
of reusing the source Workspace id.
…kens

Replaces the single replace-write workspace token with a multi-token
model: named tokens carry a full/read_only permission, a display
prefix, and a per-workspace quota (10). The auth middleware now
rejects mutating requests from read-only tokens, and token lookups are
cached with tag-based invalidation on delete.
@realcodesiman realcodesiman changed the title feat(security): hash workspace API tokens refactor(security): hash workspace API tokens and support multiple permissioned tokens Sep 1, 2026
@github-actions github-actions Bot added the improvement Refactor or performance improvement label Sep 1, 2026
…on manage screens

Drop the SettingRow grid wrapper in favor of the bordered-table layout
used by whatsapp-manage.tsx and api-manage.tsx, and move the empty
state to render inside the table body instead of a separate ternary.
…ycle

Fixes from PR review: read_only tokens could bypass DELETE endpoints,
any member could mint/revoke a full-access token, the token cap had a
TOCTOU race, tokenHash leaked to the client, delete errors were
swallowed, audit failures could surface as user-facing errors, and a
stale cache entry could leak a raw notFound past auth. Also brands
TokenHash at the type level and cleans up dead exports, a stale Make
description, and an inaccurate migration comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request improvement Refactor or performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant