SQL API (ClickHouse)¶
loom-kernel provides an injectable SqlQueryService for running SQL through named,
config-driven connections, plus an optional generic REST endpoint
(POST /sql/{connection}). Every query carries one or more roles derived from the
caller’s verified JWT claims, intersected with a per-connection allowlist and
applied as per-query settings, so roles never leak across queries sharing the
connection pool.
Important
Through the REST endpoint the roles come from the identity, never from the request body: the body can only narrow what the claim already grants. The allowlist is the ceiling of the connection — the last barrier, not a per-caller permission. Read Threat model before exposing the endpoint.
The first supported backend is ClickHouse. The sql: section is backend-agnostic:
each connection declares its backend explicitly.
Install¶
pip install "loom-kernel[clickhouse]"
# and, if you authenticate the endpoint with the native JWT middleware:
pip install "loom-kernel[jwt]"
Minimum versions (enforced fail-closed at startup):
Component |
Minimum |
Why |
|---|---|---|
ClickHouse server |
24.4 |
Per-query |
|
0.9.2 |
|
At startup the registry asserts driver support and runs a sentinel-role probe
(SELECT 1 with a nonexistent role must be rejected with code 511). If the server
silently accepts the sentinel role, startup aborts — the server’s role enforcement
is never assumed. A connection whose allowed_roles holds more than one role also
verifies that the driver can emit repeated role HTTP parameters (see
Several roles in one query); if it cannot, startup aborts instead of sending an invalid
single role.
Configuration¶
Add a top-level sql: section. Absent section → zero changes: no connection is
opened and no route is mounted (SqlQueryService resolves to a null implementation
that raises an actionable ConfigError on first use).
sql:
connections:
analytics:
backend: clickhouse
url: ${oc.env:CLICKHOUSE_ANALYTICS_URL} # secret via env/SSM — never inline
# Ceiling for the CONNECTION, never a per-caller permission: the effective
# roles are these intersected with the caller's verified roles claim.
allowed_roles: [role_viz_reader, role_viz_sales]
default_role: role_viz_reader # required if sql_endpoint.enabled and no allowlist
readonly: true # default: readonly=1 on every query
default_limit: 1000
max_limit: 10000
max_execution_time: 30 # seconds
max_sql_bytes: 262144
connect_timeout: 10
send_receive_timeout: 60
executor_threads: null # driver default when null
pool_size: null # driver default when null
settings: {} # extra ClickHouse settings — can never override policy
sql_endpoint:
enabled: true # opt-in (default false)
auth: identity # REQUIRED to mount: 'identity' or 'external'
path: /sql/analytics # default: /sql/{connection}
include_in_schema: false
The claim carrying the caller’s roles belongs to the authentication mechanism, not
to the connection — it is declared once for the whole app and is required
whenever a mounted endpoint has a non-empty allowed_roles:
app:
rest:
auth:
jwt:
audience: loom-api
roles_claim: loom_sql_roles # verified claim carrying the caller's roles
Connections open inside the app lifespan and close on shutdown. A connection that fails its startup probe aborts the whole app start (explicit fail-fast).
Three config rules make the unsafe shapes unrepresentable — they fail at parse or at startup, never at request time:
Rule |
Why |
|---|---|
A mounted endpoint with a non-empty |
It is the only mode carrying a verified identity to bind the roles to |
That endpoint also requires a mechanism that binds roles (for JWT, |
Otherwise any authenticated caller could pick any allowlisted role |
|
An identity-bound role is worthless if tokens minted for another service are accepted, or if the path skips authentication altogether |
Note
The sql: section is independent from the ETL storage.clickhouse section — they
serve different purposes (interactive queries vs. pipelines). Share the DSN through a
single environment variable if both point at the same cluster.
Query from a use case, as the caller¶
Two collaborators are always registered in the container (APPLICATION scope) and are injected by constructor, like any other service:
Collaborator |
Roles come from |
Use it for |
|---|---|---|
|
The verified identity ∩ |
Anything done on behalf of a caller — the default |
|
A |
System work with no caller: a scheduled job, a migration, a health probe |
CallerBoundSql is the default because it is the only one whose privileges are decided
by the caller’s own entitlements. It has no roles parameter in any form:
from loom.core.identity import Identity
from loom.core.sql import CallerBoundSql
from loom.core.use_case.markers import Caller
from loom.core.use_case.use_case import UseCase
class TopProductsUseCase(UseCase[Product, dict]):
def __init__(self, sql: CallerBoundSql) -> None:
self._sql = sql
async def execute(self, identity: Identity = Caller()) -> dict:
result = await self._sql.execute(
"SELECT product, sum(amount) AS total FROM sales "
"WHERE day >= {start:Date} GROUP BY product ORDER BY total DESC",
connection="analytics",
identity=identity,
parameters={"start": "2026-01-01"},
limit=100,
)
return {"rows": result.rows, "has_more": result.has_more}
Warning
The identity must be declared with Caller(). That marker is what makes the
executor inject the identity the transport verified. Written without it —
async def execute(self, identity: Identity) — it is an ordinary primitive parameter,
which the compiler binds from the params the calling code supplies: the caller then
picks its own identity, and with it the roles. On the agent path those params are
tool arguments the model writes, and identity would appear in the tool schema.
A query bound to a forged identity is not bound at all.
A caller holding no allowlisted role for that connection is refused with
RolesNotBoundError before the backend is touched — never run as default_role. So is
a connection with an empty allowed_roles: its only role is the shared one, which is
derived from nobody. Use SqlQueryService for that unbound work.
Parameters are always bound server-side ({name:Type} placeholders) — never
interpolated into the SQL string.
Note
SqlQueryService.roles takes a sequence: a query may carry several roles and runs with
the union of their privileges. Each one is validated against allowed_roles; a
single rejected role refuses the whole call instead of silently narrowing it. That
service applies no identity binding: the roles are whatever the calling code wrote,
bounded only by the allowlist. Identity binding is what CallerBoundSql and the REST
edge add on top.
The REST endpoint¶
A connection mounts POST /sql/{connection} only with double opt-in:
sql_endpoint.enabled: true and an explicit sql_endpoint.auth value. Without
auth, the endpoint does not mount.
|
Meaning |
|---|---|
|
Requires a configured authentication mechanism — the built-in |
|
Explicit acknowledgement that the operator provides authentication in front of the app. No identity the framework can read, therefore no allowlist: single-role endpoint only |
|
Deprecated alias of |
auth decides who gets in; the authentication mechanism decides what they may
become once in — for JWT, through app.rest.auth.jwt.roles_claim.
Request body — backend settings are rejected by schema:
{
"sql": "SELECT id, name FROM products ORDER BY id",
"roles": ["role_viz_reader"],
"parameters": {"start": "2026-01-01"},
"limit": 100,
"offset": 0
}
roles is optional and can only narrow: it must be a subset of the roles the
verified claim already grants. Omitting it runs the query with all of them. Asking for
a role the identity does not hold is a 403, even if the role is allowlisted.
Response envelope¶
Every query returns the same tabular envelope (SqlQueryResult):
{
"columns": [
{"name": "id", "type": "UInt64"},
{"name": "name", "type": "String"}
],
"rows": [[1, "widget"], [2, "gadget"]],
"row_count": 2,
"limit": 100,
"offset": 0,
"has_more": false,
"elapsed_ms": 12.4
}
Column types are the native backend names. Serialization covers
datetime/date/UUID/Decimal natively, IPv4/IPv6 as strings, bytes as base64, and a
documented str() fallback for exotic types — a result never produces a bodyless 500.
Pagination¶
Effective limit:
min(limit or default_limit, max_limit); the executor fetcheslimit + 1rows to computehas_moreand trims the extra row.offsetskips rows; both are applied as native query settings — the SQL text is never rewritten.Backstop:
max_result_rows = max_limit + 1withresult_overflow_mode='throw'— oversized results fail loudly instead of truncating silently.
Warning
Stable pagination requires an explicit ORDER BY in your SQL. Each page re-executes
the query; without a deterministic order, rows can repeat or disappear between pages.
Errors¶
Errors use the standard framework body (detail.code, detail.message,
detail.trace_id):
Status |
When |
|---|---|
401 |
Missing, expired or otherwise invalid token (emitted by the JWT middleware, before the endpoint) |
404 |
Unknown connection name |
403 |
The verified identity grants no allowed role, the body asks for a role the identity does not hold, or the role is outside the allowlist |
422 |
SQL rejected by the backend (sanitized first line, no host/DSN), invalid body, or SQL larger than |
500 |
Backend unreachable — generic message, no URL leaked |
Every 403 caused by identity binding carries the same generic message on purpose (no oracle telling an attacker which part of their token failed); the precise reason — connection, subject and cause — is logged server-side at WARNING.
Role model and grants¶
The policy is fail-closed and resolves in this order for a request reaching a mounted
endpoint whose app declares a roles_claim:
Identity. No verified claims in the request → 403. The claim must be a string or a list of non-empty strings; absent, empty or otherwise typed → 403. Nothing is coerced.
Intersection. The authorized set is the claim values ∩
allowed_roles. Roles the caller claims but the connection does not allow are dropped; if nothing survives → 403.default_roleis never a fallback here.Narrowing. A
roleslist in the body must be a subset of the authorized set; anything else → 403. Absent, it runs with the whole authorized set.Allowlist, again. The service re-validates every effective role against
allowed_rolesbefore the executor is touched — the last barrier, independent of how the roles were resolved.The effective roles are sent as per-query settings — nothing leaks from one query to the next across the shared connection pool.
The invariant, stated once: identity and body may only ever restrict the set of roles, never widen it.
Connections used directly from a use case (no HTTP) skip steps 1-3: there is no
identity to bind, and allowed_roles plus default_role are the whole policy.
Provision ClickHouse with exactly these statements per data role and connection user:
-- The data ROLE needs:
GRANT SELECT ON <db>.* TO <role>;
-- (SHOW TABLES/COLUMNS only if callers need introspection without SELECT)
-- The connection USER needs:
GRANT <role> TO <user>; -- one per allowlisted role
ALTER USER <user> DEFAULT ROLE NONE; -- no privileges when no role is applied
Principal |
Grant |
Purpose |
|---|---|---|
Data role ( |
|
Read the data it exposes |
Connection user |
|
May activate exactly the allowlisted roles |
Connection user |
|
Fail-closed: no privileges without an explicit role |
Startup probe |
— |
Needs no grant (sentinel role must not exist) |
Note the direct consequence of row two: the connection user must hold every allowlisted role for any of them to be assumable, so ClickHouse alone would happily grant a caller the most privileged role it is asked for. What stops that is the claim binding above, not the server. Keep the allowlist as small as the roles your identity provider actually issues.
Several roles in one query¶
A caller whose claim grants more than one role runs each query with the union of their privileges — ClickHouse activates every role sent with the query:
{"sql": "SELECT * FROM sales JOIN customers USING (id)", "roles": ["role_sales", "role_crm"]}
Mechanically, roles travel as repeated HTTP parameters (role=a&role=b); a
comma-joined value is read as a single role name and rejected with code 511.
Note
clickhouse-connect 0.15.1 (and upstream main) builds the request URL with
urlencode(params) without doseq=True, so a sequence value would be sent as its
Python repr. The driver boundary
(loom.core.sql.clickhouse._client) rebinds that encoder to the doseq=True variant:
output is byte-identical for the scalar settings the driver builds today and only
sequences change — and their current encoding is invalid anyway. A startup check
(ConfigError) and a unit test pinning the patch point make sure the workaround can
never degrade silently, and it is removed as soon as upstream encodes lists correctly.
Single-role connections never depend on that workaround: one role is sent as a plain scalar, exactly as before.
Threat model¶
Danger
allowed_roles is the ceiling of the connection, not a per-caller permission.
What makes it safe is the identity binding described here. Read this section in full
before mounting the REST endpoint.
What the framework now guarantees¶
1. Roles come from a verified identity. The JWT mechanism verifies signature,
exp/nbf, sub and the configured aud/iss, then projects the claims onto an
Identity — roles from roles_claim, string
claims as attributes. The endpoint intersects the roles that identity holds with
allowed_roles. The request body cannot select a role: it can only narrow the result.
A caller holding no allowlisted role gets a 403 — never default_role. Swapping the
mechanism changes nothing here: the endpoint sees an identity, not a token.
2. The unsafe shapes are unrepresentable. A mounted endpoint with a non-empty
allowlist and an auth mode other than identity fails at config parse; the same
endpoint without a role-binding mechanism, without audience under JWT, or with a
mounted path listed in the auth exclude_paths, fails at startup. There is no runtime
path into the old behaviour.
3. audience is mandatory. Without a validated aud, any token signed by the same
key — including one minted for a different service, carrying its own idea of a roles
claim — would be accepted and could name your ClickHouse roles.
4. Auditability. Each request emits a span labelled with the effective roles and
the caller subject; denials are logged at WARNING with connection, subject and cause.
CallerBoundSql opens a span of its own for every query it accepts, carrying the same
roles, subject and mechanism labels. It is a different span from the endpoint’s,
under a narrower scope and its own name, so a use case acting on behalf of a caller is
auditable on the same terms rather than through the same record.
Where this comes from. Measured on ClickHouse 25.3 with a single credential and
only the role parameter changed: the default role returned 497 ACCESS_DENIED, while
role=role_viz_reader returned 1,171,206 rows from a table holding PII. When role
travelled in the request body, that was one JSON edit away for any bearer of any valid
token.
What is still on you¶
1. The claim is only as good as its issuer. The framework verifies the signature
and the audience; it cannot tell whether your identity provider was right to put
role_viz_sales in that token. Issue the claim from the same system that owns the
business authorization, and keep token lifetimes short — verification is stateless, so
a role revoked at the IdP stays usable until the token expires.
2. There is still no per-route authorization. JwtAuthMiddleware is mounted for the
whole application. A valid token reaches every mounted SQL route; what it can do
there is now bounded per route by that connection’s allowlist ∩ the claim, but the
authentication decision itself remains all-or-nothing.
3. ClickHouse is the floor, not the ceiling. The server rejects unknown roles (511
UNKNOWN_ROLE) and non-granted roles (512 SET_NON_GRANTED_ROLE), which protects
against typos and role injection. It cannot distinguish callers: the connection user
holds every allowlisted role by design. Identity separation exists only because the
framework applies the intersection above.
4. The unbound path is still reachable. SqlQueryService remains registered, and
its roles argument is bound to nobody: a use case that injects it queries with any
allowlisted role, whatever its caller holds. That is the point — system work has no
caller — but nothing stops it being injected where CallerBoundSql belongs. Treat the
choice as a review item: inside a request or an agent tool, the bound one is the answer.
5. allowed_roles should list composite roles, one per exposed profile, not
individual data roles — so the union any caller can reach matches a declared profile.
app:
rest:
auth:
jwt:
audience: loom-analytics
roles_claim: loom_sql_roles # what each caller may use
sql:
connections:
analytics:
backend: clickhouse
url: ${oc.env:CLICKHOUSE_ANALYTICS_URL}
allowed_roles: [role_api_viz, role_api_sales] # ceiling of the connection
sql_endpoint:
enabled: true
auth: identity
Without an identity provider that can issue the claim, the single-role shape stays
valid and needs no binding: allowed_roles: [] plus a fixed default_role runs every
caller with exactly one role — no escalation, and no per-caller distinction either.
The binding is decided per connection: a single-role connection keeps working
unchanged in an application that also serves a multi-role one.
Hardening¶
Never expose the endpoint without authentication. The
authfield is mandatory to mount;externalis an explicit acknowledgement, not a bypass. Every mounted endpoint emits a startup warning naming its path, connection, readonly flag, auth mode, role count and the mechanism its roles are bound to.Keep the allowlist to the roles your IdP actually issues. It is the ceiling of what any claim can unlock on that connection: a role nobody should reach through HTTP does not belong in it (see Threat model).
Issue the roles claim from the system that owns the authorization, and keep token lifetimes short: verification is stateless, so a revoked role remains usable until the token expires.
No SOURCES grants for the connection user. Table functions such as
url(),s3(),remote(),mysql()andpostgresql()enable SSRF and data exfiltration even underreadonly=1. Do not grant them.No broad
system.*access. Introspection tables leak topology, settings and query history.Keep
DEFAULT ROLE NONEon the connection user so a query without an applied role has no privileges.readonly: trueis the default. Disabling it while the endpoint is enabled emits a startup warning, and the driver runs withquery_retries=0to avoid re-executing non-idempotent statements.Credentials via
${oc.env:...}/ SSM / Secrets Manager — never inline. The password is redacted from logs, reprs and errors; SQL is logged only at DEBUG (truncated), parameters never.Rate limiting is the operator’s responsibility (reverse proxy / API gateway).
Minimum versions: ClickHouse ≥ 24.4 and
clickhouse-connect≥ 0.9.2 — older combinations fail the startup probe.
Authentication¶
sql_endpoint.auth: identity requires an authentication mechanism. Any
Authenticator satisfies it — see
Caller identity and authorization for a non-JWT one. The batteries-included mechanism is the
framework’s native stateless JWT, configured under app.rest.auth.jwt:
app:
rest:
auth:
jwt:
secret_path: ${oc.env:LOOM_JWT_SECRET_PATH} # HS256 — a path, not the value
# public_keys: # RS256/ES256/EdDSA — one PEM per key id
# "2026-08": ${oc.env:LOOM_JWT_PUBLIC_KEY_PEM}
algorithms: [HS256] # explicit allowlist; 'none' always rejected
audience: loom-api # REQUIRED by any sql_endpoint with auth: identity
issuer: null # validated only when set
leeway_seconds: 0
exclude_paths: [/docs, /redoc, /openapi.json, /metrics, /health]
roles_claim: loom_sql_roles # REQUIRED by any mounted sql_endpoint whose
# connection has a non-empty allowed_roles
Stateless — signature,
expandsub(both required) plusnbf, andaud/isswhen configured, are verified per request. No Redis or server-side session store is needed; early token revocation is out of scope.subis mandatory. A token without a subject carries no identity to authorize against, nor to audit afterwards, so it is rejected with 401.Exactly one of
secret_path(HS*) orpublic_keys(RS*/ES*/EdDSA static PEMs, keyed by key id) must be set, and the algorithm allowlist must match the key type — validated fail-fast at startup.secret_pathis a path, not the value. The config is amsgspec.Struct, so any serializer emits its fields verbatim: holding an HS secret would let a config dump publish the key that both verifies and signs. Public keys are safe to inline because they only verify. The container has to materialize the value to a file: Kubernetes mounts aSecretas a volume directly, while ECS/Fargate injects task-definition secrets as environment variables only, so there an entrypoint must write the value to a path on atmpfsvolume before the app starts.With
public_keys, the key is chosen by the token’skidheader, never by trying each key in turn: exhaustive trial would decouple every algorithm from its key family, which is what makes algorithm confusion impossible here. A token with nokidverifies only when a single key is configured, so a rotation overlap needs the issuer to stamp one.On success the verified claims are projected onto an
Identitypublished for the request; on failure the response is a 401 with the standard error body, aWWW-Authenticatechallenge and no hint about the cryptographic reason. Read the caller anywhere withcurrent_identity()— the raw claims are deliberately not exposed.The identity roles are consumed by the SQL endpoints when
roles_claimis set: that is where an authenticated caller becomes a set of ClickHouse roles (Threat model).exclude_pathsbypasses authentication for exact paths (docs, scrape endpoints). A mounted SQL path listed there aborts startup — it would serve SQL unauthenticated.When the section is present,
create_appmounts the middleware for the whole app — authentication is all-or-nothing, there is no per-route authorization. A missingpyjwtextra fails at startup with an install hint, so the API never starts silently unauthenticated.audienceis required by anysql_endpointwithauth: identity(startup fails otherwise): without a validatedaud, any token signed by the same key is accepted whatever it was issued for, and could carry the roles claim. Setissuertoo when several issuers share the key material.
Example token¶
{
"sub": "svc-reporting@example.com",
"aud": "loom-api",
"exp": 1785600000,
"loom_sql_roles": ["role_api_viz"]
}
The claim may also be a single string ("loom_sql_roles": "role_api_viz"). Anything
else — a number, an object, a list holding non-strings — is refused with 403 rather
than coerced.