Skip to content

fix(auth): scope alerts/reports to readable explorations; tighten datasource writes - #92

Merged
doktorhelgi merged 3 commits into
mainfrom
fix/alerts-reports-authz
Oct 9, 2026
Merged

doktorhelgi merged 3 commits into
mainfrom
fix/alerts-reports-authz

Conversation

@doktorhelgi

@doktorhelgi doktorhelgi commented Oct 8, 2026 •

Copy link
Copy Markdown

Follow-up to #91, from an independent review (CodeRabbit doesn't run on this repo).

1. alerts / reports: cross-tenant query exfiltration (High)

The insert and update checks were _or: [user_id = caller, team member]. user_id is preset to the caller, so the check was always true, and exploration_id was unconstrained. checkAlert (and the report runner) executes the exploration as its owner (exploration.user_id). Anyone who knew another tenant's exploration id could schedule an alert or report that runs that tenant's query and delivers the results to their own webhook or email.

Fix (insert and update check):

  • the exploration must be readable by the caller, using the same predicate as explorations select (datasource owned by the caller, or in a team the caller belongs to), and
  • team_id is null or a team the caller belongs to.

Select and delete filters are unchanged.

2. datasources: the creator branch only covers personal rows

Update and delete filters (and the update check) allowed user_id = caller on any row. A datasource planted in another team before #91 stayed under its creator's control, e.g. repointing db_params. That branch now requires team_id null. Team rows are governed by membership.

3. create_team: admin-secret callers are trusted

#91 limited user-invoked create_team to portal admins, which broke the StepCI workflows that create teams with a fresh @example.com user. Role admin (admin secret) is now allowed; x-hasura-role can't be spoofed, since user JWTs don't include admin. The 7 workflows' create_team steps now send the admin secret and keep x-hasura-user-id, so the test user still becomes owner. access-controls/provisioning.yml already used the admin secret.

Not changed (noted)

  • A team admin can set member_roles to owner (pre-existing): an admin can promote themselves and then invite.
  • cxs2 callers of create_team (provision/route.ts, [serviceId]/team/route.ts) appear dead already (they depend on /auth/register).
  • At deploy, audit for datasources whose user_id is not a member of their team_id (planted rows).

Tests

  • node --test in services/actions: 64/64, including new canCreateTeam cases (admin role allowed, user role refused).
  • tables.yaml and all edited workflows parse; the permission JSON renders as described above.
  • StepCI workflows were not run; they need the local stack.

4. send_test_alert bypass (High), added after review

rpc/sendTestAlert.js loaded the exploration with the admin secret and ran it as its owner (sendExplorationScreenshot), delivering results to a caller-chosen webhook, Slack or email. That made the alerts/reports check above moot. It now reads the exploration with the caller's token, so the explorations select rule applies, and refuses when there is no token (fetchGraphQL would otherwise fall back to the admin secret).

5. Former team members (Medium), added after review

The "caller created the datasource" branch now requires team_id null in the alerts/reports checks and in the explorations select filter. A user removed from a team can no longer read or schedule that team's explorations.

Known, not changed here

  • Alerts and reports run as the exploration's owner, not the alert's creator, so a member with a restricted access list can get a teammate's wider view (pre-existing, within one team).
  • Alerts/reports already created against foreign explorations keep firing. At deploy, audit rows whose exploration or team their user_id cannot read, and delete them (the delete trigger removes the cron job).

Tests: 65/65 (adds a send_test_alert no-token refusal case).

6. export_data_models (High), found by an admin-secret sweep

The action read branches_by_pk and the latest version's dataschema code with the admin secret and no membership check, then returned a 7-day presigned zip. Anyone with a foreign branch id could download that tenant's full models. The action now has forward_client_headers: true and the handler reads with the caller's token, refusing when there is none. Members without model read rights now get an empty export, matching the dataschemas select permission.

Follow-ups from the sweep (Low, not in this PR)

  • cubejs profileTable.js:205 doesn't check the body branchId (leaks a foreign table's model existence and generation_filters). Fix: authorizeBranchAccess, as generate/smart-generate do.
  • validateInBranch.js append mode leaks branch existence. Same fix.
  • fetchDataset/genSql fall back to the admin secret without a token. Only reachable by calling actions /rpc directly, which has no auth of its own.

Tests: 66/66.

🤖 Generated with Claude Code

doktorhelgi and others added 3 commits October 8, 2026 22:44
…asource writes

Follow-up to #91 (independent review):
- alerts/reports insert+update checks were always true (user_id preset to
  the caller) and exploration_id was unconstrained. The alert/report runs
  the exploration as its owner, so anyone knowing an exploration id could
  schedule another tenant's query to their own webhook/email. The row's
  exploration must now be one the caller can read, and team_id must be
  null or a team the caller belongs to.
- datasources update/delete: the creator branch now only covers personal
  rows (team_id null), so a row planted in another team cannot be kept
  under control by its creator.
- create_team: admin-secret callers (role admin) are trusted, so the
  StepCI workflows create teams with the admin secret and keep
  x-hasura-user-id for ownership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… creator branches

Review of #92:
- send_test_alert loaded the exploration with the admin secret and ran it
  as its owner, delivering any tenant's results to a caller-chosen
  webhook/Slack/email. It now reads the exploration with the caller's
  token and refuses when there is none.
- The "caller created the datasource" branch in the alerts/reports checks
  and the explorations select filter now requires team_id null, so a user
  removed from a team can no longer read or schedule its explorations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The action read branches_by_pk and the latest version's dataschema code
with the admin secret and no membership check, then returned a 7-day
presigned zip: anyone with a foreign branch id could download that
tenant's models. The action now forwards client headers and the handler
reads with the caller's token, refusing when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@doktorhelgi
doktorhelgi merged commit bf00cd5 into main Oct 9, 2026
4 checks passed
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