Skip to content

feat(webapp): share rate limit bucket across additional API keys per environment - #4508

Open
carderne wants to merge 23 commits into
feat/multi-keys-surfacefrom
feature/tri-12888-add-redis-cache-for-additional-api-key-environment-lookup
Open

feat(webapp): share rate limit bucket across additional API keys per environment#4508
carderne wants to merge 23 commits into
feat/multi-keys-surfacefrom
feature/tri-12888-add-redis-cache-for-additional-api-key-environment-lookup

Conversation

@carderne

@carderne carderne commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

Rate-limit the API by environment rather than per API key.

Previously the limiter keyed its bucket on the hash of the full Authorization header — one bucket per key. With additional environment API keys (tr_*_sk_*), an environment can mint many keys and each got its own full bucket, so more keys = higher effective rate limit. This collapses all of an environment's keys onto a single shared per-environment bucket, so the ceiling is exactly the configured limit regardless of key mix.

How

  • authorizationRateLimitMiddleware now lets the override return { config?, identifier? }. identifier, when present, is the rate limit bucket key; otherwise it falls back to the hashed Authorization header (unchanged legacy behavior, still used by engineRateLimiter and any unauthenticated fallthrough).
  • apiRateLimiter's override resolves the environment id and uses it as the identifier:
    • Additional keys (isAdditionalApiKey) resolve via a new resolveAdditionalApiKeyRateLimitScope() — a scope-agnostic keyHash → (environmentId, org limiter config) lookup. It is deliberately permissive (restricted keys resolve too) because it's used only for bucketing, never as an auth decision — request auth still goes through the RBAC bearer controller, which enforces scopes. Revoked/expired keys are excluded so they can't hold a bucket warm.
    • Root/legacy keys reuse the environment already resolved by authenticateAuthorizationHeader and key on environment.id too.
  • The identifier is always the stable environment id, never the secret key (which can rotate and would split the bucket).
  • The whole override result is cached per key by the existing SWR cache, so no extra per-request lookup and no separate Redis mapping is added.

Behavior notes

  • Root + additional keys of the same environment now share one bucket (ceiling = configured limit, not a multiple of it). Restricted additional keys are included — they were the biggest gap, since they authenticate via the RBAC controller and previously fell back to per-key buckets.
  • Public JWTs keep their existing fixed-window, per-token bucketing.
  • One-time bucket reset on deploy (bucket keys change); harmless.

Tests

  • New: two tokens resolving to the same identifier share one bucket.
  • New: with no identifier, bucketing stays per-key (legacy behavior preserved).
  • Updated existing override tests to the new { config } return shape.

Base: feat/multi-keys-surface. Closes TRI-12888.

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9ae728

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
trigger.dev Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d8e390b-6b3b-4285-8ea5-da8f3d06070b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@carderne
carderne marked this pull request as ready for review August 5, 2026 09:20
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

carderne added 12 commits August 5, 2026 18:07
A number passed to `expirationTime` is a Unix timestamp in seconds, not
milliseconds as the JSDoc claimed. Following the old docs produced a token
that effectively never expired.

Also fail loudly when an additional API key reaches a local self-signing
fallback. Those keys are not the environment's JWT signing material, so the
token would never verify. Every endpoint that returns a public access token
sets `x-trigger-jwt`, so this is unreachable today.
The API key policy methods are optional on the plugin-facing controller
contract, so `Pick` over it yields optional members that these call sites
would have to guard. Both already receive the LazyController singleton,
which has substituted its fail-closed defaults, so point them at
HostRbacController and keep the call sites guard-free.
Require both the global issuance switch and organization rollout flag before creating additional keys, while leaving existing credentials available for use and revocation. Show nullable creators and identify SDK v4.5.8 as the first compatible public-token version.
Record bounded outcomes for additional key creation, policy preparation, revocation, and public-token minting.
@carderne
carderne force-pushed the feature/tri-12888-add-redis-cache-for-additional-api-key-environment-lookup branch from ba817b9 to 316db63 Compare August 5, 2026 17:08
@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@316db63

trigger.dev

npm i https://pkg.pr.new/trigger.dev@316db63

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@316db63

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@316db63

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@316db63

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@316db63

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@316db63

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@316db63

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@316db63

commit: 316db63

devin-ai-integration[bot]

This comment was marked as resolved.

carderne and others added 3 commits August 5, 2026 17:14
Use environment identifiers when displaying remaining API capacity and ignore additional keys tied to deleted projects.
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@carderne
carderne force-pushed the feature/tri-12888-add-redis-cache-for-additional-api-key-environment-lookup branch from 316db63 to cd2a0c0 Compare August 5, 2026 17:14
devin-ai-integration[bot]

This comment was marked as resolved.

@carderne
carderne force-pushed the feature/tri-12888-add-redis-cache-for-additional-api-key-environment-lookup branch from eb0e8f7 to e9ae728 Compare August 6, 2026 09:42

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +21 to +24
constructor(
prismaClient: PrismaReplicaClient = $replica,
rbacController: ApiKeyPolicyPresenter = rbac
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Newly created or revoked API keys can be missing from the list right after the change

The API keys page now reads its data from a replica database ($replica default at apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts:22) while the create and revoke actions on the same page write to the primary, so the list shown immediately after a change can still be the pre-change state.
Impact: After creating a key a user may not see it in the table, and after revoking one it may still show as Active until the page is reloaded.

Read-your-writes across primary/replica on the API keys page

Previously ApiKeysPresenter defaulted to the writer client (prisma). This PR changes the default to $replica and, in the same PR, adds mutations on the same route: createEnvironmentApiKey / revokeEnvironmentApiKey (apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx:260-289) write via the primary prisma client and then the route revalidates/redirects straight back into the loader, which calls the presenter. Under any replication lag the just-created key can be absent from keyEnvironment.apiKeys, and a just-revoked key can still have revokedAt: null (so it renders as Active with a Revoke button). The revoke flow uses redirectWithSuccessMessage, so the user sees "API key revoked" next to a row that still looks active.

If the read must stay on the replica, the mutation responses should either carry the new state or the loader should read the mutated rows from the primary.

Prompt for agents
ApiKeysPresenter's default Prisma client was changed from the writer (`prisma`) to the read replica (`$replica`). The same PR adds create/revoke API key mutations on the route that uses this presenter (apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx). Those mutations write through the primary and then immediately trigger a loader revalidation/redirect that reads through the presenter, so under replication lag the newly created key may be missing from the table and a just-revoked key may still render as Active. Decide whether this page needs read-your-writes: either keep the presenter on the writer for the api key rows, re-read the mutated rows from the primary, or have the action response carry the authoritative post-mutation state.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant