refactor(security): hash workspace API tokens and support multiple permissioned tokens - #1066
Open
realcodesiman wants to merge 8 commits into
Open
refactor(security): hash workspace API tokens and support multiple permissioned tokens#1066realcodesiman wants to merge 8 commits into
realcodesiman wants to merge 8 commits into
Conversation
This was referenced Aug 30, 2026
feat(api-contract): add @chatbotx.io/api-contract package and implement public routers from it
#1069
Open
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
force-pushed
the
feat/workspace-token-hash
branch
from
August 31, 2026 02:35
690d202 to
455cc07
Compare
…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.
…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.
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.
Summary
WorkspaceApiTokentable storing only a SHA-256 hash (tokenHash) of each workspace API token, and drops the plaintextWorkspace.tokencolumn in the same migration.workspaceTokenAuthMidddlewarenow looks up solely bytokenHash— there is no plaintext fallback, since the drop ships in this same PR/migration and no plaintext token is ever persisted after it runs.WorkspaceApiTokenfrom every non-nullWorkspace.token(hashed) and drops the column in one transaction, so no existing token is left orphaned or exposed in a DB dump afterward.channel-api-rate-limit.tsintoapi-rate-limit.ts(adds ascopeparam) 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.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 existingsafe-action.tsbehavior for server actions).apps/builder/src/features/integration-api/lib/generate-credentials.tsmoved from remeda'sMath.random()-backedrandomString(32)torandomUrlSafeString.Latest: multiple named, permissioned tokens
The single replace-write token (one per workspace, regenerating invalidates the old one) is now a multi-token store:
WorkspaceApiTokengainsname,permission(full|read_onlyenum), andtokenPrefix(first 12 chars, shown in the UI so a token can be identified without ever re-displaying the secret).cbx_ws_<random>(generateWorkspaceToken, alongside the existingcbx_api_channel-token format) via a sharedgenerateBearerTokenhelper. Legacy tokens predate the prefix column (tokenPrefixis null) and keep authenticating — verification is hash-lookup-only and never parses the format.workspaceTokenAuthMidddlewarenow rejects any mutating request from aread_onlytoken withFORBIDDEN, checked before the owner-quota gate so it costs no extra DB call.workspaceApiTokenService.findWorkspaceByTokenHashis now cached (withCache, 300s TTL, workspace-scoped tag) — caching is skipped whenever the caller passes its owntx, since a caller-owned transaction may still roll back.deleteTokeninvalidates the tag on every successful delete; permission is immutable post-creation (no update path exists), so there's no stale-permission read path.MAX_WORKSPACE_API_TOKENS = 10, enforced increateToken.update-workspace-token-action/manage-access-token.tsx(single-token regenerate) replaced bycreate-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 needshasWorkspaceTokensince the new UI has its own empty state.chatbotResource→workspaceResource(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.tokenaround with a plaintext-fallback read path, deferring the column drop to a follow-up. That's no longer necessary or desirable:Workspace.tokenexists with values, every DB dump/backup reveals every workspace API token in plaintext.20260610084543_drop_contact_last_activity_at,20260522174527_remove_platform_setting_fields).Workspace.token.tokenwas removed fromWorkspaceWhere(packages/business/src/workspace/service.ts), andgetWorkspacePublicResource's.omit({ token: true })was deleted since the DTO now derives from the token-less schema.Deploy / rollback notes (please read before merging)
DROP COLUMNdeploy: old pods will error on any workspace query once the column is gone, so self-hosters should migrate-then-restart.{{api_key}}system field now resolves tonull(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.name/permission/tokenPrefixcolumns to the same in-place migration for the same reason.Test plan
pnpm lint— cleanpnpm --filter builder check-types/@chatbotx.io/business check-types/@chatbotx.io/database check-types— cleanpnpm --filter @chatbotx.io/database test— 29 files / 533 tests passingpnpm --filter @chatbotx.io/business test— 140 files / 1495 tests passing (incl. updatedworkspace-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 passinginvariant-guardagent review — PASS (triple-d middleware naming, i18n key parity across locales, no directdbin app layer,.bind()/bindArgsSchemasusage,chatbotResourcerename fully propagated, no stale-permission cache path)read_onlytoken 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.packages/database/drizzle/20260828025915_create_workspace_api_token/migration.sqlbeforedb:migrateruns anywhere shared.