README
¶
pathql-server
PathQL server implementation in Go (see: PathQL.org).
PathQL lets you write SQL queries that automatically produce nested JSON from flat SQL result rows. The nesting structure is inferred from table aliases and foreign key metadata, with optional path hints for overrides.
How it works
You send a POST request to /pathql with a JSON body containing a SQL query and
optional parameters. The pathsqlx
engine automatically determines the JSON structure by:
- Parsing the query to identify tables, aliases, and joins.
- Detecting cardinality using foreign key metadata (one-to-many vs many-to-one).
- Generating JSON paths for each column based on the query structure.
If automatic inference isn't sufficient, you can use PATH hints to override the structure.
PATH hints
PATH hints override the automatic path inference for table aliases. Provide
hints using the paths parameter in the request body:
{
"query": "SELECT ...",
"params": {},
"paths": { "alias": "$.path" }
}
PATH hint format:
alias: the table alias (or$for queries without a real table)$.path: the JSON path for that table's columns- If the path ends with
[], it's an array; otherwise, it's an object $alone means the root is a single object
Quick start
The simplest setup is one shared database connection and no row-level security,
which is the default. Point dsn at your database:
# config.ini
driver = "postgres"
dsn = "host=localhost port=5432 dbname=pathql sslmode=disable"
listen = ":8000"
Build and run, then send a query:
go build -o pathql-server
./pathql-server
curl -s localhost:8000/pathql \
-H 'Content-Type: application/json' \
-d '{"query":"SELECT id, content FROM posts WHERE id = :id","params":{"id":1}}'
# [{ "id": 1, "content": "blog started" }]
That is the whole product: send SQL, get nested JSON (see Examples). Everything below is optional hardening you add as you need it: authentication, abuse limits, TLS, and per-user row-level security.
There are two identity models, selected by [security] identity_kind:
none(default): a single shared connection. There is no per-caller isolation, so every authenticated caller runs as the same database role. This is the development / single-tenant on-ramp shown above.login_role: the server connects as the caller's own per-user database role and PostgreSQL row-level security keys oncurrent_user, an unforgeable identity. This is how multi-tenant isolation is enforced; it needs per-role provisioning (see Row-level security).
Each model has a one-command Docker demo: examples/demo for the simple shared-connection mode, and examples/login-role for per-user row-level security.
Configuration
Create a config.ini file in the project root. It is TOML. The minimal file
above is enough to start; the full set of options is:
driver = "postgres"
dsn = "host=localhost port=5432 dbname=pathql sslmode=disable" # identity_kind = "none"
listen = ":8000"
verbose = false
[database]
max_open_conns = 50
max_idle_conns = 10
conn_max_lifetime_ms = 300000
[security]
identity_kind = "none" # "none" (shared dsn, no RLS) or "login_role"
auth_table_prefix = "pathql_auth_"
read_only = true
metrics_user = "metrics"
startup_checks = "warn"
sql_gate = "off" # "off" or "on" (see SQL gate)
xsrf = "off" # "off" or "on" (see XSRF / CSRF protection)
writes = "off" # "off" or "on" (see Writes)
# admin_user = "admin"
# trusted_proxies = ["10.0.0.0/8", "192.168.0.0/16"]
# allow_ips = ["10.0.0.0/8"] # IP firewall allowlist (see IP firewall)
# deny_ips = ["203.0.113.7"] # IP firewall denylist
[auth]
methods = ["apikey", "basic"]
api_key_header = "X-API-Key"
# [roles] is used only when identity_kind = "login_role". Omit it for the shared
# "none" mode (which uses the top-level dsn instead).
[roles]
base_dsn = "host=localhost port=5432 dbname=pathql sslmode=disable" # no user=
baseline_role = "pathql_auth"
prefix = "pathql_r_"
reader_role = "pathql_readers"
password_secret = "${PATHQL_ROLE_SECRET}"
[limits]
max_query_ms = 5000
max_body_bytes = 1048576
max_response_bytes = 10485760
max_concurrent_per_user = 10
max_concurrent_global = 200
max_requests_per_min_ip = 120
max_auth_failures_per_min = 60
work_mem_kb = 0
max_estimated_cost = 0 # EXPLAIN cost ceiling; 0 disables (Postgres only)
max_estimated_rows = 0 # EXPLAIN estimated-rows ceiling; 0 disables
max_affected_rows = 0 # write blast-radius cap; 0 disables (see Writes)
[timeouts]
read_ms = 10000
write_ms = 30000
idle_ms = 60000
[cache]
memory_mb = 64
auth_ttl = "30s"
jwks_ttl = "1h"
[tls]
enabled = false
hsts = true
# cert_file = "/etc/pathql/tls.crt"
# key_file = "/etc/pathql/tls.key"
# redirect_http = ":8080"
[cors]
allowed_origins = []
Top-level options:
driver: database driver (e.g."postgres").dsn: the shared connection string used whenidentity_kind = "none"(the default). Required in that mode; ignored inlogin_rolemode, which uses[roles] base_dsninstead. Supports${ENV}expansion, and thePATHQL_DSNenvironment variable overrides it verbatim.listen: listen address servingPOST /pathqlandGET /metrics(default:8000).verbose: verbose logging (defaultfalse). When enabled, logs timestamp, status code, response size, and latency for each request.
Section options:
[database]: connection-pool caps. Inlogin_rolemode they apply to each per-role pool (max_open_conns,max_idle_conns,conn_max_lifetime_ms), plusmax_total_backends, the hard ceiling on connections across all pools.[security]:identity_kindselects the connection/identity model:"none"(default, single shareddsn, no RLS) or"login_role"(per-role connections withcurrent_userRLS, see Row-level security).auth_table_prefixnamespaces the auth tables.metrics_useris the principal allowed to read/metrics(see Metrics).admin_usergates the admin routes.startup_checkscontrols the startup hardening check.sql_gateenables the optional pre-execution SQL gate.xsrfenables the optional XSRF / CSRF protection.writesenables optional write support (off by default).trusted_proxiesis a list of CIDRs (or bare IPs) whoseRemoteAddris trusted to setX-Forwarded-For/X-Real-IP; the rate limiter (and the IP firewall) use it to find the real client IP.allow_ipsanddeny_ipsare the optional IP firewall lists.[auth]: see Authentication.[roles]: the per-role connection model, used only whenidentity_kind = "login_role", see Row-level security.base_dsnis the user-less connection string the server appendsuser=<role>and a derived password to;baseline_roleis the role used for auth lookups;prefixderives a user's role name from its id;reader_roleis the group granting read access; andpassword_secretderives each role's password.[limits]:max_body_bytescaps the request body andmax_response_bytescaps the encoded response (an oversized result is rejected with413).max_query_msbounds each query (a Go-side request timeout plus a Postgresstatement_timeout,idle_in_transaction_session_timeout, and an optionalwork_mem_kb).max_concurrent_per_user,max_concurrent_global,max_requests_per_min_ipandmax_auth_failures_per_minare the abuse-protection caps, see Rate limiting and concurrency.max_estimated_costandmax_estimated_rowsare the proactive cost ceiling.max_affected_rowsis the write blast-radius cap (see Writes).[timeouts]: HTTP serverread_ms,write_ms, andidle_ms.[cache]: the in-process counter/JWKS cache, see Cache.[tls]: optional TLS termination, see TLS.[cors]:allowed_originsis an explicit list of origins for browser cross-origin access, see CORS.
Secrets
Secrets should not live in the file in clear text. roles.base_dsn,
roles.password_secret and auth.jwt_hs256_secret support ${ENV} expansion,
so put the value in an environment variable and reference it, for example
password_secret = "${PATHQL_ROLE_SECRET}". Tokens that are not set expand to
the empty string. Keep config.ini readable only by the server user
(chmod 600).
Authentication
Authentication is configured in the [auth] section. methods is an ordered
list; each request is tried against each method until one succeeds. Supported
methods are "apikey", "basic", and "jwt". Leaving methods empty disables
authentication entirely (allowed only in identity_kind = "none" mode, since
login_role needs a principal to pick a role); the server logs a clear warning
at startup when it does.
apikey: presented asAuthorization: ApiKey <key>or in the header named byapi_key_header(defaultX-API-Key). The server stores only a SHA-256 hash of the key and looks it up by a non-secret prefix.basic: standard HTTP Basic; the password is verified against a bcrypt hash. Use it only over TLS.jwt: a bearer token presented asAuthorization: Bearer <jwt>, see JWT.
Failed authentication returns 401 with a generic body and a
WWW-Authenticate header. The response never reveals which field was wrong.
Once a request authenticates, the resolved app_user is the principal name used
for audit logging, metrics, per-user rate limiting, and the metrics_user /
admin_user route checks. It is not the row-level-security identity: in
login_role mode RLS keys on the caller's own database role (current_user),
which is derived from the user id, not from app_user. See
Row-level security.
JWT
jwt is not enabled by default. Add "jwt" to methods and configure the
jwt_* keys under [auth]:
[auth]
methods = ["jwt"]
jwt_algorithms = ["RS256"]
jwt_jwks_url = "https://issuer.example/.well-known/jwks.json"
jwt_issuer = "https://issuer.example/"
jwt_audience = "pathql"
jwt_user_claim = "sub"
require_user_row = false
# For HS256 instead of RS256/ES256:
# jwt_algorithms = ["HS256"]
# jwt_hs256_secret = "${JWT_HS256_SECRET}"
jwt_algorithms: the accepted signing algorithms; must be non-empty. The parser rejects any token whosealgis not in this list, which is the primary defense against algorithm-confusion attacks. The unsecurednonealgorithm is never accepted.jwt_hs256_secret: the shared secret forHS256. Required when an HMAC algorithm is configured. It is${ENV}-expandable, keep it out of the file.jwt_jwks_url: the JWKS endpoint for asymmetric algorithms (RS256,ES256, and so on). Required for those. The server fetches the key set, selects the key by the tokenkid, and caches the document forjwks_ttl(see Cache).jwt_issuer/jwt_audience: when set, the tokeniss/audmust match. Leave empty to skip the check.jwt_user_claim: the claim mapped to theapp_useridentity (defaultsub).require_user_row: when true, the claim value must match an enabled row in the users table; the row'sapp_useris then used. When false, the claim value is used directly.
Auth tables
The authenticators read two tables, <prefix>users and <prefix>api_keys,
where <prefix> is auth_table_prefix (default pathql_auth_). The PostgreSQL
schema, plus notes on how to insert a user and an API key (store sha-256(key)
as key_hash and the first 8 characters as key_prefix), is in
internal/auth/schema.sql. Apply it to your database
before enabling auth:
psql "$DATABASE_URL" -f internal/auth/schema.sql
Row-level security
Row-level security is the hardened, multi-tenant mode, enabled with
[security] identity_kind = "login_role". The default none mode has no
row-level security: every caller runs on one shared connection as the same
database role, so use it only for development or a single trusted tenant.
In login_role mode the server connects to PostgreSQL as the caller's own
database role and runs the query inside a read-only transaction (when
read_only = true) on that connection. A statement_timeout matching
max_query_ms (plus an idle_in_transaction_session_timeout and an optional
work_mem) is set transaction-locally via set_config(name, value, true).
Your row-level-security policies read the identity with current_user. Because
the role is fixed by authentication and the PostgreSQL role system enforces
membership, a query cannot forge another identity, even in a single statement
(no CTE or function can change current_user). This is an unforgeable
tenant-isolation boundary. A runnable example, including the policy, the
ENABLE ROW LEVEL SECURITY statements, and the least-privilege grants, is in
examples/rls_policy.sql. See
examples/login-role for a full runnable setup and
ROLE_MANAGEMENT_PLAN.md for the design.
Role configuration
The per-role model is configured under [roles] and needs at least one auth
method (it needs a principal to pick the role):
-
base_dsn: the connection string without a user; the server appendsuser=<role>and the role's derived password per connection. -
baseline_role: the role used for pre-auth work (reading the auth tables) before the caller is known. Defaultpathql_auth. -
prefix: a user with id N maps to the login role<prefix>N(defaultpathql_r_); the role name is derived from the id. -
reader_role: a group role granting read access that every managed role is a member of (defaultpathql_readers). Managed roles are never members of each other. -
password_secret(required): the master secret each per-role connection password is derived from asHMAC(password_secret, role). The same derivation goes into the sync DDL (CREATE ROLE ... PASSWORD) and is re-derived at connect time, so no per-role secret is stored. Thebaseline_rolemust have that derived password set too. Pair it withscram-sha-256inpg_hba.conffor production.${ENV}-expandable; keep it out of the file. -
[database] max_total_backendscaps total connections across all per-role pools (a shared semaphore);warm_pool_limitbounds how many pools keep idle connections. Both are config only.Client-cert +
pg_identis intentionally not used for per-role auth: with one server cert it would require enumerating apg_identline per role and reloading on every role creation, which does not fit dynamically managed roles.
The server never runs role DDL and never holds CREATEROLE. GET /admin/roles/sync
emits the exact CREATE ROLE / GRANT / DROP ROLE statements needed to make
the database roles match the users table; an operator or cron job applies them.
Admin routes
When [security] admin_user is set, the server serves /admin/* on the main
listener, authorized only for that principal (which may do nothing else: it is
refused on /pathql and /metrics). An empty admin_user disables the routes
(fail closed). Admin requests authenticate like any other, are rate-limited, and
are audit-logged.
POST /admin/users{username, app_user?, password?, generate_api_key?}: creates a user (optionally with a bcrypt password for Basic and a freshly generated API key, returned once) and reports the managed role name the next sync will create.DELETE /admin/users/{id}: removes the user and its API keys and evicts the role's pool; the role is dropped by the next sync.GET /admin/roles/sync: returns the role-sync DDL (create,grant_reader,drop, and the orderedddl) for a cron job to apply. login_role only.
Connection-pool sizing is set in [database] config, not at runtime.
Rate limiting and concurrency
Three abuse-protection caps run on the public listener, all configured under
[limits]:
max_requests_per_min_ip: a fixed-window per-IP rate limit. Over the budget returns429with aRetry-Afterheader. The client IP is taken fromRemoteAddr, or fromX-Forwarded-For/X-Real-IPonly whenRemoteAddris one of thetrusted_proxiesCIDRs, so a spoofed header from an untrusted peer is ignored.max_concurrent_global: caps the total number of in-flight requests. Over the cap returns503withRetry-Afterrather than queueing.max_concurrent_per_user: caps in-flight requests per authenticatedapp_user. Over the cap returns429. Unauthenticated requests are not per-user limited (the limiter runs after authentication, so the key is the resolved identity).max_auth_failures_per_min: a fixed-window brute-force lockout. After this many authentication failures in a minute for the same credential, further attempts are rejected with429and aRetry-Afteruntil the window rolls over. The counter is keyed by the credential being presented (API-key prefix or HTTP Basic username), falling back to the client IP, so one bad key or username is throttled regardless of source IP. Bearer tokens fall back to the IP key.
Set any cap to 0 to disable it. The per-IP limiter and the lockout counter use
the cache (see below).
Cache
The [cache] section configures a small in-process cache used for the rate-limit
counters and for caching fetched JWKS documents. It is bounded to memory_mb
MiB. auth_ttl and jwks_ttl are
duration strings (e.g. "30s", "1h"); jwks_ttl is how long a fetched JWKS
document is reused before refetching.
TLS
TLS termination is optional and off by default. To enable it, set
[tls] enabled = true and provide cert_file and key_file. The public
listener then serves HTTPS and adds an HSTS header (controlled by hsts, on by
default). Set redirect_http to an address (for example ":8080") to run a
small extra listener that 301-redirects plain HTTP to the HTTPS URL.
Terminating TLS at a reverse proxy or load balancer instead is also fine; in that
case leave TLS disabled here and set trusted_proxies so the rate limiter reads
the forwarded client IP.
CORS
[cors] allowed_origins is an explicit list of origins permitted for browser
cross-origin requests. A matching Origin is echoed back in
Access-Control-Allow-Origin; a preflight OPTIONS is answered with 204. The
wildcard * is never emitted, so the response is always safe to combine with
credentials. An empty list disables cross-origin access.
IP firewall
An optional allow/deny IP firewall gates every route (/pathql, /schema,
/health, /metrics, /admin/*) by the resolved client IP, before
authentication. It is configured under [security]:
deny_ips: a list of CIDRs (or bare IPs). A request from any address in the list is rejected with403.allow_ips: a list of CIDRs (or bare IPs). When non-empty, only addresses in the list are admitted and everything else gets403(default-deny). An empty list admits all addresses (still subject todeny_ips).
deny_ips is evaluated first, so an address in both lists is denied. Leaving both
empty disables the firewall. The client IP is resolved with the same
trusted_proxies rules the rate limiter uses, so an untrusted peer cannot lift
itself onto the allowlist with a spoofed X-Forwarded-For; set trusted_proxies
accurately when running behind a reverse proxy or load balancer. The firewall is
a coarse network gate, not a substitute for authentication.
XSRF / CSRF protection
[security] xsrf enables double-submit-cookie CSRF protection ("off" by
default). It is defense in depth for browser deployments that authenticate with
cookies or HTTP Basic; an API driven only with X-API-Key or
Authorization: Bearer headers does not strictly need it, and the
application/json body requirement plus the locked-down CORS policy
already make /pathql hard to drive from a cross-site form.
When xsrf = "on":
- A safe request (
GET/HEAD/OPTIONS) that arrives without anXSRF-TOKENcookie is given a fresh one. The cookie isSameSite=Strictand (over TLS)Secure, and is deliberately readable by JavaScript so a first-party client can echo it back. - A state-changing request (
POST/PUT/PATCH/DELETE) must send anX-XSRF-TOKENheader equal to theXSRF-TOKENcookie, or it is rejected with403. Because the same-origin policy stops a cross-site page from reading the cookie, only first-party code can produce a matching header.
The intended flow is: make one safe request to obtain the cookie, then include
its value in the X-XSRF-TOKEN header on every later unsafe request. The check
applies to /pathql and the /admin/* routes.
Operations / hardening
- Run the application as a least-privilege,
SELECT-only database role and rely on row-level security, not the application, as the last line of defense. See examples/rls_policy.sql for the grants and revokes (no write access, noCOPY, nopg_read_file/pg_sleep/ large-object functions). - Keep
config.inireadable only by the server user (chmod 600). The server logs a warning at startup if the file is group- or world-accessible. - Serve over TLS, either here or at a reverse proxy, and keep
trusted_proxiesaccurate so the rate limiter cannot be bypassed with a spoofedX-Forwarded-For. - Run
govulncheck ./...in CI to catch known vulnerabilities in the toolchain and dependencies, and keep the Go toolchain patched (standard-library fixes ship in patch releases).
Running
go build -o pathql-server
./pathql-server
The server listens on listen (default :8000) and serves POST /pathql
(execute queries), GET /schema (reflect the schema as DBML),
GET /health (readiness probe) and GET /metrics (request
metrics). Because top_queries exposes raw query text, /metrics is authorized
only for the configured metrics_user principal, who may read metrics and
nothing else, see Metrics. At startup the server also runs a database
hardening self-check, see Startup hardening checks.
It shuts down gracefully on SIGINT/SIGTERM.
Startup hardening checks
Almost every guarantee here (read-only, least privilege, no file access, RLS on
every table) depends on the database role and grants being set up correctly,
off-server. With startup_checks set, the server verifies the connected role's
actual posture once at startup using read-only catalog queries, and reports what
it finds:
- Critical: the role is a superuser or has the
BYPASSRLSattribute (either bypasses RLS and read-only), or it can write (INSERT/UPDATE/DELETE) to tables outside the auth tables. Inenforcemode underlogin_role, a readable table with no row-level security is also critical, since it is a silent full-table exposure where RLS is the boundary. - Warning: the role can execute sensitive functions (
pg_read_file,pg_sleep, large-object functions, ...); it can read tables that have no row-level security (so every authenticated caller sees all their rows); it owns a table whose RLS is enabled but not forced (a table owner bypasses its own non-forced policies, so applyFORCE ROW LEVEL SECURITY); or a table has RLS enabled but no policy (a safe default-deny, reported so an intentional lockdown is not mistaken for a missing one).
startup_checks = "warn" (the default) logs the findings and keeps running;
"enforce" additionally refuses to start when there is a critical finding;
"off" skips the check. The checks are PostgreSQL-only and are skipped for other
drivers. See examples/rls_policy.sql for the grants
and revokes that make them pass.
SQL gate
The SQL gate is an optional, pre-execution validator that narrows the
"send SQL, get JSON" surface before a query reaches the database. The database
remains the real boundary (a least-privilege role, the read-only transaction,
and RLS); the gate is defense in depth that rejects classes of query those
controls do not fully cover. It is set with [security] sql_gate and is "off"
by default.
When sql_gate = "on", a query is rejected with 400 (and a clean,
shape-describing message) unless it is:
- a single statement - stacked statements (
SELECT ...; DROP ...) are rejected even where a driver would run them; - read-only - it must begin with
SELECT,WITH,TABLEorVALUES, soSET,SHOW,EXPLAIN,COPY,CALL,DOand any DDL/DML are refused at the edge (several of these run even inside aREAD ONLYtransaction); and - free of system catalogs - no identifier may name
information_schemaor start with the reservedpg_prefix, closing off catalog enumeration (pg_class,pg_authid,pg_stat_activity, ...) that row-level security does not protect.
The check is content-aware: a ;, a pg_ name, or a keyword that appears
inside a string literal, a quoted identifier, a comment, or a dollar-quoted body
does not trigger a rejection. The value is a string so stricter modes (table or
column allowlists, a forced LIMIT) can be added later without breaking
existing configs.
Cost ceiling
The cost ceiling rejects a query before running it when the PostgreSQL planner
estimates it would be too expensive. With [limits] max_estimated_cost or
max_estimated_rows set above 0, the server runs EXPLAIN (FORMAT JSON) on
the query first (inside the same read-only transaction, with the same bound
parameters, and bounded by the same statement_timeout). Plain EXPLAIN only
asks the planner for an estimate; it never executes the query, so the check has
no side effects. If the top node's estimated total cost exceeds
max_estimated_cost, or its estimated output rows exceed max_estimated_rows,
the request is rejected with 400 and a generic message; the actual estimate
and the limit are logged server-side, not returned, so data volume is not
disclosed.
Both bounds default to 0 (disabled). max_estimated_cost is in PostgreSQL
planner cost units (the same units EXPLAIN prints), so pick a value by running
EXPLAIN on representative and pathological queries. The check is PostgreSQL
only; it is skipped for other drivers. It is one EXPLAIN round-trip per request,
so enable it when you accept queries from untrusted callers and want to stop a
sequential scan over a huge table or an accidental cross join before it ties up a
connection.
Enable the SQL gate alongside the cost ceiling. The gate restricts each request to a single read-only statement, so the EXPLAIN the ceiling runs plans exactly that one statement; without it a stacked statement could be planned or run during the EXPLAIN step (the read-only transaction still blocks writes, but the gate is the cleaner boundary).
Writes
The server is read-only by default: every query runs in a READ ONLY
transaction and the role is granted only SELECT. Set [security] writes = "on"
to also accept data-modifying statements. It is off by default, and it cannot be
combined with read_only = true (that would block every write at the database;
the server refuses to start on the contradiction).
When writes are on, each request is classified by its leading statement keyword:
- a read (
SELECT/TABLE/VALUES) runs in aREAD ONLYtransaction, exactly as when writes are off, so enabling writes never relaxes the read path; - a write (
INSERT/UPDATE/DELETE, or aWITHwrapping one) runs in a read-write transaction; and - anything else (DDL,
TRUNCATE,COPY,SET,SHOW,EXPLAIN,CALL,DO, stacked statements, system-catalog access) is rejected with400.
Classification applies the same structural rules as the SQL gate (a
single statement over non-catalog objects) no matter how sql_gate is set.
Enabling writes therefore has no side effects: stacked statements and catalog
access stay blocked either way.
Response shape
- A write with
RETURNINGreturns the written rows as JSON, subject tomax_response_bytes. pathsqlx infers nesting from aSELECT ... FROMshape, so it does not auto-nest a write: theRETURNINGcolumns come back under their bare names (a flat array). Shape the response withpathshints, exactly as for a read (for example"paths": {"$": "$"}). - A write without
RETURNINGreturns{"affected": N}, the number of rows changed.
RETURNING is PostgreSQL syntax (full INSERT/UPDATE/DELETE); MariaDB
supports it only for INSERT and DELETE. The affected-count path works on any
driver.
Blast-radius cap
[limits] max_affected_rows (0 disables, the default) is the write-side analogue
of the cost ceiling. A write that affects (or, with RETURNING,
returns) more rows than the cap is rolled back before commit and rejected with
400; the actual count is logged server-side, not returned. The pre-execution
max_estimated_rows ceiling also applies, so an obviously unbounded UPDATE/
DELETE is stopped before it runs.
Tenant isolation for writes
Writes are only tenant-isolated under
identity_kind = "login_role", where the connected role is
the caller's own. RLS read policies are not enough: a USING clause filters which
rows a write can see, but only a WITH CHECK clause constrains which rows a
caller can create or change. Without WITH CHECK, a caller could insert or
update a row attributed to another tenant. Add FOR INSERT/FOR UPDATE policies
with WITH CHECK (owner = current_user) (and a FOR DELETE policy with USING);
see the optional writer block in
examples/rls_policy.sql. Under login_role with
startup_checks = "enforce", the server refuses to start when a writable table
has no WITH CHECK policy, so a silent cross-tenant write path is caught at boot.
Under identity_kind = "none" there is no per-caller identity, so writes are
trusted single-tenant: every authenticated caller writes as the same role with no
row-level authorization. The server logs a warning at startup in that case. For
browser deployments that authenticate with cookies or HTTP Basic, enable
xsrf = "on" once writes are accepted.
Example: insert returning the new row
Request:
{
"query": "INSERT INTO posts (content) VALUES (:content) RETURNING id, content",
"params": { "content": "hello" },
"paths": { "$": "$" }
}
Response:
{ "id": 3, "content": "hello" }
Example: update without RETURNING
Request:
{
"query": "UPDATE posts SET content = :content WHERE id = :id",
"params": { "content": "edited", "id": 3 }
}
Response:
{ "affected": 1 }
Testing
The default suite is hermetic (no database needed):
go test ./...
End-to-end tests drive the real HTTP stack against a live PostgreSQL: they seed
the auth tables and a row-level-security demo table, then exercise API-key,
Basic and JWT authentication, RLS isolation per principal, read-only
enforcement and rate limiting over HTTP. They are
behind the e2e build tag and skip cleanly when no database is reachable:
# Uses host=localhost user=pathql password=pathql dbname=pathql by default.
go test -tags e2e -run TestE2E ./...
# Or point at your own database:
PATHQL_E2E_DSN="host=... user=... password=... dbname=... sslmode=disable" \
go test -tags e2e -run TestE2E ./...
Each run isolates its tables under a process-specific prefix and drops them on completion.
Request format
{
"query": "SELECT id, content FROM posts WHERE id = :id",
"params": { "id": 1 },
"paths": { "posts": "$.posts" }
}
Request parameters:
query(required): SQL query stringparams(optional): Named parameters for the query (must be an object, not an array)paths(optional): PATH hints to override automatic JSON path inference. Each key is a table alias, and each value is the JSON path (e.g.,{"p": "$", "c": "$.comments[]"})
On error the response is a generic JSON body
({"type":"Error","message":"..."}); driver internals are logged server-side,
never returned to the client.
Metrics
GET /metrics on the main listener returns JSON with request statistics. It is
authenticated like any request and then authorized: only the principal whose
app_user equals metrics_user (default "metrics") may read it, and that
principal is forbidden on /pathql. Any other identity gets 403, and a missing
or invalid credential gets 401. An empty metrics_user, or auth being
disabled, makes the endpoint return 403 for everyone (fail closed), since no
request can present the metrics identity. Create the metrics principal like any
other user (see Auth tables); an API-key-only account with
app_user = 'metrics' is typical. The response looks like:
{
"status_codes": {
"200": 1523,
"400": 12,
"500": 3,
"other": 0
},
"latency_ms": {
"<1": 45,
"<5": 892,
"<10": 421,
"<50": 123,
"<100": 34,
"<500": 7,
"<1000": 1,
"<5000": 0,
"<10000": 0,
">=10000": 0
},
"auth": {
"success": 1502,
"failure": 33
},
"rejections": {
"429": 7,
"503": 0
},
"top_queries": [
{"query": "SELECT * FROM users WHERE id = :id", "count": 18234, "total_ms": 41210},
{"query": "SELECT * FROM posts", "count": 9120, "total_ms": 33870}
],
"top_users": [
{"user": "alice", "count": 12044, "total_ms": 51230},
{"user": "bob", "count": 5310, "total_ms": 18900}
]
}
Status codes, latency buckets, and auth counters are tracked using atomic
64-bit counters and are safe for concurrent access. auth.success and
auth.failure count successful and failed authentications. rejections.429 and
rejections.503 count abuse-protection rejections from the rate limiter and the
per-user / global concurrency caps.
top_queries lists the queries that consumed the most total time, with the
request count and accumulated duration (total_ms) for each. It uses the
Space-Saving algorithm, which keeps a bounded set of counters (up to 1000
distinct queries) and evicts the entry with the lowest accumulated duration when
full, so memory stays bounded regardless of how many distinct queries the server
sees.
top_users is the same, keyed by the authenticated app_user instead of the
query: it ranks identities by the total request-handling time attributed to
them, with the request count and accumulated duration for each. It uses the same
bounded Space-Saving counter (up to 1000 distinct identities), and only
authenticated requests are attributed (the metrics principal is excluded, since
it is refused on /pathql).
Health check
GET /health is an unauthenticated readiness probe for load balancers and
orchestrators. It returns 200 with {"status":"ok","database":"up"} when the
database answered a recent ping, and 503 with
{"status":"unavailable","database":"down"} when it did not, so an orchestrator
keeps traffic away until the database is reachable. The reachability result is
cached for about a second, so frequent probes (or a flood of requests) cannot
turn the endpoint into a database-load amplifier. It is intentionally exempt from
authentication, rate limiting and the concurrency caps so a probe always gets a
prompt answer; the IP firewall, if configured, still applies.
Schema reflection
GET /schema returns the tables, columns, primary keys and foreign keys the
caller can read, rendered as DBML by the
dbml-tools library, so a client can
discover what to query (table names, columns, and the foreign-key relationships
PathQL nests on) without any write access or DDL. The response is text/plain
DBML:
Project {
database_type: 'PostgreSQL'
}
Table "posts" {
"id" bigint [pk, not null]
"category_id" bigint [not null]
"content" text
}
Ref: "posts"."category_id" > "categories"."id"
It is read-only and PostgreSQL-only (other drivers get 501). It authenticates
like /pathql and runs on the caller's own connection, so in
login_role mode PostgreSQL's information_schema
restricts the output to exactly the tables that role was granted, the same set
the caller can query. The metrics and admin principals are forbidden, and the
response is subject to max_response_bytes.
Examples
The examples below are based on a database with posts, comments, and
categories tables.
Simple query: flat array
Request:
{
"query": "SELECT id, content FROM posts WHERE id = :id",
"params": { "id": 1 }
}
Response:
[{ "id": 1, "content": "blog started" }]
Multiple records
Request:
{
"query": "SELECT id FROM posts WHERE id <= 2 ORDER BY id",
"params": {}
}
Response:
[{ "id": 1 }, { "id": 2 }]
Join with automatic inference: posts with comments
Using table aliases (p, c), pathsqlx automatically detects the one-to-many
relationship via foreign keys. Each result row holds the post under p and its
comments as a sibling c array, grouped per post:
Request:
{
"query": "SELECT p.id, c.id, c.message FROM posts p LEFT JOIN comments c ON c.post_id = p.id WHERE p.id <= 2 ORDER BY p.id, c.id",
"params": {}
}
Response:
[
{
"p": { "id": 1 },
"c": [{ "id": 1, "message": "great!" }, { "id": 2, "message": "nice!" }]
},
{
"p": { "id": 2 },
"c": [{ "id": 3, "message": "interesting" }, { "id": 4, "message": "cool" }]
}
]
PATH hint: nested posts with comments
Using a PATH hint to control the root structure:
Request:
{
"query": "SELECT posts.id, comments.id FROM posts LEFT JOIN comments ON post_id = posts.id WHERE posts.id <= 2 ORDER BY posts.id, comments.id",
"params": {},
"paths": { "posts": "$.posts" }
}
Response:
{
"posts": [
{ "id": 1, "comments": [{ "id": 1 }, { "id": 2 }] },
{ "id": 2, "comments": [{ "id": 3 }, { "id": 4 }] }
]
}
PATH hint: count as object
Request:
{
"query": "SELECT count(*) AS posts FROM posts p",
"params": {},
"paths": { "p": "$" }
}
Response:
{ "posts": 2 }
PATH hint: nested statistics object
Request:
{
"query": "SELECT count(*) AS posts FROM posts p",
"params": {},
"paths": { "p": "$.statistics" }
}
Response:
{ "statistics": { "posts": 2 } }
PATH hint: multiple scalar counts
Request:
{
"query": "SELECT (SELECT count(*) FROM posts) as posts, (SELECT count(*) FROM comments) as comments",
"params": {},
"paths": { "$": "$.statistics" }
}
Response:
{ "statistics": { "posts": 2, "comments": 4 } }
Group by
Request:
{
"query": "SELECT categories.name AS name, count(posts.id) AS post_count FROM posts, categories WHERE posts.category_id = categories.id GROUP BY categories.name ORDER BY categories.name",
"params": {}
}
Response:
[{ "name": "announcement", "post_count": 2 }]
Only announcement appears: both posts belong to it, and the inner join
excludes article, which has no posts.
License
See LICENSE.
Documentation
¶
There is no documentation for this package.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
auth
Package auth implements pluggable authentication for pathql-server.
|
Package auth implements pluggable authentication for pathql-server. |
|
cache
Package cache provides a small key/value cache abstraction used for rate limiting and short-lived auth/JWKS caching.
|
Package cache provides a small key/value cache abstraction used for rate limiting and short-lived auth/JWKS caching. |
|
config
Package config loads, defaults, expands and validates the pathql-server configuration.
|
Package config loads, defaults, expands and validates the pathql-server configuration. |
|
db
Package db provides a single shared, capped connection pool over pathsqlx.DB.
|
Package db provides a single shared, capped connection pool over pathsqlx.DB. |
|
middleware
Package middleware provides small, independent HTTP middlewares used by the pathql-server request lifecycle: panic recovery, request body size limiting, security headers, and request-id propagation.
|
Package middleware provides small, independent HTTP middlewares used by the pathql-server request lifecycle: panic recovery, request body size limiting, security headers, and request-id propagation. |
|
roles
Package roles computes the DDL needed to synchronize PostgreSQL LOGIN roles with the pathql auth users table for the login_role RLS model.
|
Package roles computes the DDL needed to synchronize PostgreSQL LOGIN roles with the pathql auth users table for the login_role RLS model. |
|
sqlgate
Package sqlgate is an optional, pre-execution validator for the SQL a client submits.
|
Package sqlgate is an optional, pre-execution validator for the SQL a client submits. |