Load the bot environment first: set -a; source /etc/queryhub/env; set +a.
Most commands assume it, and anything that connects to the bot DB needs it.
This page holds all CLI commands, DB configs and admin SQL snippets in one place, ready to copy and paste. Each section stands alone, so read only what you need.
Not in this document:
- Backup and restore of the control plane, and what happens if you lose the master key: DISASTER_RECOVERY.md.
- Replacing the master key without downtime: KEY_ROTATION.md.
- What personal data the system stores, for how long, and how to answer a data-subject request: COMPLIANCE.md.
- Bot lifecycle
- Encrypted secrets
- Bot config knobs (bot_config table)
- Migrations
- Targets — add / disable / rotate
- Admins
- Requesters (allowlist + bypass)
- Teams + members + grants
- Per-user grants (overrides)
- Audit / inspection queries
- Master key + crypto
- Maintenance
- Ratings & feedback
- Multi-statement / SET prelude
- Product metrics (p_metrics_*)
- Admin scopes (role-based approval)
- Keeping real identifiers out of what you share
- Batch submissions (
/sql batch) - Auto-approve grants
- Milestone annotations
- Temporary admin grants (vacation / on-call coverage)
- Excluding test traffic from product metrics
- Publishing the metrics dashboard to S3
- Monitoring: /metrics and structured logs
- Super-admin elevation on a target
- Read replicas
- Verifying target certificates
- Splitting the metadata database roles
# Status / health
systemctl is-active queryhub
systemctl status queryhub --no-pager | head -15
# Restart (after any code or env change)
sudo systemctl restart queryhub
# Live logs
sudo journalctl -u queryhub -f
# Last 50 lines
sudo journalctl -u queryhub -n 50 --no-pager
# Errors only, last hour
sudo journalctl -u queryhub --since "1 hour ago" --no-pager | grep -iE "ERROR|WARN"Service file: /etc/systemd/system/queryhub.service →
WorkingDirectory=<repo-path>, EnvironmentFile=/etc/queryhub/env. The
service runs as the user that owns the repo (the user you chose at install
time). For the placeholder substitution, see deploy/INSTALL.md.
Update flow: cd <repo-path> && git pull && sudo systemctl restart queryhub.
The 3 secrets (SLACK_BOT_TOKEN, SLACK_APP_TOKEN, BOT_DB_PASSWORD) live
encrypted in /etc/queryhub/secrets.enc (Fernet via master.key). The
plaintext /etc/queryhub/env no longer contains any secrets.
# Show metadata (which keys are present, mode, mtime — never values)
sudo .venv/bin/python scripts/manage_env_secrets.py list
# First-time setup (prompts for all 3, hidden input)
sudo .venv/bin/python scripts/manage_env_secrets.py init
# Update a single key
sudo .venv/bin/python scripts/manage_env_secrets.py set SLACK_BOT_TOKEN
sudo .venv/bin/python scripts/manage_env_secrets.py set BOT_DB_PASSWORD
# Soft-delete the file (renames to .deleted[_N]; bot needs env-var
# fallback to start after this)
sudo .venv/bin/python scripts/manage_env_secrets.py remove
# Always restart after changes
sudo systemctl restart queryhubFile format: line 1 is SLBOT_SECRETS_v1, a signature that lets a human
identify the file. Line 2 is the Fernet ciphertext. The permissions must be
0600: the loader rejects any group/other bits.
All runtime tunables live here. Edit them with SQL. The bot reads them on each relevant operation, so a change needs no restart unless noted.
-- See everything:
SELECT key, value, left(description, 100) AS desc FROM bot_config ORDER BY key;
-- Update a single key:
UPDATE bot_config SET value = '<new>' WHERE key = '<key>';| Key | Default | Restart? | What it does |
|---|---|---|---|
bot_display_icon |
:query_hub: |
no | Emoji that the bot uses as its avatar in chat. Upload it to the workspace as a custom emoji. |
bot_display_name |
QueryHub |
no | Username shown in chat |
csv_size_mb |
10 |
no | Max CSV file size. Result streaming aborts at this cap |
kill_switch |
off |
no | Master kill switch. on blocks new submissions and scheduler dispatch. Admins can still approve |
kill_switch_message |
(banner text) | no | Ephemeral message shown when kill_switch is on |
log_level |
INFO |
yes | Python logging level |
max_open_requests_per_user |
5 |
no | Cap on in-flight requests per non-admin Slack user (pending/approved/scheduled/executing) |
max_rows |
1000 |
no | Max rows returned in CSV |
max_schedule_days |
7 |
no | Max future days for /sql scheduling. Set 0 to disable scheduling |
min_query_length |
6 |
no | The bot rejects queries shorter than this |
pre_flight_explain |
off |
no | Runs EXPLAIN at modal-submit time to catch typos. RO queries only: the bot skips RW/DDL automatically |
query_plan_logging |
off |
no | When pre_flight_explain AND this key are on, the bot stores the EXPLAIN plan in requests.explain_plan (capped at 64KB) |
query_timeout_sec |
300 |
no | Per-query statement_timeout (5 min) |
require_justification |
false |
no | Requires the justification field for RO queries too. RW/DDL always require it |
results_ttl_hours |
72 |
no | How long the bot keeps CSV results, in Slack and locally, before cleanup deletes them. The default is short on purpose: it limits how long sensitive result data stays in Slack |
rating_enabled |
on |
no | Sends a DM with a 1-5 rating prompt after every terminal-state request. The bot suppresses it for 30 days after a user's most recent rating. See section 13 |
set_allowed_params |
(~23 params) | no | Comma-separated list of Postgres parameters allowed in a SET LOCAL prelude. See section 14 |
Common toggles:
-- Maintenance window: stop new traffic
UPDATE bot_config SET value = 'on' WHERE key = 'kill_switch';
-- Resume:
UPDATE bot_config SET value = 'off' WHERE key = 'kill_switch';
-- Tighten / loosen the row cap:
UPDATE bot_config SET value = '5000' WHERE key = 'max_rows';
-- Disable scheduling entirely:
UPDATE bot_config SET value = '0' WHERE key = 'max_schedule_days';
-- Boost a power-user (admin always exempt; this raises the cap globally):
UPDATE bot_config SET value = '20' WHERE key = 'max_open_requests_per_user';
# RO-burst nudge (modal banner that offers a short RO auto-approve window):
# ro_burst_threshold — min RO requests in the window before the nudge shows (default 3)
# ro_burst_window_min — look-back window in minutes for that count (default 10)
# ro_window_minutes — length of the auto-approve window granted on approval (default 60)
UPDATE bot_config SET value = '5' WHERE key = 'ro_burst_threshold';
UPDATE bot_config SET value = '15' WHERE key = 'ro_burst_window_min';
UPDATE bot_config SET value = '120' WHERE key = 'ro_window_minutes';
# Stale-grant reaper (scripts/reap_stale_grants.py, run daily):
# grant_reaper_enabled — master switch (on|off). OFF = dry-run report only.
# grant_idle_revoke_days — soft-revoke a user grant idle this many days (default 30)
# Review dry-runs first (.venv/bin/python scripts/reap_stale_grants.py), then enable:
UPDATE bot_config SET value = 'on' WHERE key = 'grant_reaper_enabled';Migrations are idempotent SQL files under migrations/, numbered in sequence.
# Apply all (skips already-applied; commits per file)
.venv/bin/python scripts/apply_migrations.py
# What's applied: the runner records each file in the schema_migrations
# ledger (version + checksum), so a re-run applies only what is pending.
.venv/bin/python scripts/apply_migrations.py --dry-runTo add a new migration:
- Create
migrations/NNN_name.sqlwith idempotent DDL/DML - Run
apply_migrations.py - Commit the file
Files stay idempotent even with the ledger (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, ON CONFLICT DO NOTHING). Never edit an applied file. The ledger
refuses a changed checksum, so put each change in a new file.
A target is a Postgres cluster that the bot can query. Each target is a row in
target_servers, with Fernet-encrypted credentials per tier (RO is required,
RW/DDL are optional).
-- All targets (creds masked):
SELECT id, alias, host, port, default_database, enabled,
(username IS NOT NULL) AS has_ro_creds,
(username_rw IS NOT NULL) AS has_rw_creds,
(username_ddl IS NOT NULL) AS has_ddl_creds,
notes
FROM target_servers
ORDER BY alias;
-- One target's grants (who has access):
SELECT * FROM v_user_targets WHERE target_alias = 'acme-prod-orders';.venv/bin/python scripts/encrypt_secret.py
# Prompts twice (no echo), prints Fernet ciphertext to stdout.
# Paste into the SQL below.INSERT INTO target_servers
(alias, host, port, default_database,
username, password_encrypted, enabled, notes)
VALUES
('acme-prod-orders',
'acme-prod-orders.<aws-id>.<region>.rds.amazonaws.com',
5432,
'orders',
'queryhub_ro',
'<paste Fernet ciphertext from encrypt_secret.py>',
TRUE,
'orders cluster, prod');UPDATE target_servers
SET username_rw = 'queryhub_rw',
password_rw_encrypted = '<paste Fernet ciphertext>'
WHERE alias = 'acme-prod-orders';UPDATE target_servers
SET username_ddl = 'queryhub_ddl',
password_ddl_encrypted = '<paste Fernet ciphertext>'
WHERE alias = 'acme-prod-orders';-- Same as add — just overwrite:
UPDATE target_servers
SET password_encrypted = '<new Fernet ciphertext>'
WHERE alias = 'acme-prod-orders';UPDATE target_servers SET enabled = FALSE WHERE alias = 'acme-prod-orders';
UPDATE target_servers SET enabled = TRUE WHERE alias = 'acme-prod-orders';.venv/bin/python scripts/import_targets_from_inventory.py
# Adds new endpoints DISABLED, with the sentinel 'PASSWORD_NOT_SET' password:
# fill real credentials, then enable. A name already in use is settled by
# targets.claim_alias() -- see SCHEMA.md, target_servers.alias.Admins approve or reject requests. They bypass team grants and the allowlist, and their queries can run against any enabled target.
-- List active admins
SELECT slack_user_id, name, email, enabled, added_at
FROM admins WHERE enabled = TRUE ORDER BY added_at;
-- Add an admin
INSERT INTO admins (slack_user_id, name, added_by)
VALUES ('U01ABCDEFG', 'Person Name', 'system');
-- Disable (reversible)
UPDATE admins SET enabled = FALSE WHERE slack_user_id = 'U01ABCDEFG';
-- Re-enable
UPDATE admins SET enabled = TRUE WHERE slack_user_id = 'U01ABCDEFG';The bot refreshes name and email from Slack automatically on every
interaction (profile_sync). You can leave them blank on insert.
This table is the allowlist for /sql. Only enabled requesters can submit.
Exception: if the table is empty (zero enabled rows), the bot is OPEN to
everyone in the workspace.
-- List
SELECT slack_user_id, name, email, enabled, bypass_team_grants
FROM requesters ORDER BY added_at;
-- Add
INSERT INTO requesters (slack_user_id, name, added_by)
VALUES ('U01ABCDEFG', 'Person Name', 'system');
-- Disable / re-enable
UPDATE requesters SET enabled = FALSE WHERE slack_user_id = 'U01ABCDEFG';
UPDATE requesters SET enabled = TRUE WHERE slack_user_id = 'U01ABCDEFG';bypass_team_grants = TRUE lets the user see every enabled target, with the
same visibility as an admin. The user can also submit any tier (a synthetic
'ddl' grant on everything). The user is still NOT an admin, and cannot approve
or reject.
-- Grant bypass
UPDATE requesters
SET bypass_team_grants = TRUE
WHERE slack_user_id = 'U01ABCDEFG';
-- Remove bypass
UPDATE requesters
SET bypass_team_grants = FALSE
WHERE slack_user_id = 'U01ABCDEFG';Teams own targets (through team_target_grants). Users belong to teams
(through team_members). On a given target, a user's effective grant is the
most permissive of their team grants.
-- Teams summary
SELECT * FROM v_team_summary ORDER BY name;
-- Members of a team
SELECT t.name AS team, tm.slack_user_id, r.name
FROM team_members tm
JOIN teams t ON t.id = tm.team_id
LEFT JOIN requesters r ON r.slack_user_id = tm.slack_user_id
WHERE t.name = 'payments'
ORDER BY tm.added_at;
-- All grants of a team
SELECT t.name AS team, ts.alias AS target, g.mode,
g.allowed_databases, g.target_role
FROM team_target_grants g
JOIN teams t ON t.id = g.team_id
JOIN target_servers ts ON ts.id = g.target_server_id
WHERE t.name = 'payments';
-- Effective view for a single user
SELECT * FROM v_effective_user_grants
WHERE slack_user_id = 'U01ABCDEFG';-- New team
INSERT INTO teams (name, description) VALUES ('payments', 'Payments squad');
-- Add a member
INSERT INTO team_members (team_id, slack_user_id)
SELECT id, 'U01ABCDEFG' FROM teams WHERE name = 'payments'
ON CONFLICT DO NOTHING;
-- Remove a member
DELETE FROM team_members
WHERE team_id = (SELECT id FROM teams WHERE name = 'payments')
AND slack_user_id = 'U01ABCDEFG';
-- Grant a team RO on a target (entire RDS = NULL allowed_databases)
INSERT INTO team_target_grants
(team_id, target_server_id, allowed_databases, mode, target_role)
VALUES
((SELECT id FROM teams WHERE name = 'payments'),
(SELECT id FROM target_servers WHERE alias = 'acme-prod-orders'),
NULL, 'ro', NULL)
ON CONFLICT (team_id, target_server_id) DO UPDATE
SET mode = EXCLUDED.mode,
allowed_databases = EXCLUDED.allowed_databases;
-- Same but restrict to specific DBs
INSERT INTO team_target_grants
(team_id, target_server_id, allowed_databases, mode, target_role)
VALUES
((SELECT id FROM teams WHERE name = 'payments'),
(SELECT id FROM target_servers WHERE alias = 'acme-prod-orders'),
ARRAY['orders', 'invoices'], 'rw', NULL);
-- Upgrade a grant to RW
UPDATE team_target_grants
SET mode = 'rw'
WHERE team_id = (SELECT id FROM teams WHERE name = 'payments')
AND target_server_id = (SELECT id FROM target_servers WHERE alias = 'acme-prod-orders');
-- Revoke entire grant
DELETE FROM team_target_grants
WHERE team_id = (SELECT id FROM teams WHERE name = 'payments')
AND target_server_id = (SELECT id FROM target_servers WHERE alias = 'acme-prod-orders');target_role lets the executor run SET LOCAL ROLE <name> for queries from
this team. This adds an extra defense layer at the cluster level. It is
optional, but recommended for the RW/DDL tier. It is not yet provisioned for
the pilot.
# Generate runbook for currently-unprovisioned (team, target) pairs:
.venv/bin/python scripts/plan_team_role_provisioning.py > /tmp/runbook.mdThe runbook lists the psql commands to run on each target cluster. It also
gives the UPDATE team_target_grants SET target_role = ... statement that
sets the role on the bot side.
user_target_grants overrides team grants for a specific (user, target).
Use it sparingly. Prefer team grants.
-- See overrides for a user
SELECT ts.alias AS target, ug.mode, ug.allowed_databases
FROM user_target_grants ug
JOIN target_servers ts ON ts.id = ug.target_server_id
WHERE ug.slack_user_id = 'U01ABCDEFG';
-- Grant a single user RW on one target (regardless of their team grant)
INSERT INTO user_target_grants
(slack_user_id, target_server_id, allowed_databases, mode)
VALUES
('U01ABCDEFG',
(SELECT id FROM target_servers WHERE alias = 'acme-prod-orders'),
NULL, 'rw')
ON CONFLICT (slack_user_id, target_server_id) DO UPDATE
SET mode = EXCLUDED.mode,
allowed_databases = EXCLUDED.allowed_databases;
-- Grant on every enabled target at once (e.g. for a power user)
INSERT INTO user_target_grants
(slack_user_id, target_server_id, allowed_databases, mode)
SELECT 'U01ABCDEFG', ts.id, NULL, 'rw'
FROM target_servers ts WHERE ts.enabled = TRUE
ON CONFLICT (slack_user_id, target_server_id) DO UPDATE
SET mode = EXCLUDED.mode;
-- Drop overrides for a user
DELETE FROM user_target_grants WHERE slack_user_id = 'U01ABCDEFG';With access_model_v2 on (the live model, see
SCHEMA.md → Access model), the resolver does not read
this table. The mirror copies every write above into access_grant: one row
per database, marked mirrored_from = 'user_target_grants'. The resolver reads
that row, so the statements above still work.
A person's own grant replaces their teams' grants on the whole server, not
only on the databases it names. So a personal grant on one database of a
server hides a team grant that the person has on another database there. To
keep the team's grants as well, set merge_with_team on the mirrored row:
-- Keep a person's team grants beside their own grant on one server
-- (dry-run it as a SELECT first, and write an audit_log row in the same
-- transaction)
UPDATE access_grant g SET merge_with_team = TRUE
FROM principal_identity i
WHERE i.provider = 'slack' AND i.external_id = 'U01ABCDEFG' AND NOT i.is_deleted
AND g.principal_id = i.principal_id
AND g.target_id = (SELECT id FROM target_servers WHERE alias = 'acme-prod-orders')
AND NOT g.auto_approve AND g.revoked_at IS NULL AND NOT g.is_deleted;The flag stays until the person's user_target_grants row changes scope or
tier. Then the mirror replaces the row without the flag. Set the flag again
after such a change. The Effective access screen shows the result: under the
team's grant, it names each member whose own grant applies instead.
SELECT id, status, requester_slack_id, requester_name,
(SELECT alias FROM target_servers WHERE id = r.target_server_id) AS target,
database_name,
created_at AT TIME ZONE 'Europe/Istanbul' AS submitted_tr,
scheduled_for AT TIME ZONE 'Europe/Istanbul' AS scheduled_tr
FROM requests r
WHERE status IN ('pending', 'approved', 'scheduled', 'executing')
ORDER BY created_at DESC;SELECT
r.id,
r.created_at AT TIME ZONE 'Europe/Istanbul' AS submitted_tr,
r.status,
r.requester_slack_id,
coalesce(r.requester_name, rq.name, '(?)') AS by_name,
rq.email AS by_email,
ts.alias AS target,
r.database_name AS db,
regexp_replace(left(r.query, 120), E'\\s+', ' ', 'g') AS preview,
r.row_count,
r.error_message,
r.decided_by_name,
EXTRACT(EPOCH FROM (r.decided_at - r.created_at))::int AS wait_for_decision_s,
EXTRACT(EPOCH FROM (r.completed_at - r.executed_at))::int AS exec_duration_s
FROM requests r
LEFT JOIN target_servers ts ON ts.id = r.target_server_id
LEFT JOIN requesters rq ON rq.slack_user_id = r.requester_slack_id
WHERE r.requester_slack_id <> '<YOUR_SLACK_USER_ID>' -- replace with your Slack user ID
AND r.created_at >= NOW() - INTERVAL '30 days'
ORDER BY r.created_at DESC;SELECT *
FROM requests
WHERE id = 42; -- replace with request idSELECT
coalesce(rq.name, r.requester_name, '(?)') AS by_name,
rq.email,
r.requester_slack_id,
count(*) AS total,
count(*) FILTER (WHERE r.status = 'completed') AS completed,
count(*) FILTER (WHERE r.status = 'failed') AS failed,
count(*) FILTER (WHERE r.status = 'rejected') AS rejected,
count(*) FILTER (WHERE r.status IN ('pending','approved','scheduled','executing'))
AS in_flight,
sum(coalesce(r.row_count, 0)) AS total_rows,
max(r.created_at) AS last_request_at
FROM requests r
LEFT JOIN requesters rq ON rq.slack_user_id = r.requester_slack_id
GROUP BY 1, 2, 3
ORDER BY total DESC;SELECT
a.created_at AT TIME ZONE 'Europe/Istanbul' AS at_tr,
a.action,
a.actor_slack_id,
a.actor_name,
a.details
FROM audit_log a
WHERE a.request_id = 42 -- replace
ORDER BY a.created_at;SELECT
a.created_at AT TIME ZONE 'Europe/Istanbul' AS at_tr,
a.action,
a.actor_slack_id,
a.actor_name,
a.request_id,
r.requester_name AS request_owner_name,
ts.alias AS target
FROM audit_log a
LEFT JOIN requests r ON r.id = a.request_id
LEFT JOIN target_servers ts ON ts.id = r.target_server_id
WHERE a.created_at >= NOW() - INTERVAL '7 days'
AND (a.actor_slack_id IS NULL OR a.actor_slack_id <> '<YOUR_SLACK_USER_ID>')
ORDER BY a.created_at DESC;SELECT id, requester_slack_id,
jsonb_pretty(explain_plan) AS plan
FROM requests
WHERE explain_plan IS NOT NULL
ORDER BY id DESC LIMIT 10;# Where it lives
ls -la /etc/queryhub/master.key # mode 600 ubuntu:ubuntu, 44 bytes
# Fingerprint sidecar (optional sanity check)
cat /etc/queryhub/master.key.fingerprint
# First-time generation (one-shot, with backup ritual)
sudo .venv/bin/python scripts/init_master_key.py
# Encrypt a single secret (for INSERT into target_servers.password_*)
.venv/bin/python scripts/encrypt_secret.pyThe same key:
- Decrypts
target_servers.password_encrypted(and_rw_encrypted,_ddl_encrypted) - Decrypts
/etc/queryhub/secrets.enc(Slack tokens + bot DB password)
If you rotate this key, you must also re-encrypt every dependent
secret with the new key. scripts/rotate_master_key.py does that. KEY_ROTATION.md describes the procedure. Do not rotate
the key casually.
# Manual run (deletes local + Slack uploads older than results_ttl_hours)
.venv/bin/python scripts/cleanup_old_results.py
# Schedule daily via cron, a systemd timer, or your job runner of
# choice. See deploy/INSTALL.md section 12 for a systemd timer
# template.du -sh /var/lib/queryhub/results /var/log/queryhub 2>/dev/nullsudo systemctl stop queryhub # waits up to 90s
sudo systemctl kill --signal=SIGKILL queryhub
sudo systemctl start queryhubSELECT 'kill_switch' AS k, value AS v FROM bot_config WHERE key='kill_switch'
UNION ALL SELECT 'pre_flight_explain', value FROM bot_config WHERE key='pre_flight_explain'
UNION ALL SELECT 'in_flight',
count(*)::text FROM requests WHERE status IN ('pending','approved','scheduled','executing')
UNION ALL SELECT 'last_24h',
count(*)::text FROM requests WHERE created_at >= NOW() - INTERVAL '24 hours'
UNION ALL SELECT 'targets_enabled',
count(*)::text FROM target_servers WHERE enabled = TRUE
UNION ALL SELECT 'requesters_enabled',
count(*)::text FROM requesters WHERE enabled = TRUE;After each terminal-state request (completed / failed / rejected /
cancelled), the bot DMs the requester a 1-5 rating prompt. The bot silently
skips a user who rated anything in the last 30 days (cooldown). Low ratings
(1-2) get a contextual "What went wrong?" follow-up button that opens a
feedback modal. High ratings get an "Add feedback" button. Each request takes
one rating: the first click locks it.
Storage: request_ratings (request_id, slack_user_id, rating 1-5, feedback_text, rated_at). See migrations/019_request_ratings.sql.
-- Stop showing rating prompts (existing ratings stay)
UPDATE bot_config SET value = 'off' WHERE key = 'rating_enabled';
-- Resume
UPDATE bot_config SET value = 'on' WHERE key = 'rating_enabled';DELETE FROM request_ratings WHERE slack_user_id = 'U01ABCDEFG';-- Weekly avg rating + low/high counts + with_feedback count
SELECT * FROM p_metrics_rating_weekly LIMIT 12;
-- Weekly response rate: of all terminal requests, what fraction was rated
SELECT * FROM p_metrics_rating_response_rate LIMIT 12;
-- Drill-down for ratings ≤ 2 (with the request preview, for quality review)
SELECT * FROM p_metrics_rating_low_with_feedback LIMIT 50;-- Distribution
SELECT rating, count(*) AS n FROM request_ratings GROUP BY 1 ORDER BY 1;
-- Top recent feedback (any rating)
SELECT rated_at, rating, feedback_text
FROM request_ratings
WHERE feedback_text IS NOT NULL
ORDER BY rated_at DESC LIMIT 20;
-- Per-user rating activity
SELECT slack_user_id, count(*) AS n_ratings,
round(avg(rating)::numeric, 2) AS avg_rating,
max(rated_at) AS last_rating
FROM request_ratings
GROUP BY 1 ORDER BY n_ratings DESC;Users can submit multiple ;-separated statements in one /sql request.
Optional SET LOCAL <param> = <value> lines can come before them. Rules:
- All non-SET ("main") statements must be the same tier (all RO, or
all RW, or all DDL). The bot rejects a mix, e.g.
SELECT 1; UPDATE t SET x=1 WHERE id=1, with a "mixed-tier" error. - The bot rewrites
SET ...toSET LOCAL ...automatically (transaction-scoped). - The bot accepts only parameters in
bot_config.set_allowed_params. It rejects others with a friendly error. - SET must come BEFORE any main statement. The bot rejects trailing SETs.
- For multiple read result sets, each SELECT writes its own CSV. The bot
zips them into
req_<id>_results_<ts>.zipfor the user.
-- Tune memory and run a heavy query in one approval
SET work_mem = '256MB';
SELECT user_id, count(*) FROM big_table GROUP BY user_id;
-- Two SELECTs → ZIP with two CSVs
SELECT name FROM teams ORDER BY id;
SELECT alias FROM target_servers WHERE enabled = TRUE;The default has ~23 safe tuning parameters (work_mem, statement_timeout, enable_*, random_page_cost, ...). To add or delete a parameter:
-- See current
SELECT value FROM bot_config WHERE key = 'set_allowed_params';
-- Update (comma-separated, no spaces necessary)
UPDATE bot_config
SET value = 'work_mem,statement_timeout,enable_seqscan,...'
WHERE key = 'set_allowed_params';The bot reads the list on every submission, so no restart is needed.
These break the security model:
search_path— schema-redirect attackrole,session_authorization,current_user,authorization— privilege escalationclient_min_messages— error suppressionlog_*— disables audit loggingrow_security— RLS bypass
The bot DB has thirteen read-only views. All carry the p_metrics_ prefix.
You can tune the cost numbers with the bot_config.cost_* rows.
| View | What |
|---|---|
p_metrics_cost_savings |
Single-row USD summary: DBA time saved (this month + lifetime), replica savings (monthly recurring), grand total |
Tunables (edit to match your org):
-- Per-request DBA minutes saved vs the "open a ticket" path
UPDATE bot_config SET value = '8' WHERE key = 'cost_dba_minutes_per_request';
-- DBA fully-loaded hourly cost
UPDATE bot_config SET value = '75' WHERE key = 'cost_dba_hourly_usd';
-- Read replicas the bot replaces
UPDATE bot_config SET value = '5' WHERE key = 'cost_avoided_replicas';
-- Per-replica monthly cost
UPDATE bot_config SET value = '200' WHERE key = 'cost_per_replica_monthly_usd';
-- Anything else (bastion, BI seat avoidance, ...)
UPDATE bot_config SET value = '0' WHERE key = 'cost_other_monthly_usd';
SELECT * FROM p_metrics_cost_savings;| View | What |
|---|---|
p_metrics_volume_daily |
Daily request volume + status + DAU (last 90 days) |
p_metrics_volume_weekly |
Weekly + WAU (all-time) |
p_metrics_volume_monthly |
Monthly + MAU (all-time) |
| View | What |
|---|---|
p_metrics_team_usage |
Per-team: active users, total/completed/rejected/failed, avg exec time |
p_metrics_top_users |
Per-user leaderboard with 7d/30d/total counts |
p_metrics_scheduled_usage |
Weekly scheduled-feature adoption % + success/cancel |
p_metrics_tier_distribution |
Weekly ro/rw/ddl mix (from leading keyword) |
| View | What |
|---|---|
p_metrics_failure_breakdown |
Weekly outcomes + success rate |
p_metrics_admin_workload |
Per-admin approve/reject/changes volume + last activity |
| View | What |
|---|---|
p_metrics_rating_weekly |
Weekly avg rating + low/high counts + with_feedback |
p_metrics_rating_response_rate |
Rated / terminal requests % |
p_metrics_rating_low_with_feedback |
Drilldown for ≤2 ratings + query preview |
-- Top 10 users this week
SELECT name, last_7d FROM p_metrics_top_users ORDER BY last_7d DESC LIMIT 10;
-- Team adoption right now
SELECT * FROM p_metrics_team_usage;
-- This week's scheduled %
SELECT * FROM p_metrics_scheduled_usage LIMIT 4;
-- Cost savings dashboard query
SELECT total_saving_this_month_usd, dba_saving_this_month_usd,
replica_saving_monthly_usd, req_this_month
FROM p_metrics_cost_savings;Every admin starts as a "super admin", who can approve every request. Three optional scope columns narrow that authority. Set any of them, all of them, or none. NULL on a column means "no restriction in that dimension".
| Column | Type | Meaning when NULL | Meaning when set |
|---|---|---|---|
max_tier |
text | Approves any tier | 'ro' / 'rw' / 'ddl' — highest tier this admin can approve (hierarchy: ro < rw < ddl) |
scope_target_ids |
int[] | Any target | Only requests on these target_servers.id values |
scope_team_ids |
int[] | Any requester team | Only requests from a requester who is a member of at least one of these teams |
A single function resolves the scope: admins.can_approve. The button
guards, the DM-button visibility and the modal-submit guards all use it.
When an out-of-scope admin clicks anyway, the bot rejects them with a DM:
"This request is outside your admin scope (tier / target / team)."
Out-of-scope admins still receive the request DM (audit + transparency). Their DM has a "view only" footer instead of the Approve / Reject / Request changes buttons.
-- DBA-only DDL: keep this admin able to approve RO+RW everywhere, NOT DDL
UPDATE admins SET max_tier = 'rw' WHERE slack_user_id = 'U01ABCDEFG';
-- Team-lead pattern: this admin approves only their team's requests
UPDATE admins
SET scope_team_ids = ARRAY[
(SELECT id FROM teams WHERE name = 'payments')
]
WHERE slack_user_id = 'U01ABCDEFG';
-- Target-owner pattern: admin only approves requests on a few targets
UPDATE admins
SET scope_target_ids = (
SELECT array_agg(id) FROM target_servers
WHERE alias IN ('acme-prod-orders', 'acme-prod-payments')
)
WHERE slack_user_id = 'U01ABCDEFG';
-- Combination: team-lead with RW max
UPDATE admins
SET scope_team_ids = ARRAY[(SELECT id FROM teams WHERE name = 'payments')],
max_tier = 'rw'
WHERE slack_user_id = 'U01ABCDEFG';
-- Reset to super admin (all scopes wildcard)
UPDATE admins
SET scope_team_ids = NULL, scope_target_ids = NULL, max_tier = NULL
WHERE slack_user_id = 'U01ABCDEFG';SELECT slack_user_id, name, enabled, max_tier,
scope_team_ids, scope_target_ids
FROM admins ORDER BY enabled DESC, name;
-- Resolve scope-id arrays to readable names
SELECT a.slack_user_id, a.name, a.max_tier,
(SELECT array_agg(t.name ORDER BY t.name)
FROM unnest(a.scope_team_ids) AS sid
JOIN teams t ON t.id = sid) AS teams,
(SELECT array_agg(ts.alias ORDER BY ts.alias)
FROM unnest(a.scope_target_ids) AS sid
JOIN target_servers ts ON ts.id = sid) AS targets
FROM admins a
WHERE a.enabled = TRUE
ORDER BY a.name;- At least one super admin (all scopes NULL) should exist. If every admin has a non-NULL scope, some requests may have no eligible approver and stay pending.
- No-team requesters do not match a non-NULL
scope_team_ids. If you scope an admin toteam_ids, that admin cannot approve requests from standalone users (users with onlyuser_target_grants). Either widen the admin's scope, or grant a super admin alongside. - Tier changes when a query is edited through Request-changes. The bot re-classifies the query on resubmit, and verifies the admin scope again, against the new tier.
Almost everything QueryHub puts on a screen names something real: a connection alias, a database, a hostname in an error message, the person who asked. In normal use, that is the point. It becomes a problem as soon as any of it leaves the deployment. For example: a screenshot in a ticket, a log excerpt in a chat, a config snippet in a bug report upstream.
It leaks most easily in four places:
- The connection list and the audit log. Both are dense with aliases by design. Crop or redact them before you paste.
- Error text.
errors.pyscrubs libpq messages before a user sees them, but the unscrubbed original is in the service log. - Result files. A CSV or XLSX under
QH_RESULTS_DIRis real data until the retention job deletes it (results_ttl_hours). - A fork of this repository. If you commit your own
bot_configrows, migrations or fixtures, the aliases and user ids go with them.
If you maintain a fork, this practice is worth copying. Let the pre-commit scan build its denylist from the metadata database, not from a hand-kept list. The denylist then contains every alias, host, team name and user id that the database currently holds. So the list cannot go stale as the set of people and targets changes. Static patterns alone (token shapes, private keys, RFC1918 addresses) will not catch the thing most likely to leak, which is a name.
A user can submit up to N queries in one approval round. Each item becomes
its own requests row, linked by bundle_id. The per-item Approve / Reject /
Changes buttons re-use the existing handlers. The "Approve all remaining" /
"Reject all remaining" buttons decide all of one admin's pending items in one
click. When the whole bundle is decided and executed, one summary DM arrives,
with the CSV of every completed item attached.
Every admin gets the batch DM. A scoped approver, such as a pod captain, gets it too when they can approve every item on its own. A captain's role reaches RO on their own pod's servers. So for a captain, that means an all-RO batch from their pod: the same rule that brings them a single RO request. The bulk buttons admit the same people, and act only on the items in the presser's scope. A batch with one item outside a captain's scope, such as a write or another pod's server, stays with the admins.
-- Turn on (off by default — modal toggle + sub-command hidden when off).
UPDATE bot_config SET value = 'on' WHERE key = 'batch_enabled';
-- Cap the max items per bundle.
UPDATE bot_config SET value = '5' WHERE key = 'batch_max_items';/sql→ the modal shows a Single ↔ Batch radio toggle at the top whenbatch_enabled = 'on'. A switch keeps whatever the user already typed. The single query becomes batch item #1, and batch item #1 becomes the single query. A switch from batch to single warns when it drops items #2+./sql batch→ opens the modal directly in batch mode (fast path for power users).
-- All bundles + per-item status mix.
SELECT b.id, b.status, b.requester_slack_id,
b.created_at, b.scheduled_for,
count(*) AS items,
count(*) FILTER (WHERE r.status = 'completed') AS done,
count(*) FILTER (WHERE r.status = 'failed') AS failed,
count(*) FILTER (WHERE r.status = 'rejected') AS rejected
FROM request_bundles b
LEFT JOIN requests r ON r.bundle_id = b.id
GROUP BY b.id
ORDER BY b.created_at DESC
LIMIT 25;
-- Drill into one bundle.
SELECT r.position, r.status, ts.alias, r.database_name,
left(r.query, 80) AS q, r.row_count, r.error_message
FROM requests r JOIN target_servers ts ON ts.id = r.target_server_id
WHERE r.bundle_id = :bundle_id
ORDER BY r.position;A change to requests.status fires an AFTER UPDATE trigger
(trg_recompute_bundle_status) that recomputes request_bundles.status.
The trigger uses pg_advisory_xact_lock(bundle_id) to serialise concurrent
recomputes. The rule set:
| Item mix | Bundle status |
|---|---|
| any pending / approved / scheduled / executing / awaiting_dba_manual / changes_requested | pending |
all cancelled |
cancelled |
at least one completed AND at least one terminal-negative |
partial |
| else (all completed / all rejected / all failed) | decided |
request_bundles.requester_summary_message_ts records the Slack ts of
the requester's bundle-summary DM. The first time the bundle reaches a
terminal state, the bot posts the DM and saves the ts. Later state changes
(e.g. a manually-completed DDL item closed hours later) chat.update
the same DM instead of posting a new one.
An auto-approve grant is a per-user, time-bounded, tier-scoped exemption from admin approval. The bot evaluates grants at submit time AND at scheduled run time (if scheduled_for is set). If a grant is active now but expires before the run, the request goes to admin approval instead, with a user-facing warning.
\d auto_approve_grants
\d v_active_auto_approvemax_tier is ro / rw / ddl. RO covers RO only. RW covers RO+RW.
DDL covers everything. Queries above the grant's tier use the normal admin
flow.
A waiver skips review, but it grants no access. So the web panel and
/sql grant tie the two together:
-
Grant + auto-approve in one step. Two boxes write the waiver in the grant's own transaction. One is the web grant form's Auto-approve box (
POST /admin/grantswithautoApprove: true,autoApproveTierroby default). The other is the "Auto-approve read-only queries" box on/sql grant. The waiver gets one row per granted database (NULL for all of them), with the same target, the same expiry and the reasonauto-approve with the grant: <reason>.A person's row goes into
auto_approve_grants, and the migration-109 mirror projects it. A team's row is anaccess_grantrow (auto_approve = TRUE,team_idset,mirrored_fromNULL). QueryHub refuses a tier above the grant's, or DDL, before it writes anything. -
Not written twice. If the subject already holds an equal or broader waiver, the new one is redundant, so QueryHub skips it. Equal or broader means any target or the same one, any database or the same one, and a tier at least as high. The held waiver must also have started, and it must end no sooner. The response's
autoApprove.skipped, the web toast and the Slack summary name the covering row. A team's waiver does not count for a person: their own grant on the server displaces the team's rows there. -
Only where they can query.
POST /admin/auto-grantsand/admin/auto-grants/bulkread the subject's access from the effective-access resolvers. They refuse a target or database that the subject cannot reach, and a tier above the one they hold there. A refusal returns409and writes nothing. QueryHub does not verify a fleet-wide row (no target): it already means "every server they can reach". It does not verify rows written by hand in SQL either, so open the person's Effective access screen first.
-- 2-week RO for a teammate during reporting season.
INSERT INTO auto_approve_grants
(slack_user_id, max_tier, expires_at, reason, granted_by)
VALUES
('U0XXXXXXXXX', 'ro', NOW() + INTERVAL '14 days',
'Q4 reporting auto-pull', :your_slack_id);
-- TR-local cutoff: Friday 18:00 Europe/Istanbul.
INSERT INTO auto_approve_grants
(slack_user_id, max_tier, expires_at, reason, granted_by)
VALUES
('U0XXXXXXXXX', 'ro',
'2026-05-22 18:00:00+03'::timestamptz,
'Pilot operator', :your_slack_id);
-- Open-ended RW for a trusted on-call engineer.
INSERT INTO auto_approve_grants
(slack_user_id, max_tier, expires_at, reason, granted_by)
VALUES
('U0XXXXXXXXX', 'rw', NULL,
'Trusted operator', :your_slack_id);
-- One-day DDL window for a planned schema migration.
INSERT INTO auto_approve_grants
(slack_user_id, max_tier, starts_at, expires_at, reason, granted_by)
VALUES
('U0XXXXXXXXX', 'ddl',
'2026-06-01 08:00:00+03'::timestamptz,
'2026-06-01 18:00:00+03'::timestamptz,
'Schema migration window', :your_slack_id);-- Currently active grants.
SELECT * FROM v_active_auto_approve ORDER BY max_tier DESC, expires_at NULLS LAST;
-- Everything ever issued for one user.
SELECT id, max_tier, starts_at, expires_at, reason, granted_by, granted_at
FROM auto_approve_grants
WHERE slack_user_id = 'U0XXXXXXXXX'
ORDER BY granted_at DESC;
-- All auto-approved requests in the last 7 days (who, when, what).
SELECT r.id, r.requester_slack_id, ts.alias, r.database_name,
r.decided_by_name, r.status, r.created_at
FROM requests r JOIN target_servers ts ON ts.id = r.target_server_id
WHERE r.decided_by_slack_id = 'AUTO'
AND r.created_at > NOW() - INTERVAL '7 days'
ORDER BY r.created_at DESC;-- Hard delete.
DELETE FROM auto_approve_grants WHERE id = :grant_id;
-- Or expire in place (preserves history).
UPDATE auto_approve_grants SET expires_at = NOW() WHERE id = :grant_id;- A modal banner appears at the top of the
/sqlmodal whenever the user has any active grant. The banner reads ":zap: Auto-approve active — up to RO, until …". - On INSERT, an auto-approved request gets
status=approved(or'scheduled'),decided_by_slack_id='AUTO', and adecided_by_namelikeauto-approved (grant #N, max_tier=ro, until ...). - Every active admin gets a short FYI DM per auto-approved request: a header and the inline query (truncated at 500 chars). The bundle FYI puts all auto-approved items in one DM per admin.
- A higher-tier query (e.g. the user has an RO grant and submits RW) goes to the standard pending → admin approval flow instead. There is no silent privilege escalation.
- Scheduling guardrail: if
scheduled_foris AFTERexpires_at, the submit handler sends the request to admin approval instead. It warns the user in the confirmation DM.
metric_annotations is a free-form table that marks notable moments on the
product-metrics timeline (go-live, access cutover, incident, config change).
The p_metrics_usage_daily view joins these by day, so a dashboard can label
its bars.
-- Add an annotation (TR-local time).
INSERT INTO metric_annotations (occurred_at, label, description)
VALUES ('2026-06-01 10:00+03', 'Rollout v2',
'Wave 2 teams onboarded.');
-- List in chronological order.
SELECT id, occurred_at AT TIME ZONE 'Europe/Istanbul' AS local_ts,
label, description
FROM metric_annotations
ORDER BY occurred_at;
-- Days with usage AND annotations.
SELECT day, submitted, completed, active_users, annotations
FROM p_metrics_usage_daily
WHERE annotations IS NOT NULL
ORDER BY day DESC;A UNIQUE(occurred_at, label) constraint makes the migration seed safe to re-run.
A temporary admin grant is a time-bounded admin role. A super-admin (a
permanent admin with ALL scope columns NULL) can deputise someone for a
defined window. The deputy automatically loses admin status the moment
expires_at passes.
-- The set of users allowed to issue temp grants.
SELECT slack_user_id, name
FROM admins
WHERE enabled = TRUE
AND max_tier IS NULL
AND scope_team_ids IS NULL
AND scope_target_ids IS NULL;-- 2-week full coverage during a vacation.
INSERT INTO temp_admin_grants
(slack_user_id, expires_at, reason, granted_by)
VALUES ('U0XXXXXXXXX',
NOW() + INTERVAL '14 days',
'Vacation coverage for @alex',
:your_slack_id);
-- RO-only deputy for a specific team during off-hours.
INSERT INTO temp_admin_grants
(slack_user_id, max_tier, scope_team_ids, expires_at,
reason, granted_by)
VALUES ('U0YYYYYYYYY',
'ro', ARRAY[2]::int[],
NOW() + INTERVAL '3 days',
'Weekend on-call (payments RO only)',
:your_slack_id);
-- Scheduled future window (e.g., during a planned migration).
INSERT INTO temp_admin_grants
(slack_user_id, max_tier, starts_at, expires_at,
reason, granted_by)
VALUES ('U0ZZZZZZZZZ',
'ddl',
'2026-06-01 08:00:00+03'::timestamptz,
'2026-06-01 20:00:00+03'::timestamptz,
'Schema migration window admin',
:your_slack_id);The bot keeps the permanent admins table immutable. Temp grants go into
temp_admin_grants, and is_admin / can_approve / list_active consult
both tables.
Not in force while
access_model_v2is on. Under the new model, those three answers come fromaccess.py. It readsrole_assignmentand does not know this table. The migration-109 mirror does not project it either, so a row written here decides nothing. This gap caught nobody, because the table never held a row.Do not use this recipe to arrange on-call cover until one of two changes is made. Either the temp grant is mirrored into
role_assignment, or a time-boundedadminrole row replaces it.role_assignment.valid_untilalready supports such a row. In the meantime, write the role row directly.
-- Currently active.
SELECT * FROM v_active_temp_admins
ORDER BY max_tier DESC NULLS FIRST, expires_at NULLS LAST;
-- Full history (including expired and revoked).
SELECT id, slack_user_id, max_tier, starts_at, expires_at,
reason, granted_by, granted_at, revoked_at
FROM temp_admin_grants
ORDER BY granted_at DESC
LIMIT 30;
-- One user's complete temp admin trail.
SELECT id, max_tier, scope_team_ids, scope_target_ids,
starts_at, expires_at, reason, granted_by, revoked_at
FROM temp_admin_grants
WHERE slack_user_id = 'U0XXXXXXXXX'
ORDER BY granted_at DESC;-- Soft-revoke: keeps the row for audit, but the deputy loses
-- admin status immediately on the next is_admin() check.
UPDATE temp_admin_grants
SET revoked_at = NOW()
WHERE id = :grant_id
AND revoked_at IS NULL;If you prefer Python over raw SQL:
from queryhub import admins
from datetime import datetime, timedelta, timezone
# Returns the new grant_id; raises admins.NotASuperAdmin if the
# granter isn't a super-admin.
gid = admins.grant_temp_admin(
granted_by='U0XXXXXXXXX', # the super-admin
slack_user_id='U0YYYYYYYYY', # the deputy
expires_at=datetime.now(timezone.utc) + timedelta(days=14),
reason='Vacation coverage',
max_tier='ro', # optional; default = any
scope_team_ids=[2], # optional; default = any
)
admins.revoke_temp_admin(granted_by='U0XXXXXXXXX', grant_id=gid)
# Read paths
admins.is_admin('U0YYYYYYYYY') # True during the active window
admins.list_active() # source column = 'permanent' | 'temp'
admins.list_temp_grants('U0YYYYYYYYY')/sql whoamishows active temp grants on the deputy's own profile, with the expiry per grant.- Temp admins receive every per-request admin DM (they're in
admins.list_active()). - Approval / reject buttons respect their scope (
can_approve()evaluates both permanent and temp rows). - The admin DM "Approved by @user @
2026-05-20 17:38 UTC" does not distinguish permanent and temp admins. The Slack id is enough for audit, and thetemp_admin_grantstable holds the per-grant context.
The report_excluded_users table excludes operator self-tests from every
p_metrics_* view (volume, top users, admin workload, ratings, cost savings).
Without it, the self-tests would pollute those views. The table is an
allowlist-of-exclusions. Three thin wrapper views consult it
(requests_reportable, audit_log_reportable, request_ratings_reportable),
and every metric view reads from those instead of the raw base tables.
The bot's runtime paths (kill-switch, allowlist, team grants, admin scope, audit_log) all keep reading the raw tables. The exclusion is metrics-only.
| View family | Filter applied |
|---|---|
requests_reportable |
drops rows where requester_slack_id is in report_excluded_users |
audit_log_reportable |
drops rows whose linked request_id is itself dropped from requests_reportable. Actor identity alone never excludes a row. An excluded user's approvals of OTHER people's real requests stay visible in admin reports, so the reports keep that user's actual DBA workload. |
request_ratings_reportable |
drops ratings whose rater is excluded OR whose underlying request was dropped from requests_reportable |
So an excluded user's own requests (and any audit actions on them) vanish from the reports. Actions that the excluded user took on OTHER people's real requests stay visible. Admin reports show the user's actual DBA workload, minus the self-test noise.
-- Exclude
INSERT INTO report_excluded_users (slack_user_id, reason, added_by)
VALUES ('U0XXXXXXXXX', 'On-call rotation test scripts', :your_slack_id)
ON CONFLICT (slack_user_id) DO NOTHING;
-- Re-include
DELETE FROM report_excluded_users WHERE slack_user_id = 'U0XXXXXXXXX';
-- Who's currently excluded
SELECT slack_user_id, reason, added_by, added_at
FROM report_excluded_users ORDER BY added_at;A change takes effect on the next view read. It needs no restart, no re-aggregation and no migration.
A job on a systemd timer regenerates the dashboard HTML
(metrics_dashboard.html) and uploads it to an S3 bucket. Browsers open a
fronted URL (private ALB / CloudFront with auth) → S3 → fresh-ish HTML. The
refresh interval is the timer cadence (default hourly).
What has to exist before the upload works:
-
Private S3 bucket, e.g.
<company>-internal-dba-dashboards. Block-Public-Access ON, with no public listing. -
IAM role attached to the bot's EC2 instance (preferred) with exactly this policy on the dashboard prefix:
{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:PutObject", "s3:PutObjectAcl"], "Resource": "arn:aws:s3:::<bucket>/dba-metrics/*" }] }(Alternative: an IAM user with an access key written to the bot user's
~/.aws/credentials. Prefer the role approach, because it leaves no key to rotate.) -
Fronting, with one of these two:
- CloudFront distribution with an Origin Access Control (OAC) that reads the bucket privately.
- Private ALB with an S3 VPC endpoint.
-
TLS cert on the front (ACM).
-
Internal DNS record (e.g.
dba-metrics.<company-internal>.com). -
Authentication: the minimum is Cognito (IdP-backed) at the CloudFront / ALB layer. Anonymous internal access is also acceptable on a tight VPN, but it adds a rotation-of-trust step.
Once the bucket exists, the operator's side is three files (already in the repo):
# 1. Bucket name + key (key is optional; default is dba-metrics/index.html).
sudo tee /etc/queryhub/dashboard.env >/dev/null <<EOC
METRICS_DASHBOARD_BUCKET=<the-bucket-name>
METRICS_DASHBOARD_KEY=dba-metrics/index.html
AWS_REGION=eu-central-1
EOC
sudo chown __USER__:__USER__ /etc/queryhub/dashboard.env
sudo chmod 600 /etc/queryhub/dashboard.env
# 2. Drop the systemd unit + timer in place with placeholders
# substituted (same pattern as queryhub.service in INSTALL.md).
sudo cp deploy/dba-metrics-publish.service /etc/systemd/system/
sudo cp deploy/dba-metrics-publish.timer /etc/systemd/system/
sudo sed -i "s|__USER__|$BOT_USER|g; s|__INSTALL_PATH__|$REPO_DIR|g" \
/etc/systemd/system/dba-metrics-publish.{service,timer}
# 3. Enable + start.
sudo systemctl daemon-reload
sudo systemctl enable --now dba-metrics-publish.timer
# Trigger a one-off run to confirm permissions + connectivity.
sudo systemctl start dba-metrics-publish.service
sudo journalctl -u dba-metrics-publish.service -n 30 --no-pager# Next-scheduled timer fire + last-run timestamp.
systemctl list-timers dba-metrics-publish.timer --no-pager
# Recent publish logs (last 5 runs).
sudo journalctl -u dba-metrics-publish.service -n 100 --no-pager
# Force a publish right now (after a config change or an annotation insert).
sudo systemctl start dba-metrics-publish.service
# Disable until DevOps fixes upstream.
sudo systemctl disable --now dba-metrics-publish.timerEdit /etc/systemd/system/dba-metrics-publish.timer's OnCalendar=
line:
| Cadence | Value |
|---|---|
| Default | hourly |
| Every 15 minutes | *:0/15 |
| Every 5 minutes | *:0/5 |
| Twice a day (09:00, 17:00 server time) | OnCalendar=*-*-* 09,17:00:00 |
Then sudo systemctl daemon-reload && sudo systemctl restart dba-metrics-publish.timer.
- HTML size: ~50 KB. 24 uploads/day × 50 KB ≈ 1.2 MB/day → bucket storage ≈ negligible.
Cache-Control: max-age=300, must-revalidate: readers see a fresh copy within 5 minutes of the next upload, even through CloudFront.
An operator needs two things that did not exist before: an endpoint to scrape, and log lines that a parser can read without a regex.
Prometheus text format, off by default. A self-hosted tool should not start to publish its queue depth, fleet size and user counts because somebody upgraded. So enabling it is a decision:
UPDATE bot_config SET value = 'on' WHERE key = 'web_metrics_enabled';Runtime-effective: no restart. While off, the route answers 404, not 403. A 403 would reveal that the endpoint is there.
For a scraper, set a token as well. Prometheus can send a bearer header, but it cannot hold a session cookie:
UPDATE bot_config SET value = 'PASTE_TOKEN' WHERE key = 'web_metrics_token';Generate it like any other credential:
python -c 'import secrets; print(secrets.token_urlsafe(32))'With the token empty, the endpoint requires an admin session instead. So enabling the key alone does not publish it. The endpoint compares the token in constant time.
curl -s -H "Authorization: Bearer $TOKEN" https://queryhub.internal/metricsScrape config:
scrape_configs:
- job_name: queryhub
metrics_path: /metrics
authorization:
type: Bearer
credentials: PASTE_TOKEN
static_configs:
- targets: ['queryhub.internal:8080']The values come from SQL at scrape time, not from in-process counters. That
is deliberate. Counters in memory reset on restart. Also, the two processes
(queryhub and queryhub-web) each see only part of the traffic, so neither
would see all of it. Counters here are cumulative over all history, which is
what rate() expects.
| Metric | Why |
|---|---|
queryhub_oldest_request_age_seconds{status="pending"} |
The one that matters. Depth alone cannot tell "three arrived this second" from "one has waited since yesterday". A developer experiences this wait as the tool being broken. |
queryhub_requests_in_state{status="executing"} |
Stuck executions. The value should return to 0. A value that never falls means that a lease stays held. |
queryhub_kill_switch_active |
1 means the kill switch stops all new query traffic. It is easy to leave enabled after an incident. |
queryhub_auth_event_outbox_depth |
Monotonic growth means that the auth-event poller is not running. QueryHub records grant changes but never announces them. This is a real failure mode. The poller once lived only in the Slack process, so a vanilla install grew this table forever, and nothing said so. |
queryhub_scrape_errors |
Non-zero means that some collector failed and the numbers are partial. When every query breaks, the dashboard shows only zeros. That looks exactly like a healthy idle system. |
rate(queryhub_requests_total{status="failed"}[15m]) |
Execution failures. |
The endpoint exposes durations as _sum/_count pairs, so
rate(queryhub_execution_seconds_sum[1h]) / rate(queryhub_execution_seconds_count[1h])
gives the average over whatever window you pick. There are no quantiles. Real
quantiles need histogram buckets kept in process memory. As explained above,
there is no process memory to keep them in.
One failing collector does not fail the scrape. A partial payload is better than a 500 at the exact moment something is already wrong.
LOG_FORMAT=json in the service environment switches both processes to one JSON
object per line:
{"timestamp":"2026-07-25T15:26:01+00:00","level":"INFO","logger":"queryhub.executor","message":"executing request 4242 on target demo-primary","request_id":4242,"tier":"ro"}timestamp is RFC 3339 in UTC, regardless of web_display_timezone.
Correlating two hosts across a DST boundary is the kind of problem that costs
an hour at 3am. Tracebacks stay on one line as an exception field, and that
is the main reason to switch. In text format, a traceback arrives at the log
backend as N unrelated lines. The line that names the exception is not the line
with the context.
text remains the default, because a human who tails journalctl is the
common case. JSON is worse for that. Each process reads this setting and
LOG_LEVEL once, at process start, so a change needs a restart:
sudo systemctl restart queryhub queryhub-webA super-admin runs without the tier and safety limits that an ordinary requester has. The point is not to give them power they lack: a DBA already has it through their own tooling. The point is that the audit trail records the work they were going to do anyway. The steps below provision the one role that makes this possible on a cluster.
The model is in docs/SCHEMA.md → "Super-admin elevation". This section
is the runbook.
QueryHub refuses each of these for everyone else, as before:
- End or cancel a session.
SELECT pg_terminate_backend(pid)orpg_cancel_backend(pid), alone or overpg_stat_activity. QueryHub asks first. Then it runs the statement at the ddl tier, as the elevated role, because the read-only login cannot signal another role's backend. On a cluster without the role (nosuper_ddl_role), the DDL login runs it, and the server may refuse. - Run a mixed script. A SELECT, an UPDATE and a SELECT go as one request, at the script's highest tier, in one transaction.
- Set
search_path.SET search_path = app, publicbefore the query. QueryHub limits the value to a list of schema names, and keeps it to the one request (SET LOCAL).
QueryHub still refuses these for a super-admin too: logging-setting changes,
file reads, dblink, pg_reload_conf, and replication-slot changes. A
dropped slot breaks the CDC streams that migrations run on.
Run as an operator login that holds the platform's admin role
(rds_superuser on RDS, root on Huawei):
DO $$
BEGIN
IF current_setting('server_version_num')::int < 160000 THEN
RAISE EXCEPTION 'pre-16 server (%): WITH INHERIT FALSE is unavailable',
current_setting('server_version');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'queryhub_superadmin') THEN
CREATE ROLE queryhub_superadmin NOLOGIN CREATEROLE CREATEDB;
END IF;
EXECUTE 'GRANT ' || (SELECT rolname FROM pg_roles
WHERE rolname IN ('rds_superuser','root')
ORDER BY 1 LIMIT 1)
|| ' TO queryhub_superadmin';
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'queryhub_ddl') THEN
EXECUTE 'GRANT queryhub_superadmin TO queryhub_ddl '
'WITH INHERIT FALSE, SET TRUE';
END IF;
END $$;The version guard is not decoration. WITH INHERIT FALSE, SET TRUE needs
PostgreSQL 16+. Without the guard, an older server would create the role,
fail on the grant, and leave the login inheriting admin rights in every
session. That is the exact property the design exists to prevent. The safe
outcome is that the whole block fails.
The block picks the admin role from the catalog instead of naming it. So one statement works on both clouds.
UPDATE target_servers
SET super_ddl_role = 'queryhub_superadmin'
WHERE alias = '<target>';Runtime-effective: the executor reads it per request, so no restart is needed.
SELECT CASE WHEN pg_has_role('queryhub_superadmin','rds_superuser','USAGE')
AND EXISTS (SELECT 1 FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles u ON u.oid = m.member
WHERE r.rolname = 'queryhub_superadmin'
AND u.rolname = 'queryhub_ddl'
AND m.inherit_option = false
AND m.set_option = true)
THEN 'VERIFIED' ELSE 'INCOMPLETE' END;Then prove the property end to end, connected as the DDL user:
SELECT pg_has_role(current_user,'rds_superuser','USAGE'); -- must be false
BEGIN;
SET LOCAL ROLE queryhub_superadmin;
SELECT pg_has_role(current_user,'rds_superuser','USAGE'); -- true, inside only
COMMIT;
SELECT current_user; -- back to the login- The control-plane cluster.
audit_loglives there. An elevated session that can write it could delete its own trail. This exclusion is not negotiable. - Non-PostgreSQL targets. The role model is Postgres-specific.
- Read replicas need nothing. Roles are cluster-global, so a replica shows VERIFIED as soon as its primary is done. Do not write to it.
target_servers.username_ddland the cluster's actual roles disagree in both directions. A stored credential can name a role that nobody created, and a role can exist with no stored credential. Neither is evidence of the other, so verify the roles on the cluster.- A target with zero request history hides this indefinitely. Grants gate visibility, so a target that nobody holds a grant on gets no requests. When you audit, start from the targets with no requests.
- Fernet ciphertext is not deterministic. Two targets that show
different
password_ddl_encryptedblobs may hold the identical password. Do not infer a mismatch from the ciphertext. pg_authidis not readable on RDS even asrds_superuser. So you cannot copy a SCRAM verifier between clusters to clone a login without knowing its plaintext.
A read-only request on a target that has a read replica runs on the replica
when the replica is healthy, and on the primary otherwise. Nobody picks a
replica. Every list shows the primary's one name, and the request, its grant
and its history stay on the primary. The one exception is a super-admin, who
can choose the node for a single query (see "Choosing where one query runs"
below). Code: src/queryhub/replicas.py.
How a replica is known. target_servers.replica_of points a replica row at
its primary. The hourly inventory import sets it from v_server.replica_source
(step 1c). The import clears it when the inventory stops calling that host a
replica. A target that the inventory does not list keeps a link set by hand.
What decides where a request runs, in order:
bot_config.replica_routing = 'on'. This is the kill switch. The default isoff.- The target is PostgreSQL, and the request runs at the RO tier.
- The SQL reads nothing that describes the server itself (
pg_stat_*,pg_locks, WAL positions,txid_*...). On a replica, those answer about the replica. - The requester ran no RW/DDL on this target in the last
replica_read_your_writes_minutes(5). So a SELECT that verifies their own UPDATE sees that UPDATE. - The replica row is enabled, and healthy: still in recovery, and at most
replica_max_lag_seconds(10) behind. QueryHub measures lag against the primary's current WAL position, then the replica's replay position. Each process trusts one health probe forreplica_health_ttl_seconds(15).
A replica needs no credential of its own. A physical replica has the primary's roles and passwords, so QueryHub uses the primary's RO login. An admin can therefore enable a replica row that has only the placeholder password.
Putting one in or out of rotation. Enable or disable the replica row, in the admin Connections screen or with SQL. The importer never enables anything.
-- Which replicas exist, and which are in rotation
SELECT r.id, r.alias, r.enabled, p.alias AS primary_alias
FROM target_servers r JOIN target_servers p ON p.id = r.replica_of;If the replica fails the query. When the replica is unreachable, the query
runs again on the primary, once. The same happens when the replica cancels the
statement to keep replaying ("conflict with recovery", measured
max_standby_streaming_delay = 30s on the fleet). The audit log records a
replica_fallback row. QueryHub does not re-run a timeout or a user's cancel.
Where a request ran. requests.executed_target_id names the replica. The
execution_started audit row carries replica and replica_lag_s. Or it
carries replica_skipped, with the reason why QueryHub skipped the replica. The
requester sees "Ran on a read replica ..." in the Slack result and in the web
Messages tab. Neither shows the replica's name.
-- Read requests served by a replica, last day
SELECT r.id, t.alias AS target, x.alias AS ran_on, r.completed_at
FROM requests r
JOIN target_servers t ON t.id = r.target_server_id
JOIN target_servers x ON x.id = r.executed_target_id
WHERE r.executed_at > now() - interval '1 day'
ORDER BY r.id DESC;Choosing where one query runs (super-admin). The web submit takes
runOn: auto (the rules above, the default), primary, or replica with a
replicaId. That id is optional when the connection has exactly one enabled
replica. QueryHub refuses anyone who is not a super-admin with 403. QueryHub
stores the choice as requests.run_on (NULL, 'primary', 'replica:<target id>').
Like unmasked, QueryHub verifies the choice again at execution. A requester
who is no longer a super-admin runs as auto, and execution_started records
run_on_ignored. A scheduled submit keeps its choice. QueryHub verifies the
requester's standing when the scheduler runs it, not at submit time.
primarynever consults a replica, at any tier. On SQL Server, a read skips the availability group's readable secondary and goes to the listener.- A chosen replica skips
replica_routing, the node-local rule, read-your-writes and the lag limit. This option exists to read a replica's ownpg_stat_activity. Three conditions still apply:- It must be an enabled replica of the request's target.
- The statement must be read-only. The submit refuses anything else with 400.
- A fresh probe must find the replica answering and in recovery. The probe does not need the primary: while the primary is down, the lag is the replica's replay age.
- If a chosen replica cannot run the query, before or during the run, the request fails. The message names the replica and the reason. The request never runs on the primary instead. To exclude a replica from chosen runs as well as from automatic ones, disable its row.
Every honoured choice writes an execution_run_on_forced audit row in the
claim's transaction: requested, ran_on (primary / replica),
target_id, target and lag_s. GET /api/queries/<id> and every
GET /api/history row carry ranOn (kind, name, forced,
lagSeconds), read from that row. The web UI builds its "ran on" sentence
from it. The server writes no Messages line and no Slack line for a chosen
node. The "Ran on a read replica ..." line for automatic routing does not
change. GET /api/connections/<conn>/replicas shows a super-admin each
replica's health as automatic routing sees it (the cached probe,
replica_health_ttl_seconds).
-- Queries a super-admin sent somewhere on purpose, last week
SELECT a.request_id, a.actor_name, a.details->>'requested' AS requested,
a.details->>'target' AS ran_on, a.details->>'lag_s' AS lag_s, a.created_at
FROM audit_log a
WHERE a.action = 'execution_run_on_forced'
AND a.created_at > now() - interval '7 days'
ORDER BY a.id DESC;Masking follows the request's target, the primary, wherever the query runs.
QueryHub finds exemptions and the column catalog by target_server_id, never
by the replica's own id. So an exemption written against a replica's
connection row never applies.
Cancel and lockout. A cancel signals the backend on the server that runs
the query (executed_target_id), with the primary's login.
scripts/breakglass_lockout.py ends QueryHub sessions on replicas too. It
writes no role change there: the NOLOGIN reaches a replica through replication.
The script processes the replicas after the primaries.
sslmode=require encrypts a target connection but accepts any certificate.
So a machine in the network path could answer in the server's place, and read
the login and the results. You enable verification per host, so a fleet spread
over two clouds (two certificate authorities) moves one cloud at a time. The
keys are in CONFIGURATION.md, "Target TLS".
1. Measure without a login. A TLS handshake is enough to see whether a CA file verifies a server, host name included:
openssl s_client -starttls postgres -connect <host>:5432 -servername <host> \
-verify_hostname <host> -CAfile <ca-file.pem> </dev/null 2>/dev/null \
| grep "Verify return code"0 (ok) means verify-full will connect to that host with that file.
2. Install the CA file on the QueryHub host, readable by the service user.
For AWS RDS, it is the global bundle, which covers every region:
https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem. For
another cloud, take the provider's database CA from its console or API, never
from the server itself. A certificate that the server gives you proves nothing
about who the server is. When two clouds verify, put both CAs in one file.
sudo install -D -m 0644 global-bundle.pem /etc/queryhub/tls/target-ca.pem3. One host first. Set target_ssl_rootcert to the file and
target_ssl_verify_hosts to one host. The settings screen refuses the change
while it cannot read the file. Run a query there, then wait a day.
4. Then the whole cloud, for example *.rds.amazonaws.com. Each process
logs one line at startup with the number of enabled targets still unverified.
5. A server that cannot verify belongs on target_ssl_verify_exempt_hosts,
with the reason in its connection notes.
Undo: clear target_ssl_verify_hosts. The change applies to the next
connection, with no restart.
SQL Server reads the same two lists (TrustServerCertificate=no for a listed
host). mssql_trust_server_cert covers the hosts that neither list names. A
target reached by IP address verifies only if its certificate names that
address. ClickHouse always verifies, against the public CA bundle.
The metadata DB is not a target. It has its own two settings,
BOT_DB_SSLMODE and BOT_DB_SSLROOTCERT, in the service environment. A
change there needs a restart of both services, and a wrong value stops both.
So measure it with the command above first. Do not set libpq's PGSSLROOTCERT
instead. libpq applies it to every connection that names no root file, which
turns each target's require into verify-ca against the wrong CA.
One login owns the metadata database and everything in it. Both services and
the scheduled jobs connect with it. Owning audit_log means UPDATE, DELETE
and TRUNCATE on it, whatever the code does. So a leaked runtime credential
could rewrite the audit trail. The split withdraws that power without a change
to the services' configuration: the runtime keeps its login and password.
| Role | Login | Holds |
|---|---|---|
owner (e.g. queryhub_owner) |
no | the database and every object in public |
migrator (e.g. queryhub_migrator) |
yes | membership in owner. Only scripts/apply_migrations.py uses it |
runtime (BOT_DB_USER, unchanged) |
yes | DML on tables, SELECT on views, USAGE on sequences, CONNECT and TEMPORARY on the database. On audit_log, only SELECT and INSERT. On schema_migrations, nothing |
Code: src/queryhub/metadata_roles.py (the policy),
scripts/split_metadata_roles.py (the change). CI runs the whole integration
suite a second time as a split runtime. So a code path that needs more than
DML fails there first.
What the runtime needs, and why each piece is there:
- TEMPORARY on the database. The access-model mirror trigger (migration
109) creates a temporary table on every write to the legacy access tables.
That includes the profile refresh on each
/sqlsubmission. - SELECT only on views. A simple view such as
audit_log_reportableis auto-updatable. PostgreSQL verifies an UPDATE through it against the view's owner. A blanket grant on all tables includes views. It would leave the audit rows editable indirectly, through a view. - INSERT and USAGE on
audit_log's sequence. Thebot_configaudit trigger (migration 066) writes it as the invoker.
1. Read the plan. The command is read-only, and it locks nothing:
PGPASSWORD=... python scripts/split_metadata_roles.py --owner queryhub_owner --admin-user <admin login>The admin login is the database's administrative user (the RDS master), not the runtime. The runtime is the role that loses privileges, and it cannot transfer them.
2. Create the two roles, as that admin login. You choose the migrator's password, and it never passes through the script:
CREATE ROLE queryhub_owner NOLOGIN;
CREATE ROLE queryhub_migrator LOGIN IN ROLE queryhub_owner;
\password queryhub_migrator
-- ALTER ... OWNER needs the admin login to be able to SET ROLE to both:
GRANT queryhub_owner TO <admin login>;
GRANT <runtime login> TO <admin login>;Put the migrator's login in its own file, readable by the operator only, and never in the services' environment:
# /etc/queryhub/migrator.env, mode 0600
BOT_DB_MIGRATOR_USER=queryhub_migrator
BOT_DB_MIGRATOR_PASSWORD=...
BOT_DB_OWNER_ROLE=queryhub_owner3. Rehearse. --rehearse runs everything in one transaction, verifies
it, and rolls back. It takes an ACCESS EXCLUSIVE lock on every table for the
second or two it runs. So run it at a quiet moment, or on a restored copy.
4. Apply. --apply runs the same transaction. It commits only if all of
these hold:
- nothing left owned by the runtime
- INSERT but no UPDATE, DELETE or TRUNCATE on
audit_log - UPDATE still on
requests - TEMPORARY held
- no CREATE on
public, and no view writable
If another session is using a table, the script aborts after 3 seconds. Run it again. No restart is needed.
5. Test the live paths once: a /sql read, a web submit, an admin
button. Each one exercises a trigger.
6. Migrations from now on run as the migrator:
set -a; . /etc/queryhub/migrator.env; set +a
python scripts/apply_migrations.pyWithout those variables, the runner says the ledger is unreadable and stops. After the last file, it re-applies the runtime's grants, so a new table gets DML and a new view gets SELECT only. The container entrypoint unsets the three variables after it migrates. With a separate one-off migration container, the service container never holds them at all.
Undo: --rollback --apply returns everything to the runtime login, which
is the state before the split.
Not done by the split, and still open (SEC-AUDIT):
- A hash chain over
audit_log, with an anchor outside the database. audit_log.request_id'sON DELETE SET NULL, which lets a DELETE onrequestsrewrite audit rows through the foreign key. No runtime path deletes an audited request today. Drafts are the only requests that the runtime deletes, and they have no audit rows.