Skip to content

feat(masking): mask column values in the grid, exports, AI and MCP - #3978

Merged
datlechin merged 6 commits into
TableProApp:mainfrom
J2TeamNNL:feat/masking-rules
Nov 1, 2026
Merged

datlechin merged 6 commits into
TableProApp:mainfrom
J2TeamNNL:feat/masking-rules

Conversation

@J2TeamNNL

@J2TeamNNL J2TeamNNL commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Masking rules per connection, in Options > Masking of the connection form. A rule matches a column by table.column or column pattern (* and ? wildcards, any case), or by declared type, and picks a mask: Full (••••), Last 4 Characters, Email (j•••@example.com), Stable Hash or NULL. The first matching rule wins.
  • Rules are stored in the connection's additionalFields under maskingRules, so iCloud sync and the Team Catalog carry them with no new CloudKit field. A rule saved while a window is open masks the rows it already shows, in every tab and result set; a removed rule takes effect on the next run. A rule with a style or target from a newer release decodes as Full, so it never shows a value.
  • One type, ColumnMasks, masks every row. It is applied where values enter or leave the app:
    • TableRows, the store behind the grid, query results, Row Details, Copy and Copy As, charts, Export Results, JSON mode and the assistant's results summary. Every write of fetched values (first page, reload, load more, a value loaded later) goes through it, and a masked cell takes no edit.
    • MaskingExportDataSource wraps the data source File > Export and the streaming query export hand to every format plugin, for streamRows and the plugin's own execute. A query export matches by column, since its export table is named after the file. Rows that arrive before a header are refused, not passed through. Export Results masks a large value it loads from the server.
    • DatabaseAccessBridge.runStatement and runScript, which every MCP tool, the AI chat tools, AppleScript and Shortcuts read through. browse_table names its table so table.column rules match by table there.
    • The foreign key preview and the JSON inspector's referenced rows, Server Dashboard tiles and the Routine Debugger result grid.
  • A query result has no source table on most engines, so there a table.column rule matches by column name. This masks more, never less. A query that renames or computes the column (SELECT email AS e) is not caught; the docs say so, and a Type rule narrows it.
  • Rules fail closed: a rule with an unknown style masks Full, and one with an unknown target, no pattern, or stored JSON that cannot be read masks every column.
  • The header of a masked column shows an eye-slash symbol, a tooltip line and an accessibility note.
  • Editing: the issue says a masked cell is read-only until Reveal. A grid holding any masked column is read-only, with the refusal "These rows show masked values, which cannot be written back. Reveal them to edit." A masked key cannot find its row, and a duplicated or pasted row would insert the mask, so locking only the cell was not safe.
  • Reveal: right-click a masked header, choose Reveal Masked Values…, pass Touch ID or the macOS password. The tab reloads unmasked and stays so until it closes or opens another table; the flag is never saved. A query tab reruns only when its statement is a read; otherwise it reveals on the next run, so Reveal never repeats a write.
  • Display As (feat(display-format): decode igbinary and Avro values #3962 and the Protobuf follow-up) formats the value the grid holds, which is already masked, so a decoder never sees a raw value. Neither change touches the other's files.

MCP and AI: no scope, gate or tool is widened. Masked columns now come back masked from browse_table, execute_query, execute_script and export_data, and from the in-app assistant's query tool. Filters and sorts in a request still run on the stored values.

Left open:

  • Rules per connection tag. Tags sync through their own CloudKit record with no free-form field, so this needs a new field deployed to Production first.
  • Copy To, Copy Objects and Compare read and write stored values. They move data between databases, and masking there would write the mask into the target.
  • The iOS app has its own grid and export code and does not read these rules yet.

Docs: TableProApp/docs#154, to merge after this one.

Test plan

  • MaskingPolicyTests: pattern matching by table, column, wildcard and type; each mask; the stable hash; rule order; round trips through additionalFields and iCloud sync; unknown styles decode as Full.
  • MaskingTableRowsTests and MaskingGridTests: a fetched result lands masked through applyPhase1Result; reload, load more and a later loaded value stay masked; a rule added later masks loaded rows; Copy as JSON copies the mask; the grid is read-only; Reveal is refused without authentication, applies to its own tab only, and turns the masks off for that tab.
  • MaskingExportTests: File > Export streams by table and by object come back masked, a query export ignores its file name, a plugin's own query is masked, rows before a header are refused, Export Results masks a value loaded from the server.
  • MaskingBridgeTests: a statement, every result set of a script, and the JSON an MCP client receives hold no raw value; a named table narrows a table.column rule.
  • ConnectionFormEditsTests and ConnectionBundleExporterTests: the form saves and clears rules; a Team Catalog bundle carries them.
  • Built with xcodebuild build-for-testing -testPlan Unit CODE_SIGNING_ALLOWED=NO on Xcode 27.2. This Mac has no signing profile, so the hosted suites run in CI.

Fixes #3615

@J2TeamNNL

J2TeamNNL commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

The red macOS Tests check is not from this PR. main at e6fca8d does not compile its tests: TableProTests/Core/Menu/MainMenuBuilderTests.swift:789 sets context.isCurrentTabEditable, which MenuValidationContext no longer has after #3961 and #3958 landed together. This PR leaves that file as it is on main.

CI passed in full on 3bf4dc5, the commit before the merge. I will merge main again once it builds.

@datlechin
datlechin enabled auto-merge (squash) November 1, 2026 09:09
@datlechin
datlechin merged commit c1959db into TableProApp:main Nov 1, 2026
8 checks passed
datlechin added a commit that referenced this pull request Nov 2, 2026
Brings main's 43 commits, among them the Pro plan name (#3989), column
masking (#3978) and the SSH host name hardening (GHSA-94m6-qx4g-cpwg).

- LicenseTier, LicensePresentationTests, LicenseTierTests: stay deleted, since
  accounts replace license keys; the next commit renames the plan in
  AccountCodes instead.
- ProFeature: keeps paidPlanRequirement; main changed only a comment on
  requiredTier, which this branch dropped.
- TeamLibraryModels and its tests: stay deleted with /v1. The refused SSH host
  check main added to sanitized() now guards TeamLibraryChange.connection,
  and its test moves to TeamLibrarySyncTests.
- MainEditorContentView: the Query Insights refresh main added reads
  FeatureAccess, not LicenseManager.
- project.yml: the app links main's TableProInfluxDBCore and
  TableProQdrantCore; TableProAnalytics stays out.
- Localizable.xcstrings: merged key by key; no key changed on both sides.
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.

Masking rules that redact column values in the grid, exports, AI and MCP

2 participants