Skip to content

fix(api): prevent cross-org event broadcasts#1802

Merged
riderx merged 6 commits into
mainfrom
fix/events-org-broadcast-authz
Mar 18, 2026
Merged

fix(api): prevent cross-org event broadcasts#1802
riderx merged 6 commits into
mainfrom
fix/events-org-broadcast-authz

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Mar 16, 2026

Summary (AI generated)

  • validate caller-supplied org ids in /private/events before using them for realtime or analytics
  • reject cross-org notifyConsole and tracking requests for orgs the caller cannot read
  • add API key and JWT regression coverage for authorized and unauthorized org targets

Motivation (AI generated)

The /private/events handler trusted body.user_id as an org identifier and used it to route realtime broadcasts and analytics side effects. That allowed callers to target organizations they do not belong to.

Business Impact (AI generated)

This closes a cross-org integrity issue that could inject fake operational activity into another customer's console and analytics. It reduces support risk and protects tenant isolation.

Test Plan (AI generated)

  • bunx eslint supabase/functions/_backend/private/events.ts tests/events.test.ts
  • bun run lint:backend
  • bun run typecheck
  • bun run supabase:with-env -- bunx vitest run tests/events.test.ts
  • bun run test:all

Generated with AI

Summary by CodeRabbit

  • New Features
    • Enforced access control for event broadcasting: requests to broadcast events to unauthorized organizations are rejected; when an explicit target org is provided, it is used as the resolved organization for notifications and subject to authorization checks.
  • Tests
    • Added tests covering authorization for broadcasting across API-key and JWT flows, including allowed and rejected cross-organization scenarios.

@riderx riderx added the codex label Mar 16, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

Adds org-level access checks to event broadcasting: when notifyConsole is true, resolves a requested organization ID from the request body, authorizes it via API-key or JWT paths before proceeding (returns 403 if denied), and updates tests to cover allowed and forbidden broadcast scenarios.

Changes

Cohort / File(s) Summary
Authorization Control
supabase/functions/_backend/private/events.ts
Introduce canAccessRequestedOrg logic, import Context, hasOrgRight, hasOrgRightApikey, and supabaseWithAuth. Derive requestedOrgId from body.user_id when notifyConsole is set, enforce access (403 on denial), and resolve orgId to use in notify flow.
Authorization Tests
tests/events.test.ts
Add tests asserting API-key and JWT paths reject broadcasts to a foreign org and allow broadcasts for authorized orgs. Import getAuthHeaders and ORG_ID from test utils and add FOREIGN_ORG_ID test constant.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Backend as Backend/Events
    participant Auth as Auth System

    Client->>Backend: POST /events (body, notifyConsole, user_id?)
    Backend->>Backend: Parse body, extract requestedOrgId (if notifyConsole)
    alt requestedOrgId provided
        Backend->>Auth: Verify access (hasOrgRightApikey or hasOrgRight) for requestedOrgId
        alt Access granted
            Auth-->>Backend: OK
            Backend->>Backend: Use requestedOrgId for notifyConsole flow
            Backend-->>Client: 200 OK (event processed)
        else Access denied
            Auth-->>Backend: Forbidden
            Backend-->>Client: 403 Forbidden
        end
    else No requestedOrgId
        Backend->>Backend: Use authenticated userId as orgId
        Backend-->>Client: 200 OK (event processed)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

💰 Rewarded

Poem

🐰 I sniffed the headers, hopped the gate,

Checked each token, small and great,
No foreign hop unless invited in,
Org burrows safe beneath my grin,
Thump! Tests pass — let the broadcasts begin! 🥕🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: preventing cross-org event broadcasts, which is the core security fix across all modifications.
Description check ✅ Passed The description includes Summary, Motivation, Business Impact, and a complete Test Plan with verification steps. All required template sections are present and adequately filled with substantive information.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/events-org-broadcast-authz
📝 Coding Plan
  • Generate coding plan for human review comments

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

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: f60fcb52d0

ℹ️ 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".

Comment on lines +31 to +34
const requestedOrgId = typeof body.user_id === 'string' && body.user_id.length > 0 ? body.user_id : undefined

if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId)))
return c.json({ error: 'Forbidden' }, 403)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow non-org analytics identifiers in events payload

The new guard treats every non-empty body.user_id as an organization ID and rejects the request unless hasOrgRight* passes, but existing callers still use user_id as a user identifier (for example sendEvent in src/modules/auth.ts sends main.auth?.id on login). With this change, those requests now return 403 because a user UUID is not an org UUID, so login/event analytics are silently dropped for authenticated users. Restrict this org-access check to org-scoped flows (e.g. notifyConsole/org-targeted events) or otherwise distinguish org IDs from user IDs before enforcing org authorization.

Useful? React with 👍 / 👎.

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)
tests/events.test.ts (1)

82-107: ⚠️ Potential issue | 🟠 Major

Add the missing JWT authorized-org success test.

Line 90 adds only the JWT forbidden case, while Line 84 still skips the JWT happy path. This leaves a gap where a regression that rejects all JWT org-targeted requests would still pass this suite.

Suggested test addition
+  it('allows jwt broadcasts for an authorized org', async () => {
+    const authHeaders = await getAuthHeaders()
+    const response = await fetch(`${BASE_URL}/private/events`, {
+      method: 'POST',
+      headers: authHeaders,
+      body: JSON.stringify({
+        channel: 'test',
+        event: 'test_event',
+        description: 'Testing event tracking',
+        notifyConsole: true,
+        user_id: ORG_ID,
+      }),
+    })
+
+    const data = await response.json() as { status: string }
+    expect(response.status).toBe(200)
+    expect(data.status).toBe('ok')
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/events.test.ts` around lines 82 - 107, Add a new test that verifies
JWT-authenticated requests succeed when targeting the token's own org: use
getAuthHeaders() to obtain authHeaders, POST to `${BASE_URL}/private/events`
with a body similar to the forbidden test but set user_id to the token's org (or
omit user_id so the server uses the JWT's org), then assert response.status is
200 and the response body indicates success (e.g., contains the created event id
or no error). Place this alongside the existing jwt tests (referencing
getAuthHeaders, BASE_URL, and the /private/events route) so the suite covers
both the FOREIGN_ORG_ID forbidden case and a positive authorized-org case.
🧹 Nitpick comments (1)
tests/events.test.ts (1)

42-107: Use it.concurrent() for the newly added tests.

Line 42, Line 62, and Line 90 introduce new it() tests instead of it.concurrent(), which is inconsistent with the test concurrency rule for this repo.

Suggested refactor
-  it('rejects apikey attempts to broadcast events to a foreign org', async () => {
+  it.concurrent('rejects apikey attempts to broadcast events to a foreign org', async () => {

-  it('allows apikey broadcasts for an authorized org', async () => {
+  it.concurrent('allows apikey broadcasts for an authorized org', async () => {

-  it('rejects jwt attempts to broadcast events to a foreign org', async () => {
+  it.concurrent('rejects jwt attempts to broadcast events to a foreign org', async () => {

As per coding guidelines: "ALL TEST FILES RUN IN PARALLEL; use it.concurrent() instead of it() to maximize parallelism; create dedicated seed data when tests modify shared resources."

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

In `@tests/events.test.ts` around lines 42 - 107, Three tests use plain it()
instead of the repo-required it.concurrent(): replace the three it(...) blocks
whose descriptions are "rejects apikey attempts to broadcast events to a foreign
org", "allows apikey broadcasts for an authorized org", and "rejects jwt
attempts to broadcast events to a foreign org" with it.concurrent(...) so they
run in parallel; while doing so ensure any shared-resource modification in those
tests is backed by dedicated seed/setup/teardown (create isolated test data or
mocks) to avoid cross-test interference.
🤖 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 `@tests/events.test.ts`:
- Around line 82-107: Add a new test that verifies JWT-authenticated requests
succeed when targeting the token's own org: use getAuthHeaders() to obtain
authHeaders, POST to `${BASE_URL}/private/events` with a body similar to the
forbidden test but set user_id to the token's org (or omit user_id so the server
uses the JWT's org), then assert response.status is 200 and the response body
indicates success (e.g., contains the created event id or no error). Place this
alongside the existing jwt tests (referencing getAuthHeaders, BASE_URL, and the
/private/events route) so the suite covers both the FOREIGN_ORG_ID forbidden
case and a positive authorized-org case.

---

Nitpick comments:
In `@tests/events.test.ts`:
- Around line 42-107: Three tests use plain it() instead of the repo-required
it.concurrent(): replace the three it(...) blocks whose descriptions are
"rejects apikey attempts to broadcast events to a foreign org", "allows apikey
broadcasts for an authorized org", and "rejects jwt attempts to broadcast events
to a foreign org" with it.concurrent(...) so they run in parallel; while doing
so ensure any shared-resource modification in those tests is backed by dedicated
seed/setup/teardown (create isolated test data or mocks) to avoid cross-test
interference.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2635e65-83b4-4f80-a3cf-ae60fd793fca

📥 Commits

Reviewing files that changed from the base of the PR and between e1cd9a0 and f60fcb5.

📒 Files selected for processing (2)
  • supabase/functions/_backend/private/events.ts
  • tests/events.test.ts

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 (1)
tests/events.test.ts (1)

42-80: Tests correctly validate cross-org authorization boundaries for API keys.

Both rejection (foreign org → 403) and allowance (authorized org → 200) paths are covered with appropriate assertions.

Consider using it.concurrent() for these tests to align with the parallelism guideline, since they don't modify shared database state.

As per coding guidelines: "ALL TEST FILES RUN IN PARALLEL; use it.concurrent() instead of it() to maximize parallelism."

♻️ Optional refactor to use concurrent tests
-  it('rejects apikey attempts to broadcast events to a foreign org', async () => {
+  it.concurrent('rejects apikey attempts to broadcast events to a foreign org', async () => {
-  it('allows apikey broadcasts for an authorized org', async () => {
+  it.concurrent('allows apikey broadcasts for an authorized org', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/events.test.ts` around lines 42 - 80, Convert the two synchronous tests
"rejects apikey attempts to broadcast events to a foreign org" and "allows
apikey broadcasts for an authorized org" to run in parallel by replacing the
top-level it() calls with it.concurrent(), keeping all headers, request bodies
and assertions unchanged; update both test declarations in tests/events.test.ts
so they use it.concurrent() to follow the guideline that all tests run in
parallel and ensure there are no shared DB mutations in these specific cases
before making the change.
🤖 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/events.test.ts`:
- Around line 42-80: Convert the two synchronous tests "rejects apikey attempts
to broadcast events to a foreign org" and "allows apikey broadcasts for an
authorized org" to run in parallel by replacing the top-level it() calls with
it.concurrent(), keeping all headers, request bodies and assertions unchanged;
update both test declarations in tests/events.test.ts so they use
it.concurrent() to follow the guideline that all tests run in parallel and
ensure there are no shared DB mutations in these specific cases before making
the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 019ea312-e680-4d5f-a532-f0b94343ccb2

📥 Commits

Reviewing files that changed from the base of the PR and between b77036e and 967481d.

📒 Files selected for processing (1)
  • tests/events.test.ts

@sonarqubecloud
Copy link
Copy Markdown

@riderx riderx merged commit 7b79416 into main Mar 18, 2026
15 checks passed
@riderx riderx deleted the fix/events-org-broadcast-authz branch March 18, 2026 16:17
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