Inbound identity¶
Inbound identity tells sutura who asks. sutura checks a signed token from your identity provider
and gets a verified subject, its scopes and its groups. sutura uses the subject for the audit
record and for the scope check. A source with posture: impersonation-at-source also runs the
query as the caller: on BigQuery as the caller's own principal in a workload identity pool, on
ClickHouse as the user that the caller's verified subject names, and on Oracle in a session
opened with the caller's own token. No other data system supports this posture.
The settings are under security.inbound. There are two modes, and there is no default:
| Mode | Use it when |
|---|---|
direct |
Callers send their own access token to sutura in Authorization: Bearer |
behind-gateway |
A gateway authenticates the caller and sends sutura a signed assertion in a header |
Without a security.inbound block, sutura has no caller identity. Then security.access_token
authenticates the deployment, and every question runs with the access of the deployment.
Settings for direct¶
| Key | Type | Default | Meaning |
|---|---|---|---|
mode |
string | required | direct |
resource |
https URI |
required | The identifier that sutura publishes. aud must equal it exactly, unless audience is set |
audience |
string | none | The value that aud must equal exactly instead: an https URI, or a client ID of letters, digits, -, . and _ |
extra_scope_claims |
list | none | More claims that grant scopes, beside scope: scp, roles, or both |
authorization_server |
https URI |
required | The value that iss must equal exactly |
key_set_file |
path | required unless key_set_url is set |
A JWK set, or one PEM public key or certificate, of the identity provider. Set this or key_set_url |
key_set_url |
https URL |
none | The jwks_uri of the identity provider, instead of key_set_file |
key_set_max_stale_seconds |
integer | 3600 |
How long the keys verify after the last look that read a usable key set, 300 to 86400 |
algorithms |
list | required | The accepted algorithms, of one key family: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or EdDSA |
token_type |
string | at+jwt |
The required typ header. any turns the check off |
accept_any_token_type |
boolean | false |
Must be true beside token_type: any |
address_claim |
string | email |
The claim that holds the caller's address for a domain_delegation source. See The caller's address |
accept_address_claim |
boolean | false |
Must be true beside an address_claim other than email |
direct cannot be used with security.access_token, because both use Authorization: Bearer.
Settings for behind-gateway¶
| Key | Type | Default | Meaning |
|---|---|---|---|
mode |
string | required | behind-gateway |
transit_header |
string | required | The header that holds the assertion |
transit_issuer |
https URI |
required | The issuer that must sign the assertion |
transit_audience |
string | required | The aud of the assertion: an https URI or a path that starts with one / |
granted_scopes |
list | none | The scopes of an assertion that has no scope claim |
key_set_file |
path | required unless key_set_url is set |
A JWK set, or one PEM public key or certificate, of the gateway |
key_set_url |
https URL |
none | The JWK set URL of the gateway, instead of key_set_file |
key_set_max_stale_seconds |
integer | 3600 |
As for direct |
algorithms |
list | required | The accepted algorithms, as for direct |
transit_token_type |
string | required | The typ that the gateway sets, or any |
transit_max_lifetime_seconds |
integer | 120 |
The longest accepted exp - iat, 1 to 3600 |
accept_long_transit_lifetime |
boolean | false |
Must be true for a lifetime above 300 |
transit_single_use |
boolean | false |
Admit each assertion once, by its jti |
address_claim |
string | email |
As for direct |
accept_address_claim |
boolean | false |
As for direct |
Some gateways authenticate the calling application, for example with its OAuth 2.0 client
credentials, and replace its token with their own assertion in Authorization: Bearer. For such a
gateway, set transit_header: authorization. sutura then reads the assertion after the Bearer
scheme, and security.access_token cannot be set, because both use Authorization: Bearer. Behind
such a gateway, the subject is the application that the gateway's assertion names.
The gateway and sutura must be on a trusted network path. An assertion is not bound to one
request, so keep its lifetime short. With transit_single_use: false, sutura accepts one assertion
again and again until its exp plus 30 seconds: at most transit_max_lifetime_seconds plus 30
seconds after its iat, 150 seconds at the default. See
Single use.
The key set¶
sutura reads key_set_file before it opens the listener, and it does not start if the file is not
a usable key set. It reads the file again 60 seconds after its last read, and when a token names an
unknown kid, at most once every 30 seconds. To rotate keys, replace the file, and wait 60 seconds
after the file holds the new key before the gateway signs with it. Behind a gateway, a key that leaves
the file verifies for transit_max_lifetime_seconds plus 30 seconds more, unless the new key keeps
its kid - see Rotating the keys.
key_set_url reads a JWK set from that URL the same way, at the same times. Set key_set_file or
key_set_url, not both. The URL must be https, with no query or fragment. sutura does not follow a
redirect, accepts only a 200 of at most 64 KiB, and gives up on one read after 10 seconds. It
trusts security.outbound.transport_anchors when that is set, and the built-in web roots when not.
If the last look at the file or the URL failed to read it and a token names an unknown kid, the
answer is 503 identity_unavailable with no challenge, and the keys sutura already has keep
verifying. A document sutura reads and refuses is 401. An unknown kid less than 30 seconds after
a look that read the source is 401 too, because sutura does not look again so soon.
The keys verify for at most key_set_max_stale_seconds after the last look that read a usable key
set. After that, every token gets 503 identity_unavailable with no challenge, whatever its kid,
until a look reads a usable key set again. This is the same for a source that cannot be read and
for a document sutura refuses. Each look that finishes without a usable key set logs stale_for_ms
and max_stale_ms, so you can alert before the limit.
The file is a JWK set, or one PEM key: a single PUBLIC KEY block, or a certificate followed by its
chain. A file that starts with -----BEGIN is read as PEM. Only the first certificate is a key,
and its validity period is not checked. The key id of a PEM key is its RFC 7638 SHA-256 thumbprint,
so a token must carry that thumbprint as its kid. A file with a private key, or with a second
public key, does not load.
Related settings¶
| Key | Default | Meaning |
|---|---|---|
security.identity |
none | single-user or multi-user. Required when a source is declared |
security.single_user_because |
none | Your reason for single-user. Required with it |
security.audience_mapping |
empty | Maps a groups claim value to the catalog audiences that it can see |
server.agent_surface.enabled |
false |
Mounts /mcp. Needs security.inbound, except on a guarded single-user deployment (Serving over HTTP) |
server.allowed_hosts |
empty | More Host names to answer, beyond loopback and the host of resource |
Scopes¶
The scope claim of a verified token decides what the caller may do:
| Scope | Allows |
|---|---|
sutura:catalog.read |
Read the catalog |
sutura:metrics.ask |
Ask a question |
sutura:sql.run |
Use the raw SQL tool |
A verified caller without one of these scopes gets 403 insufficient_scope. A scope that names
none of these grants nothing.
In direct, extra_scope_claims also reads scp and roles. Each is a space-delimited string or
a list of strings. sutura adds their scopes to the scopes of scope. When the setting is not set,
sutura reads only scope.
Behind a gateway, granted_scopes gives scopes to an assertion that has no scope claim. All
subjects that the gateway signs an assertion for without scope get these scopes, so sutura writes
them to the start-up log at WARN. An assertion with a scope claim, also an empty one, gets only
the scopes of that claim. granted_scopes cannot hold sutura:sql.run.
The caller's address¶
A BigQuery source with domain_delegation runs each query as the Google account with the caller's
address. By default sutura reads the address from email, and only when email_verified is true.
Some issuers never send email_verified, or send the Google address in another claim. For them, set
address_claim to the claim that holds the address, for example upn, or sub behind a gateway
that names the user in sub. sutura then reads that claim without email_verified, so the claim
must be one that the issuer verifies and that users cannot change. sutura does not start unless
accept_address_claim: true is set beside it, and it logs one WARN at each start.
The claim must occur once in the token and hold one address: one @, printable ASCII, no spaces,
at most 254 bytes. sutura does not change the case of the address. A token without such a value has
no address, and a domain_delegation source refuses that caller with credential_unavailable.
Writing address_claim: email is the default, and it still requires email_verified: true.
To limit which addresses a source runs as, whatever claim they come from, set
domain_delegation.address_domains on the source. See
BigQuery.
Microsoft Entra ID¶
An Entra access token has typ: JWT and the application ID of your API in aud. Set:
authorization_server:https://login.microsoftonline.com/<tenant>/v2.0.key_set_file: a copy ofhttps://login.microsoftonline.com/<tenant>/discovery/v2.0/keys. Entra rotates its keys, so refresh the file on a schedule.token_type: anyandaccept_any_token_type: true.audience: the application ID of the API.extra_scope_claims: [scp, roles]:scpholds the delegated scopes of a user, androlesholds the app roles assigned to the caller, an application or a user.
In the app registration of the API, set accessTokenAcceptedVersion to 2. A version 1 token has
a different issuer, and sutura refuses it. Register the API separately from every client: with
extra_scope_claims set, a token must name its client in azp, and the client must differ from the
API.
An Entra access token has no email_verified, so a domain_delegation source refuses every caller
by default. Entra sends upn in a version 2 access token only when the app registration of the API
adds it as an optional claim. Then set address_claim: upn and accept_address_claim: true, and
set domain_delegation.address_domains to the domains of your tenant. Microsoft documents upn as
a value for display only, so the acknowledgement is yours: sutura cannot verify it. Do not use
preferred_username, which Microsoft documents as mutable.
ClickHouse token checker¶
A ClickHouse source with token sign-in needs a token
checker that you run. Keycloak cannot answer the request of ClickHouse itself: ClickHouse sends
GET with Authorization: Basic, the userinfo endpoint of Keycloak expects Authorization: Bearer,
and its introspection endpoint expects POST with client authentication.
Run a small checker in front of Keycloak. For each request, the checker:
- Reads the user and the token from
Authorization: Basic. - Verifies the signature of the token with the JWKS of the issuer.
- Verifies the
issandexpclaims, and thataudis the ClickHouse audience: theaudienceof the last hop indelegation. - Makes sure that the
subof the token is the Basic user. - Sends
200if all checks pass, or401if one fails.
Keycloak puts the ID of the user in sub, so each ClickHouse user is named by that ID.
Example¶
This is conf/base.yaml of the multi player example. Keycloak is the
identity provider:
# sutura's settings for this example. infra/ writes the BigQuery source into development.yaml.
server:
agent_surface:
enabled: true
security:
identity: "multi-user"
# Every caller presents a Keycloak access token (`typ: at+jwt`, set on the `sutura` client in
# keycloak/realm.json). sutura checks it against the realm's public keys.
inbound:
mode: "direct"
resource: "https://sutura.example.com"
authorization_server: "https://localhost:8443/realms/sutura-example"
key_set_file: "/run/sutura/jwks.json"
algorithms: ["RS256"]
catalogs:
# DataHub, read with one token for every caller. The name is also the BigQuery source's name.
- name: "warehouse"
kind: "datahub"
# Required for every catalog kind, and not read for DataHub.
dir: "/unused-for-datahub"
data_dir: "/unused-for-datahub"
version: "example"
endpoint: "http://127.0.0.1:8081"
token_file: "/run/sutura/datahub-token"
metric_property: "sutura"
Serving over HTTP has the complete serving settings: tokens, TLS and rate limits.