Skip to content

Commit 80ad9d7

Browse files
authored
Merge pull request #25 from andreagrandi/pii-redaction-docs-tests
Strengthen PII redaction docs and regression tests
2 parents 22e3d90 + edf7a74 commit 80ad9d7

5 files changed

Lines changed: 480 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Add `MB_SESSION_TOKEN` as an alternative authentication method for users without admin access to mint an API key; mutually exclusive with `MB_API_KEY` (#9)
66
- Document changelog update workflow in `AGENTS.md` and add a pull request template prompting contributors to update `CHANGELOG.md` for user-visible changes (#20)
77
- Move main package to `cmd/mb-cli` so `go install github.com/andreagrandi/mb-cli/cmd/mb-cli@latest` produces an `mb-cli` binary that matches the documented command name (#10)
8+
- Expand PII redaction documentation with covered commands, semantic-type enrichment behavior, known gaps, and export opt-out instructions (#17)
9+
- Enrich semantic types on non-parameterized `card run` results so PII columns from native saved questions are redacted consistently with the parameterized path (#17)
810

911
## [0.2.0] - 2026-03-12
1012

README.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,57 @@ The dashboard and parameter workflows use these Metabase endpoints:
263263

264264
## PII Redaction
265265

266-
When AI agents use mb-cli directly (via shell commands), query results containing PII (emails, names, phone numbers) flow through stdout into the model's context. This feature prevents agents from seeing sensitive data by redacting PII columns before data leaves the client layer. Agents can still cross-reference records using IDs; for actual PII values, the user can check directly in Metabase.
266+
When AI agents use mb-cli directly (via shell commands), query results containing PII (emails, names, phone numbers) flow through stdout into the model's context. mb-cli prevents that by replacing PII column values with `[REDACTED]` before data leaves the client layer. Agents can still cross-reference records using IDs; for actual PII values, the user can check directly in Metabase.
267267

268-
The approach leverages Metabase's own field semantic types (type/Email, type/Name, etc.). Redaction is ON by default — no configuration needed. Users must explicitly opt out with `MB_REDACT_PII=false` or `--redact-pii=false`. This is defense-in-depth: it makes the default path safe. An agent would have to actively choose to bypass it (a visible, auditable action).
268+
Redaction is **ON by default**. Disabling it is a visible, auditable action: when the flag or env var is set to `false`, mb-cli prints `Warning: PII redaction is disabled` to stderr on every invocation.
269+
270+
### What gets redacted
271+
272+
Columns whose Metabase field semantic type is one of:
273+
274+
`type/Email`, `type/Name`, `type/Phone`, `type/Address`, `type/City`, `type/State`, `type/ZipCode`, `type/Country`, `type/Latitude`, `type/Longitude`, `type/Birthdate`, `type/AvatarURL`, `type/URL`, `type/ImageURL`, `type/Company`.
275+
276+
For native SQL results, Metabase does not always populate the semantic type on result columns. mb-cli fills it in by fetching field metadata from the source database and matching by column name. When two fields share a name and disagree, mb-cli picks the PII type (err on the side of caution).
277+
278+
### Where redaction applies
279+
280+
| Command | Redacted? |
281+
|---------|-----------|
282+
| `query sql` | Yes (with column-name enrichment) |
283+
| `query filter` (MBQL) | Yes |
284+
| `card run` (with or without `--param`) | Yes (with column-name enrichment) |
285+
| `dashboard run-card` | Yes (with column-name enrichment) |
286+
| `table data` | Yes |
287+
| `field values` | Yes, when the field's semantic type is PII |
288+
289+
Schema and metadata commands (`database metadata`, `table metadata`, `field get`, etc.) return field definitions, not data, so there is nothing to redact.
290+
291+
### Limits and known gaps
292+
293+
- Redaction relies on Metabase semantic types. A column holding PII without a semantic type (or with a custom type not in the list above) will not be redacted. Set semantic types in Metabase to close that gap.
294+
- Derived columns from joins, aggregations, or `CASE` expressions may lose the upstream semantic type. Native SQL enrichment matches by column name only — alias derived columns to match a known PII field name if you want them caught.
295+
- Free-form `type/Description` fields are not treated as PII; if you store names or emails in description-style columns, set their semantic type explicitly.
296+
- Redaction operates on result rows, not column headers. Column names like `email` remain visible — values are replaced.
297+
298+
### Export behavior
299+
300+
When redaction is enabled, the `--export` flag on `query sql` and `query filter` is **blocked** with this error:
301+
302+
```
303+
export is not supported when PII redaction is enabled (use JSON or table format instead)
304+
```
305+
306+
Raw export bytes (CSV/XLSX/JSON straight from Metabase) cannot be post-processed reliably for redaction, so the safe default is to refuse the operation. To export, explicitly opt out of redaction for that invocation:
307+
308+
```bash
309+
MB_REDACT_PII=false mb-cli query sql --db 1 --sql "..." --export csv
310+
# or
311+
mb-cli query sql --redact-pii=false --db 1 --sql "..." --export csv
312+
```
313+
314+
### Opt-out precedence
315+
316+
The `--redact-pii` flag wins if set; otherwise `MB_REDACT_PII` is consulted; otherwise the default is `true`. Any value other than the literal string `false` is treated as enabled.
269317

270318
## License
271319

internal/client/cards.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,23 @@ func (c *Client) GetCard(id int) (*Card, error) {
4141

4242
// RunCard executes a saved question and returns the query result.
4343
func (c *Client) RunCard(id int) (*QueryResult, error) {
44-
resp, err := c.Post(fmt.Sprintf("/api/card/%d/query", id), nil)
45-
if err != nil {
46-
return nil, fmt.Errorf("failed to run card %d: %w", id, err)
47-
}
48-
49-
return c.decodeCardQueryResult(resp, 0)
44+
return c.RunCardWithParams(id, nil)
5045
}
5146

52-
// RunCardWithParams executes a saved question with parameter values.
47+
// RunCardWithParams executes a saved question, optionally with parameter values.
48+
// The card is always fetched so semantic types can be enriched for redaction,
49+
// keeping behavior consistent between parameterized and non-parameterized runs.
5350
func (c *Client) RunCardWithParams(id int, params map[string]string) (*QueryResult, error) {
54-
if len(params) == 0 {
55-
return c.RunCard(id)
56-
}
57-
5851
card, err := c.GetCard(id)
5952
if err != nil {
6053
return nil, err
6154
}
6255

63-
body := map[string]any{
64-
"parameters": buildCardQueryParameters(card, params),
56+
var body any
57+
if len(params) > 0 {
58+
body = map[string]any{
59+
"parameters": buildCardQueryParameters(card, params),
60+
}
6561
}
6662

6763
resp, err := c.Post(fmt.Sprintf("/api/card/%d/query", id), body)

tests/cards_test.go

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,24 +94,29 @@ func TestGetCard(t *testing.T) {
9494

9595
func TestRunCard(t *testing.T) {
9696
c, server := setupCardTestClient(func(w http.ResponseWriter, r *http.Request) {
97-
if r.URL.Path != "/api/card/1/query" {
98-
t.Errorf("expected path '/api/card/1/query', got %s", r.URL.Path)
99-
}
100-
if r.Method != "POST" {
101-
t.Errorf("expected POST, got %s", r.Method)
102-
}
103-
10497
w.Header().Set("Content-Type", "application/json")
105-
json.NewEncoder(w).Encode(map[string]any{
106-
"data": map[string]any{
107-
"cols": []map[string]any{
108-
{"name": "count", "display_name": "Count", "base_type": "type/Integer"},
109-
},
110-
"rows": [][]any{
111-
{42},
98+
switch r.URL.Path {
99+
case "/api/card/1":
100+
json.NewEncoder(w).Encode(map[string]any{
101+
"id": 1, "name": "User Count", "database_id": 1, "display": "scalar", "query_type": "native", "archived": false,
102+
})
103+
case "/api/card/1/query":
104+
if r.Method != "POST" {
105+
t.Errorf("expected POST, got %s", r.Method)
106+
}
107+
json.NewEncoder(w).Encode(map[string]any{
108+
"data": map[string]any{
109+
"cols": []map[string]any{
110+
{"name": "count", "display_name": "Count", "base_type": "type/Integer"},
111+
},
112+
"rows": [][]any{
113+
{42},
114+
},
112115
},
113-
},
114-
})
116+
})
117+
default:
118+
t.Errorf("unexpected path %s", r.URL.Path)
119+
}
115120
})
116121
defer server.Close()
117122

0 commit comments

Comments
 (0)