Authentication, authorization & backend identity
QueryFlux separates four questions, each configured independently:
| Question | Config key | Answers |
|---|---|---|
| Who is this client? | auth | none, static users, OIDC, LDAP |
| What are they allowed to do? | authorization | allow-all, per-group allow-lists, OpenFGA |
| Which identity reaches the backend engine? | clusters[].queryAuth | the service account, the client's own credential, an impersonated user, or an exchanged token |
| Which tables/columns may they see? | accessControl | allow/deny, row filters, and column masks via OPA (overview); connection + per–cluster-group scope in Studio Access Control, policy in Rego |
The first two gate access to QueryFlux itself. The third — backend identity — decides what Trino, ClickHouse, StarRocks, or Snowflake see as the query's principal, which matters for the backend's own audit log, row-level security, and access control. The fourth — data access control — rewrites or denies SQL at the gateway based on the verified client identity; see Access control.
Frontend authentication (auth)
auth:
provider: oidc # none | static | oidc | ldap
required: true
oidc:
issuer: https://keycloak.internal/realms/my-realm
jwksUri: https://keycloak.internal/realms/my-realm/protocol/openid-connect/certs
audience: queryflux
groupsClaim: groups
rolesClaim: roles
| Provider | Use when |
|---|---|
none | Local development, or the network path to QueryFlux is already trusted (e.g. a private VPC with its own perimeter) |
static | A small, fixed set of service users — bcrypt-hashed passwords in config |
oidc | You already run an IdP (Keycloak, Okta, Auth0, Entra ID) and want SSO |
ldap | Active Directory / OpenLDAP-backed organizations |
auth.required: true rejects unauthenticated requests on every enabled frontend. AuthContext (the verified identity — user, groups, roles, and the raw OIDC token when applicable) is what the rest of this page builds on.
Authorization (authorization)
authorization:
provider: none # none | openfga
clusterGroups:
analytics:
authorization:
allowGroups: [data-team, analysts]
allowUsers: [oncall-bot]
provider: none with no allowGroups/allowUsers on any group is allow-all — anyone who authenticates to QueryFlux can route to anything. Add per-group allow-lists to restrict that without standing up a full policy engine, or set provider: openfga for Zanzibar-style fine-grained authorization.
authorization gates routing — which cluster group a client can reach. It does not decide which identity a query runs under on the backend; that's queryAuth, below. A client authorized to route to a passthrough cluster still only sees what its own backend credential is allowed to see.
Backend identity (queryAuth)
This is the piece that decides what the engine itself sees as the query's user — independent of how the client authenticated to QueryFlux.
clusters:
trino-1:
engine: trino
endpoint: https://trino.internal:8443
auth: # Type 1 — QueryFlux's own service credential
type: basic
username: qf_svc
password: "..."
queryAuth: # Type 2 — which identity the query runs as
type: impersonate
| Mode | The backend sees | Engines |
|---|---|---|
serviceAccount (default) | QueryFlux's own service credential only — the user is known to QueryFlux for audit/routing, not proven to the backend | All |
passthrough | The client's own credential, forwarded unchanged | Trino, StarRocks (MySQL wire, LDAP-backed), Snowflake (ADBC, per-caller connection) |
impersonate | The service account authenticates; the real user is injected via an engine-specific mechanism (X-Trino-User, ClickHouse EXECUTE AS) | Trino, ClickHouse |
tokenExchange | A backend-scoped OAuth token, exchanged (RFC 8693) from the client's own token | Trino, Snowflake (ADBC) |
Which mode should I use?
- Same IdP on both sides, engine trusts bearer tokens directly (Trino with JWT auth against the same Keycloak realm) →
passthrough. Simplest — the engine validates the token itself, QueryFlux just forwards it. - Engine needs a fixed service principal but you still want per-user attribution in its own audit log (Trino/ClickHouse ACLs,
system.query_log) →impersonate. - Engine speaks OAuth but not your IdP's token format directly (Snowflake external OAuth, a Trino cluster in a different trust domain) →
tokenExchange. - Engine has no per-user wire mechanism, or you haven't set any of this up yet →
serviceAccount. This is the default and always works; it's the starting point, not a fallback to be embarrassed about.
Every mode other than serviceAccount fails closed: if the client has no forwardable credential, the exchange fails, or the caller is unauthenticated, the query is rejected — QueryFlux never silently downgrades to the service account and submits under the wrong principal.
Engine-specific requirements
Backend identity needs a small amount of setup on the engine side — QueryFlux can't configure your engine's own access control for you:
- Trino
impersonate— requires file-based access control granting the service account impersonation rights (http-server.access-control.config-files). Trino denies impersonation by default. - ClickHouse
impersonate— requires ClickHouse 25.11+, self-hosted (not supported on ClickHouse Cloud),access_control_improvements.allow_impersonate_user = 1, andGRANT IMPERSONATE ON {user} TO {service_account}. Cancelling an impersonated query additionally needsGRANT KILL QUERY ON *.*on the service account. - StarRocks
passthrough— authenticates each query on a dedicated connection as the target user viaauthentication_ldap_simple. Requires the cluster endpoint to use TLS (?require_ssl=true) and the passthrough user to holdOPERATEforcancel_queryto work. - Snowflake
tokenExchange— the exchanged token is sent asadbc.snowflake.sql.client_option.auth_tokenwithauth_type=auth_oauth; register QueryFlux as an OAuth client in the same IdP as the target Snowflake OAuth integration. - Snowflake
passthrough— the caller's own Snowflake username/password (captured at HTTP wire v1 login) opens a dedicated per-caller connection; QueryFlux does not verify the password itself, the connection attempt is the verification. Not available over SQL API v2 (stateless, Bearer-only) — a passthrough cluster reached that way fails closed.
Full per-engine wiring, the resolver's fail-closed contract, and the queryAuth × engine compatibility matrix: Auth & authorization design.
ADBC scoped connection pools
ADBC isolates tokenExchange and Snowflake passthrough credentials in separate
connection sub-pools. Snowflake session overrides (USE ROLE, USE WAREHOUSE,
USE SCHEMA) also create separate scopes when they differ from the cluster config.
These pools share a per-cluster cache with the following optional ADBC cluster
config fields (admin API/UI, alongside driver, uri, and poolSize):
| Field | Default | Meaning |
|---|---|---|
scopedPoolIdleTimeoutSecs | 900 | Seconds since the last pool lookup before a cached scope expires. |
scopedPoolMaxCount | 500 | Maximum cached scopes per cluster per process; evicts the least recently used scope at capacity. |
Both values must be positive integers. The limits cover identity and session-override
scopes together; the base service-account pool is separate. Identity scopes have at
most two connections each; scopes containing only session overrides use poolSize.
A background task sweeps expired scopes every min(scopedPoolIdleTimeoutSecs, 60)
seconds, including when no queries arrive. Expired scopes are not reused between
sweeps. Eviction drops the cache's pool reference, allowing r2d2 and the ADBC driver
to release connections and backend sessions. In-flight queries retain their pools
until completion, so eviction does not interrupt them; retained pools and builds
in progress can temporarily exceed the cache count limit.
Prometheus /metrics exposes queryflux_adbc_scoped_pools (cached pool count) and
queryflux_adbc_scoped_pool_evictions_total (counter). Both have cluster_group
and cluster_name labels; the counter also has reason="idle" or reason="lru".
No identity, password, or token is included in metric labels. The gauge includes
both adapter generations while a config reload drains the old adapter.
Expired scopes remain in the gauge until the next background sweep or pool
insertion removes them, even if a lookup has already rejected them as expired.
Allow for this cleanup delay when alerting on the gauge: the background task waits
min(scopedPoolIdleTimeoutSecs, 60) seconds between sweeps, with additional delay
possible from worker scheduling and cleanup. Connection cleanup runs on blocking
workers because driver release callbacks can involve network I/O.
Try it
examples/with-keycloak-oidc is a runnable Docker Compose stack: Keycloak as the IdP, Trino as the backend, and three ready-to-swap configs — config.yaml (passthrough), config-impersonate.yaml, config-token-exchange.yaml.
cd examples/with-keycloak-oidc
docker compose up -d --wait
TOKEN=$(curl -s http://localhost:8180/realms/queryflux/protocol/openid-connect/token \
-d grant_type=password -d client_id=queryflux \
-d username=alice -d password=alice | jq -r .access_token)
curl -X POST http://localhost:8080/v1/statement \
-H "Authorization: Bearer $TOKEN" -d "SELECT current_user"
See the example's own README for the full walkthrough, including how to swap between the three queryAuth modes.
Related: data access control
Gateway auth/authz and backend identity do not by themselves rewrite SQL for row filters or column masks. For identity-driven table grants, filters, and masks via OPA, see Access control and OPA provider.