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.

  • v0.1.0 · pre-1.0
  • PostgreSQL · MySQL · MariaDB
  • Docker · Helm
  • Apache-2.0
  • Checked Oct 11, 2026
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.

  1. 1NetworkOrg and cluster allowlists403
  2. 2AccessSession and grant level401 · 403
  3. 3AnalyzeVerdict and risk422 · 409
  4. 4RecordIntent saved firstfails closed
  5. 5LimitRewrite, slot, cost429 · 422
  6. 6ExecuteGuarded transactionDatabase
  7. 7Mask, auditResults and historyrecorded
  1. 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).
  2. 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 have admin everywhere.
  3. Analyze the whole statement. The guard returns allow, requires_approval or deny, a risk level from low to critical, and the SQL that will actually run. A deny returns 422 query_denied and is recorded as blocked; an approval verdict returns 409 approval_required.
  4. Record intent before touching the database. Full SQL, identity, verdict and client IP are written to history with status running, along with a query.execute audit event. If that write fails, the query is refused.
  5. Apply the limits. A read gets an outer LIMIT max_rows + 1 so truncation can be detected; smaller explicit limits are kept. The query waits up to 2 s for one of the cluster’s concurrency slots (429 busy otherwise), and, if max_cost is set, an EXPLAIN cost gate refuses expensive reads (422 cost_exceeded).
  6. 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.
  7. 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.
Select a stage to read what happens there. The 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.

CodeRaised for
Always refused
parse_error · multiple_statements · query_too_largeSQL that can’t be parsed, more than one statement, or more than 1 MiB
write_without_where · tautological_whereUPDATE or DELETE with no WHERE, or one that is always true
dangerous_functionSleeps, 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_catalogpg_authid, pg_shadow, pg_user_mapping, mysql.user, mysql.global_priv, and writes to performance_schema
statement_not_allowedSET, transaction control, COPY, CALL, LOCK, KILL, PREPARE, VACUUM and similar
file_writeSELECT … INTO OUTFILE or DUMPFILE
executable_comment · optimizer_hintMySQL /*! … */ comments and optimizer hints
Refused by access or policy
insufficient_accessA write with read access, or DDL without admin
writes_disabled · ddl_disabledThe cluster’s policy doesn’t allow writes, or DDL
blocked_tableA table matching the policy’s blocked_tables
locking_readSELECT … FOR UPDATE and similar: refused for read users, needs approval for others
masked_data_serializationJSON functions, row constructors or whole-row references on tables with masked columns
Warnings that raise the risk
data_modifying_cte · explain_analyze_writeA write inside a CTE, or EXPLAIN ANALYZE of a write, which is treated as that write
select_intoClassified as DDL
cross_join · unbounded_insert_select · unbounded_generatorStatements 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.

LevelreadSELECT, EXPLAIN and SHOW, within the policy’s limits
LevelwriteMay submit DML. It runs only if the policy allows writes, and through approval by default
LeveladminManages the cluster’s policy and grants. DDL only with allow_ddl, and always through approval
Your effective level is the highest unexpired grant. Org admins 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

  1. 1RequestPOST /approvalsSQL and a reason; re-analyzed on the serverwrite access
  2. 2ReviewApprove or rejectanother admin
  3. 3Claim…/executeOnce, within 24 hourseither of you
  4. 4Re-checkBoth grants, againgateway
  5. 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.

KeyProductionStaging, developmentRange
max_rows1,0005,0001–100,000
statement_timeout_ms15,00060,000100–600,000
lock_timeout_ms2,0002,000≤ statement timeout
max_costoffoff> 0
max_concurrent_queries4 per node4 per node1–64
allow_writesfalsetrue
require_approval_for_writestruetrue
max_affected_rows1,0001,0001–100,000
allow_ddlfalsefalse
route_reads_to_replicatruetrue
masked_columns · blocked_tables · allowed_cidrsemptyempty≤ 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.

SuggestionWhenOne-click fix
add_limitAn unfiltered, non-aggregate read with no LIMITAdd LIMIT 100 (or max_rows if smaller)
preview_writeA single-table UPDATE or DELETE with a WHEREOpen SELECT COUNT(*) with the same predicate in a new tab
select_starSELECT * or t.*—
leading_wildcardA LIKE pattern that starts with %—
function_on_columnA function or cast around a column in WHERE—
large_offset · large_in_listOFFSET of 10,000 or more; an IN list of 500 or more—
order_by_random · not_in_subqueryORDER 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:

EngineHostPortDatabaseUser / password
PostgreSQLdemo-postgres5432shopdemo / demo-local-only
MySQLdemo-mysql3306shopdemo / 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.

VariableDefaultMeaning
VDA_DATABASE_URLrequiredPostgreSQL metadata store; needs migration privileges on first start
VDA_MASTER_KEYrequiredBase64 32-byte key; openssl rand -base64 32 or visp-db-access gen-key
VDA_BIND0.0.0.0:8080Listening address
VDA_BOOTSTRAP_ADMIN_EMAIL · _PASSWORDunsetFirst admin, created only when no users exist; at least 12 characters
VDA_COOKIE_SECUREtrueSecure cookie and HSTS; false only for local plain HTTP
VDA_ALLOWED_CIDRSempty (all)Client allowlist, intersected with the one set in the console
VDA_TRUST_PROXY_HEADERS · VDA_TRUSTED_PROXIESfalse · emptyHonor X-Forwarded-For from these proxy networks
VDA_ALLOW_PRIVATE_TARGETSfalseAllow loopback database targets; RFC 1918 targets are always allowed
VDA_METRICS_TOKENunsetWhen set, /metrics requires Authorization: Bearer <token>
VDA_METADATA_POOL_SIZE20Metadata connections per node (1–200)
VDA_LOG_FORMAT · RUST_LOGtext · infotext 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
/healthz returns 200 while the process runs; /readyz returns 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
/metrics exports Prometheus request counters, query durations, blocked queries and pool gauges, behind the same network gate as the API. Protect it with VDA_METRICS_TOKEN or 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.

  1. 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.
  2. Allow the gateway to call sts:AssumeRole on that exact role ARN.
  3. Add a source under Settings → Cloud discovery with the role ARN, external ID and up to 20 regions, then select Test.
  4. 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"}}.

EndpointPurpose
Querying
POST /clusters/:id/analyzeVerdict, risk and suggestions for {sql}, without touching the database
POST /clusters/:id/queryRun {sql, query_id?}; returns rows, truncated, executed_sql, routed_to, elapsed_ms
POST /queries/:query_id/cancelCancel your own query; admins can cancel any
GET /historyQuery history; members see only their own
Approvals and access
POST /approvalsRequest approval for {cluster_id, sql, reason}
POST /approvals/:id/approve · /reject · /executeReview, then run once within 24 hours
GET · POST /grantsList or create {user_id, scope, scope_id, level, expires_at?}
GET /auditThe audit log (org admins)
Clusters and settings
GET · POST /clusters · PATCH /clusters/:idList, create or update clusters; endpoint changes need the password
GET · PUT /clusters/:id/policyRead or set the safety policy
GET /clusters/:id/schemaSchema tree, cached for 5 minutes; ?refresh=true bypasses it
GET · PUT /settings/networkOrganization allowlist and proxy trust; refuses a change that locks you out
POST /discovery/sources/:id/scan · /discovery/resources/:id/importScan 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_time covers only SELECT; writes rely on the client deadline and lock timeouts. Rollback needs transactional storage such as InnoDB, MySQL DDL commits implicitly, and a connection lost during COMMIT leaves the outcome uncertain.
  • DNS rebinding: a DNS change between validation and connection remains a risk. Enforce outbound firewall rules too.
  • A history row left running by a crash becomes unknown, 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_KEY to a standard base64, 32-byte key from openssl rand -base64 32 or visp-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 development environment.
409 approval_required
Select Request approval and give a reason. Someone else with admin on the cluster reviews it.
422 query_denied or cost_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.