You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f90f60f
Browse filesBrowse the repository at this point in the historyBrowse files
fix: View as Agent must enforce the target user's data policy (#71)
<!-- 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> <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> </div>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Copy file name to clipboardExpand all lines: CLAUDE.md
+30-4Lines changed: 30 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -199,8 +199,9 @@ returns a number).
199
199
2.**LLM Provider Registry**: Use `LlmProviderRegistry` for all provider-specific LLM behavior. Do NOT add if/else or switch on provider type. Chat and embedding providers are registered and resolved independently — some providers offer only one. Providers are *factories* over credentials, not `ChatModel`s, so credentials stay resolvable per call and key rotation needs no restart.
4.**SQL Rule**: All generated SQL MUST use table-qualified column names (`table.column_name`).
202
-
5.**RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
203
-
6.**Virtual Threads**: Enabled for concurrency (JDK 25).
202
+
5.**Chat access policy**: Fail closed. Walk the whole SQL tree (CTEs, set ops, subqueries). Deny unparseable or unhandled statements. Require an actor except `INTERNAL`/`SCHEDULED`. MCP/Editor identity comes from `SecurityContext`, not `QueryActorContextHolder`. Persist `allowed_schemas`. Do not let "how many" override a protected-column mention. Public share is refused when the connection has an active policy.
203
+
6.**RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
204
+
7.**Virtual Threads**: Enabled for concurrency (JDK 25).
204
205
205
206
### Frontend Rules
206
207
1.**API Centralization**: ALL API calls through `src/lib/api/client.js`. Never create direct axios instances.
@@ -210,7 +211,13 @@ returns a number).
210
211
5.**Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
211
212
212
213
### Admin profile switch
213
-
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav. The admin JWT stays on the session; `ImpersonationService` sets an httpOnly `impersonate_user` cookie and `JwtAuthenticationFilter` overlays the target principal. `POST|DELETE|GET /api/admin/impersonate` are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. `/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername`.
214
+
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav.
215
+
216
+
The admin JWT **subject** stays the administrator so logout, refresh, and `/admin/impersonate` still own the real session. Policy identity is the target: an httpOnly `impersonate_user` cookie plus an `impUid` claim on the access token. `JwtAuthenticationFilter` overlays that principal onto the SecurityContext for every request except the impersonation control plane, logout, and session refresh. Chat, Editor, schema listing, and Agent MCP calls then run `AccessControlService` / `ConnectionChatAccessPolicyService` as the target (`actorIsAdmin` is false, so policies apply).
217
+
218
+
The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints an MCP token for the effective user and never falls back to the admin session JWT while View as is active. nginx `auth_request` on `/agent-api` forwards `/api/auth/me`'s `X-Remote-User` (the overlaid username) instead of hardcoding `admin`.
219
+
220
+
`POST|DELETE|GET /api/admin/impersonate` are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. `/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername`.
214
221
215
222
### Git Rules
216
223
- Do NOT commit automatically — wait for explicit user instruction.
@@ -238,7 +245,16 @@ Admins can **View as** a sub-user from the top-right of the home layout (`Profil
238
245
`hermes_requires <0.20.0` on a "verified" 401 that came from a hand-rolled
239
246
`hermes serve` run rather than `hermes webui`. 0.20.0 works. Verify against the
240
247
real start path before writing a version constraint.
241
-
5.**The agent image build clones two third-party repos over the public internet,
248
+
5.**Hermes MCP is process-global.** One `deepsql-phase1-server.js` stdio server
249
+
is started from the first loaded profile (usually `u-admin`) and lives until
250
+
the Agent API process dies. `POST /api/profile/switch` is `process_wide=False`
251
+
on purpose (per-browser cookie), so a View as / new-thread Agent chat tagged
252
+
`profile: u-marts-editor` still sends the admin MCP bearer. Chat-access policy
253
+
then `resolveEffectivePolicy(..., actorIsAdmin=true)` → `none()`. The
254
+
provisioner mirrors the target user's token onto every `deepsql.token` the
255
+
live process might re-read (`DEEPSQL_TOKEN_FILE` mtime cache in
256
+
`mcp/deepsql-phase1-lib.js`).
257
+
6.**The agent image build clones two third-party repos over the public internet,
242
258
unauthenticated.**`agent/Dockerfile` fetches `NousResearch/hermes-agent` and
243
259
`nesquena/hermes-webui` at build time. GitHub rate-limits unauthenticated
244
260
requests *per source IP*, and Actions runners share pooled egress addresses, so
@@ -276,6 +292,16 @@ broken. Assert the *outcome*, never the attempt:
276
292
-**`set -e` + `read` at EOF aborts silently.** Prompts in `install.sh` use
277
293
`read … || true` so the explicit emptiness checks report the problem. Without it the
278
294
installer exited 1 with no message, after writing generated secrets to `.env`.
295
+
-**Minting an Agent MCP token ≠ Hermes using it.**`/api/agent/session` can mint
296
+
`u-marts-editor`'s token and `POST /api/profile/switch` can 200 while
297
+
`execute_sql` still authenticates as admin. Hermes keeps one DeepSQL MCP
298
+
stdio process (started from the first profile that loaded `mcp_servers`) and
299
+
`profile/switch` is `process_wide=False`. A new chat thread does not respawn
300
+
MCP. `probeMcpAuth` only proves the *minted* token works against Spring, not
301
+
that the live MCP process will send it. The provisioner must mirror the
302
+
token onto every `deepsql.token` the live process might be watching
303
+
(`scripts/local-agent-provisioner.py`). Audit: `security_event.user_id` on
304
+
`EDITOR_QUERY_EXECUTED` with `clientType=mcp`.
279
305
-**Silent-failure rule, concretely:** the CLI rendered an unreachable server as
280
306
`No databases connected yet` because one `catch` covered both the connection fetch
281
307
and decorative extras. An unreachable host must never look like an empty account.
0 commit comments