Skip to content

Fix/brain endpoint authz - #72

Merged
notSumit25 merged 2 commits into
mainfrom
fix/brain-endpoint-authz
Aug 20, 2026
Merged

Fix/brain endpoint authz#72
notSumit25 merged 2 commits into
mainfrom
fix/brain-endpoint-authz

Conversation

@notSumit25

Copy link
Copy Markdown
Collaborator

Problem

93 of 116 BrainController endpoints performed no authorization — only
authentication. SecurityConfig asserts .anyRequest().authenticated() and
JwtAuthenticationFilter only resolves a principal; neither inspects a
connectionId, and no filter/interceptor/aspect fills the gap. Connections are
private per user (ConnectionAccessService.resolveAccess keys on ownerUsername

  • an explicit grant table), so any authenticated user with someone else's
    connection id could read their database intelligence.

Verified live against the pre-fix binary (v1.2.0): a CHAT_EDITOR user pulled
91 KB of health-score data, 71 KB of cost-attribution data, and
131 table names from a connection they were never granted — all HTTP 200.
Same calls are 403 after this change.

Fix

  • All 116 endpoints now authorize: assertCanReadConnectionContent (GET),
    assertCanManageConnectionContent (writes).
  • Indirect-ID endpoints (simulationId/experimentId/patternId) resolve the
    owning connection first via three new getConnectionId lookups.
  • 2 endpoints have no connection scope → @PreAuthorize("hasRole('ADMIN')").
  • BrainControllerAuthorizationSafetyTest fails the build if a new endpoint
    ships unguarded.

Found during hands-on QA (2nd commit)

  • DELETE /brain/calibration/{id} used a fully-qualified @DeleteMapping,
    so the first sweep's regex skipped it — a destructive endpoint left open.
    The safety test shared the same blind spot and reported "0 unguarded" while it
    was live. Regex hardened; differential-checked (old pattern: 116/0 → misses;
    new: 117/1 → catches).
  • Bogus indirect ids returned 500 instead of 404 (access was still denied).

Verification

API (curl), real browser session (Chrome DevTools MCP), and DB read-back all
agree: granted → 200 with real data, ungranted → 403; owner writes still 200
with rows confirmed in Postgres. Adjacent features unaffected.

notSumit25 and others added 2 commits August 20, 2026 15:54
BrainController shipped with 93 of its 116 endpoints performing no
authorization at all. SecurityConfig only asserts
`.anyRequest().authenticated()` and JwtAuthenticationFilter only resolves a
principal — neither inspects a connectionId, and there is no filter,
interceptor or aspect that does. Connections are private per user
(ConnectionAccessService.resolveAccess keys on ownerUsername plus an explicit
grant table), so any authenticated user who passed somebody else's connection
id to /brain/health-scores/{id}, /brain/data-sensitivity/{id} (which names the
PII columns), /brain/cost-attribution/{id}, /brain/ml-overview/{id} and ~90
others got that user's schema, sensitivity, cost and workload intelligence
back.

Only the first ~15 endpoints had the check. The misses clustered by when a
section was written — every later "Phase" block omitted it — not by read/write
semantics, so the scalability, brain-score, classification, column-values,
insights, workload, config-tuning, statistics, executions, patterns,
ml-overview and query-intelligence families were open in full.

All 116 now authorize: assertCanReadConnectionContent for GETs,
assertCanManageConnectionContent for writes. Endpoints whose path carries some
other id resolve the owning connection first, via three new getConnectionId
lookups (ScalabilitySimulationService, ConfigTuningService,
PlanPatternLibraryService) matching the existing BrainNoteService /
BrainTaskService precedent.

Two endpoints have no connection scope and are admin-only instead:
/column-values/embed-all spans every connection, and
/key-columns/anti-pattern/{patternId}/acknowledge is an unimplemented stub
whose body never loads the anti-pattern, so there is nothing to authorize
against yet.

Asserts go inside each handler's try, before the catch-all that returns 500,
relying on the existing `catch (ResponseStatusException e) { throw e; }` so a
denial surfaces as a real 403 rather than looking like a broken feature.

BrainControllerAuthorizationSafetyTest locks both properties structurally —
every mapping is authorized, and every inline assert rethrows. Verified it
fails on 92 endpoints against the pre-fix file and passes on 0 after, so it
catches the regression rather than passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by hands-on QA against the running stack, not by reading the diff.

DELETE /brain/calibration/{connectionId} was annotated
`@org.springframework.web.bind.annotation.DeleteMapping` rather than the bare
`@DeleteMapping` every other handler uses. The sweep that added the access
checks matched on `^\s*@(Get|Post|Delete|Put)Mapping`, so this one endpoint was
skipped — and it is destructive. Verified live: a user holding only a
CHAT_EDITOR grant on a *different* connection got 200 from
`DELETE /brain/calibration/<unshared-id>`; it is 403 for both the read-granted
and the ungranted connection now, and still 200 for the owner.

BrainControllerAuthorizationSafetyTest shared the blind spot exactly — it
reported "116 endpoints, 0 unguarded" while that endpoint was open, which is
the vacuous-pass failure mode CLAUDE.md's verification anti-patterns warn
about. Its mapping pattern now accepts an optional package qualifier;
confirmed it reports 117/1-unguarded against the pre-fix shape and 117/0 after.

Also: the three new getConnectionId lookups throw IllegalArgumentException for
an unknown id, and two of their handlers had no IllegalArgumentException catch,
so a bogus simulation/pattern id returned 500 instead of 404 (observed, with
the stack trace in the backend log). Access was still correctly denied — this
was a status-code and log-noise defect, not an exposure. Both now 404, matching
the pre-existing convention at /inferred-relationships/{id}/validate.

Not fixed here, pre-existing and out of scope: POST /brain/tasks and
POST /brain/tasks/{taskId}/status have the same missing-404 shape via
brainTaskService.getConnectionId, and reproduce on v1.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@notSumit25
notSumit25 requested a review from a team as a code owner August 20, 2026 12:15
@notSumit25
notSumit25 merged commit 323da34 into main Aug 20, 2026
9 checks passed
@notSumit25
notSumit25 deleted the fix/brain-endpoint-authz branch August 20, 2026 13:27
geekypunk pushed a commit that referenced this pull request Aug 20, 2026
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## What was going wrong

A **new Agent chat thread** while Viewing as `marts-editor` still
returned `crm.customers` rows (including `amount`). SQL Editor already
blocked `crm`. Direct MCP API calls with a marts-editor token also
blocked `crm`. The Agent path did not.

End-to-end logs for the failing turn (`session=fca723ca8171`, 14:14
UTC):

1. `/api/agent/session` minted MCP token **257** for `marts-editor` and
provisioned `u-marts-editor`.
2. Hermes `POST /api/profile/switch` returned **200** three times; the
session JSON is tagged `"profile": "u-marts-editor"`.
3. `mcp__deepsql__get_brain_context` and `mcp__deepsql__execute_sql`
authenticated as **admin** token **253**.
4. Audit: `EDITOR_QUERY_EXECUTED` / `clientType=mcp` / `user_id=1`
(`admin@demo.local`) / `SELECT … FROM crm.customers` / `rowCount=2`.
5. Token 257 `last_used_at` is only the Spring `probeMcpAuth` right
after mint — Hermes never sent it.
6. `u-marts-editor` has **no** `mcp-stderr.log`; `u-admin` MCP was
started on Aug 18 and reused.

Hermes keeps **one** DeepSQL MCP stdio process, started from the first
loaded profile (`u-admin`). `POST /api/profile/switch` is explicitly
`process_wide=False` (cookie / thread-local only). A new chat does not
respawn MCP. `resolveEffectivePolicy(..., actorIsAdmin=true)` returns
`none()`, so schema and column policy never run.

`probeMcpAuth` only proves the minted token works against Spring, not
that the live MCP process will use it.

## Fix

`scripts/local-agent-provisioner.py` (the Agent container's `/provision`
handler) now mirrors the minted token onto `$HERMES_HOME/deepsql.token`
**and every** `profiles/*/deepsql.token`. The long-lived MCP client
re-reads `DEEPSQL_TOKEN_FILE` per request (mtime cache), so the next
`execute_sql` authenticates as the viewed-as user and policy applies.

Chat-path policy is also fail-closed for unparseable/unhandled SQL and
missing actors, and walks CTEs, UNIONs, and nested `FROM`/`WHERE`
selects.

## Merge with main

Resolved conflicts with `main` (`#70` whole-statement schema allowlist +
nested protected-table inspectability, `#72` brain endpoint authz).

Kept this branch's recursive SELECT walker and fail-closed parse/actor
rules. Took main's `assertProtectedTablesAreInspectable` / `namesMatch`
so a qualified protection (`public.customer_profiles`) does not catch
the same table name in another schema, while an unqualified nested
reference still fails closed.

`UserDataAccessPolicyServiceTest` (24) and
`BrainControllerAuthorizationSafetyTest` (2) pass after the merge.

## How to verify

1. View as `marts-editor`, Agent tab, **New chat**.
2. Ask: `show me all customer information from crm tables`.
3. Expect a policy block, not Acme/Globex rows. `security_event.user_id`
for that SQL must be the editor, not admin.

Rebuild/restart `deepsql-agent` so the container picks up the
provisioner change, or copy `scripts/local-agent-provisioner.py` onto
`/opt/deepsql-agent/provisioner.py` and restart the provisioner process,
then reopen the Agent tab (triggers `/provision`).

## Re-test after the fix

Same prompt in a new Agent thread while Viewing as marts-editor. Agent
refused CRM; audit `user_id=2` / `clientAgent=marts-editor`.

[Agent refuses CRM while viewing as
marts-editor](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_view_as_crm_blocked.webp)

[agent_view_as_crm_policy_block.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_view_as_crm_policy_block.mp4)


<sub>To show artifacts inline, <a
href="https://cursor.com/dashboard/cloud-agents#my-pull-requests">enable</a>
in settings.</sub>
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-019fe687-99b1-76fd-80fa-dd213aecc497&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
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.

2 participants