QueryFlux Auth/AuthZ Design
The Two-Credential Model (Core Principle)
Every backend cluster has two distinct credential relationships, both configured per-cluster in ClusterConfig:
Credential Type 1 — Service Credentials (auth, existing ClusterAuth)
- QueryFlux's own service account for the backend
- Used for: health checks, schema/catalog discovery, cluster management
- Static, ops-owned; ideally stored in Secrets Manager (not inline in config)
- Auth types:
basic,bearer,keyPair(RSA — new, for Snowflake/Databricks) - Never changes at request time; independent of which user is running a query
Credential Type 2 — Query Execution Credentials (queryAuth, new field in ClusterConfig)
- The credentials used to execute a specific user's query on the backend
- Configured per-cluster — operators choose which mode their engine supports
- Resolved per-request from
AuthContext(verified identity) + the configuredqueryAuthtype - Validated at startup: each engine accepts only its supported modes
- Default when omitted:
serviceAccount(falls back to Type 1 for everything)
queryAuth has four explicit types: serviceAccount | passthrough | impersonate | tokenExchange
Anonymous requests fail closed for explicit per-user modes. An unauthenticated (AuthContext.user == "anonymous") request only ever resolves to serviceAccount — there is no per-user credential to forward, impersonate, or exchange. If a cluster is explicitly configured with passthrough, impersonate, or tokenExchange, an anonymous request is rejected with an auth error rather than silently executing under the service account.
passthrough forwards the client's own credential to the backend — a Bearer/Basic header unchanged for Trino, a dedicated per-query LDAP connection for StarRocks, a per-caller ADBC connection for Snowflake — so the backend authenticates the user's own credential, not a QueryFlux-vouched identity. Support is per-engine and per-adapter, not universal; startup validation rejects it for engines/drivers whose adapters don't yet consume QueryCredentials for this mode (see the compatibility table below).
Before passthrough existed as an explicit type, the Trino adapter forwarded all headers stored in SessionContext.extra verbatim whenever a cluster had no HTTP-setting auth of its own — including the client's Authorization header — regardless of queryAuth. That implicit behavior is deprecated but still supported for serviceAccount/omitted queryAuth on Trino clusters, for backward compatibility (a startup warning is emitted). New configs should set queryAuth.type: passthrough explicitly instead of relying on the implicit fallback.
Health checks always use Type 1 (auth) directly, never queryAuth. This ensures they work even when a user's token is expired or missing.
# config.yaml — per-cluster dual credentials (camelCase to match existing serde config)
clusters:
trino-prod:
engine: trino
endpoint: https://trino.internal:8443
auth: # Type 1 — service credentials
type: basic
username: qf_svc
password: "..."
queryAuth: # Type 2 — query execution mode
type: impersonate # service account + X-Trino-User header
clickhouse-prod:
engine: clickHouse
endpoint: http://clickhouse:8123
auth:
type: basic
username: qf_svc
password: "..."
queryAuth:
type: serviceAccount # only viable option for ClickHouse
snowflake-prod: # ADBC driver — see configuration.md for the full shape
engine: adbc
driver: snowflake
uri: qf_svc@myaccount/mydb/myschema
auth:
type: keyPair # RSA key-pair, Snowflake standard
username: QF_SVC
privateKeyPem: "..."
queryAuth:
type: tokenExchange
tokenEndpoint: https://keycloak.internal/realms/my-realm/protocol/openid-connect/token
clientId: queryflux-gateway
clientSecret: "..."
# Trino→Trino same-IdP: forward the client's own credential explicitly.
trino-analytics:
engine: trino
endpoint: https://trino-analytics.internal:8443
auth:
type: basic
username: qf_svc
password: "..."
queryAuth:
type: passthrough # forward the client's Authorization unchanged
queryAuth engine compatibility (startup validation rejects unsupported combinations):
| Engine | serviceAccount | passthrough | impersonate | tokenExchange |
|---|---|---|---|---|
| Trino | ✅ | ✅ | ✅ X-Trino-User (needs Trino file-based ACL) | ✅ |
| ClickHouse | ✅ | ❌ not yet wired | ✅ EXECUTE AS (self-hosted 25.11+ only, see below) | ❌ not yet wired |
| StarRocks (MySQL wire) | ✅ | ✅ dedicated per-query LDAP connection (requires TLS; see below) | ❌ no wire mechanism | ❌ no wire mechanism |
| StarRocks (HTTP, future) | ✅ | — | — | — |
| Snowflake (ADBC) | ✅ | ✅ per-caller connection, fails closed if unverified (see below) | ❌ | ✅ per-identity connection pool |
| Databricks (ADBC, future) | ✅ | ❌ not yet wired | ❌ | ❌ not yet wired |
| DuckDB | ✅ (no-op) | — | — | — |
"Not yet wired" means the adapter does not yet consume QueryCredentials for that mode — startup validation rejects the config rather than silently accepting it and doing nothing. See the backend-identity plan for the phase that adds each one.
BackendIdentityResolver pseudocode:
At this point the caller has already: selected ClusterGroupMember, merged connection hints for the adapter, and resolved which ClusterAuth / profile supplies Type 1 material.
fn resolve(auth_ctx: &AuthContext, cluster: &ClusterConfig, type1: &ClusterAuth) -> QueryCredentials {
// If no user identity available, always fall back to service account
if auth_ctx is NoneIdentity {
return ServiceAccountCreds(type1.clone())
}
match cluster.queryAuth.type {
serviceAccount =>
ServiceAccountCreds(type1.clone())
passthrough =>
// No-op at the resolver: the adapter/dispatch layer resolves the actual
// forwarded credential from SessionContext.extra["authorization"], or a
// Bearer built from auth_ctx.raw_token when that header is missing.
// Fails closed (rejects the query) if neither is available — never
// silently substitutes ServiceAccountCreds.
PassthroughCreds
impersonate =>
// Use Type 1 credentials on the wire; inject user identity separately.
// IMPORTANT: suppress the client's Authorization header — do NOT forward it.
// Only Type 1 (service account) auth reaches the backend.
// User identity is injected via an engine-specific mechanism AFTER
// authentication: Trino sets X-Trino-User; ClickHouse wraps the SQL as
// `EXECUTE AS {user} {sql}`.
ImpersonateCreds {
service_auth: type1.clone(), // resolved profile or cluster.auth
user: auth_ctx.user.clone(),
}
tokenExchange =>
// Exchange auth_ctx.raw_token at the configured OAuth endpoint.
// Fails closed (rejects the query) if raw_token is None or the exchange
// fails — never silently substitutes ServiceAccountCreds, since that would
// submit the query under the wrong principal.
// Per-provider contract: see Layer 3 → tokenExchange section.
exchange_token(auth_ctx.raw_token?, cluster.queryAuth.token_exchange_config)
}
}
Mixed queryAuth within one cluster group: Allowed — each cluster carries its own config. If a group uses engineAffinity or weighted strategy with members of different queryAuth types, the resolver uses whichever cluster was selected. Operators should ensure all members of a group use the same queryAuth type unless they explicitly want per-cluster behaviour; a startup warning is emitted if a group has members with mixed types.
Cluster group membership: array of connection options
Problem: members: [ "cluster-a", "cluster-b" ] is not enough when the same logical cluster (same endpoint) participates in multiple groups with different team defaults (e.g. Snowflake role/warehouse, or which auth profile to prefer).
Model: clusterGroups[].members becomes an array of objects — each entry is one connection option in the group’s pool (ordering preserved for failover / round-robin / weighted).
Each ClusterGroupMember (name TBD) contains at minimum:
cluster— required; name of an entry inclusters.connection— optional, engine-specific non-secret hints merged at dispatch after a member is selected (or used to disambiguate defaults). Validated at startup: fields must match the referenced cluster’sengine; unknown or wrong-engine fields are rejected (fail fast).weight— optional; for weighted strategies within the group.defaultAuthProfile— optional; names a profile defined on that cluster (see below). Supplies the group-scoped default when this member is the path into the cluster (“team A uses role ANALYST”).
Backward compatibility: Config loader may accept either a bare string ("trino-prod") or a full object, so existing YAML keeps working during migration.
Per-engine connection shapes (all supported types)
Each engine exposes a closed set of connection option types. Implement as a serde tagged enum EngineConnectionOptions (or nested Option structs with validation) so only valid combinations deserialize.
| Engine | Purpose of group-level connection | Typical fields (non-secret) | ClusterAuth (Type 1) variants |
|---|---|---|---|
| Trino | Session context defaults for this group’s path | catalog, schema, optional sessionProperties map | basic, bearer |
| ClickHouse | Default database / settings | database, optional role (if using CH RBAC features) | basic (maps to X-ClickHouse-User / Key) |
| StarRocks (MySQL wire) | Default catalog/db context | database | basic (user/password) |
| StarRocks (HTTP, future) | JWT-forwarding session hints | TBD aligned with StarRocks HTTP API | basic, bearer |
| Snowflake | Same account URL, different team slice | role, warehouse, database, schema | keyPair (recommended), future password-based if needed |
| Databricks (future) | Warehouse / HTTP context | warehouseId, catalog, httpPath (product-specific) | bearer, OAuth client creds via queryAuth |
| DuckDB | Usually none (file path on cluster) | rarely attach hints | N/A (embedded) |
Rule: Secrets (passwords, PEMs, client secrets) stay on clusters[].auth or authProfiles via inline (dev) or secretRef (prod) — not duplicated per group. Group connection carries which role/warehouse/catalog to use, not private keys.
Teams / groups pattern: Create one cluster group per team (or workload). Each group lists the same Snowflake cluster name once, with different connection.role / warehouse and/or defaultAuthProfile. Combined with allowGroups / OpenFGA, “Alice may only hit group:team-analytics” implies she only gets that group’s Snowflake role default.
Auth profiles (cluster-scoped, named Type 1 variants)
When one cluster needs multiple service identities or Snowflake logins (different key-pair users, or same user + different static contexts), define authProfiles on ClusterConfig:
clusters:
snowflake-prod:
engine: snowflake
endpoint: https://xyz.snowflakecomputing.com
defaultAuthProfile: svc_readonly
authProfiles:
svc_readonly:
type: keyPair
username: QF_READONLY
privateKeySecretRef: { provider: vault, path: secret/data/qf/sf-readonly, field: key }
svc_etl:
type: keyPair
username: QF_ETL
privateKeySecretRef: { provider: vault, path: secret/data/qf/sf-etl, field: key }
queryAuth:
type: serviceAccount # or tokenExchange — profile picks which Type 1 for serviceAccount path
Resolution order after cluster + group member are known:
defaultAuthProfileon the group member entry (if set) — team/unit-specific service account.- Else
defaultAuthProfileon the cluster (if set) — cluster-level default. - Else legacy single
authblock on the cluster.
The client never influences which auth profile is used. Clients influence routing (which group to target) via the existing router chain — headers, client tags, regex, protocol — but the auth/authz layer must approve access to that group. Once a group is selected, the auth profile is entirely determined by operator config. This separation means the group IS the privilege boundary: being authorized for team-a-snowflake group means you get the svc_readonly service account; being authorized for team-etl means you get svc_etl. No escalation possible from the client side.
Config is invalid (startup error) if a defaultAuthProfile name references a profile not defined on that cluster's authProfiles.
Default routing (when no router matches)
When the router chain evaluates all configured routers (protocol-based, header, user-group, query-regex, client-tags, python-script) and none produces a group selection, QueryFlux applies a two-step fallback rather than blindly using the static routingFallback config key:
- Authorization-aware first-fit: enumerate all cluster groups in config order. For each group, check whether
AuthContextis authorized (OpenFGA check orallowGroups/allowUsersmatch). Pick the first group the user is authorized for. - Static fallback (
routingFallback): if the user is not authorized for any group (or has no identity), fall back to the staticroutingFallbackgroup — same behavior as today, but only reached when step 1 finds nothing.
Why this order? Clients route implicitly via router rules when they send headers/tags/regex-matching SQL. When they send nothing, they still belong to some team — the authorization layer already knows which groups they may access. Picking the first authorized group gives users a deterministic default without requiring them to always specify routing hints. The static routingFallback remains for unauthenticated or unauthorized requests (e.g. health probers, legacy clients with no identity).
Startup constraint: If auth.required: true, an unauthenticated request is rejected before routing — routingFallback is not reached. If auth.required: false, NoneAuthProvider still derives a user from sessionCtx.user(); the first-fit check runs against that identity.
Config remains unchanged: routingFallback is still a required top-level string. No new config key needed — the behavior is implicit when the router chain produces no match and an AuthContext is available.
RouterChain result = None
→ for each group in config order:
if authz.check(auth_ctx, group) == allowed → use this group
→ if none found → use routingFallback
End-to-end dispatch (single query)
- Authenticate →
AuthContext. - Route → cluster group name (router chain → authorization-aware first-fit →
routingFallback). - Authorize → user may use that group (OpenFGA or
allowUsers/allowGroups). - ClusterManager → pick one member of the group (strategy: RR, weighted, failover, engine affinity).
- Merge connection context → load
ClusterConfigformember.cluster, applymember.connection(engine-validated) +member.defaultAuthProfile. - Resolve profile → pick
authmaterial (singleauthor namedauthProfiles). BackendIdentityResolver→QueryCredentialsfromqueryAuth+AuthContext(token exchange, impersonate, service account, implicit header forward).- Adapter → submit query with merged wire auth + engine session hints (role, catalog, etc., per adapter).
Audit logs should record: auth_ctx.user, group, cluster, resolved profile, member index or id (if useful).
Secret storage (operations)
| Approach | Use when |
|---|---|
Vault / cloud Secrets Manager (secretRef on auth / authProfiles) | Production default; rotation and audit at the secrets layer. |
| Envelope encryption in Postgres (ciphertext in DB, DEK wrapped by KMS) | Policy requires all config in DB; avoid a single static app-wide passphrase without rotation. |
| Plain YAML / plain DB columns | Dev and test only. |
QueryFlux should resolve secretRef at startup or config reload, not on every query, unless operators explicitly need dynamic secrets.
Context
QueryFlux is a universal SQL proxy routing queries across heterogeneous backends (Trino, DuckDB, StarRocks, ClickHouse, and future cloud platforms). Today there is no verified frontend authentication — on Trino HTTP, client headers may be forwarded to a Trino backend; there is no gateway-level JWT validation or OpenFGA. As it grows to multi-tenant use, it needs:
- Frontend auth: verify who the user is (AuthProvider — pluggable)
- Authorization: decide what they can access (OpenFGA or simple policy)
- Backend identity: propagate the right credentials to each engine per its capabilities
Multi-engine routing (frontend A → backend D)
The design fits heterogeneous routing: any supported frontend (Trino HTTP, Postgres wire, …) can target any supported backend cluster type, as long as routers and SQL translation allow it. Gateway auth and authz depend only on AuthContext and cluster group — not on whether the backend is Trino or ClickHouse. Backend identity is always resolved per selected cluster via queryAuth + engine capabilities: the same user may hit Trino with forwarded JWT and ClickHouse with a service account in the same deployment.
Operator choices: static backend creds vs forwarding client creds
| Approach | Meaning | When to use |
|---|---|---|
Static Type 1 only (queryAuth omitted or serviceAccount) | No per-request resolution for the wire: every query uses clusters[].auth. User identity may still exist in AuthContext for audit, authz, and metrics. | Default for ClickHouse, StarRocks MySQL wire, DuckDB; safe baseline everywhere. |
| Implicit header forwarding (Trino adapter today) | Client Authorization / X-Trino-* from SessionContext are applied after cluster auth — client's Authorization wins if present. No separate queryAuth type; not “free security.” | Same-IdP Trino→Trino, dev, or locked-down networks where routing is narrow. |
impersonate | Type 1 only on the wire + X-Trino-User; client Authorization must be suppressed. | Trino with file-based ACL when JWT passthrough is not used. |
Authorization (provider: none) and passthrough are independent. Turning off OpenFGA/simple lists does not make forwarded client creds a substitute for gateway policy: anyone who can reach the gateway may get queries routed per router rules, and the backend decides what those creds allow. Do not auto-enable “forward everything” based solely on authorization: none. Prefer explicit per-cluster behavior (queryAuth + adapter rules). Emit a startup warning when authorization.provider: none and implicit Authorization forwarding is active on a frontend that routes to multiple cluster groups (broad blast radius).
auth.required: true with NoneAuthProvider still means no cryptographic proof of identity — only network trust. Document clearly for operators.
Architecture Overview
Client (any protocol)
↓
Frontend Listener
├─ Extract Credentials (protocol-specific) ← raw material for AuthProvider
└─ Build SessionContext (unverified, as today)
↓
AuthProvider.authenticate(credentials) → AuthContext
{ user, groups, roles, raw_token }
Pluggable: None | Static | OIDC | LDAP
↓
RouterChain → ClusterGroup selection
(routers can inspect AuthContext.user/groups)
↓
OpenFGA / Policy check
"can user X execute queries on cluster group Y?" → allowed | 403
↓
ClusterManager → pick GroupMember (cluster name + connection options + optional defaultAuthProfile)
↓
Merge ClusterConfig + member.connection (engine-specific hints) + resolved auth profile
↓
BackendIdentityResolver(AuthContext, cluster.queryAuth) → QueryCredentials
serviceAccount → cluster.auth (Type 1)
impersonate → cluster.auth + user identity header (suppress client Authorization)
tokenExchange → exchange raw_token at OAuth endpoint
(implicit: Trino adapter forwards SessionContext headers unchanged when no suppression needed)
↓
Adapter.submit_query(sql, QueryCredentials) ← Type 2 used here
Adapter.health_check() uses cluster.auth (Type 1) ← always independent
↓
Backend Engine
Layer 1: Frontend Authentication
AuthProvider trait (new queryflux-auth crate)
trait AuthProvider: Send + Sync {
async fn authenticate(&self, creds: &Credentials) -> Result<AuthContext>;
}
struct Credentials {
username: Option<String>,
password: Option<String>, // from Basic auth or wire handshake
bearer_token: Option<String>, // from Authorization: Bearer
// Future: extensible fields or a sealed enum for mTLS principal, Kerberos, IAM delegation, etc.
}
struct AuthContext {
user: String,
groups: Vec<String>,
roles: Vec<String>,
raw_token: Option<String>, // original JWT, needed for tokenExchange
}
Why gateway auth if clients already send credentials? Client material (Basic, Bearer, wire username) is input. AuthProvider answers: is it valid (signature, LDAP bind, static password), and what is the canonical subject for policy? Authorization answers: what may that subject do at QueryFlux (which cluster groups)? Query resolution answers: what credentials go on the wire to this engine (often Type 1 only). Unverified headers (e.g. X-Trino-User alone) are trivial to forge from any client that can reach the gateway — so multi-tenant or untrusted networks need verified auth, not only forwarding.
Implementations:
NoneAuthProvider— derives identity fromsessionCtx.user()only; no cryptographic verification.auth.required: truewith this provider does not add JWT/signature checks — it only enforces that a username is present unless paired with network trust (VPC, mTLS at the load balancer). Make this explicit in operator docs.StaticAuthProvider— user/password map in config (dev/simple deployments)OidcAuthProvider— validates JWT signature against JWKS endpoint; extracts groups/roles from claimsLdapAuthProvider— binds with user credentials to verify; extracts group membership from DN
Credential extraction per protocol (no password verification for wire protocols):
TrinoHttp: parseAuthorizationheader → Basic or Bearer →CredentialsPostgresWire: captureuserfrom startup message →Credentials { username, .. }MySqlWire: captureuserfrom handshake →Credentials { username, .. }ArrowFlightSQL: gRPC metadata bearer token →Credentials { bearer_token, .. }
Auth config block (in queryflux-core/src/config.rs):
auth:
provider: none | static | oidc | ldap
required: true # with NoneProvider: network-trust only, not cryptographic assurance
# IdP roles/groups that may cancel any query (not poll another user's results).
operatorRoles: [queryflux-operator]
operatorGroups: [platform-ops]
oidc:
issuer: https://...
jwksUri: https://...
audience: queryflux
groupsClaim: groups
rolesClaim: roles
ldap:
url: ldap://...
bindDn: cn=svc,...
userSearchBase: ou=users,...
static:
users:
alice: { password: "...", groups: [analysts] }
Keycloak as OIDC provider
Keycloak maps directly onto OidcAuthProvider — no special code:
auth:
provider: oidc
oidc:
issuer: https://keycloak.internal/realms/my-realm
jwksUri: https://keycloak.internal/realms/my-realm/protocol/openid-connect/certs
audience: queryflux-client
groupsClaim: groups # requires "Group Membership" token mapper on Keycloak client
rolesClaim: realm_access.roles
Keycloak also enables tokenExchange for backends: QueryFlux exchanges the user's access token for a backend-scoped token (requires Keycloak token-exchange preview feature and "Token Exchange" permission on the target client). This is configured in clusters[].queryAuth, not here.
Layer 2: Authorization via OpenFGA
OpenFGA implements Google Zanzibar-style fine-grained authorization, stored and managed outside QueryFlux code.
Scope: OpenFGA (and simple allowlists) answer gateway questions — e.g. “may this subject run queries against cluster group G?” They do not replace engine-native RBAC (Trino system access control, ClickHouse users, Ranger on StarRocks, etc.). Table/column policies remain on the engines unless the model is extended and kept in sync deliberately.
Authorization Model:
type user
type group
relations
define member: [user]
type cluster_group
relations
define reader: [user, group#member]
define writer: [user, group#member]
define admin: [user, group#member]
Check at dispatch time (after routing, before query execution):
openfga_client.check(
user: format!("user:{}", auth_ctx.user),
relation: "reader",
object: format!("cluster_group:{}", selected_group),
).await? // → allowed | 403
Tuple lifecycle (who writes authorization data):
- Bootstrap: an init script or migration tool writes tuples from a seed file when QueryFlux first starts against a new OpenFGA store
- Admin API:
POST /admin/authz/tuples(new endpoint) allows operators to grant/revoke access at runtime without redeploy - IdP sync (optional): a background task reads group memberships from the IdP (LDAP, Keycloak) and syncs group-member tuples into OpenFGA on a configured interval
- Manual: operators use the OpenFGA CLI or Playground directly against the OpenFGA store
Config:
authorization:
provider: openfga | none
openfga:
url: http://openfga:8080
storeId: "..."
credentials:
method: api_key
apiKey: "..."
Fallback when provider: none: simple allowGroups/allowUsers lists on each clusterGroup (same as trino-gateway's role approach). No external dependency.
clusterGroups:
analytics:
members:
- cluster: trino-prod
connection:
type: trino
catalog: hive
schema: default
- cluster: clickhouse-prod
connection:
type: clickHouse
database: analytics
authorization: # used only when provider: none
allowGroups: [analysts, admins]
allowUsers: [svc-etl]
team-a-snowflake:
members:
- cluster: snowflake-prod
defaultAuthProfile: svc_readonly
connection:
type: snowflake
role: ANALYST_TEAM_A
warehouse: WH_TEAM_A
authorization:
allowGroups: [team-a]
team-b-snowflake:
members:
- cluster: snowflake-prod
defaultAuthProfile: svc_etl
connection:
type: snowflake
role: ETL_TEAM_B
warehouse: WH_TEAM_B
authorization:
allowGroups: [team-b]
Note: connection.type should align with the cluster’s engine for that member; startup validation rejects mismatches. Bare strings in members remain supported for backward compatibility during migration.
Layer 3: Backend Identity (queryAuth modes)
All modes configured under clusters[].queryAuth (per-cluster, not per-group).
Deprecated: implicit header forwarding (Trino HTTP → Trino, no config needed)
Before passthrough existed as an explicit queryAuth type, a Trino cluster with no HTTP-setting cluster.auth of its own would forward all headers stored in SessionContext.extra to the backend — including Authorization — regardless of queryAuth. This still works today for backward compatibility (a startup warning is emitted), but new configs should use queryAuth: passthrough explicitly instead — see the mode below.
When queryAuth: impersonate is set on a Trino cluster, the adapter must suppress the client's Authorization header and use only Type 1 credentials for authentication. The X-Trino-User injection happens after the service account auth is applied. Failing to suppress the client Authorization would cause the backend to see conflicting auth credentials.
Mode: serviceAccount
Use Type 1 credentials (cluster.auth) for query execution. User identity is known to QueryFlux (logged in audit/metrics) but the backend sees only the service account.
Works for all engines. Default when queryAuth is omitted.
Mode: passthrough (Trino, StarRocks)
Forward the client's own credential unchanged. The shape of "the client's own credential" is engine-specific — enrich_session_for_passthrough (queryflux-engine-adapters::wire_auth) populates both shapes from AuthContext before the adapter runs, and each adapter reads whichever one applies:
- Trino (HTTP-shaped): the value already captured in
SessionContext.extra["authorization"](the client's original header), or — if that's missing but the frontend authenticated via OIDC — aBearer {auth_ctx.raw_token}injected by dispatch. The backend authenticates the user's own token; QueryFlux does not vouch for the identity beyond forwarding it. - StarRocks (MySQL-wire-shaped):
session.extra["passthrough_username"]/["passthrough_password"], sourced fromAuthContext.raw_password(only ever populated byLdapAuthProvider, which just verified it via a real LDAP bind) and used to open a dedicated connection directly as that user — see "StarRocks" below for why this shape is necessary, what it costs, and why it's a fresh connection rather thanCOM_CHANGE_USERon a pooled one.
Fails closed: if the engine-appropriate credential isn't available, the query is rejected with an auth error rather than silently falling back to serviceAccount.
Startup validation rejects passthrough for engines whose adapters don't yet consume QueryCredentials — see the compatibility table above.
Mode: impersonate (Trino, ClickHouse)
Service account authenticates to the backend; user identity injected via an engine-specific mechanism — a header for Trino, a SQL prefix for ClickHouse.
Trino:
- Remove client's
Authorizationheader from the outgoing request - Apply
cluster.auth(Type 1, Basic or Bearer) as the backend authentication - Set
X-Trino-User: {auth_ctx.user}header
Trino-side requirement — Trino's built-in access control prohibits impersonation by default. File-based access control must be configured:
{ "impersonation": [{ "original_user": "qf_svc", "new_user": ".*", "allow": true }] }
http-server.access-control.config-files=/etc/trino/rules.json
This is high operator burden. For OIDC deployments where Trino is configured with JWT auth pointing to the same IdP, prefer explicit queryAuth: passthrough over impersonate — omitting queryAuth relies on the deprecated implicit forwarding path (see above); new configs should never depend on it.
ClickHouse:
The adapter wraps the outgoing SQL as EXECUTE AS {user} {sql} — the service account (cluster.auth) still authenticates the HTTP request; EXECUTE AS re-scopes just that one query to run as user. Only safe for a single statement per request, which matches how QueryFlux's ClickHouse HTTP path already works.
ClickHouse-side requirements, verified against the current upstream docs and changelog (not guessed):
- ClickHouse 25.11 or newer —
EXECUTE ASwas added in that release (EXECUTE ASdocumentation, 25.11 release notes). - Self-hosted only — the current ClickHouse docs state
EXECUTE ASis not supported on ClickHouse Cloud. - Server setting
access_control_improvements.allow_impersonate_user = 1. GRANT IMPERSONATE ON {user} TO {service_account}(orGRANT IMPERSONATE ON * TO {service_account}for all users).
QueryFlux cannot verify any of these remotely at startup — an unsupported version, a Cloud endpoint, or a missing grant all surface as an ordinary ClickHouse query error at run time (not a startup failure), since EXECUTE AS is just SQL syntax as far as the adapter is concerned.
Cancelling an impersonated query needs KILL QUERY too. EXECUTE AS re-scopes the query to run as user, so ClickHouse attributes it to user, not the service account. cancel_query issues KILL QUERY WHERE query_id = … using the service account's own connection (it doesn't re-apply per-query wire auth — there's nothing to re-apply for impersonate, since the identity is baked into the SQL text at submit time, not carried as a separate credential). By default, ClickHouse's KILL QUERY privilege only lets a user kill their own queries; killing a different user's requires the global KILL QUERY privilege, granted with GRANT KILL QUERY ON *.* TO {service_account}. Without it, cancelling an impersonated query fails server-side (User {service_account} attempts to kill query created by {user}) — caught by the existing best-effort cancel error handling (logged, ignored), so the query keeps running until it finishes on its own.
Only Trino and ClickHouse support impersonate. StarRocks has no equivalent over MySQL wire (no trusted-proxy mechanism). Startup validation rejects impersonate for any other engine type.
Mode: tokenExchange (Trino, Snowflake in this release)
QueryFlux exchanges the user's OIDC JWT (auth_ctx.raw_token) for a backend-specific OAuth access token via RFC 8693 token exchange. Requires OidcAuthProvider on the frontend (so raw_token is populated). Fails closed: if raw_token is absent or the exchange fails, the query is rejected with an auth error — it never silently falls back to serviceAccount, since that would submit the query under the wrong principal.
Startup validation rejects tokenExchange for engines whose adapters don't yet consume it — see the compatibility table above.
Per-provider contract:
| Provider | Grant type | Subject token type | Audience / scope |
|---|---|---|---|
| Keycloak token exchange | urn:ietf:params:oauth:grant-type:token-exchange | urn:ietf:params:oauth:token-type:access_token | audience: <target-client-id> |
The provider must be registered as an OAuth client in the same IdP as QueryFlux. The exchanged token is used as Authorization: Bearer <exchanged_token> in the adapter request. Exchanged tokens are cached (keyed by the full effective grant — user, endpoint, client, audience, scope, and a digest of the raw subject token — until expires_in minus a buffer) to avoid an exchange call on every query.
clusters:
trino-1:
engine: trino
auth:
type: bearer
token: "..." # QueryFlux's own service credential to Trino
queryAuth:
type: tokenExchange
tokenEndpoint: https://keycloak.internal/realms/my-realm/protocol/openid-connect/token
clientId: queryflux-gateway
clientSecret: "..."
targetAudience: trino-client # Keycloak target client ID
Snowflake (shipped), Databricks (future)
Non-Trino OAuth-based engines are natural tokenExchange targets once their adapters consume QueryCredentials::Bearer. Snowflake's ADBC adapter does today — see "Session-scoped pooling" under Engine-Specific Notes below for how the exchanged token is applied per-identity. Databricks has no ADBC adapter yet, so tokenExchange remains rejected at startup for it.
| Provider | Grant type | Subject token type | Audience / scope |
|---|---|---|---|
| Snowflake external OAuth | urn:ietf:params:oauth:grant-type:token-exchange | urn:ietf:params:oauth:token-type:access_token | scope: session:role:<ROLE> |
| Databricks OAuth U2M (future) | urn:ietf:params:oauth:grant-type:token-exchange | urn:ietf:params:oauth:token-type:access_token | scope: all-apis |
The same queryAuth: tokenExchange config shape applies — only the targetAudience/scope and the engine's own OAuth registration differ.
Persisted wire credentials (poll/cancel reuse)
Poll and cancel requests don't repeat the client's original headers, so the resolved wire credential is persisted on ExecutingQuery.wire_auth (StoredWireAuth::Authorization for passthrough/tokenExchange's Authorization value, StoredWireAuth::ImpersonateUser for impersonate's username) at submit time and re-applied on every subsequent poll/cancel — including after a replica restart, since ExecutingQuery is durably stored.
Residual risk: with a Postgres persistence backend, ExecutingQuery (including wire_auth) is serialized into the executing_queries.data JSONB column unencrypted. A StoredWireAuth::Authorization value is a live, reusable bearer/basic credential for the duration of the query — anyone with read access to that column (DB backups, replication, a database-level compromise) could reuse it. This applies to passthrough, tokenExchange, and the deprecated implicit-forwarding path under serviceAccount (see above) — all three can produce a StoredWireAuth::Authorization value, and all three get persisted the same way. impersonate's StoredWireAuth::ImpersonateUser is just a username, not a credential, so it isn't part of this risk. This is an accepted, documented gap, not an oversight:
- The row is deleted as soon as the query reaches a terminal state — every exit path (normal completion, client/admin cancel, and the zombie-eviction sweep for a disconnected client) calls
persistence.deleteon it. This bounds, but does not eliminate, the exposure window: a backup or replica snapshot taken while the query is still running would still capture the row. StoredWireAuth'sDebugimplementation is hand-written to redact theAuthorizationvalue, so it never leaks through incidental{:?}logging even though it isn't encrypted at rest.- Admin-facing DTOs and query-history conversions never include
wire_auth— it exists only in the internalExecutingQueryrecord. - Operators running
passthrough/tokenExchangein production should restrict read access to theexecuting_queriestable (and its backups/replicas) to the application role — treat it as sensitive as the secrets store, since it transiently holds equivalent material. - Encryption-at-rest for this column (envelope encryption via a KMS, or a short-lived protected-credential-store reference instead of the raw value) is a real follow-up, deliberately not implemented in this release — it needs an explicit key-management decision this codebase doesn't otherwise make. This is an accepted release gap, not a completed mitigation — treat
passthrough/tokenExchangewith a Postgres backend as requiring the DB-access restriction above until encryption-at-rest ships; get explicit security sign-off before relying on them in an environment whereexecuting_queriesbackups/replicas aren't already tightly restricted.
Engine-Specific Notes
ClickHouse
- No JWT/OIDC support;
X-ClickHouse-User+X-ClickHouse-Keyare full auth credentials (username + password), not impersonation headers — there is no trusted-proxy header on this engine impersonateis viable via theEXECUTE AS {user} {sql}SQL statement — see "Mode:impersonate" above for the version/config/grant requirements (self-hosted ClickHouse 25.11+ only)passthrough/tokenExchange: not wired in this release- ClickHouse Cloud: further restricted to password-only (no LDAP/Kerberos/cert), and does not support
EXECUTE ASat all —impersonateis a self-hosted-only capability here
Trino HTTP frontend → ClickHouse backend, serviceAccount: Gateway auth still produces AuthContext (who the analyst is for authz and audit). Gateway queryAuth for the ClickHouse cluster resolves to Type 1 service credentials only. ClickHouse sees the service user, not the Trino username, unless queryAuth: impersonate is configured (see above) or operators add a custom integration (password mirroring, external authenticator).
StarRocks
- MySQL wire (port 9030, current adapter):
serviceAccountalways works.passthroughis wired via a dedicated per-query LDAP connection — see below. No impersonation mechanism (noEXECUTE AS-equivalent) exists on this protocol;impersonateis not viable for StarRocks. passthrough(LDAPauthentication_ldap_simple) — wired, verified against a real StarRocks+LDAP server, gated on TLS:- MySQL wire protocol authenticates the whole connection, not the individual query — there's no per-statement identity header (Trino) or SQL prefix (ClickHouse). The only way to make StarRocks enforce a specific user's permissions is a connection actually authenticated as that user.
- Mechanism: a dedicated connection per passthrough query, opened directly with the target user's LDAP credentials (
mysql_native::open_passthrough_connection) — notCOM_CHANGE_USERon a pooled connection. That was the original design (the same mechanism ProxySQL uses for per-user backend connections, and cheaper in principle), until a live-StarRocks integration test caught a realmysql_asyncbug:change_userto an LDAP user reportedOk(())but left the connection's packet sequence counter desynchronized — every query afterward failed withpacket out of order. Root cause, isolated with a minimal repro:COM_CHANGE_USERneeding to switch auth plugins mid-flight (the pooled connection's own plugin vs. the target user'smysql_clear_password) breaks specifically inmysql_async's auth-switch-during-change-user handling — achange_userto a plain-password user needing no plugin switch worked fine, and so did a fresh connection authenticated directly as the LDAP user. Fixed by using the fresh-connection approach, confirmed against the same live server (including that StarRocks actually enforces the target user's real, distinct grants — not just reports their name). - StarRocks LDAP auth negotiates the standard
mysql_clear_passwordplugin — confirmed via StarRocks' own docs — whichmysql_asyncalso supports, gated behindenable_cleartext_plugin(true). - Sends the password with no hashing or encryption at the MySQL protocol layer.
StarRocksAdapter::newrefuses to start apassthrough-configured cluster unless the endpoint URL already requests TLS (?require_ssl=true) — checked against the builtmysql_async::Opts, not assumed. - Cost: every passthrough query pays a fresh connection handshake — in practice the same cost the original
change_userdesign already had, since a re-authenticated connection was never returned to the shared pool either. A per-identity reuse cache (mirroring the ADBC/Snowflake sub-pool) is the natural next step if this becomes a bottleneck. AuthContext.raw_password— new, and populated only byLdapAuthProvider(the password was just verified by a real LDAP bind).StaticAuthProviderdeliberately never populates it: its password map is QueryFlux's own local config, not necessarily valid against any backend.- Bounded by a 10s connect timeout (
mysql_native::PASSTHROUGH_CONNECT_TIMEOUT) — a slow or unreachable LDAP server (StarRocks binds to LDAP FE-side, during the handshake this call performs) fails the query instead of hanging it indefinitely. Verified with a test that actually forces the timeout to fire (a raw listener that accepts the TCP connection but never completes the handshake), not just that the code compiles. - Operator requirement: the service account (
cluster.auth) needs StarRocks'OPERATEsystem privilege.cancel_queryissuesKILL QUERYfrom the service account's own connection, but StarRocks requiresOPERATEto kill a query owned by a different user — which every passthrough query is, from the service account's point of view. Without it, cancelling a passthrough query fails silently (the existingKILL QUERYerror path is best-effort/logged only, matching how backend-side cancel failures are handled for every engine) — the client believes the query is cancelled while it keeps running server-side.
- StarRocks 3.5+'s JWT MySQL auth (
authentication_jwton the user,authentication_openid-connect_clientplugin on the wire) is verified infeasible formysql_nativeas built — this is the mechanism that stays undone, not LDAP. Checked both sides:- StarRocks' own docs specify the client must implement the
authentication_openid-connect-clientplugin and pass the token via--authentication-openid-connect-client-id-token-file=<path>— Oracle's officialmysqlCLI 9.2+ does this; it is a vendor-specific extension, not standard MySQL wire protocol. mysql_async0.36.1 hardcodes exactly five auth plugins (mysql_native_password,caching_sha2_password,mysql_old_password,mysql_clear_password,ed25519). Confirmed at the source level: any other plugin name — including StarRocks' JWT plugin — falls intoAuthPlugin::Other, whosegen_data()returnsNone(mysql_commonpackets/mod.rs) and whose continuation-handshake arm returnsDriverError::UnknownAuthPluginoutright (mysql_asyncconn/mod.rs). The connection attempt fails immediately; there is no configuration path around this, unlike LDAP'smysql_clear_password.- Making this work means implementing an undocumented (from
mysql_async's side) vendor protocol extension — either patching/forkingmysql_asyncor hand-rolling this part of the MySQL wire protocol.
- StarRocks' own docs specify the client must implement the
- HTTP API (ports 8030/8040, future adapter): StarRocks natively supports JWT and OAuth 2.0 over this interface too; a future HTTP adapter could use implicit header forwarding when StarRocks and QueryFlux share an IdP — the likely path to JWT-based StarRocks passthrough, since the MySQL-wire route is blocked.
Snowflake (ADBC driver)
- No header-based impersonation on the ADBC wire —
impersonateis rejected at startup for every ADBC driver tokenExchangeis wired: the resolved OAuth token is set via the driver's own connection options (adbc.snowflake.sql.auth_type=auth_oauth,adbc.snowflake.sql.client_option.auth_token), each user's token requiring its ownManagedDatabase(see the per-identity sub-pool design in the backend-identity plan's Phase 3a) since ADBC bakes connection options in at open time, not per-checkout- Service account (
serviceAccountmode, Type 1) should use key-pair auth (RSA JWT), not password — Snowflake's recommended pattern for automated connections - Private keys must not be stored in config files in production; use
secretRefto Secrets Manager passthroughis wired, for deployments where a Snowflake-fronted caller already holds a real Snowflake identity and per-caller connections (not a shared service account) are what's wanted:- The Snowflake HTTP wire v1 login handler (
POST /session/v1/login-request) captures the submitted username/password into session state, unverified — QueryFlux itself never checks this password against anything. The ADBC connection attempt the sub-pool builds is the verification: exactly what would happen connecting to Snowflake directly, and consistent with howUSE ROLE/USE WAREHOUSEfailures are already left to surface naturally rather than pre-validated. - Fails closed: if a cluster is configured
queryAuth: passthroughand no verified-at-connection-time username/password was captured for the session, the query is rejected outright rather than silently falling back to the service account. - SQL API v2 (stateless, Bearer-only) has no password to capture for this mode — a passthrough-configured cluster reached that way hits the same fail-closed path automatically.
- The captured credential is deliberately not routed through the
raw_password/enrich_session_for_passthroughmechanism MySQL-wire/StarRocks passthrough use — that mechanism's contract requires the password to already be verified against the same backend the target authenticates against (LDAP-verified passwords, which StarRocks also trusts). Snowflake has no equivalent shared identity system QueryFlux can check at login time, so it uses its own namespacedsnowflake.passthrough_username/snowflake.passthrough_passwordsession keys instead of claiming a pre-verification guarantee it can't back up.
- The Snowflake HTTP wire v1 login handler (
Session-scoped pooling (USE ROLE / USE WAREHOUSE / USE SCHEMA, and per-identity tokens)
An ADBC ManagedDatabase bakes its connection options in at open time — there's no way to swap credentials, role, warehouse, or schema on a connection already checked out of the shared pool. Two mechanisms build on the same scoped sub-pool machinery to work around that:
USE ROLE/USE WAREHOUSE/USE DATABASE/USE SCHEMAon the Snowflake HTTP wire v1 frontend are intercepted client-side and never forwarded as literal SQL — doing so against a connection shared across unrelated sessions would leak state between them. Instead they update the frontend's own session state and ack locally (mirroring the MySQL-wire frontend'sUSE <db>handling); the next real query on that session carries the requested role/warehouse/schema and dispatches through the scoped pool below.- Token-scoped pools (
tokenExchange) work the same way, keyed by the resolved OAuth token instead of a role/warehouse/schema value.
A distinct scope (PoolScopeKey: some combination of token, passthrough credential, role, warehouse, schema) only exists when it actually differs from the cluster's own base config — a USE ROLE naming the cluster's already-configured role is a no-op, not a new pool. Two important asymmetries:
- Per-identity scopes (keyed by a token or a passthrough credential) are capped at 2 connections each — they exist to amortize connection-setup cost across the handful of queries one caller runs in quick succession, not to serve as a general-purpose pool.
- Role/warehouse/schema-only scopes (no token or passthrough credential) are a shared resource across every session requesting that same combination — e.g. "every analyst using the
ANALYSTrole" — so they get the cluster's own full configured pool size instead.
Idle sub-pools are evicted after 15 minutes, and a hard ceiling of 500 distinct scoped sub-pools per cluster evicts the least-recently-used entry once reached — role/warehouse/schema values come straight from client USE statements with no check that they exist on the backend, so this bounds a client that cycles through many distinct (nonexistent or real) values in a loop from forcing unbounded backend connections before the idle sweep catches up.
Trino
- Implicit header forwarding works for same-IdP deployments
impersonaterequires Trino file-based ACL — high operator burden, document clearly- For
impersonate: suppress clientAuthorization; apply service account auth; injectX-Trino-User
Snowflake Key-Pair Auth (ClusterAuth extension)
The existing ClusterAuth only supports Basic and Bearer. A KeyPair variant is needed for Snowflake (and Databricks):
pub enum ClusterAuth {
Basic { username: String, password: String },
Bearer { token: String },
KeyPair { // NEW
username: String,
private_key_pem: String, // PEM string or secretRef
private_key_passphrase: Option<String>,
},
}
Future: support secretRef on any auth type so private keys are fetched from AWS Secrets Manager / Vault at startup, not stored in YAML:
auth:
type: keyPair
username: QF_SVC
privateKeySecretRef:
provider: awsSecretsManager
secretId: "arn:aws:secretsmanager:us-east-1:123:secret:qf-snowflake-key"
field: private_key
What Changes Where
New: crates/queryflux-auth/
AuthProvidertrait,Credentialsstruct,AuthContextstructNoneAuthProvider,StaticAuthProvider,OidcAuthProvider,LdapAuthProviderBackendIdentityResolver— takes(AuthContext, QueryAuthConfig, ResolvedProfile)→QueryCredentialsConnectionContextMerge(or inline in dispatch) — mergesClusterGroupMember.connectioninto adapter-facing session hintsOpenFgaAuthorizationClient— wraps OpenFGA HTTP APISimpleAuthorizationPolicy— fallback allowGroups/allowUsers- Optional:
SecretResolvertrait — resolvessecretRefto material forClusterAuth/ profiles at load time
queryflux-core/src/config.rs
- Add
AuthConfig(provider + per-provider sub-configs) - Add
AuthorizationConfig(openFga | none) toProxyConfig - Add
QueryAuthConfigenum (serviceAccount | passthrough | impersonate | tokenExchange) toClusterConfig - Add
authProfilesmap +defaultAuthProfileoptional field onClusterConfig; supportsecretRefon credential fields (resolve at load/reload) - Replace
ClusterGroupConfig.members: Vec<String>withVec<ClusterGroupMember>:{ cluster, connection?: EngineConnectionOptions, weight?, defaultAuthProfile? }; serde untagged or custom deserializer to accept legacy string OR object - Add
EngineConnectionOptionsas a tagged enum (or per-engine struct union) listing all supported per-engine connection types; startup validation: each member’sconnectionmatchesclusters[cluster].engine - Add
authorizationblock (allowGroups/allowUsersfallback) toClusterGroupConfig - Extend
ClusterAuthwithKeyPairvariant QueryAuthConfigvalidated at startup against engine type; error on unsupported combination
queryflux-core/src/session.rs
- No change.
SessionContextstays as-is (unverified protocol metadata). AuthContextlives inqueryflux-auth.
queryflux-frontend/src/state.rs
- Add
auth_provider: Arc<dyn AuthProvider> - Add
authorization: Arc<dyn AuthorizationChecker>
queryflux-frontend/src/dispatch.rs
- Accept
AuthContextindispatch_query()andexecute_to_sink() - First step in
dispatch_query(): callstate.authorization.check(auth_ctx, group)→ 403 if denied (beforeacquire_cluster) - After cluster pick: thread
ClusterGroupMember(or equivalent) so adapters receive merged engine session hints (connection) + resolved profile (auth/authProfiles) - Pass
QueryCredentials(resolved byBackendIdentityResolver) to adapter alongsideSessionContext(both needed until Phase 3b replaces session hints withEngineConnectionOptions)
queryflux-routing/src/lib.rs (RouterTrait)
- Update
RouterTrait.route()signature to acceptOption<&AuthContext>alongside&SessionContextand&FrontendProtocol UserGrouprouter and any future identity-aware router must use verifiedAuthContext.user, notsession.user()(which is unverified)NoneAuthProviderstill produces anAuthContextderived fromsession.user(), so the interface is consistent regardless of provider
queryflux-frontend/src/trino_http/handlers.rs
- Extract
Authorizationheader →Credentials→auth_provider.authenticate()→AuthContext(before callingroute_with_trace()) - Pass
&auth_ctxto routers - Default routing (here, not in dispatch): if
route_with_trace()returnsused_fallback == true, iteratestate.group_configsin config order; callstate.authorization.check(auth_ctx, group)for each; pick first authorized group; only use staticroutingFallbackif none found.stateneeds ordered group config list for this (add toAppState). - Thread
AuthContextthrough to dispatch
queryflux-frontend/src/postgres_wire/mod.rs
- Capture
userfrom startup message →Credentials { username, .. }→auth_provider.authenticate() - Same default routing logic as Trino HTTP handler
- Thread
AuthContextthrough
queryflux-engine-adapters/src/lib.rs
- Add
QueryCredentialsenum alongsideSessionContext— not replacing it. Until Phase 3b (EngineConnectionOptions),SessionContextstill carries session hints (catalog, schema, X-Trino-* headers). Adapters need both:QueryCredentialsfor wire auth,SessionContextfor session setup. - Update
submit_query/execute_as_arrowto accept&QueryCredentialsas an additional parameter
queryflux-engine-adapters/src/trino/mod.rs
serviceAccount: applycluster.auth(Basic/Bearer); session headers forwarded as todayimpersonate: applycluster.auth; remove clientAuthorizationfrom headers; addX-Trino-User: {user}
queryflux-engine-adapters/src/clickhouse/mod.rs (future — no module exists yet)
serviceAccountonly:X-ClickHouse-User+X-ClickHouse-Keyfromcluster.auth
Phased Implementation
Phase 1 — Foundation: AuthContext plumbing + NoneProvider
- Define
AuthContext/Credentials/AuthProvider/QueryCredentialstypes NoneAuthProvider: identity fromsessionCtx.user(), no verification (current behaviour)- Thread
AuthContextandQueryCredentialsthrough dispatch and adapter calls - No behaviour change; all existing deployments unaffected
Phase 2 — Frontend auth (Trino HTTP first)
OidcAuthProvider: JWT validation via JWKS, groups/roles extractionStaticAuthProvider: config-driven user/password map- Extract
Authorizationheader in Trino HTTP handlers →Credentials
Phase 3 — Authorization
- Simple
allowGroups/allowUserspolicy per cluster group (no external dep) - OpenFGA client integration as optional provider
- Admin API endpoint for tuple management
Phase 3b — Structured group members & per-engine connection options
- Migrate
memberstoVec<ClusterGroupMember>with backward-compatible deserializer for string entries - Implement
EngineConnectionOptionstagged enum covering all engines in the compatibility table; reject cross-engine field sets at startup - Plumb merged connection context from selected member into dispatch and adapters (Trino catalog/schema, Snowflake role/warehouse, etc.)
Phase 3c — Auth profiles + secretRef (can overlap with Phase 3b)
authProfiles/defaultAuthProfileonClusterConfigandClusterGroupMember- Profile resolution order: group member default → cluster default → single
auth(no client influence) secretRefresolution from Vault / AWS Secrets Manager at config load
Phase 4 — impersonate mode for Trino
BackendIdentityResolverproducingImpersonateCreds- Trino adapter: suppress client
Authorization, apply service account auth, injectX-Trino-User - Startup validation: reject
impersonatefor non-Trino engines
Phase 5 — LDAP + wire protocol auth
LdapAuthProvider- PG/MySQL wire: OIDC bearer as session parameter
Phase 6 — tokenExchange + cloud adapters
tokenExchangeresolver with per-provider contract and token caching- Snowflake adapter (REST API, key-pair auth)
- Databricks adapter (SQL Warehouses REST or Arrow Flight SQL)
Key Files
| File | Change |
|---|---|
| queryflux-core/src/config.rs | Add AuthConfig, QueryAuthConfig (3 variants), AuthorizationConfig; ClusterGroupMember, EngineConnectionOptions (per-engine variants); authProfiles + defaultAuthProfile; secretRef; extend ClusterAuth with KeyPair |
| queryflux-core/src/session.rs | No change — AuthContext is in queryflux-auth |
| queryflux-frontend/src/state.rs | Add auth_provider, authorization checker |
| queryflux-frontend/src/dispatch.rs | Thread AuthContext + QueryCredentials; authz check as first step before acquire_cluster |
| queryflux-frontend/src/trino_http/handlers.rs | Authenticate before routing; pass AuthContext to routers; authorization-aware first-fit when used_fallback==true |
| queryflux-frontend/src/postgres_wire/mod.rs | Same as Trino HTTP: authenticate, pass AuthContext to routers, default routing |
| queryflux-routing/src/lib.rs | Add Option<&AuthContext> to RouterTrait.route() signature; UserGroup router uses verified identity |
| queryflux-engine-adapters/src/lib.rs | Add QueryCredentials alongside SessionContext in submit_query / execute_as_arrow signatures |
| queryflux-engine-adapters/src/trino/mod.rs | serviceAccount (current behaviour) + impersonate (suppress + inject) |
New: crates/queryflux-auth/ | AuthProvider trait + all implementations + BackendIdentityResolver + OpenFGA client |