This document says what this system stores about people and how long it keeps it. It also says how to delete that data, and where the honest limits of the "PII masking" claim are.
This is not legal advice, and it is not a certification. QueryHub has no SOC 2 report and no ISO 27001 certificate. It is a self-hosted tool, so you are the controller of the data. This document tells you exactly what you are controlling. You need that, because you cannot answer a data-subject request or a DPIA question about software that will not say what it stores.
The short version. QueryHub stores who asked for what, and the SQL they asked. It does not store query results beyond a short TTL. The identities are your own staff, not your customers. But humans write the SQL text, and it can contain anything, including a customer's email address in a WHERE clause. QueryHub keeps that text for as long as it keeps the audit trail.
There are two groups, and the distinction matters for a retention argument:
| Who | Where it comes from | |
|---|---|---|
| Operators and developers | your own staff — the people who submit, approve and administer | the identity provider (Slack profile, or a local account you create) |
| Third parties | anyone whose data appears inside a SQL statement or its results | a developer types it, or a query reads it from your target databases |
The first group is unavoidable: an audit trail with no actor is not an audit trail. The second group is the one to think about. Section 4 is about how to limit it.
The tables below come from the live schema, not from memory. "Actor" means one of your own people. "Content" means free text that may contain anything.
| Table | Personal data | Retention |
|---|---|---|
requesters |
principal id, name, email, timezone | until an operator deletes it |
local_users |
username, display name, email, password hash (PBKDF2, salted — never the password) | until an operator deletes it |
admins, team_members, temp_admin_grants |
principal id, name, email, timezone | until deleted |
user_target_grants, import_grants, auto_approve_grants, user_row_limit_overrides, report_excluded_users |
principal id, plus a free-text reason |
until revoked. The rows persist after revocation as a record |
web_sessions |
principal id, user agent, avatar URL | until expiry/revocation. The retention job (migration 077) sweeps the rows |
| Table | Personal data | Retention |
|---|---|---|
requests |
requester principal id and name, approver id and name, the SQL text, decision reason | indefinite — this is the audit trail |
audit_log |
actor id and name, a details JSON blob |
indefinite |
access_requests, auto_approve_requests |
requester/approver identity, reason, the attempted SQL | indefinite |
csv_imports |
requester/approver identity, target table | indefinite |
submission_failures |
principal id and name, the rejected SQL | indefinite |
request_ratings |
principal id, free-text feedback | indefinite |
query_favorites, query_templates, web_saved_sessions |
owner principal id, saved SQL | until the owner deletes it |
Result files hold whatever the query returned. For a query against a customer
table, that is customer data. QueryHub writes them as CSV and XLSX files under
the results directory (QH_RESULTS_DIR). Result files are:
- Masked on output (section 4).
- Deleted after
bot_config.results_ttl_hours(default 72), from disk and from Slack.scripts/cleanup_old_results.pydeletes them. - Not in backups. See
docs/DISASTER_RECOVERY.md, which explicitly excludes them.
The result file is transient. The statement that produced it is not.
target_servers holds database credentials (Fernet-encrypted).
schema_tables / schema_columns hold your target schemas: table and column
names, no rows.
| Data | Default | How to change it |
|---|---|---|
| Query result files | 72 hours | bot_config.results_ttl_hours |
| Web sessions | 12 hours (refresh token) | web_refresh_token_hours. The retention job sweeps expired rows |
| Authorization-event outbox | drained continuously, then pruned | the retention job |
| Requests, audit log, ratings, failures | forever | no built-in expiry — see below |
The last row is a deliberate design decision, not an oversight. The audit trail is the product's central promise. A gateway that quietly forgets who ran what is worse than no gateway. But "forever" is a choice you make about personal data, so make it knowingly:
- If your retention policy requires a limit, add a scheduled job for it. The job
deletes or anonymises
requestsandaudit_logrows past your horizon. There is no supported tooling for that yet. It is on the roadmap as retention / partitioning. The honest interim is aDELETE ... WHERE created_at < now() - interval 'N days'. You should decide whether to keep the row with the identity nulled, rather than delete it. - If your policy allows indefinite retention of operational logs, consider
clearing
requests.querypast a horizon. The SQL text is the part most likely to contain third-party data. Keep the identity and the decision when you clear it. That preserves the accountability record and drops the content.
PII masking rewrites values as they stream into the result file. It has two layers:
- Content detectors match by value, not by column name, so an aliased or
wrapped column cannot evade them. They catch IBAN (ISO 13616 mod-97), payment
card (Luhn + network prefix), email, E.164 phone, plus the national
identifiers of the configured region pack (
bot_config.pii_region). - Column-name catalog (
pii_column_patterns) for free-text PII with no detectable shape, such as a name or an address. It matches on the result column name.
Be clear about what this is. It is accidental-exposure mitigation, not a data boundary:
- It masks what leaves QueryHub in the result file. It does not stop a developer from putting personal data in the query itself, and QueryHub retains the query.
- Layer 1 catches formats. QueryHub masks a free-text
notescolumn with a person's address only if a pattern in layer 2 matches the column name. - Layer 2 matches the output column name. QueryHub resolves lineage through derived tables, CTEs and set operations, so an alias does not defeat it. But a column that the catalog does not know stays unmasked.
- Nothing here replaces column privileges, row-level security, or masking views on the target database. The database enforces those. QueryHub enforces this, and QueryHub is not the only way into your data.
If you need a hard guarantee that nobody can ever read a column, revoke SELECT on it. Use masking for the case it is good at. In that case, a developer runs a legitimate query and does not need to see the customer's card number to answer the question.
For one of your own staff (an operator or developer):
-- 1. What identity does this person have?
-- Slack: U0XXXXXXXXX Local: local:<username>
-- 2. Everything keyed on it:
SELECT 'requests' AS t, count(*) FROM requests WHERE requester_slack_id = :p
UNION ALL SELECT 'audit_log', count(*) FROM audit_log WHERE actor_slack_id = :p
UNION ALL SELECT 'ratings', count(*) FROM request_ratings WHERE slack_user_id = :p
UNION ALL SELECT 'favorites', count(*) FROM query_favorites WHERE slack_user_id = :p
UNION ALL SELECT 'sessions', count(*) FROM web_sessions WHERE slack_user_id = :p;Access / portability is that query set. Erasure needs an explicit decision from you:
-
Directory data (
requesters,local_users,admins, grants): you can delete it outright. Delete it. Then revoke their sessions withsessions.revoke_user(). -
Audit rows: you cannot delete them without destroying the record of decisions that affected production. The usual resolution is to keep the row and pseudonymise the actor:
- Replace
actor_namewith a tombstone, and keepactor_slack_idas an opaque reference. - Or replace both with a deletion marker, if your legal basis for retention does not survive the request.
QueryHub does not decide this for you. It does not ship a script that pretends to.
- Replace
For a third party whose data appeared in a query or result, the result file
is already gone after the TTL. What remains is the SQL text in requests.query.
Search it (WHERE query ILIKE ...). Treat those rows under the same
keep-or-pseudonymise decision.
QueryHub is self-hosted. It makes no outbound network calls of its own, except to the systems you configure:
- your metadata database,
- your target databases,
- Slack's API, only if you enable the Slack profile. That is a transfer to Slack (and therefore, typically, to the US). The approval card carries the requester's identity and the SQL text, and QueryHub uploads result files as Slack files. The vanilla profile makes no Slack calls at all. Choose that configuration if a US transfer is a problem for you.
- AWS Secrets Manager, only if you select that secrets provider.
The web UI has no third-party assets. Fonts are self-hosted, and there is no CDN, no analytics, and no telemetry. Nothing sends data to the authors of QueryHub. There is no "usage statistics" switch, because there is no such feature.
The optional metrics dashboard publishes to an object store that you name.
It contains identities and the access matrix. See the warning in
docs/OPERATIONS.md about access control for that bucket.
- No built-in retention for
requests/audit_log(section 3). - No erasure tooling (section 5). This is by choice, because the correct action is a policy decision. But it does mean the work is manual.
- No append-only or externally-anchored audit log yet. An operator with database
access can edit
audit_log. The control-plane grant block exists so that nobody can do it through QueryHub. But a DBA with direct access is outside the tool's control. Immutable audit is on the roadmap. - No DPA template, no sub-processor list. There is no vendor here to sign one. You are the processor.
- No certifications. If a buyer requires SOC 2, this is not that.