Where data goes¶
What QueryAPIGate keeps, where, for how long, who can read it - and what it never keeps. For a record of processing (GDPR Article 30), a data-classification review, or a security questionnaire's data section (ADR 0016). Every table of the store, every setting that bounds what is kept, and every place data leaves the process is on this page: a test fails if one isn't. Walkthrough, answering a questionnaire from a running server: Answer a security questionnaire about data and cryptography.
What passes through, and isn't kept¶
QueryAPIGate sits between callers and databases. The rows a query returns pass through and aren't kept: they're streamed or paged to the caller, after column masking, and written nowhere - with two exceptions you turn on: the response cache and exports.
Parameter values aren't kept either. A saved query's parameters are bound, not written into the SQL; run history
records which query ran and with which parameter names, never their values; traces never hold a value
(ADR 0010); the access log leaves the query string out
(QUERYAPIGATE_ACCESS_LOG_QUERY, below).
Personal data it handles¶
| Category | Where it comes from | Why it's kept |
|---|---|---|
| Callers' names | API key names; jwt:<subject> for a signed-in user - often an email address |
To say who ran what, apply their limits and quotas |
| Administrators' names and email addresses | Created by an owner, or from SSO | To say who changed what |
| IP addresses | Every request | Rate limits, IP allow-lists, the access log, traces |
| Whatever the connected databases hold | Your databases | Passes through - kept only where listed below |
| An agent's write arguments | An AI agent asking to write (ADR 0008) | So a person can approve exactly what runs |
The store¶
queryapigate.db, or the PostgreSQL store. Read through the Management API by administrators, each to what their
role's capabilities allow (Administrators and admin roles).
| Table | Holds | Personal data | Kept for | Read by |
|---|---|---|---|---|
connections |
Each connection's settings; its password encrypted (QUERYAPIGATE_SECRET_KEY) or a reference |
- | Until deleted | connections.read (passwords masked) |
saved_queries, saved_query_versions |
Saved queries and every version: SQL, parameters, descriptions, the author's name | Author | Until deleted | queries.read |
descriptions |
Descriptions of tables and columns, and who wrote each | Administrator's name | Until changed | connections.read; agents |
execution_history |
Each run: when, which query and version, the caller, the transport, rows, time, status, QueryAPIGate's error message; an ad-hoc run's parameter names and its SQL as QUERYAPIGATE_HISTORY_ADHOC_SQL says (text, hash, none) |
Caller | The newest QUERYAPIGATE_HISTORY_LIMIT runs per version and QUERYAPIGATE_HISTORY_ADHOC_LIMIT ad-hoc runs - or QUERYAPIGATE_HISTORY_RETENTION_DAYS days; QUERYAPIGATE_HISTORY_SAMPLE_RATE keeps only a share |
observe |
events |
The event log (ADR 0020): each run as /events sends it - the same fields as its execution_history entry, every run whatever the sample rate |
Caller | QUERYAPIGATE_EVENTS_RETENTION_HOURS (24 by default) |
Each key its own events, through /events; the admin key every one |
usage_counters |
Calls and rows per caller per day and month (quotas, ADR 0011) | Caller | 400 days | observe |
audit_log, audit_chain, audit_delivery |
Every administrative change: who, how they signed in, what changed (passwords only as "changed"); its hash chain; how far each SIEM output has got | Administrator's name | The newest QUERYAPIGATE_AUDIT_LOG_LIMIT entries - more while a SIEM output is behind |
observe |
api_keys, roles |
Keys' names, grants and the SHA-256 of their secrets; when each was last used | Key names | Until deleted | access.read (never a secret) |
administrators, admin_tokens |
Administrators' names, roles, email addresses, SSO subjects, when last seen; their tokens' hashes | Name, email | Until deleted | admins.read |
workspaces, workspace_members |
Each workspace's name and description; which administrators belong to which, with what role, added by whom (ADR 0023) | Administrators' names | Until deleted - a member until removed or their administrator is deleted | admins.read |
admin_sessions |
Console sessions through SSO, by the hash of their id | Administrator's name | QUERYAPIGATE_SSO_SESSION_HOURS |
- |
sso_logins |
A sign-in under way: its state and PKCE verifier | - | 10 minutes | - |
write_requests |
An agent's write: its arguments, who asked, who approved or rejected it, the result | Caller, approver; the arguments may be personal | 30 days | writes.approve |
destinations, exports |
Where exports go, with credentials encrypted or as references; each export's watermark - the last value of its incremental column it delivered | The watermark may be a value | Until deleted | exports.read (secrets masked) |
instances |
Each running process: host name, process id, version | - | 1 day after it stops | observe |
config_managed, config_applied |
What a configuration file manages, and each file applied, by its hash (ADR 0014) | - | Until deleted | observe |
server_secrets |
Keys the server makes for itself - masking's hash key, unless QUERYAPIGATE_MASK_KEY is set |
- | For the store's life | - |
schema_version |
The store's schema version | - | - | - |
The response cache and Redis¶
| Where | Holds | Kept for |
|---|---|---|
The response cache (in the process, or Redis with QUERYAPIGATE_REDIS_URL) |
Responses of saved queries that set cache_ttl - rows, after masking - by query, version, parameter values, format and page; never by caller |
The query's cache_ttl |
| Redis's rate-limit counters | Calls per key and per client address in the current window | The window |
| Redis's quota counters | Calls and rows per caller per period | 40 days |
| Redis's limits | Queries running now, per caller (ADR 0011) | While they run |
Where data is sent¶
| To | What | Bounded by |
|---|---|---|
| The log (stdout) | One line per request: method, path, status, time, the caller's name, the request id - not the query string; each statement's SQL, or with QUERYAPIGATE_HISTORY_ADHOC_SQL hash or none, its SHA-256; warnings and errors - a database's own error message among them, which can quote a value. QUERYAPIGATE_JSON_LOGS for JSON |
Your log platform |
The access log (the image's gunicorn, queryapigate serve) |
Client address, time, method, path without the query string, status, size, time taken, request id, user agent - never the referrer. QUERYAPIGATE_ACCESS_LOG_QUERY=1 keeps the query string |
Your log platform |
Traces (OTEL_EXPORTER_OTLP_ENDPOINT) |
Spans: the route, the saved query's name, rows, the caller's name and address - never a parameter value, row, statement or credential (Tracing) | Your collector |
/metrics |
Counts and timings by connection, query and API key name; every signed-in user under one jwt label, every administrator under one admin |
Your Prometheus - and whoever can reach /metrics, unless QUERYAPIGATE_METRICS_TOKEN is set (the Helm chart sets it) |
The audit log's copies (QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE, QUERYAPIGATE_AUDIT_SYSLOG_URL, QUERYAPIGATE_AUDIT_OTLP) |
Every audit entry, with its place in the chain | Your file and SIEM |
Live events (/events) |
Each run as it happens: the same fields as run history | Not kept |
| AI agents (MCP) | Rows, after masking, up to QUERYAPIGATE_MCP_MAX_ROWS a call; descriptions |
What the agent keeps |
| Exports (destinations) | A saved query's rows, after the export's masking, to the bucket or folder it names | Your storage |
| Secret providers (Vault, AWS) | Requests for the secrets connections name (ADR 0015) | - |
What a deployer controls¶
- Run history: how much is kept (
QUERYAPIGATE_HISTORY_LIMIT,QUERYAPIGATE_HISTORY_ADHOC_LIMIT,QUERYAPIGATE_HISTORY_RETENTION_DAYS), whether every run is (QUERYAPIGATE_HISTORY_SAMPLE_RATE), and what of an ad-hoc statement (QUERYAPIGATE_HISTORY_ADHOC_SQL). - The audit log: how many entries the store keeps (
QUERYAPIGATE_AUDIT_LOG_LIMIT); its copies elsewhere are yours to keep for as long as your obligations say. - What leaves: column masking, by key, role and export; the response cache, per query; the access log's query strings; tracing, when an endpoint is set.
- A person's data, on request: below.
A person asks for their data to be erased¶
A person who signs in to call QueryAPIGate is named jwt:<subject> - their email address, often - in run history
and usage counts. To erase them:
queryapigate history forget jwt:alice@example.com --dry-run # how many runs, events and usage periods
queryapigate history forget jwt:alice@example.com
or DELETE /api/v1/history?caller=jwt:alice@example.com (owners only, history.forget). Their runs, events and usage
counts - in the store and in Redis - are deleted, and the erasure is recorded in the audit log as forget_caller with the
caller's SHA-256 and the counts, not their name. That record is pseudonymous: someone who already knows the name can
confirm that it was erased - which is how you show a request was met - but it names no one to anyone who doesn't.
Their quota's count for the period starts again.
With several instances, runs another instance has queued but not yet written - for at most
QUERYAPIGATE_HISTORY_FLUSH_INTERVAL, a second by default - can land just after. Check with --dry-run a few seconds
later, or run it again: it finds what arrived since.
What isn't erased, and what to say:
- The audit log names administrators acting in their role - keep it under legitimate interest, for as long as your accountability obligations require. It is hash-chained (ADR 0012): an entry can't be removed without breaking the chain. A person who isn't an administrator isn't in it.
- Write requests - an agent's, naming the caller - are kept 30 days, then deleted.
- Logs, traces and SIEM copies are in your platforms, under their retention.
- The connected databases are yours: QueryAPIGate doesn't hold their data.
Cryptography¶
Every algorithm QueryAPIGate uses is FIPS-approved (FIPS 140-3, SP 800-131A); a test fails if the code gains one that
isn't - MD5, SHA-1, BLAKE2, or random for anything that must be unpredictable.
| Protects | Algorithm | Through |
|---|---|---|
Connection and destination passwords at rest (QUERYAPIGATE_SECRET_KEY) |
Fernet: AES-128-CBC and HMAC-SHA256 | cryptography |
| API keys, admin tokens, Console sessions, sign-in state - stored as hashes | SHA-256 | hashlib |
The audit log's chain (QUERYAPIGATE_AUDIT_KEY) |
HMAC-SHA256 (SHA-256 without a key) | hmac, hashlib |
Column masking's hash |
HMAC-SHA256 | hmac |
| PKCE for Console sign-in | SHA-256 (S256) |
hashlib |
| Secrets it makes - keys, tokens, verifiers, ids | The operating system's random source | secrets |
| Signed-in users' tokens | RS256 with a JWKS, HS256 with a shared secret - or QUERYAPIGATE_JWT_ALGORITHMS (keep to RS*, PS*, ES*, HS*) |
PyJWT, cryptography |
| TLS: to Vault, the SIEM, the OTLP collector, Redis | As the platform's OpenSSL negotiates | Python's ssl |
| RDS IAM tokens | AWS Signature Version 4: HMAC-SHA256 | botocore |
| Cache keys, ETags, configuration files' hashes - not secrets | SHA-256 | hashlib |
random is used only where nothing depends on it being unpredictable: which runs history samples, and the example
database, from a fixed seed.
On a FIPS-validated module¶
Validation is a property of the cryptographic module, not of the application: QueryAPIGate is FIPS-ready when it runs on a validated OpenSSL in FIPS mode, and that is a choice of platform.
- Use a distribution whose OpenSSL is validated, in FIPS mode - Red Hat Enterprise Linux 9 (booted with
fips=1, orfips-mode-setup --enable), or Ubuntu Pro FIPS. A container uses its host's mode. - Python and
cryptographyfrom the distribution, not PyPI - PyPI'scryptographywheel carries its own OpenSSL, which is not a validated module. On RHEL 9:
dnf install python3.12 python3.12-pip python3.12-cryptography
python3.12 -m venv --system-site-packages /opt/queryapigate
/opt/queryapigate/bin/pip install "queryapigate[server,postgres]" # keeps the distribution's cryptography
- Check it: both must say so.
/opt/queryapigate/bin/python -c "import hashlib; hashlib.md5(b'x')" # ValueError: unsupported
/opt/queryapigate/bin/python -c "from cryptography.hazmat.backends.openssl import backend; print(backend._fips_enabled)"
- Database drivers bring their own TLS.
psycopg2-binarycarries its own libpq and OpenSSL: on a FIPS platform, buildpsycopg2from source against the system's libpq (dnf install libpq-devel gcc python3.12-devel, thenpip install psycopg2 --no-binary psycopg2).mysql-connector-python's C extension carries its own OpenSSL too: MySQL connections are outside the validated boundary unless your connector build uses the system's. Also outside QueryAPIGate's control: DuckDB's own TLS for remote files, and a JDBC or H2 connection's Java runtime - use a FIPS-configured JRE, or don't use them there. - What your platform's FIPS policy refuses is refused - a JWT algorithm in
QUERYAPIGATE_JWT_ALGORITHMSits module doesn't offer, a TLS cipher it doesn't allow - with an error, never a weaker fallback.
Tested in CI: a job runs the whole suite in Red Hat UBI 9 with OpenSSL's FIPS provider forced
(OPENSSL_FORCE_FIPS_MODE=1), Python 3.12 and cryptography from the distribution - and first checks that FIPS is
really in force: MD5 refused, cryptography on the system's OpenSSL, in FIPS mode. RHEL 9's SQLite is 3.34, older than
the 3.35 that RETURNING and DROP COLUMN need: an agent's write with RETURNING to a SQLite database fails there,
and the two tests that need 3.35 skip.