fix: schema-scoped chat access policies and acme_erp multi-schema fixture - #65
Merged
venkateshsakamuri-lab merged 7 commits intoAug 19, 2026
Merged
Conversation
Schema-scoped policies (e.g. marts-only amount redaction) previously matched bare column names across all schemas and blocked SELECT * globally. Protection descriptors are now keyed by schema.table, allowed-schema lists are parsed from policy text and enforced at SQL pre-check, and numeric amount denials respect the allowed schema scope. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
Adds seeded Postgres database with crm/sales/finance/inventory/hr/marts schemas, duplicate bare table names for collision testing, and a seed script to register the ACME ERP connection. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
Replace the amount/currency heuristic with generic deny/allow clauses that match SQL type families and actual schema column names, still limited to allowed schemas. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
Filter /objects, /schema, visualization, and table index/stats APIs by the user's allowedSchemas so Chat + Editor users only see schemas their policy permits. Schema object cache is now keyed per username to avoid leaking an admin's full tree. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
Admins can switch into a sub-user profile from the top-right of the app
to verify that connection ACLs, chat/editor policies, and role-gated
navigation actually apply.
## How it works
The admin JWT stays on the session. Starting a switch sets an httpOnly
`impersonate_user` cookie. `JwtAuthenticationFilter` overlays the target
user's principal so every access check (`AccessControlService`,
policies, `/auth/me`) runs as that user.
The impersonation control plane (`GET|POST|DELETE
/api/admin/impersonate`), logout, refresh, and MCP tokens are excluded
from the overlay so the real admin can stop the switch and so CLI tokens
cannot ride the cookie.
## Guardrails
- ADMIN only
- Cannot switch into another admin, yourself, or a non-ACTIVE account
- Logout clears the impersonation cookie
- Audited as `IMPERSONATION_STARTED` / `IMPERSONATION_STOPPED`
## UI
- Top-right **View as** control, admin-only (hover helper popups removed
— they blocked clicks)
- While switched: dark banner `Viewing as <user>` with **Exit**
- Nav, Settings, and connection visibility follow the target
role/permissions
- **Docs** sidebar item removed; old `docs` nav state lands on Agent
- Client session state (query cache, selected connection) resets on
switch and on exit
## API
- `GET /api/admin/impersonate` — candidates + current status
- `POST /api/admin/impersonate` `{ userId }` — start
- `DELETE /api/admin/impersonate` — stop
- `GET /api/auth/me` returns the **effective** user plus `impersonating`
/ `impersonatorUsername`
Stacked on #65 so policy enforcement can be verified by switching into
`marts-editor`.
## Native Agent tab (follow-up on this branch)
The Agent tab 503'd on native `mvn spring-boot:run` because `.env` still
used Compose DNS (`deepsql-agent:8788`) and Vite had no nginx
`X-Remote-User` stamp. `scripts/start-backend.sh` now remaps
unresolvable Compose hosts to loopback, the agent container can reach a
host-side backend via `host.docker.internal`, and Vite forwards the
effective username (including impersonation) to the Agent API.
[Agent answered table count on ACME
ERP](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_chat_ten_tables.webp)
[Only ACME ERP Multi-Schema connection
remains](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fconnections_acme_erp_only.webp)
[agent_working_and_acme_connection.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_working_and_acme_connection.mp4)
## Profile switch walkthrough
[Sidebar without
Docs](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fdocs_nav_removed.webp)
[Admin View as
control](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_view_as_control.webp)
[View as dropdown listing
marts-editor](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_view_as_dropdown.webp)
[Viewing as marts-editor
banner](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fviewing_as_marts_editor_banner.webp)
[Editor shows only marts tables while
switched](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Feditor_marts_only_while_switched.webp)
[admin_profile_switch_view_as_exit.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_profile_switch_view_as_exit.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>
Keep schema-scoped policy filtering from #65 and the query-cancel registry/ActiveQueryService fields added on main. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
CodeQL flagged `.+?` plus `\s+` lookaheads on untrusted policy text. Collapse whitespace, match prefixes only, and slice to stop words linearly. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
geekypunk
approved these changes
Aug 19, 2026
venkateshsakamuri-lab
deleted the
cursor/schema-policy-multischema-c497
branch
August 19, 2026 15:13
3 tasks
geekypunk
added a commit
that referenced
this pull request
Aug 19, 2026
…s across schemas The previous commit matched a protected table against a query's tables by adding both the qualified name and its bare part to each side, then intersecting. That collapsed public.customer_profiles and marts.customer_profiles to the same key, so protecting one refused queries against the other -- with a message naming a table the user never referenced. This product added multi-schema support in #55 and an acme_erp fixture with crm/sales/finance/hr/inventory in #65, so same-named tables across schemas are the expected shape here, not a corner case. Over-blocking is the safe direction, which is exactly why it would have survived review and surfaced later as unexplained refusals. Matching is now asymmetric, because the two sides carry different information. ConnectionChatAccessPolicyService.qualifyTable() drops the schema when it is "public", so a bare PROTECTED name means public.<table> -- it is not unknown. A bare REFERENCE in a query is genuinely unknown: it resolves through the session search_path and could be any schema. reference unqualified -> match on bare name (ambiguous, so block) protected public -> a qualified reference must actually say public both qualified -> exact match Every bypass stays closed: an unqualified reference to a protected table is still refused, and hr.salaries still matches a bare "salaries". Also replaces a characterization test asserting the opposite. It was written before qualifyTable's public-collapsing was discovered and encoded the wrong belief that a bare protected name is ambiguous; the case genuinely worth pinning is a bare reference, which it now covers. Verified: 17/17 in UserDataAccessPolicyServiceTest. Regression baseline on the same suite selection unchanged at 8 failures / 16 errors (414 run vs 412 before, the delta being these two tests passing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Chat access policies are schema-aware for SQL enforcement, NL parsing, and the SQL editor explorer.
Root cause
Policy parsing and enforcement used bare table/column names, so schema-scoped policies leaked across the database. The editor tree (
GET /connections/{id}/objects) also returned every schema even when the user was limited to one.Fix
Schema scope (enforcement + parsing)
allowedSchemasfrom policy textschema.tablePOLICY_SCHEMA_BLOCKED)SELECT *only when the queried table is protectedGeneric NL column constraints
Deny/allow clauses match SQL type families and live schema column names, still limited to
allowedSchemas. Noamount/currencyspecial case.Editor explorer
GET /objects,/schema,/visualization, and table index/stats are filtered byallowedSchemasVerified live as
marts-editoron ACME ERP: 3martstables vs 12 objects for admin.