feat(mcp): let MCP sessions grant and revoke masking exemptions after the user confirms by vsai12 · Pull Request #21627 · bytebase/bytebase · GitHub
Skip to content

feat(mcp): let MCP sessions grant and revoke masking exemptions after the user confirms - #21627

Open
vsai12 wants to merge 5 commits into
mainfrom
feat/mcp-masking-exemptions
Open

vsai12 wants to merge 5 commits into
mainfrom
feat/mcp-masking-exemptions

Conversation

@vsai12

@vsai12 vsai12 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

feat(mcp): let MCP sessions grant and revoke masking exemptions after the user confirms

Part of BYT-9292 user journey 1: the last piece of the three PRs in plans/2026-10-13-mcp-decision-matrix.md (row 8). #21588, #21593 and #21626 are merged; this PR builds on #21626's shared confirmation helper.

An agent can now request and approve access, turn on just-in-time access and revoke a grant. The remaining dead end in the journey is masking: when a user needs a column unmasked for a while, only the console can grant the exemption. An exemption is a standing policy change, broader than a single-statement access grant, so it goes through the user's confirmation, like an approval.

What changes:

  • A new tool, grant_masking_exemption, adds one exemption to a project's MASKING_EXEMPTION policy after the user confirms in an MCP form. It names one member and a scope:

    • one database, optionally narrowed to a schema, a table or a column; the database must belong to the project, or the exemption would cover nothing; a database on a project-owned instance is named under its project (projects/{p}/instances/…), as the API names it;
    • or every database in the project, current and future, which takes an explicit allDatabases: true, so an agent that forgets the database does not exempt the whole project. The project must exist: an exemption written under an unknown project ID would apply to a project created later with that ID.

    The member must name one principal (user:, group:, serviceAccount: or workloadIdentity:), as the console's exemption picker does; allUsers, which the policy API also accepts, would unmask the scope for everyone and is refused. It takes an optional expiry and reason. The tool writes the condition itself, in the console's form, the expiry as JavaScript's toISOString writes it (request.time < timestamp("2026-10-27T00:00:00.000Z") && (resource.instance_id == "…" && …)), so the agent never supplies CEL, the form cannot show a narrower scope than the one written, and a revoke compares a tool-made and a console-made condition character for character.

  • A new tool, revoke_masking_exemption, removes one member's exemption. The agent passes the condition exactly as GetPolicy returns it. The console's create page appends without merging, so one member can sit in several entries with the same condition; the tool removes the member from every exact match, and the form states how many. An entry left with no member is dropped. No match is refused, with the member's current conditions named.

  • Every other entry is written back as read. The tools round-trip whole entries (title, description and location survive), where the console's revoke regroups the list and rewrites other entries' expressions.

  • The confirmation covers the exact write. The digest covers the list as read and the UpdatePolicy request built from it, so changed arguments, or an exemption changed while the form was open (including a duplicate a revoke would also drop), make the answer stale, and nothing is written. A failed policy read is an error, never an empty list. The ten-minute lifetime from feat(mcp): let MCP sessions turn just-in-time access on or off, and revoke access grants #21626 applies.

  • The forms fit Claude Code's dialog (four lines, each cut six columns short of the width). Four lines cannot show six unbounded values whole, so on each line the value a cut would most mislead about comes first, and the rest is ordered so a cut makes the scope look broader, never narrower: the database name before its narrowing. Labels drop the colon where it would cost the longest value a column, so a 63-character instance ID or database name shows whole at 80 columns.

    Exempt "user:bob@example.com" from masking, as alice@example.com?
    Instance "prod"
    Database "hr", column "salary" in "public"."employee"
    Unmasked in query and export results until "2026-10-27T00:00:00.000Z". Reason: "payroll audit"
    

    Every value enters through %q, so a member with a line break cannot fake form lines. The member follows the verb directly and shows whole to 64 characters, enough for a service account with its project in typical cases. A table named without a schema covers that table in every schema, and the form says any schema before it. The revoke form parses the condition with cel-go and, when its top level is the expiry && a scope, as the console writes it, shows the scope first and the expiry after; any other shape is shown whole, since in CEL && binds tighter than ||.
    Exports apply exemptions too (sql_service.go:1308); the form does not say an exemption grants export permission.

  • UpdatePolicy moves from EXCLUDED to WRITE, but only the two tools can use it. They attach the confirmation to their internal request, and the gate serves the call only when the procedure and the policy name match. call_api is refused and pointed to the tools for exemptions and to the console for every other policy. CreatePolicy and DeletePolicy stay EXCLUDED: a project's first exemption is created through UpdatePolicy's allow_missing, as the console does, and removing the last entry writes an empty list.

Copy: a new Access policy row, "Grant or revoke masking exemptions"; the Read-write summary adds "grant masking exemptions". Five locales.

Not in this change

  • Concurrent writes can still lose a change. The confirmation covers the request the tool sends; the read and the write that follow are not atomic. UpdatePolicy replaces the whole list, so an exemption changed in that moment can be lost, including one someone just removed coming back. The console has the same race, with a longer window on its revoke; BOT-153 tracks fixing it once for every writer.
  • A revoke's condition can be cut at its tail on an 80-column terminal. A revoke only removes access, and a wrong one is undone by a grant.
  • A database name longer than 63 characters (PostgreSQL's limit; MySQL allows 64, SQL Server and Oracle 128) is cut at its tail on the grant form. Four lines cannot show every value whole; the layout puts each line's identifying value first.
  • A member longer than 64 characters (46 on the revoke form) is cut at its tail, which can hide the project in a long service account or workload identity email.
  • The project line appears only for an all-databases grant. For one database the project is implied: the tool checks the database belongs to it.
  • One entry per call: several columns are several calls. One confirmation for several entries would need a longer form than Claude Code shows.
  • UpdatePolicy now appears in search_api, as ApproveIssue and UpdateProject do. A call_api call on it is refused and names the tools.
  • The permission check is the handler's, after the user confirms. A member who can read exemptions holds the write permissions too in every predefined role; GetPolicy answers NotFound before its permission check, so a member without it is asked about a project's first exemption, then refused.

Size

+1396/−34 lines in 21 files: production Go +589/−12 (tool_exemption.go is 566), tests +759, and the rest copy in five locales, the ladder doc, one proto annotation and generated code.

Tests and verification

  • grant_masking_exemption: the first exemption creates the policy, with the exact request, the console's condition form and the confirmation the gate admits; every other entry is written back with every field it read; every database only with allDatabases; refuses no scope, both scopes, narrowing without a database, a column without a table, a malformed time or database, a member that names no principal (allUsers, no prefix, a prefix alone) and an invalid project; refuses a database in another project without asking; a list changed while asking is stale; decline, cancel and an unticked box change nothing; a client without forms gets the exemption page link; a refused update is reported in the server's words.
  • revoke_masking_exemption: removes the member from every exact match, drops an entry left empty, keeps other members and entries, and the form counts the matches; a console-form condition shows its scope before its expiry, and one with a top-level || is shown whole; refuses without a match, naming the member's conditions, and when the project has no policy; decline and a refused update change nothing.
  • The forms at 80 and 120 columns in the model of Claude Code's dialog: a 60-character service-account member, a 63-character instance ID and database name show whole; two instances, or two databases within 63 characters, that differ only at the end stay distinguishable; a member with a line break stays on its line; a long database name cuts the narrowing; the expiry shows before a long reason is cut.
  • Gate: UpdatePolicy refused without the confirmation, served with it, refused for another policy or procedure, and still held to the ceiling. UpdatePolicy pinned as WRITE, CreatePolicy and DeletePolicy as EXCLUDED.
  • Live, TestMCPGrantsAndRevokesAMaskingExemption: call_api UpdatePolicy is refused; the session grants a column exemption through the tool, the policy holds the expected condition, then revokes it through the tool, and the list is empty.
  • Local gates: buf format, buf lint and buf generate; gofmt; go vet; golangci-lint (no findings in the files this PR changes); pnpm --dir frontend fix and pnpm --dir frontend test (585 files, 6,629 tests; the frontend has not changed since); comment share 7%; and the full Go suite as CI runs it at a97c554ce2: 121 packages ok, none failed. The last commit, 73ddaa3267, changes only the revoke form's condition display and its tests, and backend/api/mcp ran fresh on it.
  • Pre-review: 27 confirmed findings, 22 fixed in 86e3a107d5, 5 declined (the live test's placement; database names over 63 characters). Two rechecks found six more, all fixed in a97c554ce2 and 73ddaa3267. The last, a text scan that guessed CEL precedence for the revoke display, now goes through cel-go's parser; the recheck budget was spent, so that commit was checked by its tests, the reviewer's failing input among them, not by a fourth recheck.

Release note

MCP sessions in Read-write mode can grant and revoke masking exemptions with the new grant_masking_exemption and revoke_masking_exemption tools, after the user confirms each change in their MCP client. Bytebase permissions still apply.

Receipts

Claim Receipt
The console appends a new exemption without merging frontend/src/routes/project/ProjectMaskingExemptionCreatePage.tsx:159
The console's revoke rebuilds the list frontend/src/routes/project/ProjectMaskingExemptionPage.tsx:705-745
The console's condition form frontend/src/lib/sensitive-data/utils.ts:68-100, maskingExemption.ts:80-101
allow_missing creates the policy backend/api/v1/org_policy_service.go:190-197
Exports apply exemptions backend/api/v1/sql_service.go:1308

🤖 Generated with Claude Code

vsai12 and others added 4 commits October 20, 2026 22:29
… the user confirms

Two tools, grant_masking_exemption and revoke_masking_exemption, change
one entry of a project's masking exemption policy through UpdatePolicy
after the user confirms in an MCP form. A grant names one member and
either one database (optionally narrowed to a schema, table or column)
or, explicitly, every database in the project; the tool writes the
condition itself in the console's form, and the database must belong to
the project. A revoke removes the member from every entry whose
condition matches exactly, so a duplicate the console made cannot leave
the access in place. Every other entry is written back as read.

The confirmation's digest is the exact UpdatePolicy request built from a
fresh read of the whole list, so a list changed while the form was open
makes the answer stale. The form puts on each line the value a cut would
most mislead about first, so a cut reads broader, never narrower.
UpdatePolicy moves to WRITE, served only with the confirmation the tools
attach; CreatePolicy and DeletePolicy stay EXCLUDED, as the first
exemption is created through allow_missing, as in the console.

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

The member entered line 1 of both forms raw, so one with a line break
could fake form lines and push the scope out of the dialog. It now goes
through %q, right after the verb, where a long service-account email
keeps the project that tells two apart. A table named without a schema
covers that table in every schema, and the grant form now says "any
schema" before it. The revoke form shows a console-form condition's
scope before its expiry, so a cut hides the expiry, not what is revoked.

A database on a project-owned instance is named under its project, and
the grant now accepts that name. An all-databases grant checks the
project exists. The expiry is written as the console writes it, so a
revoke compares the two forms character for character. A revoke's digest
covers the list it read, so a duplicate added while the form is open
makes the answer stale. New tests: a forged member stays on its line,
changed arguments are stale for both tools, a failed policy read writes
nothing, and the revoke's decline, refusal and output.

The project-name check and the console link have one helper each, and
the design doc counts twelve rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rop a copied owner check

The revoke form split any condition starting with an expiry, so
"T && A || B" showed the expiry covering B, which it does not: in CEL
&& binds tighter than ||. It now splits only when the rest is one
parenthesized group, as the console writes it, and an expiry-only
condition says it covers all databases. The grant's parse-time
project-owner check copied the GetDatabase check, which already refuses
another project's database; the test mock now reports a project-owned
database's real owner. buildResourceURL gets its doc comment back, and
the ladder doc's opening rule counts twelve rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing its text

The text scan tracked only double-quoted strings, so a condition with a
single-quoted parenthesis could still pass for the console's form and
show its expiry covering a scope it does not cover. The condition is now
parsed with cel-go: the expiry is split off only when the top level is
an && whose left side is request.time < timestamp("…"), and the scope is
the parser's own rendering. Any other shape, or text that does not parse,
is shown whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T07:01:02.668895Z 77ef42a New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow CI / lint-protos (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed⏩ skippedOct 21, 2026, 6:58 AM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73ddaa3267

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread backend/api/mcp/tool_exemption.go Outdated
The grant checked only that the member was not blank, and the policy API
also accepts allUsers, which matches every user, so a confirmed grant
could unmask the scope for everyone. The tool now takes only the members
its contract and the console's exemption picker name: user:, group:,
serviceAccount: or workloadIdentity: followed by a name. A revoke still
takes any member, so an allUsers exemption made through the API can be
removed.

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

This branch has not been deployed

No deployments
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