Repository navigation
Conversation
… 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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
The latest Buf updates on your PR. Results from workflow CI / lint-protos (pull_request). |
There was a problem hiding this comment.
💡 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".
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>

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'sMASKING_EXEMPTIONpolicy after the user confirms in an MCP form. It names one member and a scope:projects/{p}/instances/…), as the API names it;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:orworkloadIdentity:), 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'stoISOStringwrites 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 asGetPolicyreturns 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
UpdatePolicyrequest 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.
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 saysany schemabefore 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.UpdatePolicymoves 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_apiis refused and pointed to the tools for exemptions and to the console for every other policy.CreatePolicyandDeletePolicystay EXCLUDED: a project's first exemption is created throughUpdatePolicy'sallow_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
UpdatePolicyreplaces 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.UpdatePolicynow appears insearch_api, asApproveIssueandUpdateProjectdo. Acall_apicall on it is refused and names the tools.GetPolicyanswers 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.gois 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 withallDatabases; 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.UpdatePolicyrefused without the confirmation, served with it, refused for another policy or procedure, and still held to the ceiling.UpdatePolicypinned as WRITE,CreatePolicyandDeletePolicyas EXCLUDED.TestMCPGrantsAndRevokesAMaskingExemption:call_apiUpdatePolicyis 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.buf format,buf lintandbuf generate;gofmt;go vet; golangci-lint (no findings in the files this PR changes);pnpm --dir frontend fixandpnpm --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 ata97c554ce2: 121 packages ok, none failed. The last commit,73ddaa3267, changes only the revoke form's condition display and its tests, andbackend/api/mcpran fresh on it.86e3a107d5, 5 declined (the live test's placement; database names over 63 characters). Two rechecks found six more, all fixed ina97c554ce2and73ddaa3267. 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_exemptionandrevoke_masking_exemptiontools, after the user confirms each change in their MCP client. Bytebase permissions still apply.Receipts
frontend/src/routes/project/ProjectMaskingExemptionCreatePage.tsx:159frontend/src/routes/project/ProjectMaskingExemptionPage.tsx:705-745frontend/src/lib/sensitive-data/utils.ts:68-100,maskingExemption.ts:80-101allow_missingcreates the policybackend/api/v1/org_policy_service.go:190-197backend/api/v1/sql_service.go:1308🤖 Generated with Claude Code