feat(nan-5088): scopes remap — accept legacy and new scopes, expand existing keys#6088
Conversation
…xisting keys Add new fine-grained scopes alongside the legacy ones. Routes accept either via withAnyScope, so existing keys with legacy scopes keep working. A migration adds the new scopes alongside the legacy ones on existing customer keys (idempotent — no-op if both already present). Legacy scopes are preserved on keys for rollback safety; a follow-up PR will remove route mappings and a final migration will strip the legacy strings from the scopes arrays. New scopes: - environment:variables:read (was: environment:config:read for /environment-variables) - environment:integrations:list_functions (was: environment:config:read for /scripts/config) - environment:integrations:create / update / delete (was: environment:integrations:write) - environment:connections:create / update / delete (was: environment:connections:write) - environment:syncs:update (was: environment:syncs:manage for update-connection-frequency) - environment:syncs:variant:create / variant:delete (was: environment:syncs:manage) Frontend scope picker shows new scopes; isScopeSelected expands legacy scopes so existing keys still display correctly in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 8 files
Confidence score: 3/5
- There is a medium-severity, high-confidence test-isolation issue in
packages/database/lib/expandLegacyApiKeyScopes.integration.test.ts: destroying the shareddbsingleton can leak state across tests in the same worker. - Because this bypasses normal
db.destroy()handling for the read-only pool, it increases regression risk through flaky or cascading test failures even if runtime code is unchanged. - Given the concrete impact and severity (6/10), this is some merge risk rather than a blocker; fixing the singleton teardown approach should make this safer.
- Pay close attention to
packages/database/lib/expandLegacyApiKeyScopes.integration.test.ts- avoid destroying shared DB state and preserve proper read-only pool cleanup.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/database/lib/expandLegacyApiKeyScopes.integration.test.ts">
<violation number="1" location="packages/database/lib/expandLegacyApiKeyScopes.integration.test.ts:47">
P2: Do not destroy the shared `db` singleton in this test file; it can break later tests in the same worker and bypasses `db.destroy()` cleanup for the read-only pool.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
| * Legacy scopes are kept on the keys for rollback safety; a follow-up | ||
| * migration will strip them. | ||
| */ | ||
| const LEGACY_SCOPE_EXPANSIONS = [ |
There was a problem hiding this comment.
we will delete the "legacy" ones in another PR
Match the pattern used in backfillConnectionTags.integration.test.ts: let Postgres assign account ids, use UUID for unique names. Removes the manual sequence counter and beforeEach cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ango into pfreixes/nan-5088-scopes-remap
| 'environment:integrations:create', | ||
| 'environment:integrations:update', | ||
| 'environment:integrations:delete', | ||
| 'environment:integrations:write', // legacy: covers create + update + delete |
There was a problem hiding this comment.
this and other legacy ones will be removed in a another PR
Soft-deleted keys can be recovered. If we leave them with only the legacy scopes, they would have stale scopes once the legacy ones are removed in a follow-up PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update the API keys reference and each endpoint's scope attribution to use the new fine-grained scopes: - environment:config:read (/environment-variables) -> environment:variables:read - environment:config:read (/scripts/config) -> environment:integrations:list_functions - environment:integrations:write -> create / update / delete - environment:connections:write -> create / update / delete - environment:syncs:manage -> update / variant:create / variant:delete Add Variables section, drop Config section. Remove legacy scope entries from the listing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…copes-remap # Conflicts: # docs/guides/auth/auth-guide.mdx # docs/reference/backend/http-api/api-keys.mdx # docs/reference/backend/http-api/connection/delete.mdx # docs/reference/backend/http-api/connection/post.mdx # docs/reference/backend/http-api/connection/set-metadata-legacy.mdx # docs/reference/backend/http-api/connection/set-metadata.mdx # docs/reference/backend/http-api/connection/update-metadata-legacy.mdx # docs/reference/backend/http-api/connection/update-metadata.mdx # docs/reference/backend/http-api/connections/delete.mdx # docs/reference/backend/http-api/connections/patch.mdx # docs/reference/backend/http-api/connections/post.mdx # docs/reference/backend/http-api/connections/set-metadata.mdx # docs/reference/backend/http-api/connections/update-metadata.mdx # docs/reference/backend/http-api/integration/create.mdx # docs/reference/backend/http-api/integration/delete.mdx # docs/reference/backend/http-api/integration/update.mdx # docs/reference/backend/http-api/scripts/config.mdx # docs/reference/backend/http-api/sync/create-variant.mdx # docs/reference/backend/http-api/sync/delete-variant.mdx # docs/reference/backend/http-api/sync/environment-variables.mdx # docs/reference/backend/http-api/sync/update-connection-frequency.mdx
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba5ca5dbe9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return selectedScopes.some((s) => s.endsWith(':*') && scope.startsWith(s.slice(0, -1))); | ||
| if (selectedScopes.some((s) => s.endsWith(':*') && scope.startsWith(s.slice(0, -1)))) return true; | ||
| // Legacy scope expansion — a legacy scope grants its mapped new scopes | ||
| return selectedScopes.some((s) => LEGACY_SCOPES.includes(s) && expandLegacyScope(s).includes(scope)); |
There was a problem hiding this comment.
Remove legacy grants when unchecking remapped scopes
For keys that still contain a legacy scope (including migrated keys, since the migration intentionally preserves environment:integrations:write, environment:connections:write, etc.), this makes the new child scopes appear checked, but the toggle paths below only remove the concrete new scope from selectedScopes. For example, unchecking environment:integrations:create on a key that still has environment:integrations:write leaves the legacy grant in the saved payload, so the checkbox stays checked and the API continues to authorize create/update/delete via withAnyScope(..., 'environment:integrations:write'). The edit UI needs to drop or replace the corresponding legacy scope when a remapped permission is removed.
Useful? React with 👍 / 👎.
When a key carries a legacy scope (e.g. environment:integrations:write), its remapped children (create/update/delete) appear checked in the UI via isScopeSelected. Unchecking one of them used to only filter the new scope from selectedScopes, leaving the legacy grant intact — so the checkbox would stay checked and the API kept authorizing. Mirror the existing wildcard handling: detect the legacy alias, replace it with the expanded set minus the unchecked scope. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1ceff399d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| export function isScopeSelected(scope: string, selectedScopes: string[]): boolean { | ||
| if (selectedScopes.includes(scope)) return true; | ||
| return selectedScopes.some((s) => s.endsWith(':*') && scope.startsWith(s.slice(0, -1))); | ||
| if (selectedScopes.some((s) => s.endsWith(':*') && scope.startsWith(s.slice(0, -1)))) return true; | ||
| // Legacy scope expansion — a legacy scope grants its mapped new scopes | ||
| return selectedScopes.some((s) => LEGACY_SCOPES.includes(s) && expandLegacyScope(s).includes(scope)); |
There was a problem hiding this comment.
Clear legacy aliases when toggling a whole group
When an edited key still contains a legacy alias such as environment:integrations:write, this legacy expansion makes the new child scopes appear selected, but toggleGroup only removes the wildcard/raw child scopes and never removes the matching legacy alias. In the migrated-key flow where a user checks a group and then unchecks it, the wildcard is dropped while the legacy alias remains, so create/update/delete permissions stay authorized and the boxes remain checked; group toggles need the same legacy-alias cleanup as individual toggles.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/webapp/src/pages/Environment/Settings/scope-logic.ts">
<violation number="1" location="packages/webapp/src/pages/Environment/Settings/scope-logic.ts:148">
P1: Legacy toggle rebuild keeps mapped scopes in `rest`, which can leave the deselected scope selected and introduce duplicates when keys contain both legacy and expanded scopes.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic
…KeyScopes test The test was inserting customer_keys with iv='' tag='' and secret='secret'. encryptDatabaseIfNeeded() (run by encryption.manager test later in the suite) iterates all customer_keys with empty iv/tag and tries to re-encrypt them. With all rows sharing the same secret, the new hashed value collides on the (hashed, key_type) unique constraint after the first update. Set iv/tag to non-empty placeholders so the encryption pass skips these rows, and use a unique secret per row as defence-in-depth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d80db9fff7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (matchingLegacy) { | ||
| const expanded = expandLegacyScope(matchingLegacy); | ||
| const without = expanded.filter((s) => s !== scope && s !== credentialChild); | ||
| const rest = selectedScopes.filter((s) => s !== matchingLegacy); |
There was a problem hiding this comment.
Drop explicit migrated scopes when clearing legacy grants
For migrated keys, selectedScopes contains both the legacy alias and the expanded scopes added by the migration, e.g. environment:integrations:write plus environment:integrations:create/update/delete. This branch removes only matchingLegacy, so rest still contains the unchecked concrete scope and the returned array keeps it selected/saved; unchecking create (or variables:read from legacy config) therefore has no effect. Fresh evidence: the current code now removes the legacy alias, but this line still preserves the explicit scope added by the new migration.
Useful? React with 👍 / 👎.
After the migration runs, every migrated key carries both the legacy scope and the new fine-grained scopes literally in its scopes array. The UI displays the new scopes as checked just from the literal scopes — no expandLegacyScope needed. Between this PR landing and the follow-up that strips legacy from route mappings, unchecking a new scope on a migrated key leaves the legacy grant in place, so the API keeps authorizing via withAnyScope. That's a transient inconsistency that self-heals once the follow-up ships. Not worth the complexity in the picker. Remove LEGACY_SCOPES, expandLegacyScope, the isScopeSelected legacy branch, the toggleScope legacy branch, and the associated tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user saves a change on a migrated key, drop legacy scopes from the payload. Closes the transient window where the API still authorizes via the legacy scope after the user thought they revoked. Trade-off: a rollback after a save would lose the legacy entries, making it harder to trace which keys were migrated. Accepted — takes a backup before deploy and the number of impacted keys is low. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrated keys still carry their legacy scope alongside the expanded new ones, which inflated the scopes count shown on the key list. Strip legacy entries before counting so the column reflects what's actually granted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/webapp/src/pages/Environment/Settings/ApiKeys.tsx">
<violation number="1" location="packages/webapp/src/pages/Environment/Settings/ApiKeys.tsx:345">
P2: Validate the stripped scope list before assigning `updates.scopes`; otherwise a key with only legacy scopes can save as an empty scope set.
(Based on your team's feedback about explicit scope validation when remapping legacy scopes.) [FEEDBACK_USED]</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic
Migrated keys keep their legacy scope hidden in editedScopes alongside the expanded new ones. The raw length check let a user uncheck every visible scope, save anyway, and end up with an empty scope array after stripLegacyScopes ran on the payload. Derive hasNoScopes from the stripped count so the inline warning, the disabled Save button, and the toast all fire as soon as the visible selection drops to zero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 34d995d6cd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| 'environment:integrations:create', | ||
| 'environment:integrations:update', | ||
| 'environment:integrations:delete', | ||
| 'environment:integrations:write', // legacy: covers create + update + delete |
There was a problem hiding this comment.
Normalize legacy scopes on API key writes
Because the legacy values remain in apiKeyScopes, the public create/patch API still accepts them, and createApiKey/patchApiKey persist the parsed scopes array unchanged. Any client that creates or updates a key with environment:integrations:write (or the other legacy scopes) after this one-time migration has already run will store only the legacy grant; that key works during this dual-read release, but it will lose access when the planned follow-up removes legacy acceptance because it was never expanded to the new scopes. Please expand or reject legacy scopes in the API write path as well as in the migration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Gotta make sure the client (webapp) lands before the migration I guess? @pfreixes
TBonnin
left a comment
There was a problem hiding this comment.
one comment. lgtm otherwise
| @@ -338,7 +344,9 @@ const KeyConfig: React.FC<KeyConfigProps> = ({ apiKey, env, onBack, canReadSecre | |||
| try { | |||
| const updates: { keyId: number; scopes?: string[]; display_name?: string } = { keyId: apiKey.id }; | |||
| if (scopesChanged) { | |||
There was a problem hiding this comment.
what if changing only the name? should it also trigger stripLegacyScopes?
| The default API key created on signup has full access and is the simplest option. | ||
|
|
||
| If the user creates a scoped API key instead, this guide needs `environment:integrations:write` (step 2) and `environment:connect_sessions:write` (steps 5 and 8). See [API keys](/reference/backend/http-api/api-keys). | ||
| If the user creates a scoped API key instead, this guide needs `environment:integrations:create` (step 2) and `environment:connect_sessions:write` (steps 5 and 8). See [API keys](/reference/backend/http-api/api-keys). |
There was a problem hiding this comment.
Should environment:connect_sessions:write become environment:connect_sessions:create in this migration as well?
| 'environment:integrations:create', | ||
| 'environment:integrations:update', | ||
| 'environment:integrations:delete', | ||
| 'environment:integrations:write', // legacy: covers create + update + delete |
There was a problem hiding this comment.
Gotta make sure the client (webapp) lands before the migration I guess? @pfreixes
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5179fa8b12
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Connections | ||
| publicAPI.use('/connections', jsonContentTypeMiddleware); | ||
| publicAPI.route('/connections').post(apiAuth, withScope('environment:connections:write'), postPublicConnection); | ||
| publicAPI.route('/connections').post(apiAuth, withAnyScope('environment:connections:create', 'environment:connections:write'), postPublicConnection); |
There was a problem hiding this comment.
Require update scope when POST upserts connections
When callers pass an existing connection_id, postPublicConnection goes through the import/upsert paths and explicitly handles updatedConnection.operation === 'override', so this endpoint can replace an existing connection's credentials/metadata. With only environment:connections:create required here, a create-only API key can update any connection ID it knows; the route needs to require environment:connections:update for the override case or keep this endpoint under a scope that covers both create and update.
Useful? React with 👍 / 👎.
| .delete(apiAuth, withAnyScope('environment:integrations:delete', 'environment:integrations:write'), deletePublicIntegration); | ||
| publicAPI | ||
| .route('/integrations/:uniqueKey/functions/:name/code') | ||
| .get(apiAuth, withAnyScope('environment:integrations:read', 'environment:integrations:read_credentials'), getFunctionCode); |
There was a problem hiding this comment.
why does the endpoint requires read_credentials permission?
There was a problem hiding this comment.
There was a problem hiding this comment.
looks like some formatting that doesn't belong into this PR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56a88cdf61
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| .route('/connection') | ||
| .post( | ||
| apiAuth, | ||
| withAnyScope('environment:connections:create', 'environment:connections:write'), |
There was a problem hiding this comment.
Require update scope when legacy POST overrides connections
For callers that pass an existing connection_id to the deprecated POST /connection endpoint, createConnection still goes through the same import/upsert branches and treats updatedConnection.operation === 'override' as a successful credential/metadata replacement. With this route now allowing only environment:connections:create, a create-only key can update any existing connection ID it knows via the legacy endpoint; this endpoint needs to require environment:connections:update for the override case or remain under a scope that covers both create and update.
Useful? React with 👍 / 👎.
The original 20260428110800 timestamp now sits behind migrations that have already been applied on master (e.g. 20260429212200_fix_disabled_syncs). Rename the file so it sorts after the latest applied migration, otherwise knex's "all applied migrations come before any pending ones" check trips when this PR deploys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…copes-remap # Conflicts: # managed-manifest.json
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c5febcd3df
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // until the user saves. Count only the scopes that would actually be persisted, so unchecking | ||
| // every visible scope flips the "Select at least one scope" warning even if a legacy entry | ||
| // is still lurking in the array. | ||
| const hasNoScopes = stripLegacyScopes(editedScopes).length === 0; |
There was a problem hiding this comment.
Allow renaming legacy-only scoped keys
When an API client creates or updates a key with one of the still-accepted legacy scopes after this release, the edit page can load with only hidden legacy scopes. In that case stripLegacyScopes(editedScopes).length is 0, so handleSave returns before sending even a display-name-only update; users cannot rename the key unless they also pick new visible scopes. The no-scope guard should only block scope updates that would persist an empty scope set, not name-only saves.
Useful? React with 👍 / 👎.
- Remove duplicate 20260428110800_expand_legacy_api_key_scopes.cjs. PR #6088 renamed this file to 20260518120000_… before merge; our branch still carried the old filename alongside the new one. Both pointed at the same blob, so deleting the old name is a no-op for history. - Restore master's .husky/prepare-commit-msg. A prior merge dropped the "skip during rebase/cherry-pick" guard that master added later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Q#6099) ## Summary Step 3 of the rollout described in NangoHQ#6088. Removes every `withAnyScope('new', 'legacy')` mapping in `routes.public.ts` and replaces it with `withScope('new')`. After this, only the new fine-grained scopes authorize requests. | Legacy scope (no longer accepted) | New scopes that replace it | |---|---| | `environment:integrations:write` | `environment:integrations:create` / `update` / `delete` | | `environment:connections:write` | `environment:connections:create` / `update` / `delete` | | `environment:syncs:manage` | `environment:syncs:update` / `variant:create` / `variant:delete` | | `environment:config:read` (`/environment-variables`) | `environment:variables:read` | | `environment:config:read` (`/scripts/config`) | `environment:integrations:list_functions` | ## What's NOT changed - The legacy scope strings remain valid in the `ApiKeyScope` union (`packages/types`, `packages/utils`) so existing rows in `customer_keys` still validate. - The legacy scope strings are still present in `customer_keys.scopes` arrays for any key that was created or migrated before this PR is deployed. - The legacy scope strings will be removed from the database by a follow-up cleanup migration (step 4 of the rollout). ## Stack / merge order This PR is stacked on top of NangoHQ#6088 (`pfreixes/nan-5088-scopes-remap`). It must be merged **after** NangoHQ#6088 is deployed and the expansion migration has run, otherwise keys that rely on a legacy scope today would lose access. See the rollout plan in NangoHQ#6088 for the full sequence. Once NangoHQ#6088 is merged into master, the base of this PR can be retargeted to master via the GitHub UI. ## Rollback If 403s spike after deploy, roll back to the previous server image. Legacy scopes are still in the DB on migrated keys, so the previous version's `withAnyScope` matches them again — no DB rollback needed. ## Test plan - [x] `routes.public.ts` mappings replaced (20 occurrences across integrations / connections / syncs / config endpoints) - [x] `scopeEnforcement.integration.test.ts` updated to assert allow/deny against the new scopes - [ ] CI integration tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
…HQ#6179) ## Summary Step 4 (final) of the legacy → fine-grained scope rollout described in NangoHQ#6088. With NangoHQ#6099 in prod, no route accepts the legacy scopes any more, so the strings sitting in \`customer_keys\` are inert. This PR removes them from the DB and rips out every dead code path that existed to deal with them. ## DB - \`20260519120000_strip_legacy_api_key_scopes.cjs\` — single UPDATE that removes the 5 legacy strings from every \`customer_keys.scopes\` array. - Idempotent: \`&&\` overlap filter in the WHERE only touches rows that still carry at least one legacy. - Per the EXPLAIN runs on prod for the expansion migration, this is ~10ms work on ~72K rows. - \`stripLegacyApiKeyScopes\` helper + 10 integration tests covering each legacy scope, idempotency, soft-deleted keys, empty arrays, and unrelated-scope preservation. ## Code - **Types / utils**: removed the 5 legacy entries from the \`ApiKeyScope\` union in \`packages/types\` and \`packages/utils\`. - **Webapp scope picker**: deleted \`LEGACY_SCOPES\` and \`stripLegacyScopes\` from \`scope-logic.ts\`. Removed the 3 call sites in \`ApiKeys.tsx\` (handleSave, countSelectedScopes, hasNoScopes). \`hasNoScopes\` reverts to the plain \`editedScopes.length === 0\` check. - **Tests**: dropped the 3 \`stripLegacyScopes\` unit tests; updated 2 \`scope.middleware.unit.test.ts\` assertions that were probing the wildcard match using legacy strings (the union no longer accepts them). ## Kept - \`expandLegacyApiKeyScopes\` helper + its migration + integration test. They reference legacy strings as plain literals in \`.cjs\` (no type), so they keep working after the union shrinks, and they document what happened on the way through. ## Stack / merge order This PR is stacked on top of NangoHQ#6099 (\`pfreixes/nan-5088-remove-legacy-scopes\`). It must merge after NangoHQ#6099 is deployed and has been live long enough to be confident no rollback is needed (~3–5 days). If a rollback to before NangoHQ#6099 is ever needed after this PR lands, you'd need to restore \`customer_keys\` from the backup taken at step 1 of the rollout — the legacy scope strings will no longer exist on any key. Once NangoHQ#6099 lands on master, retarget the base of this PR to master via the GitHub UI. ## Test plan - [x] 10 helper integration tests pass (\`stripLegacyApiKeyScopes.integration.test.ts\`) - [x] 35 webapp scope-logic unit tests pass (down from 38 after dropping the 3 strip-legacy tests) - [x] 10 server scope.middleware unit tests pass (updated 2 assertions) - [x] \`npm run ts-build\` clean across the workspace - [ ] CI scope enforcement integration tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Summary
Add new fine-grained API key scopes alongside the legacy ones. Routes accept either via
withAnyScope, so existing keys with legacy scopes keep working. A migration adds the new scopes alongside the legacy ones on existing customer keys (idempotent).Legacy scopes are preserved on keys for rollback safety. Follow-up PRs will:
scopesarrays via a final migrationScope mapping
environment:integrations:writeenvironment:integrations:create/update/deleteenvironment:connections:writeenvironment:connections:create/update/deleteenvironment:syncs:manageenvironment:syncs:update/variant:create/variant:deleteenvironment:config:read(for/environment-variables)environment:variables:readenvironment:config:read(for/scripts/config)environment:integrations:list_functionsWhat's included
@nangohq/typesand@nangohq/utilsroutes.public.tsaccept legacy + new viawithAnyScopestripLegacyScopesruns in the dashboard save handler so any explicit save on a migrated key drops the legacy entries from the payload (closes the transient window where the API would still authorize via the legacy scope after the user thought they revoked it)20260428110800_expand_legacy_api_key_scopes.cjs— thin wrapper aroundexpandLegacyApiKeyScopes(knex)helperRollout plan
Three-step rollout designed so each step is independently rollback-safe:
customer_keystable before deploying this PR. Used as a safety net for the unlikely case that a save during step 1 strips legacy scopes from a key that needs to be rolled back.scopesarrays in the database (cleanup migration).Rollback
Watch for an increase in
403 Forbiddenresponses on the affected endpoints after each step. If one shows up:Time the deploys with enough spacing (at least one business day) to give the 403 metric room to surface a regression before moving to the next step.
Test plan
packages/database/lib/expandLegacyApiKeyScopes.integration.test.ts)🤖 Generated with Claude Code