Skip to content

fix(webhooks): enforce API key expiration policy#1876

Merged
riderx merged 7 commits intomainfrom
fix/webhook-apikey-expiration-policy
Apr 8, 2026
Merged

fix(webhooks): enforce API key expiration policy#1876
riderx merged 7 commits intomainfrom
fix/webhook-apikey-expiration-policy

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Mar 30, 2026

Summary (AI generated)

  • enforce require_apikey_expiration in the shared webhook API-key permission path
  • return the existing org_requires_expiring_key 401 response for webhook management endpoints
  • add a regression test that recreates a legacy non-expiring org-scoped key and verifies webhook list/create/delete are blocked
  • increase webhook test hook timeouts so local verification reflects endpoint behavior instead of Vitest teardown limits

Motivation (AI generated)

Webhook management was still using a weaker API-key permission check than the organization endpoints. That let legacy non-expiring keys continue to manage webhooks after an org explicitly enabled the expiring-key policy.

Business Impact (AI generated)

This closes a policy-enforcement gap on an admin surface and makes Capgo's org security setting behave consistently. It reduces the risk that customers keep privileged automation running on keys they explicitly intended to phase out.

Test Plan (AI generated)

  • bun lint
  • bun run supabase:with-env -- bunx vitest run tests/webhooks-apikey-policy.test.ts tests/webhooks.test.ts tests/apikeys-expiration.test.ts
  • bun run test:backend
  • bun run test:all

Generated with AI

Summary by CodeRabbit

  • New Features

    • Webhook endpoints now enforce organization API-key expiration policy: legacy non-expiring org keys are rejected (401 requiring expiring keys).
    • App-scoped API keys are blocked from managing organization webhooks; only appropriate org-scoped/admin keys are allowed.
  • Tests

    • Added integration tests validating webhook API-key expiration policy across webhook operations (creation, listing, deletion, test, retry).
    • Increased test teardown timeout for improved stability.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f569689a-72cd-4839-af08-1d9ea502bf29

📥 Commits

Reviewing files that changed from the base of the PR and between a86d62e and e145c5d.

📒 Files selected for processing (1)
  • tests/webhooks-apikey-policy.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/webhooks-apikey-policy.test.ts

📝 Walkthrough

Walkthrough

Webhook permission checks now perform policy-aware org access validation via apikeyHasOrgRightWithPolicy(...) (using supabaseApikey(...)) and surface org_requires_expiring_key when orgs require expiring keys; app-scoped API keys are explicitly rejected for org-level webhook operations. New integration tests validate the policy enforcement.

Changes

Cohort / File(s) Summary
Webhook Permission Validation
supabase/functions/_backend/public/webhooks/index.ts
Replaced prior org-scope checks with policy-aware validation using apikeyHasOrgRightWithPolicy(...) (passed supabaseApikey(...)); returns org_requires_expiring_key when org policy mandates expiring keys, otherwise invalid_org_id. Keeps admin checks and explicitly rejects app-scoped API keys for org webhook management.
Webhook Policy Tests
tests/webhooks-apikey-policy.test.ts
Added integration tests that seed org, membership, legacy org-scoped API key and webhook, flip org to require expiring keys, create an expiring subkey, and assert webhook endpoints return 401 + org_requires_expiring_key for non-expiring keys while compliant subkeys succeed.
Test Cleanup Timeout
tests/webhooks.test.ts
Increased afterAll hook timeout to 60000ms to accommodate longer teardown.
Whitespace alignment (tests)
tests/organization-api.test.ts
Adjusted indentation/whitespace around a concurrent test block; no behavioral changes.

Sequence Diagram

sequenceDiagram
    participant Client
    participant WebhookAPI as Webhook Endpoint
    participant PermCheck as checkWebhookPermission
    participant PolicyEngine as apikeyHasOrgRightWithPolicy
    participant OrgService as Organization Policy

    Client->>WebhookAPI: HTTP request with API key / auth
    WebhookAPI->>PermCheck: validate permission (apikey path or jwt)
    alt API key path
        PermCheck->>PermCheck: reject if apikey.limited_to_apps present (no_permission)
        PermCheck->>PolicyEngine: apikeyHasOrgRightWithPolicy(orgId, supabaseApikey(...))
        PolicyEngine->>OrgService: retrieve org policy (expiring-key requirement)
        alt org requires expiring key and key non-expiring
            OrgService-->>PolicyEngine: policy enforces expiring keys
            PolicyEngine-->>PermCheck: policy violation
            PermCheck-->>WebhookAPI: 401 org_requires_expiring_key
            WebhookAPI-->>Client: 401 Error
        else org access denied
            OrgService-->>PolicyEngine: access denied
            PolicyEngine-->>PermCheck: invalid org access
            PermCheck-->>WebhookAPI: 401 invalid_org_id
            WebhookAPI-->>Client: 401 Error
        else authorized
            PolicyEngine-->>PermCheck: permission OK
            PermCheck-->>WebhookAPI: allow operation
            WebhookAPI-->>Client: proceed
        end
    else JWT/admin path
        PermCheck->>PermCheck: perform existing admin/org checks
        PermCheck-->>WebhookAPI: allow or deny based on admin check
        WebhookAPI-->>Client: proceed or 401
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop through headers, keys, and bits,
I sniff for policies where expiring time sits,
"No app-scoped key!" I thump and demand,
I guard the webhook burrow with careful hand,
401 — I jiggle, then munch on my wits.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: enforcing API key expiration policy in webhooks.
Description check ✅ Passed The description includes a comprehensive summary, motivation, business impact, and detailed test plan covering all relevant testing steps performed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/webhook-apikey-expiration-policy

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

@riderx riderx added the codex label Mar 30, 2026
@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq bot commented Mar 30, 2026

Merging this PR will not alter performance

✅ 28 untouched benchmarks


Comparing fix/webhook-apikey-expiration-policy (e145c5d) with main (999f95f)

Open in CodSpeed

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2baddc5bf6

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
supabase/functions/_backend/public/webhooks/index.ts (1)

59-73: ⚠️ Potential issue | 🟠 Major

Policy check missing in checkWebhookPermissionV2.

The checkWebhookPermissionV2 function (used by /test and /deliveries/retry endpoints via middlewareV2) does not call apikeyHasOrgRightWithPolicy. This means when an API key authenticates through the V2 path, the org_requires_expiring_key policy is not enforced.

The assertOrgWebhookScope helper only checks apikeyHasOrgRight (basic org scope) but not the policy compliance that apikeyHasOrgRightWithPolicy provides.

Consider adding the policy check to checkWebhookPermissionV2 for consistency:

Proposed fix
 export async function checkWebhookPermissionV2(
   c: Context<MiddlewareKeyVariables, any, any>,
   orgId: string,
   auth: AuthInfo,
 ): Promise<void> {
   // Check org admin access
   if (!(await hasOrgRight(c, orgId, auth.userId, 'admin'))) {
     throw simpleError('no_permission', 'You need admin access to manage webhooks', { org_id: orgId })
   }
 
   // If using API key, also check the key has org access
   if (auth.authType === 'apikey' && auth.apikey) {
+    const orgCheck = await apikeyHasOrgRightWithPolicy(c, auth.apikey, orgId, supabaseApikey(c, c.get('capgkey') as string))
+    if (!orgCheck.valid) {
+      if (orgCheck.error === 'org_requires_expiring_key') {
+        throw quickError(401, 'org_requires_expiring_key', 'This organization requires API keys with an expiration date. Please use a different key or update this key with an expiration date.')
+      }
+      throw simpleError('invalid_org_id', 'You can\'t access this organization', { org_id: orgId })
+    }
     assertOrgWebhookScope(orgId, auth.apikey)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/public/webhooks/index.ts` around lines 59 - 73,
checkWebhookPermissionV2 currently skips policy enforcement for API keys; when
auth.authType === 'apikey' and auth.apikey is present, call
apikeyHasOrgRightWithPolicy(orgId, auth.apikey, 'org_requires_expiring_key') (or
the appropriate policy-checking wrapper) instead of (or in addition to)
assertOrgWebhookScope so the org policy is enforced for V2 paths; update
checkWebhookPermissionV2 to invoke apikeyHasOrgRightWithPolicy (or a helper that
wraps it) and remove relying solely on apikeyHasOrgRight/assertOrgWebhookScope.
🧹 Nitpick comments (2)
tests/webhooks-apikey-policy.test.ts (2)

80-94: Consider adding a timeout to afterAll for consistency.

The beforeAll hook has a 60-second timeout (line 78), but afterAll does not. Given that cleanup also involves multiple async operations, adding a matching timeout would prevent Vitest teardown issues.

 afterAll(async () => {
   // ... cleanup code
-})
+}, 60000)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/webhooks-apikey-policy.test.ts` around lines 80 - 94, The afterAll
cleanup hook lacks the same 60s timeout used in beforeAll, which can cause
teardown flakiness; update the afterAll declaration (the one calling
getSupabaseClient(), deleting from 'webhooks', 'apikeys', 'org_users', 'orgs',
and 'stripe_info' using createdWebhookId, legacyApiKeyId, policyOrgId,
policyCustomerId) to include a 60_000ms timeout (e.g., pass 60000 as the second
argument) so the async cleanup has the same allowed time as beforeAll.

96-155: Consider adding test coverage for /webhooks/test and /webhooks/deliveries/retry endpoints.

These endpoints use middlewareV2 and checkWebhookPermissionV2, which is a different auth path than the endpoints covered here. Adding regression tests for these would ensure policy enforcement is consistent across all webhook endpoints.

it('rejects webhook test for legacy non-expiring org key', async () => {
  if (!legacyApiKeyValue || !createdWebhookId)
    throw new Error('Test prerequisites were not created')

  const response = await fetch(`${BASE_URL}/webhooks/test`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': legacyApiKeyValue,
    },
    body: JSON.stringify({
      orgId: policyOrgId,
      webhookId: createdWebhookId,
    }),
  })

  expect(response.status).toBe(401)
  const data = await response.json() as { error: string }
  expect(data.error).toBe('org_requires_expiring_key')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/webhooks-apikey-policy.test.ts` around lines 96 - 155, Add regression
tests to assert the org API key expiration policy is enforced for the webhook
endpoints that use the alternate auth path: /webhooks/test and
/webhooks/deliveries/retry. Create two tests similar to the existing ones that
use legacyApiKeyValue (and createdWebhookId for the test/retry cases) to call
POST /webhooks/test and POST /webhooks/deliveries/retry (with orgId and
webhookId in the JSON body and using the x-api-key header if that’s what
middlewareV2 expects), then assert response.status === 401 and
response.json().error === 'org_requires_expiring_key'; ensure you reference
middlewareV2 and checkWebhookPermissionV2 behavior by using the same auth header
and fixtures (policyOrgId, legacyApiKeyValue, createdWebhookId, BASE_URL) as the
other tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@supabase/functions/_backend/public/webhooks/index.ts`:
- Around line 59-73: checkWebhookPermissionV2 currently skips policy enforcement
for API keys; when auth.authType === 'apikey' and auth.apikey is present, call
apikeyHasOrgRightWithPolicy(orgId, auth.apikey, 'org_requires_expiring_key') (or
the appropriate policy-checking wrapper) instead of (or in addition to)
assertOrgWebhookScope so the org policy is enforced for V2 paths; update
checkWebhookPermissionV2 to invoke apikeyHasOrgRightWithPolicy (or a helper that
wraps it) and remove relying solely on apikeyHasOrgRight/assertOrgWebhookScope.

---

Nitpick comments:
In `@tests/webhooks-apikey-policy.test.ts`:
- Around line 80-94: The afterAll cleanup hook lacks the same 60s timeout used
in beforeAll, which can cause teardown flakiness; update the afterAll
declaration (the one calling getSupabaseClient(), deleting from 'webhooks',
'apikeys', 'org_users', 'orgs', and 'stripe_info' using createdWebhookId,
legacyApiKeyId, policyOrgId, policyCustomerId) to include a 60_000ms timeout
(e.g., pass 60000 as the second argument) so the async cleanup has the same
allowed time as beforeAll.
- Around line 96-155: Add regression tests to assert the org API key expiration
policy is enforced for the webhook endpoints that use the alternate auth path:
/webhooks/test and /webhooks/deliveries/retry. Create two tests similar to the
existing ones that use legacyApiKeyValue (and createdWebhookId for the
test/retry cases) to call POST /webhooks/test and POST
/webhooks/deliveries/retry (with orgId and webhookId in the JSON body and using
the x-api-key header if that’s what middlewareV2 expects), then assert
response.status === 401 and response.json().error ===
'org_requires_expiring_key'; ensure you reference middlewareV2 and
checkWebhookPermissionV2 behavior by using the same auth header and fixtures
(policyOrgId, legacyApiKeyValue, createdWebhookId, BASE_URL) as the other tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c0ac94d7-4de2-4126-9af3-01cf9f0be613

📥 Commits

Reviewing files that changed from the base of the PR and between 6bfd4f7 and 2baddc5.

📒 Files selected for processing (3)
  • supabase/functions/_backend/public/webhooks/index.ts
  • tests/webhooks-apikey-policy.test.ts
  • tests/webhooks.test.ts

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c235268288

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/webhooks-apikey-policy.test.ts (2)

3-3: Route these requests through getEndpointUrl() instead of BASE_URL.

Hard-coding BASE_URL here bypasses the test helper that switches targets under USE_CLOUDFLARE_WORKERS, so this file is easier to desync from the actual backend worker selection. Use getEndpointUrl(path) for the /apikey and /webhooks* calls.

♻️ Suggested cleanup
-import { BASE_URL, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts'
+import { getEndpointUrl, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts'
+
+const APIKEY_URL = getEndpointUrl('/apikey')
+const WEBHOOKS_URL = getEndpointUrl('/webhooks')
+const WEBHOOKS_TEST_URL = getEndpointUrl('/webhooks/test')
+const WEBHOOKS_RETRY_URL = getEndpointUrl('/webhooks/deliveries/retry')

-  const keyResponse = await fetch(`${BASE_URL}/apikey`, {
+  const keyResponse = await fetch(APIKEY_URL, {
     method: 'POST',
     headers,
@@
-    const response = await fetch(`${BASE_URL}/webhooks?orgId=${policyOrgId}`, {
+    const response = await fetch(`${WEBHOOKS_URL}?orgId=${policyOrgId}`, {
       headers: {
         'Content-Type': 'application/json',
         'Authorization': legacyApiKeyValue,
As per coding guidelines, "Use `getEndpointUrl(path)` test helper to route to correct worker based on endpoint; use `USE_CLOUDFLARE_WORKERS=true` env var to switch backend target between Supabase and CF Workers".

Also applies to: 47-104, 132-245

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/webhooks-apikey-policy.test.ts` at line 3, Tests are using BASE_URL
directly which bypasses the test helper that selects the backend; update all
requests in tests/webhooks-apikey-policy.test.ts (including calls to /apikey and
any /webhooks* requests mentioned around lines 47-104 and 132-245) to use
getEndpointUrl(path) instead of concatenating BASE_URL (e.g., replace BASE_URL +
'/apikey' and BASE_URL + '/webhooks…' with getEndpointUrl('/apikey') and
getEndpointUrl('/webhooks...')); keep other helpers like headers and
getSupabaseClient unchanged and ensure getEndpointUrl is imported/used
consistently so the USE_CLOUDFLARE_WORKERS toggle routes requests correctly.

93-104: Add one success case for a compliant expiring key.

This suite only proves the reject path. Because the second /apikey fixture keeps only id, it never verifies that a valid expiring org key can still use the webhook endpoints after the policy flip. If the new guard accidentally rejected every API key, this file would still pass. Persist the returned key and assert one happy-path request with it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/webhooks-apikey-policy.test.ts` around lines 93 - 104, The test
currently only stores expiringSubkeyId from the POST /apikey response so it
never verifies a valid expiring org key can access webhook endpoints; update the
POST assertion to capture and persist the actual API key/token returned (e.g.,
expand subkeyData to include the key string and store it in a new variable like
expiringSubkeyKey), then perform one happy-path request to the webhook endpoint
using that key (set Authorization or appropriate header) and assert a successful
response (e.g., 200). Ensure references to subkeyResponse, subkeyData,
expiringSubkeyId are updated to also handle and verify the returned key value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/webhooks-apikey-policy.test.ts`:
- Line 3: Tests are using BASE_URL directly which bypasses the test helper that
selects the backend; update all requests in tests/webhooks-apikey-policy.test.ts
(including calls to /apikey and any /webhooks* requests mentioned around lines
47-104 and 132-245) to use getEndpointUrl(path) instead of concatenating
BASE_URL (e.g., replace BASE_URL + '/apikey' and BASE_URL + '/webhooks…' with
getEndpointUrl('/apikey') and getEndpointUrl('/webhooks...')); keep other
helpers like headers and getSupabaseClient unchanged and ensure getEndpointUrl
is imported/used consistently so the USE_CLOUDFLARE_WORKERS toggle routes
requests correctly.
- Around line 93-104: The test currently only stores expiringSubkeyId from the
POST /apikey response so it never verifies a valid expiring org key can access
webhook endpoints; update the POST assertion to capture and persist the actual
API key/token returned (e.g., expand subkeyData to include the key string and
store it in a new variable like expiringSubkeyKey), then perform one happy-path
request to the webhook endpoint using that key (set Authorization or appropriate
header) and assert a successful response (e.g., 200). Ensure references to
subkeyResponse, subkeyData, expiringSubkeyId are updated to also handle and
verify the returned key value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f060d4b-9aeb-4e78-b93e-dd152a72ce11

📥 Commits

Reviewing files that changed from the base of the PR and between 248f58a and a86d62e.

📒 Files selected for processing (3)
  • supabase/functions/_backend/public/webhooks/index.ts
  • tests/organization-api.test.ts
  • tests/webhooks-apikey-policy.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/organization-api.test.ts

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 6, 2026

@riderx riderx merged commit 9f15108 into main Apr 8, 2026
15 checks passed
@riderx riderx deleted the fix/webhook-apikey-expiration-policy branch April 8, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant