Skip to content

fix: schema-scoped chat access policies and acme_erp multi-schema fixture - #65

Merged
venkateshsakamuri-lab merged 7 commits into
mainfrom
cursor/schema-policy-multischema-c497
Aug 19, 2026
Merged

fix: schema-scoped chat access policies and acme_erp multi-schema fixture#65
venkateshsakamuri-lab merged 7 commits into
mainfrom
cursor/schema-policy-multischema-c497

Conversation

@venkateshsakamuri-lab

@venkateshsakamuri-lab venkateshsakamuri-lab commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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)

  • Parse allowedSchemas from policy text
  • Key protection by schema.table
  • Apply PII/financial and explicit table/column denials only inside allowed schemas
  • Block SQL that references a schema outside the allow-list (POLICY_SCHEMA_BLOCKED)
  • Block SELECT * only when the queried table is protected

Generic NL column constraints

Deny/allow clauses match SQL type families and live schema column names, still limited to allowedSchemas. No amount/currency special case.

Editor explorer

  • GET /objects, /schema, /visualization, and table index/stats are filtered by allowedSchemas
  • Admin and unrestricted users still see the full tree
  • Frontend schema cache is keyed by username so an admin session cannot leak other schemas to an editor

Verified live as marts-editor on ACME ERP: 3 marts tables vs 12 objects for admin.

Open in Web Open in Cursor 

cursoragent and others added 3 commits August 18, 2026 06:52
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>&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>
cursoragent and others added 2 commits August 19, 2026 04:57
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>
@venkateshsakamuri-lab
venkateshsakamuri-lab merged commit 0aa2b77 into main Aug 19, 2026
9 checks passed
@venkateshsakamuri-lab
venkateshsakamuri-lab deleted the cursor/schema-policy-multischema-c497 branch August 19, 2026 15:13
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>
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.

4 participants