Documentation · Visp DB Access
DB Access docs.
A self-hosted gateway for querying production PostgreSQL and MySQL. It parses, checks, limits, runs, masks and audits every statement, and engineers never see the database credentials.
docker pull ghcr.io/djkeshawa/visp-db-access:0.1.0
How a query runs
Every statement sent to POST /api/v1/clusters/:id/query passes the same seven stages, in order. A stage that can’t decide refuses.
- 1NetworkOrg and cluster allowlists403
- 2AccessSession and grant level401 · 403
- 3AnalyzeVerdict and risk422 · 409
- 4RecordIntent saved firstfails closed
- 5LimitRewrite, slot, cost429 · 422
- 6ExecuteGuarded transactionDatabase
- 7Mask, auditResults and historyrecorded
- Check the network. The client IP must be in the organization’s allowlist, and in the cluster’s too if it sets one. Forwarded headers count only from trusted proxy networks (
VDA_TRUSTED_PROXIES). - Check access. The session cookie is HttpOnly, and mutating requests must send
X-Requested-With: vda. Your level is the highest unexpired grant on the cluster or its project; org admins haveadmineverywhere. - Analyze the whole statement. The guard returns
allow,requires_approvalordeny, a risk level fromlowtocritical, and the SQL that will actually run. A deny returns 422query_deniedand is recorded asblocked; an approval verdict returns 409approval_required. - Record intent before touching the database. Full SQL, identity, verdict and client IP are written to history with status
running, along with aquery.executeaudit event. If that write fails, the query is refused. - Apply the limits. A read gets an outer
LIMIT max_rows + 1so truncation can be detected; smaller explicit limits are kept. The query waits up to 2 s for one of the cluster’s concurrency slots (429busyotherwise), and, ifmax_costis set, anEXPLAINcost gate refuses expensive reads (422cost_exceeded). - Execute in a guarded transaction. Reads run read-only with statement and lock timeouts, stream until the row or 32 MiB byte cap, and always roll back. Writes roll back if they change more than
max_affected_rows. Reads go to a healthy replica when one is configured. - Mask and audit. Masked columns come back as
••••••, and database errors for queries touching masked data are hidden. The final status, row counts, timing and route are written to history.
EXPLAIN for the cost gate runs inside the concurrency slot, because it also uses production resources.What the analyzer refuses
The guard walks the full syntax tree, so a write hidden in a CTE, a subquery, a set operation or EXPLAIN ANALYZE is classified as a write.
Unparsable SQL, more than one statement, or input over 1 MiB is refused outright. If the analyzer itself fails, that is a deny too.
| Code | Raised for |
|---|---|
| Always refused | |
parse_error · multiple_statements · query_too_large | SQL that can’t be parsed, more than one statement, or more than 1 MiB |
write_without_where · tautological_where | UPDATE or DELETE with no WHERE, or one that is always true |
dangerous_function | Sleeps, file access, locks and side effects: pg_sleep, pg_read_file, dblink, pg_advisory_lock, set_config, nextval; MySQL sleep, benchmark, load_file, get_lock |
sensitive_catalog | pg_authid, pg_shadow, pg_user_mapping, mysql.user, mysql.global_priv, and writes to performance_schema |
statement_not_allowed | SET, transaction control, COPY, CALL, LOCK, KILL, PREPARE, VACUUM and similar |
file_write | SELECT … INTO OUTFILE or DUMPFILE |
executable_comment · optimizer_hint | MySQL /*! … */ comments and optimizer hints |
| Refused by access or policy | |
insufficient_access | A write with read access, or DDL without admin |
writes_disabled · ddl_disabled | The cluster’s policy doesn’t allow writes, or DDL |
blocked_table | A table matching the policy’s blocked_tables |
locking_read | SELECT … FOR UPDATE and similar: refused for read users, needs approval for others |
masked_data_serialization | JSON functions, row constructors or whole-row references on tables with masked columns |
| Warnings that raise the risk | |
data_modifying_cte · explain_analyze_write | A write inside a CTE, or EXPLAIN ANALYZE of a write, which is treated as that write |
select_into | Classified as DDL |
cross_join · unbounded_insert_select · unbounded_generator | Statements that can produce or copy very large row counts |
Full list: security model and architecture.
Access & approvals
An organization holds projects, and projects hold clusters. Access is granted per project, covering all its clusters, or per cluster.
SELECT, EXPLAIN and SHOW, within the policy’s limitsallow_ddl, and always through approvaladmins have admin on every cluster; org members have what they are granted.Grants can carry an expires_at, for just-in-time access. A scoped admin can’t grant to themselves, and can’t delegate access that outlasts their own.
Four-eyes approvals
- 1Request
POST /approvalsSQL and a reason; re-analyzed on the serverwrite access - 2ReviewApprove or rejectanother admin
- 3Claim
…/executeOnce, within 24 hourseither of you - 4Re-checkBoth grants, againgateway
- 5RunGuarded, then auditedDatabase
pending → approved / rejected → executing → executed / failed, or expired. The reviewer can never be the requester. A claim stuck in executing becomes failed after the statement timeout plus five minutes, and is never replayed.Safety policy
Each cluster has one policy. New clusters get the defaults for their environment, and cluster admins change them in the console or with PUT /clusters/:id/policy.
| Key | Production | Staging, development | Range |
|---|---|---|---|
max_rows | 1,000 | 5,000 | 1–100,000 |
statement_timeout_ms | 15,000 | 60,000 | 100–600,000 |
lock_timeout_ms | 2,000 | 2,000 | ≤ statement timeout |
max_cost | off | off | > 0 |
max_concurrent_queries | 4 per node | 4 per node | 1–64 |
allow_writes | false | true | |
require_approval_for_writes | true | true | |
max_affected_rows | 1,000 | 1,000 | 1–100,000 |
allow_ddl | false | false | |
route_reads_to_replica | true | true | |
masked_columns · blocked_tables · allowed_cidrs | empty | empty | ≤ 128 patterns |
Two limits are fixed rather than configurable: results stop at 32 MiB, and approvals expire after 24 hours.
Masking and blocked tables
masked_columns takes patterns such as email, users.ssn and *.password*. They match returned column names, including table-qualified patterns when the query references that table, and apply to DML RETURNING too, even inside data-modifying CTEs. Masked values stay masked in copies and CSV or JSON exports, and exports escape spreadsheet formulas.
blocked_tables takes table, schema.table, schema.* and *.table, matched case-insensitively.
Performance suggestions
Alongside the verdict, the analyzer suggests faster or safer forms of a query. Suggestions are advisory: they never change the verdict or the SQL that runs, and the server never runs a fix itself.
| Suggestion | When | One-click fix |
|---|---|---|
add_limit | An unfiltered, non-aggregate read with no LIMIT | Add LIMIT 100 (or max_rows if smaller) |
preview_write | A single-table UPDATE or DELETE with a WHERE | Open SELECT COUNT(*) with the same predicate in a new tab |
select_star | SELECT * or t.* | — |
leading_wildcard | A LIKE pattern that starts with % | — |
function_on_column | A function or cast around a column in WHERE | — |
large_offset · large_in_list | OFFSET of 10,000 or more; an IN list of 500 or more | — |
order_by_random · not_in_subquery | ORDER BY random(); NOT IN (subquery) | — |
Deploy
One binary with the console embedded. Nodes are stateless; everything lives in a PostgreSQL metadata store.
1. Quick start with demo databases
git clone https://github.com/djkeshawa/visp-db-access
cd visp-db-access
export VDA_MASTER_KEY="$(openssl rand -base64 32)"
export VDA_BOOTSTRAP_ADMIN_PASSWORD='choose-a-long-random-password'
docker compose --profile demo up --build
Open http://localhost:8080 and sign in as admin@example.com. The admin password needs at least 12 characters, and the server refuses to start without a valid 32-byte key. Register the demo databases in the development environment, since production defaults block writes:
| Engine | Host | Port | Database | User / password |
|---|---|---|---|---|
| PostgreSQL | demo-postgres | 5432 | shop | demo / demo-local-only |
| MySQL | demo-mysql | 3306 | shop | demo / demo-local-only |
To try the console with no backend at all, run it in mock mode: cd web && npm ci && npm run dev:mock, then sign in as admin@visp.dev with demo-password.
2. Docker and Helm
Each release publishes an image to GHCR and a Linux x86_64 binary on GitHub Releases. The image is distroless and runs as non-root on port 8080.
docker pull ghcr.io/djkeshawa/visp-db-access:0.1.0
The Helm chart in deploy/helm/visp-db-access runs two replicas with a Service, a pod disruption budget, and an optional TLS Ingress and autoscaler. Pods run as UID 65532 with a read-only root filesystem and every capability dropped.
kubectl create secret generic vda-secrets \
--from-literal=database-url='postgres://…' \
--from-literal=master-key="$(openssl rand -base64 32)"
helm upgrade --install vda deploy/helm/visp-db-access \
--set existingSecret=vda-secrets
The secret needs database-url and master-key. Readiness is /readyz and liveness /healthz.
3. Configuration
Every setting is an environment variable with a matching command-line flag. .env files are not loaded.
| Variable | Default | Meaning |
|---|---|---|
VDA_DATABASE_URL | required | PostgreSQL metadata store; needs migration privileges on first start |
VDA_MASTER_KEY | required | Base64 32-byte key; openssl rand -base64 32 or visp-db-access gen-key |
VDA_BIND | 0.0.0.0:8080 | Listening address |
VDA_BOOTSTRAP_ADMIN_EMAIL · _PASSWORD | unset | First admin, created only when no users exist; at least 12 characters |
VDA_COOKIE_SECURE | true | Secure cookie and HSTS; false only for local plain HTTP |
VDA_ALLOWED_CIDRS | empty (all) | Client allowlist, intersected with the one set in the console |
VDA_TRUST_PROXY_HEADERS · VDA_TRUSTED_PROXIES | false · empty | Honor X-Forwarded-For from these proxy networks |
VDA_ALLOW_PRIVATE_TARGETS | false | Allow loopback database targets; RFC 1918 targets are always allowed |
VDA_METRICS_TOKEN | unset | When set, /metrics requires Authorization: Bearer <token> |
VDA_METADATA_POOL_SIZE | 20 | Metadata connections per node (1–200) |
VDA_LOG_FORMAT · RUST_LOG | text · info | text or json; log filter |
visp-db-access serve # the default: apply migrations, then serve
visp-db-access migrate # apply metadata migrations only
visp-db-access gen-key # print a new master key
VDA_ADMIN_PASSWORD=… visp-db-access create-admin --email you@example.com --name "Your Name"
4. Operations
- Health
/healthzreturns 200 while the process runs;/readyzreturns 503 when the metadata store is unreachable. One elected node probes every cluster every 30 seconds, and health history is kept for 7 days.- Metrics
/metricsexports Prometheus request counters, query durations, blocked queries and pool gauges, behind the same network gate as the API. Protect it withVDA_METRICS_TOKENor at your ingress.- Backups
- Back up the master key together with the metadata database. If the key is lost, re-enter each cluster’s password in its settings.
- Upgrades
- Migrations run at startup under an advisory lock, so replicas don’t race. Before 1.0, minor releases may change the API and storage schema, and only the latest release is supported.
Each node keeps up to five connections per database endpoint, so budget target connections across replicas. Shutdown is graceful: active queries are cancelled and history drains first.
Full reference: deployment and development.
AWS discovery
Find RDS and Aurora databases across accounts and regions, then import the ones you choose. Discovery only reads, and stores no AWS keys.
The gateway uses the AWS SDK’s default credentials: IRSA, an ECS task role, an instance profile or environment variables. Attach deploy/aws/iam-policy.json, which allows only rds:DescribeDBInstances and rds:DescribeDBClusters.
- For another account, create a role there from
deploy/aws/cross-account-role.json, naming the gateway’s role as principal and an external ID unique to this gateway, and attach the discovery policy. - Allow the gateway to call
sts:AssumeRoleon that exact role ARN. - Add a source under Settings → Cloud discovery with the role ARN, external ID and up to 20 regions, then select Test.
- Import under Clusters → Discovered: choose a project and environment and enter credentials. TLS defaults to
verify_full.
Sources rescan every 60 minutes by default (5–1,440). Aurora clusters use the writer endpoint, plus the reader endpoint when there are readers, and RDS read replicas attach to their source. A region that fails to scan never marks its databases as gone.
Drift is flagged as endpoint_changed, replica_changed, engine_changed or deleted. Sync applies a new host, port or replica; a changed endpoint needs the password again, and engine changes and deletions wait for manual review.
Full reference: AWS discovery.
HTTP API
The console uses the same versioned API under /api/v1. Bodies are snake_case JSON, IDs are UUIDs and timestamps are UTC.
Sign in with POST /auth/login, which sets the HttpOnly vda_session cookie. Every mutating request must send X-Requested-With: vda. There are no user API tokens in 0.1. Errors look like {"error": {"code", "message", "details"}}.
| Endpoint | Purpose |
|---|---|
| Querying | |
POST /clusters/:id/analyze | Verdict, risk and suggestions for {sql}, without touching the database |
POST /clusters/:id/query | Run {sql, query_id?}; returns rows, truncated, executed_sql, routed_to, elapsed_ms |
POST /queries/:query_id/cancel | Cancel your own query; admins can cancel any |
GET /history | Query history; members see only their own |
| Approvals and access | |
POST /approvals | Request approval for {cluster_id, sql, reason} |
POST /approvals/:id/approve · /reject · /execute | Review, then run once within 24 hours |
GET · POST /grants | List or create {user_id, scope, scope_id, level, expires_at?} |
GET /audit | The audit log (org admins) |
| Clusters and settings | |
GET · POST /clusters · PATCH /clusters/:id | List, create or update clusters; endpoint changes need the password |
GET · PUT /clusters/:id/policy | Read or set the safety policy |
GET /clusters/:id/schema | Schema tree, cached for 5 minutes; ?refresh=true bypasses it |
GET · PUT /settings/network | Organization allowlist and proxy trust; refuses a change that locks you out |
POST /discovery/sources/:id/scan · /discovery/resources/:id/import | Scan now (up to 60 s) · import a discovered database |
The full contract: API.md.
Security model & known limits
The gateway is one layer. Database privileges remain the primary control: give it the least-privileged database role that fits each cluster.
- Credentials
- AES-256-GCM, bound to the cluster’s ID and write-only. Changing any endpoint field needs the password again, so a stored password can’t be pointed at a new host.
- Sign-in
- Argon2id password hashes. Ten failed sign-ins lock the email for 15 minutes across all IPs, and a locked account gets the same 401 as a wrong password.
- Targets
- Addresses are checked when a pool is created and at every health check. Cloud metadata, link-local and zero-network addresses are always blocked; loopback needs
VDA_ALLOW_PRIVATE_TARGETS. - Audit
- Query intent and final status are written synchronously. Other audit events go through a bounded queue and can be lost in a crash.
Known limits
- Concurrency limits, cancellation and login throttling are per node.
- The guard doesn’t resolve search paths, read user-defined function bodies or estimate plans.
- Masking can be bypassed with aliases and computed expressions.
- MySQL’s
max_execution_timecovers onlySELECT; writes rely on the client deadline and lock timeouts. Rollback needs transactional storage such as InnoDB, MySQL DDL commits implicitly, and a connection lost duringCOMMITleaves the outcome uncertain. - DNS rebinding: a DNS change between validation and connection remains a risk. Enforce outbound firewall rules too.
- A history row left
runningby a crash becomesunknown, which implies neither success nor rollback. - No SSO yet: users sign in with email and password.
On the roadmap: RDS IAM authentication, GCP and Azure discovery, OIDC/SAML SSO and SCIM, group grants, just-in-time access requests, saved queries, distributed cancellation, durable audit delivery and SIEM export, MongoDB and SQL Server, and SSH tunnels.
Report vulnerabilities privately through GitHub’s private reporting. Full model: SECURITY-MODEL.md.
When something is stuck
- The server won’t start: the master key is missing or invalid
- Set
VDA_MASTER_KEYto a standard base64, 32-byte key fromopenssl rand -base64 32orvisp-db-access gen-key. - Stored credentials can’t be decrypted
- The key changed. Restore the original key, or re-enter each cluster’s password.
- Writes are blocked in the demo
- The cluster is registered as production. Use the
developmentenvironment. - 409
approval_required - Select Request approval and give a reason. Someone else with admin on the cluster reviews it.
- 422
query_deniedorcost_exceeded - The response lists the issue codes. For cost, narrow the query or ask an admin to raise
max_cost. - 429
busy - The cluster’s concurrency slots on this node are full. Wait and retry; writes are never retried automatically.
- A loopback database is rejected in development
- Set
VDA_ALLOW_PRIVATE_TARGETS=true. - Cancel doesn’t stop the query
- The request reached another node. Turn on load-balancer affinity.
Source, issues and changelog: github.com/djkeshawa/visp-db-access. Checked against release 0.1.0 on October 11, 2026.
