diff --git a/documentation/changelog.mdx b/documentation/changelog.mdx index 49b052e30..274466eb8 100644 --- a/documentation/changelog.mdx +++ b/documentation/changelog.mdx @@ -16,6 +16,15 @@ This page tracks significant updates to the QuestDB documentation. ### New +- [Token providers](/docs/connect/clients/java/#token-providers) - The Java client 1.3.11 requests and renews bearer tokens itself, with `token_provider=azure` for Microsoft Entra ID or a `RefreshingTokenProvider` for other identity providers. Covers the `questdb-client-azure` artifact, credential selection, failure classification, and how senders, queries, and orphan drainers handle token failures +- [Connection health](/docs/connect/clients/java/#connection-health) - The Java client reports whether each sender and query client reaches the server, since when it has not, and its last failure +- [Managed identities and service principals](/docs/security/oidc/#managed-identities-and-service-principals) - Authenticate Azure services to QuestDB Enterprise with Entra ID app-only tokens: app roles, v2.0 tokens, claim lists, group mappings, and the Java client +- [User and group claims](/docs/security/oidc/#user-and-group-claims) - Fallback claim lists for `acl.oidc.sub.claim` and `acl.oidc.groups.claim` in QuestDB Enterprise 4.1.0, how QuestDB picks a claim, and the startup checks +- [Token validation](/docs/security/oidc/#token-validation) - What QuestDB checks in a token, and how it finds the expected issuer +- [Sessions and token expiry](/docs/security/oidc/#sessions-and-token-expiry) - HTTP sessions end when QuestDB rejects their token, and how the Web Console renews its tokens and handles failed renewals +- [Troubleshooting OIDC](/docs/security/oidc/#troubleshooting) - Log lines of rejected logins and tokens, and the errors of the Web Console login page +- [HTTP sessions in the REST API](/docs/connect/compatibility/rest-api/#http-sessions) - Open and close a session with the `qdb_session` cookie, and how QuestDB 10.1.0 and QuestDB Enterprise 4.1.0 end a session whose credential they reject +- [HTTP sessions in RBAC](/docs/security/rbac/#http-sessions) - Sessions of QuestDB Enterprise 4.1.0 end with their credential, how to end the Web Console logins of a user, and what replicas keep - [Merge-append partitions](/docs/concepts/merge-append-partitions/) - Reduce copying for backdated ingestion, monitor partition pieces and dead rows, and safely disable the layout before downgrading or converting to non-WAL - [Persisted metrics](/docs/operations/logging-metrics/#persisted-metrics) - New section on the `sys.metrics` table, where QuestDB keeps a history of its own metrics, with example queries - [Databento connector](/docs/connect/connectors/databento/) - New page for the QuestDB Enterprise Databento connector, which streams Live feeds and runs Historical backfills into QuestDB. Covers installation, TOML configuration, table mappings, storage policies, candles from a materialized view, a session VWAP live view, live dashboards in a Web Console notebook or Grafana, checkpointed recovery, and Prometheus metrics @@ -25,6 +34,11 @@ This page tracks significant updates to the QuestDB documentation. ### Reference +- [`acl.oidc.issuer`](/docs/configuration/oidc/#acloidcissuer) and [`acl.oidc.issuer.validation.enabled`](/docs/configuration/oidc/#acloidcissuervalidationenabled) - New settings for the expected issuer of a token +- [`acl.oidc.sub.claim`](/docs/configuration/oidc/#acloidcsubclaim) and [`acl.oidc.groups.claim`](/docs/configuration/oidc/#acloidcgroupsclaim) - Documented claim lists and their startup validation, and corrected that `acl.oidc.groups.claim` has no default and is required +- [`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl) - Documented that `0` disables the cache, and how long an HTTP session reuses a password check +- [`http.session.timeout`](/docs/configuration/http-server/#httpsessiontimeout) - Documented how long an idle HTTP session stays valid, and that a value without a unit suffix is read as microseconds +- [Token providers](/docs/connect/clients/connect-string/#token-providers) - The `token_provider` and `azure_*` connect-string keys of the Java client - Added [merge-append and partition compaction settings](/docs/configuration/cairo-engine/#partition-compaction) and the `deadRows`, `lastWriteTimestamp` and `pieceCount` columns in [`SHOW PARTITIONS`](/docs/query/sql/show/#show-partitions) and [`table_partitions()`](/docs/query/functions/meta/#table_partitions) - Added [`metrics.enabled`](/docs/configuration/logging-metrics/#metricsenabled) and the [`metrics.persist.*`](/docs/configuration/logging-metrics/#persisted-metrics) settings for `sys.metrics` - Added the dedicated live view refresh worker pool settings [`live.view.refresh.worker.count`](/docs/configuration/live-views/#liveviewrefreshworkercount), `.affinity`, `.haltOnError`, `.nap.threshold`, `.sleep.threshold`, `.sleep.timeout`, and `.yield.threshold`, plus [`cairo.live.view.checkpoint.purge.interval`](/docs/configuration/live-views/#cairoliveviewcheckpointpurgeinterval). Live views run on their own refresh pool, not the materialized view pool as previously documented @@ -37,6 +51,10 @@ This page tracks significant updates to the QuestDB documentation. ### Updated +- [Upgrade QuestDB](/docs/operations/upgrade/#authentication-changes-in-410) - Authentication changes to check before upgrading to QuestDB Enterprise 4.1.0 +- [QWP client behaviour](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-failures-and-token-providers) - Authentication failures by phase, and the retry with a new token after a `401`. The QWP ingress and egress, client failover, and connect string pages now describe how the Java client retries authentication failures +- [Java client](/docs/connect/clients/java/#ingestion-errors) - Corrected the error categories and policies, that the `QuestDB` builder accepts an error handler, and the `AUTH_FAILED` connection event +- [Microsoft Entra ID](/docs/security/oidc/#microsoft-entraid) - The configuration takes the principal from `preferred_username` instead of the display name - [Partition splitting](/docs/concepts/partitions/#partition-splitting-and-squashing) - Documented WAL split retention and background merging of idle splits and reclamation of Parquet dead space, including when merge-append is disabled - [HORIZON JOIN](/docs/query/sql/horizon-join/#queries-without-aggregate-functions) - Breaking change in QuestDB 10.1.0: a HORIZON JOIN without aggregate functions now returns one row per left-hand row and offset instead of grouping by the selected columns. Documented the row order, the designated timestamp of the result, how `SELECT DISTINCT` or an explicit `GROUP BY` restores the previous result, the [upgrade steps](/docs/query/sql/horizon-join/#upgrading-to-questdb-1010) for queries, outer aggregation, views, `INSERT INTO ... SELECT`, and `CREATE TABLE AS SELECT`, and the new execution plan names - [HORIZON JOIN](/docs/query/sql/horizon-join/#filtering-right-hand-tables) - Breaking change in QuestDB 10.1.0: documented right-hand filters, written in the `ON` clause, as a `(table WHERE ...)` subquery, or as a `SELECT` of the table's columns, which apply before the ASOF match. Earlier versions ignored `ON` predicates other than the join keys. Added a grammar for the right-hand side, including CTEs, and documented the `ORDER BY` limitation diff --git a/documentation/configuration/http-server.md b/documentation/configuration/http-server.md index 73e0100d9..3ce016c51 100644 --- a/documentation/configuration/http-server.md +++ b/documentation/configuration/http-server.md @@ -97,6 +97,22 @@ occurs. Changing the default value is strongly discouraged. Password for HTTP Basic Authentication in QuestDB Open Source. QuestDB Enterprise supports more advanced authentication via RBAC. +### http.session.timeout + +- **Default**: `30m` +- **Reloadable**: no + +How long an HTTP session stays valid without a request. A client opens a +session, identified by the `qdb_session` cookie, by sending `session=true`, as +the Web Console does at login. Every request that QuestDB authorizes restarts +the timer, so a session in use does not expire. QuestDB Open Source opens +sessions only when `http.user` and `http.password` are set. See +[HTTP sessions](/docs/connect/compatibility/rest-api/#http-sessions). + +The value is a duration with a unit suffix, such as `90s`, `30m`, or `1h`. A +number without a suffix is read as microseconds, so `1800000` is 1.8 seconds, +not 30 minutes. + ### http.user - **Default**: N/A diff --git a/documentation/configuration/oidc.md b/documentation/configuration/oidc.md index e0c5932bd..91be9c0e3 100644 --- a/documentation/configuration/oidc.md +++ b/documentation/configuration/oidc.md @@ -26,6 +26,11 @@ For detailed information about OIDC, see the OAuth2 audience as set on the tokens issued by the OIDC Provider. Defaults to the client ID if not set. +With `acl.oidc.groups.encoded.in.token=true`, QuestDB accepts a token only +when its `aud` claim contains this value. The ID tokens that the Web Console +sends carry the client ID, so keep the default when users sign in to the Web +Console. See [Token validation](/docs/security/oidc/#token-validation). + ### acl.oidc.client.id - **Default**: none @@ -65,6 +70,53 @@ configuration URL is set. OIDC provider HTTP request timeout in milliseconds. +### acl.oidc.issuer + +- **Default**: none +- **Reloadable**: no + +The issuer that the `iss` claim of a token must match when +`acl.oidc.groups.encoded.in.token` is `true`, in QuestDB Enterprise 4.1.0 and +later. When not set, QuestDB uses the `issuer` field of the discovery document +that it downloads from +[`acl.oidc.configuration.url`](#acloidcconfigurationurl). When set, it takes +precedence over the discovery document. The comparison is exact and +case-sensitive, and includes the path and any trailing slash. + +Set it when QuestDB has no discovery document, because it uses +[`acl.oidc.host`](#acloidchost) and the endpoint settings, or when the +discovery document names a template instead of an issuer, such as +`https://login.microsoftonline.com/{tenantid}/v2.0` for the multi-tenant +endpoints of Microsoft Entra ID. Set the issuer of your tenant, never a +template or a wildcard. With issuer validation enabled, QuestDB refuses to +start in token mode without an expected issuer: + +```text +required to set acl.oidc.issuer or provide an issuer in the OIDC discovery document when acl.oidc.groups.encoded.in.token and acl.oidc.issuer.validation.enabled are true +``` + +See [Token validation](/docs/security/oidc/#token-validation). + +### acl.oidc.issuer.validation.enabled + +- **Default**: `true` +- **Reloadable**: no + +Whether QuestDB checks the `iss` claim of a token when +`acl.oidc.groups.encoded.in.token` is `true`, in QuestDB Enterprise 4.1.0 and +later. With `false`, QuestDB neither requires nor compares the `iss` claim, +even when [`acl.oidc.issuer`](#acloidcissuer) is set, and logs an advisory at +startup. It still checks the signature, the audience, the expiry, and the +`sub` claim of a token. + +Disable it only for a provider whose tokens carry no `iss` claim. Without the +check, QuestDB accepts the token of any issuer or tenant that the provider +signs with a key that QuestDB trusts, for the expected audience. + +Accepts `true` or `false`, in any letter case. Any other value, an empty one +included, stops QuestDB from starting, even when OIDC is disabled: +`invalid configuration value [key=acl.oidc.issuer.validation.enabled, value=...]`. + ### acl.oidc.port - **Default**: `443` @@ -196,37 +248,77 @@ which it connects. ## User and group claims +QuestDB reads the principal and the group memberships of an external user +from the user information: the response of the User Info endpoint, or, when +`acl.oidc.groups.encoded.in.token` is `true`, the payload of the token. For +how QuestDB picks the claims, see +[User and group claims](/docs/security/oidc/#user-and-group-claims) in the +OIDC guide. + ### acl.oidc.cache.ttl - **Default**: `30000` - **Reloadable**: no -User info cache entry TTL in milliseconds. QuestDB caches user info responses -for each valid access token. This setting controls how often the access token -is validated and user info refreshed. +How long QuestDB caches the result of checking a token, in milliseconds: the +User Info response, or, when `acl.oidc.groups.encoded.in.token` is `true`, the +result of validating the token. QuestDB checks the token again once its cache +entry expires, so in User Info mode, this setting also controls how soon a +change in the Identity Provider, such as a disabled user, takes effect. `0` +disables the cache. + +In QuestDB Enterprise 4.1.0 and later, the same interval applies to HTTP +sessions, even with OIDC disabled. A session reuses the check of a password +that QuestDB verifies itself, the password of a database user or of the +built-in admin, until this interval has passed since the session last checked +it. A session never reuses the check of a REST token or an OIDC credential. +See [HTTP sessions](/docs/security/rbac/#http-sessions). ### acl.oidc.groups.claim -- **Default**: `groups` +- **Default**: none - **Reloadable**: no -The name of the custom claim in the user information that contains the -group memberships of the user. +The claim in the user information that contains the group memberships of the +user, as an array of group names or as a single name. Required when OIDC is +enabled. + +In QuestDB Enterprise 4.1.0 and later, accepts a comma-separated list of claim +names in priority order, such as `roles,groups`. QuestDB takes the groups from +the first claim on the list that holds at least one group name, and does not +combine the groups of several claims. QuestDB refuses to start when the list +is empty, names a claim twice, or names a claim that +[`acl.oidc.sub.claim`](#acloidcsubclaim) also lists. See +[User and group claims](/docs/security/oidc/#user-and-group-claims). ### acl.oidc.groups.encoded.in.token - **Default**: `false` - **Reloadable**: no -When `true`, QuestDB looks for group memberships in the ID token instead of -calling the User Info endpoint. Set to `true` if the OIDC Provider encodes -group memberships directly into the token. +When `true`, QuestDB reads the principal and the group memberships from a +JWT, such as the ID token, instead of calling the User Info endpoint, and +validates the token itself. Set to `true` if the OIDC Provider encodes group +memberships directly into the token, as Microsoft Entra ID does. In QuestDB +Enterprise 4.1.0 and later, the validation includes the issuer of the token. +See [Token validation](/docs/security/oidc/#token-validation). ### acl.oidc.sub.claim - **Default**: `sub` - **Reloadable**: no -The name of the claim in the user information that contains the user's name. -Could be a username, full name, or email. Displayed in the Web Console and -logged for audit purposes. +The claim in the user information that contains the principal of the user, +such as a username, an email address, or an object ID. `current_user()` +returns the principal, the Web Console displays it, and QuestDB logs it for +audit purposes. Pick a claim whose value the Identity Provider never gives to +anyone else: users who share a principal share their permissions. Avoid +display names, such as the `name` claim. + +In QuestDB Enterprise 4.1.0 and later, accepts a comma-separated list of claim +names in priority order, such as `preferred_username,oid`. QuestDB takes the +principal from the first claim on the list that holds a non-empty value. +QuestDB refuses to start when the list is empty, names a claim twice, or names +a claim that [`acl.oidc.groups.claim`](#acloidcgroupsclaim) also lists. An +empty value does not fall back to the default `sub`. See +[User and group claims](/docs/security/oidc/#user-and-group-claims). diff --git a/documentation/connect/clients/connect-string.md b/documentation/connect/clients/connect-string.md index b07e59c06..956f540fe 100644 --- a/documentation/connect/clients/connect-string.md +++ b/documentation/connect/clients/connect-string.md @@ -167,6 +167,7 @@ caveats), follow the section links from the [Key index](#key-index). | Enable TLS | both | `addr` with `wss` schema | `tls_verify`, `tls_roots`, `tls_roots_password` | | Basic-auth credentials | both | `username`, `password` | `auth_timeout_ms` | | Bearer-token credentials | both | `token` | `auth_timeout_ms` | +| Entra ID tokens that the client renews (Java) | both | `token_provider=azure`, `azure_resource` | `azure_credential`, `azure_client_id` | | Multi-host failover | both | `addr=h1,h2,…` | `target`, `zone`, `reconnect_*` (ingress), `failover_*` (egress) | | Query only the primary (freshest data) | egress | `target=primary` | — | | Query only replicas (offload primary) | egress | `target=replica` | — | @@ -235,6 +236,41 @@ the TLS handshake is server-authenticated only. `tls_roots` / `tls_roots_password` configure server-cert trust, not client identity. Use `token=` or `username=` / `password=` for client authentication. +### Token providers {#token-providers} + +*Applies to: ingress and egress. Java client 1.3.11 or later only.* + +A fixed `token` stops working when it expires. A token provider requests +bearer tokens from an identity provider instead, and renews them in the +background before they expire, so every connection, reconnect, and failover +presents a valid token: + +``` +wss::addr=node-a:9000,node-b:9000;token_provider=azure;azure_resource=api://;azure_credential=managed_identity; +``` + +- `token_provider` selects the provider. `azure` requests Microsoft Entra ID + tokens through the Azure Identity library, and needs the + `questdb-client-azure` artifact. Requires the `wss` schema. Cannot be + combined with `token`, `username` / `password`, or a token provider that + the application passes in code. +- `azure_resource` is required with `token_provider=azure`. It is the + Application ID URI of the QuestDB application in Entra ID, such as + `api://`, or its application ID. The client requests tokens + for the scope `/.default`. +- `azure_credential` selects the Azure credential that requests the tokens: + `default`, `managed_identity`, `workload_identity`, or `environment`, a + service principal configured in environment variables. `default`, the + default, is `DefaultAzureCredential`, a discovery chain meant for + development. Name the credential in production. +- `azure_client_id` is the client ID of a user-assigned managed identity or of + a workload identity. Cannot be combined with `azure_credential=environment`. + +These keys contain no secret, so they are safe to log. Clients built from +equivalent connect strings share one provider in a process. See +[Token providers](/docs/connect/clients/java/#token-providers) in the Java +client reference. + ## TLS {#tls} *Applies to: ingress and egress.* @@ -642,9 +678,18 @@ architecture is that a producer survives an arbitrarily long outage. This is the shutdown data-loss window. Setting it to `0` skips the drain entirely and drops un-ACKed batches on every clean shutdown. -Auth failures during reconnect (authentication rejected, version mismatch, -durable-ack mismatch, non-101 upgrade without a role hint) are immediately -terminal — the loop does not retry them. +In most clients, rejections of the upgrade request during reconnect +(authentication rejected, version mismatch, durable-ack mismatch, non-101 +upgrade without a role hint) are immediately terminal: the loop does not +retry them. The Java client treats a version mismatch like a transport +failure, to ride out a rolling upgrade, and the other rejections as terminal +only before the sender first connects. A Java sender that has connected keeps +retrying all of them, and reports each failed attempt in its connection +health. It also reports the rejections other than a version mismatch to its +error handler. See +[How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) +and, for orphan drainers, +[Authentication failures and token providers](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-failures-and-token-providers). ### Egress failover {#egress-failover} @@ -859,6 +904,9 @@ description and behaviour notes. | `auto_flush_bytes` | size | Java `0` (off) / .NET `8m` (Rust: rejected) | [Auto-flushing](#auto-flush) | | `auto_flush_interval` | int (ms) / `off` | `100` (Rust: rejected) | [Auto-flushing](#auto-flush) | | `auto_flush_rows` | int / `off` | `1000` (Rust: rejected) | [Auto-flushing](#auto-flush) | +| `azure_client_id` | string | unset | [Token providers](#token-providers) (Java only) | +| `azure_credential` | enum (`default` / `managed_identity` / `workload_identity` / `environment`) | `default` | [Token providers](#token-providers) (Java only) | +| `azure_resource` | string | unset (required with `token_provider=azure`) | [Token providers](#token-providers) (Java only) | | `buffer_pool_size` | int (≥ 1) | `4` | [Query client keys](#egress-keys) | | `catch_up_cap_gap_min_escalation_window_millis` | int (ms) | `300000` (5 min) | [Store-and-forward](#sf-keys) | | `client_id` | string | client-specific | [Query client keys](#egress-keys) | @@ -918,6 +966,7 @@ description and behaviour notes. | `tls_roots_password` | string | unset (JKS / PKCS#12 only) | [TLS](#tls) | | `tls_verify` | enum (`on` / `unsafe_off`) | `on` | [TLS](#tls) | | `token` | string | unset | [Authentication](#auth) | +| `token_provider` | enum (`azure`) | unset | [Token providers](#token-providers) (Java only) | | `transaction` | enum (`on` / `off`) | `off` | [Store-and-forward](#sf-keys) | | `user` | string | unset | [Authentication](#auth) (alias of `username`) | | `username` | string | unset | [Authentication](#auth) | diff --git a/documentation/connect/clients/java.md b/documentation/connect/clients/java.md index de86d9250..f1cb58f0c 100644 --- a/documentation/connect/clients/java.md +++ b/documentation/connect/clients/java.md @@ -234,14 +234,16 @@ a pool of each client type — [`Sender`](#data-ingestion)s for ingestion and query clients for SQL — plus a background housekeeper thread that closes idle and over-age connections. -The interface has exactly five members: +The interface has these members: | Method | Returns | Purpose | |--------|---------|---------| | `connect(String)` | `QuestDB` | Static factory. One connect string drives both ingress and egress (schema must be `ws`/`wss`). | +| `connect(String, HttpTokenProvider)` | `QuestDB` | Static factory that takes the bearer token of every connection from a provider, such as a [`RefreshingTokenProvider`](#other-identity-providers). | | `builder()` | `QuestDBBuilder` | Pool sizes and timeouts. | | `borrowSender()` | `Sender` | Lease a sender from the pool; `close()` flushes and returns it. | | `borrowQuery()` | `Query` | Lease a query handle from the pool; `close()` returns it. | +| `health()` | `ConnectionHealth.Aggregate` | The [connection health](#connection-health) of every pooled sender and query client. | | `close()` | `void` | Shut down pools and disconnect every underlying client. Idempotent. | A `QuestDB` instance is safe to share across threads. Borrows and query @@ -321,6 +323,7 @@ Builder methods: | Method | Default | Purpose | |--------|---------|---------| | `fromConfig(String)` | — | One `ws`/`wss` string for both sides; honors pool keys in the string. Required. | +| `httpTokenProvider(HttpTokenProvider)` | — | Supplies the bearer token of every pooled connection, such as from a [`RefreshingTokenProvider`](#other-identity-providers). Cannot be combined with `token`, `username`/`password`, or `token_provider` in the configuration. | | `senderPoolMin(int)` | `1` | Senders kept open even when idle. `0` lets the pool close them all. | | `senderPoolMax(int)` | `4` | Maximum senders the pool opens. | | `senderPoolSize(int)` | — | Shortcut: fixed `min = max = size`, all opened up front. | @@ -397,6 +400,281 @@ The token is sent as an `Authorization: Bearer YOUR_BEARER_TOKEN` header on both the ingress and egress WebSocket upgrades. It is mutually exclusive with `username`/`password`. +The client sends the same token on every connection that it opens, including +reconnects and failovers. A connection that is already open is not +authenticated again, but once QuestDB stops accepting the token, for example +because it has expired, every new connection fails. For tokens that expire, +such as the tokens of an OpenID Connect provider, use a +[token provider](#token-providers) instead. + +### Token providers + +A token provider requests bearer tokens from an identity provider, caches +them, and renews them in the background before they expire, so every +connection, reconnect, and failover presents a valid token. Use one when a +client runs longer than its tokens live, such as a service that authenticates +as an Azure managed identity. Token providers require Java client 1.3.11 or +later. + +#### Microsoft Entra ID + +Add the `questdb-client-azure` artifact, at the same version as +`questdb-client`. It brings the Azure Identity library, and registers the +`azure` token provider: + + + + ( + + {` + org.questdb + questdb-client-azure + ${release.name} +`} + + )} /> + + + ( + + {`implementation 'org.questdb:questdb-client-azure:${release.name}'`} + + )} /> + + + +Then select the provider in the connect string: + +```java +try (QuestDB db = QuestDB.connect( + "wss::addr=db.example.com:9000;" + + "token_provider=azure;" + + "azure_resource=api://8de84b90-1ea5-4e41-9e84-dba860aa01a6;" + + "azure_credential=managed_identity;")) { + // ... +} +``` + +| Key | Description | +|-----|-------------| +| `token_provider` | `azure`. Requires the `wss` schema. Cannot be combined with `token`, `username`/`password`, or a provider passed in code. | +| `azure_resource` | Required. The Application ID URI of the QuestDB application in Entra ID, such as `api://`, or its application ID. The client requests tokens for the scope `/.default`. | +| `azure_credential` | The Azure credential that requests the tokens: `default` (the default), `managed_identity`, `workload_identity`, or `environment`. | +| `azure_client_id` | The client ID of a user-assigned managed identity, or of a workload identity. Cannot be combined with `azure_credential=environment`. | + +In production, name the credential with `azure_credential`: + +- `managed_identity` requests tokens as the managed identity of the Azure + resource that the client runs on: its system-assigned identity, or the + user-assigned identity that `azure_client_id` selects. +- `workload_identity` requests tokens as an Azure Kubernetes Service workload + identity. +- `environment` requests tokens as a service principal configured with the + `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` environment + variables, or with a certificate. + +The default, `default`, uses `DefaultAzureCredential`, a chain meant for +development, which tries a service principal configured in the environment, a +workload identity, a managed identity, and then developer tools, such as the +Azure CLI. The chain checks the managed identity endpoint once, without +retrying, so at startup, an outage of that endpoint looks like a host without a +credential. The client logs a warning about this when the provider starts. A +managed identity selected with `managed_identity` retries an unreachable +endpoint instead. + +These keys contain no secret, so they are safe to log and to keep in +`QDB_CLIENT_CONF`. Secrets, such as a client secret or a certificate, come from +the configuration of the Azure platform. Every sender, query client, and pooled +connection built from equivalent connect strings shares one provider in a +process: one thread renews the token, and one request goes to Entra ID at a +time. The provider closes 60 seconds after the last client that uses it. + +To configure an Azure credential in code, such as a certificate credential, +wrap it in an `AzureTokenSource`, and pass that to a `RefreshingTokenProvider`, +as described in [Other identity providers](#other-identity-providers): + +```java +import com.azure.core.credential.TokenCredential; +import com.azure.identity.ClientCertificateCredentialBuilder; +import io.questdb.client.azure.AzureTokenSource; +import io.questdb.client.cutlass.auth.RefreshingTokenProvider; + +TokenCredential credential = new ClientCertificateCredentialBuilder() + .tenantId("") + .clientId("") + .pemCertificate("/etc/fx-ingest/client.pem") + .build(); +RefreshingTokenProvider tokens = RefreshingTokenProvider.builder( + new AzureTokenSource(credential, "api://")).build(); +``` + +Whichever credential it uses, the Azure provider classifies the failures of +Azure Identity: + +- **Permanent**: a missing credential, such as a managed identity that is not + assigned to the host, a missing credential file, or a certificate that + cannot be parsed; a `400` or `401` from Entra ID; or a configuration error + that Entra ID reports, such as `AADSTS700016` (application not found), + `AADSTS7000215` (invalid client secret), `AADSTS7000222` (expired client + secret), or `AADSTS500011` (resource not found, usually a wrong + `azure_resource`). +- **Retryable**: anything else, such as network errors, timeouts, throttling, + and `5xx` responses. The provider honors the `Retry-After` header of a + throttled response. + +The classification decides whether a startup fails at once, as described in +[How the client handles token failures](#how-the-client-handles-token-failures). +Each token request times out after 30 seconds. Error messages and log lines +name the failure type, the HTTP status, a known `AADSTS` code, and the +socket-level cause of a network failure, such as a refused connection. They +leave out the messages of the Azure SDK, which can contain the response of the +token endpoint. + +The client finds the provider as a service, so a modular application on Java +11 or later needs only `requires io.questdb.client;`, with the artifact and +the Azure Identity library on the module path. + +For the setup in Entra ID and QuestDB, see +[Managed identities and service principals](/docs/security/oidc/#managed-identities-and-service-principals). + +#### Other identity providers + +For any other identity provider, implement a `TokenSource` that requests a +token, and wrap it in a `RefreshingTokenProvider`. Pass the provider to +`QuestDB.connect(String, HttpTokenProvider)`, +`QuestDB.builder().httpTokenProvider(...)`, +`Sender.builder(...).httpTokenProvider(...)`, or +`QwpQueryClient.withBearerTokenProvider(...)`. The application owns the +provider: close it after the clients that use it. + +```java +import java.io.IOException; + +import io.questdb.client.QuestDB; +import io.questdb.client.cutlass.auth.ExpiringToken; +import io.questdb.client.cutlass.auth.RefreshingTokenProvider; +import io.questdb.client.cutlass.auth.TokenUnavailableException; + +RefreshingTokenProvider tokens = RefreshingTokenProvider.builder(() -> { + // runs on the provider's own thread, never on a connection thread + try { + IdpToken token = idp.requestToken(); // such as a client credentials grant + return new ExpiringToken(token.value(), token.expiresAtEpochMillis()); + } catch (IOException e) { + throw TokenUnavailableException.retryable("token endpoint unreachable"); + } +}).build(); +try (QuestDB db = QuestDB.connect("wss::addr=db.example.com:9000;", tokens)) { + // ... +} finally { + tokens.close(); +} +``` + +`idp` and `IdpToken` stand for your own code. `ExpiringToken` takes the token, +without the `Bearer ` prefix, and the time when it expires, in milliseconds +since the epoch. The source reports a failure with an unchecked +`TokenUnavailableException`: + +- `TokenUnavailableException.retryable(message)` for a network failure, a + timeout, throttling, or a `5xx` response. Pass a `Retry-After` with + `retryable(message, retryAfterMillis)`. +- `TokenUnavailableException.permanent(message)` for a missing or wrong + configuration, such as an invalid client secret. + +Any other exception counts as retryable. Never put a token or a raw response +body into the message. The provider keeps retrying with exponential backoff +whatever the classification, so an operator can fix a permanent failure, such +as a missing role assignment, without a restart. + +The provider requests its first token as soon as it is built, and renews the +token in the background at about half of its lifetime. It hands out a token +only while more than 60 seconds of its lifetime are left, and a connection that +needs a token while the provider holds no usable one waits up to 30 seconds +for it. To tune this, use the builder: + +| Builder method | Default | Description | +|----------------|---------|-------------| +| `handoutFloorMillis(long)` | `60000` | The provider hands out a token only while more than this is left of its lifetime. A token that arrives with less counts as a failed request, whose error names `handout_floor`. Lower it if your identity provider issues tokens that live less than about two minutes. | +| `coldWaitMillis(long)` | `30000` | How long a connection waits for a token when the provider holds no usable one. | +| `forcedWaitMillis(long)` | `5000` | How long the client waits for a new token after QuestDB rejects one with `401`. | +| `minRefreshIntervalMillis(long)` | `30000` | The least time between a request and the next scheduled renewal. | +| `backoffInitialMillis(long)` | `500` | The delay after the first failed request. It grows exponentially, with jitter, with each further failure. | +| `backoffMaxMillis(long)` | `60000` | The longest delay between failed requests. | + +To fail fast at startup, for example on an invalid client secret, wait for the +first token with `awaitReady(timeoutMillis)`, which returns `false` when the +timeout passes: + +```java +if (!tokens.awaitReady(30_000)) { + RefreshingTokenProvider.FetchFailure failure = tokens.getLastFailure(); + if (failure != null && !failure.isRetryable()) { + tokens.close(); + throw new IllegalStateException("cannot get a token: " + failure.getMessage()); + } + // a retryable failure: start anyway, and let the provider keep trying +} +``` + +`getConsecutiveFailures()`, `getLastSuccessEpochMillis()`, and +`getTokenExpiresAtEpochMillis()` report the state of the provider, such as for +a health check. None of them, nor any log line or error, contains the token. + +An application can also pass its own `HttpTokenProvider`. Its `getToken()` +returns the current token without the `Bearer ` prefix. Pooled connections can +call it at the same time, so it must be thread-safe, and it must return +promptly, without prompting the user. After QuestDB rejects a token with +`401`, the client calls `onTokenRejected(token, httpStatus)`, a default method +that does nothing, and then calls `getToken()` again. To report a transient +failure, throw `TokenUnavailableException.retryable(...)`: the client treats +any other exception from `getToken()` as permanent. + +#### How the client handles token failures + +- **A `401` from QuestDB.** When QuestDB rejects a token with `401`, and the + response carries no Bearer `error` or `error="invalid_token"`, the client + asks the provider for a new token. If it gets a different one, it retries + the same server once, immediately. A `RefreshingTokenProvider` requests a + new token for a rejected one at most once every 30 seconds, and the client + waits up to `forcedWaitMillis` for it. A `403` never triggers this retry: + it means that the token is valid, but lacks a permission. +- **Startup.** A sender connects when it is built, unless + `initial_connect_retry=async`. With `initial_connect_retry=off`, the + default, it fails when it cannot get a token. With `on`, it retries a + retryable failure of the provider within `reconnect_max_duration_millis`, + and fails at once on a permanent one. With `async`, it keeps retrying in the + background. A `401` or `403` from QuestDB fails the startup in every mode: + with `async`, it reaches the error handler. +- **The `QuestDB` handle.** The handle also connects `query_pool_min` query + clients, one by default, when it is built, and a query client does not retry + a token failure. So `QuestDB.connect()` and `build()` fail while the + identity provider is unreachable. To start anyway, set `lazy_connect=on`: + senders then connect in the background, and query clients on the first + query. +- **A sender that has connected.** It keeps retrying when it cannot get a + token, or when QuestDB rejects the token, with any kind of credential. It + buffers rows meanwhile, in memory or in + [store-and-forward](#store-and-forward), and reports each failed attempt to + the [error handler](#ingestion-errors) as a `SECURITY_ERROR` with the + `RETRIABLE` policy, and in its [connection health](#connection-health). The + message of the error starts with `credential-unavailable:` when the provider + cannot supply a token, or with `ws-upgrade-failed:` when QuestDB rejects the + connection. The sender never fails on its own: an application that prefers + a hard failure watches the error handler or the connection health, and + closes the sender itself. +- **A query.** A query whose client has to connect, and cannot get a token or + has its token rejected, fails with `QueryException`. The next query + connects again. +- **An orphan drainer.** With `drain_orphans=on`, a drainer that replays the + slot of another sender gives up when QuestDB keeps rejecting its + credential: at once with a fixed credential, and after the rejections + persist for a while with a token provider. It then sets the slot aside, + with its rows still on disk, and reports `DATA_LOSS`. + ### HTTP basic auth ```java @@ -1235,16 +1513,15 @@ apply to the query side only and are ignored by the ingest side. WebSocket ingestion uses an asynchronous error model. Batch rejections are delivered via the `SenderErrorHandler` callback, not thrown from `flush()`. -The `QuestDB` connect-string path does not yet expose this callback — if -your application needs structured `SenderError` reports, drop down to the -[low-level `Sender.builder`](#sender-low-level-primitive) directly and -skip the pool. The structured fields on `SenderError` are the same -regardless of how the sender was created: +Register the handler with `QuestDB.builder().errorHandler(...)`, for every +sender of the pool, or with `errorHandler(...)` on the builder of a +[low-level `Sender`](#sender-low-level-primitive). The structured fields on +`SenderError` are the same regardless of how the sender was created: | Field | Accessor | Description | |-------|----------|-------------| -| Category | `getCategory()` | `SCHEMA_MISMATCH`, `PARSE_ERROR`, `INTERNAL_ERROR`, `SECURITY_ERROR`, `WRITE_ERROR`, `PROTOCOL_VIOLATION`, or `UNKNOWN` | -| Policy | `getAppliedPolicy()` | `DROP_AND_CONTINUE` (batch dropped, sender continues) or `HALT` (next API call throws `LineSenderServerException`) | +| Category | `getCategory()` | `SCHEMA_MISMATCH`, `PARSE_ERROR`, `INTERNAL_ERROR`, `SECURITY_ERROR`, `WRITE_ERROR`, `NOT_WRITABLE`, `DICTIONARY_GAP`, `PROTOCOL_VIOLATION`, `DATA_LOSS`, or `UNKNOWN` | +| Policy | `getAppliedPolicy()` | `RETRIABLE` (the client reconnects and replays the batch), `RETRIABLE_OTHER` (the same, on the next endpoint), `TERMINAL` (the next API call throws `LineSenderServerException`), or `ABANDONED` (with `DATA_LOSS` only: the rows can never be sent, and stay on disk) | | Server message | `getServerMessage()` | Human-readable error text from the server (may be null) | | Table name | `getTableName()` | The rejected table (null for multi-table batches) | | FSN range | `getFromFsn()` / `getToFsn()` | Frame sequence number span identifying the rejected batch | @@ -1254,7 +1531,7 @@ regardless of how the sender was created: The error handler runs on a dedicated dispatcher thread, never on the I/O or producer thread. -When a sender owned by `QuestDB` enters a terminal `HALT` state, the next +When a sender owned by `QuestDB` enters the `TERMINAL` state, the next producer-thread call throws `LineSenderServerException`. The pool detects the failure on close/return and replaces the failed sender with a fresh one on the next borrow. @@ -1320,8 +1597,22 @@ What is and isn't carried on `onError`: ### Connection-level errors - **Authentication failure**: `401`/`403` HTTP response before the WebSocket - upgrade completes. Terminal across all endpoints. The borrow that - triggered the connect rethrows `LineSenderException`. + upgrade completes. The client does not try the remaining endpoints. A + connect at startup throws `QwpAuthFailedException` from + `QuestDB.connect()`, `build()`, or the `db.borrowSender()` that creates the + sender. It is an `HttpClientException`, not a `LineSenderException`. Its + message starts with `auth-rejected:`, `getStatusCode()` returns `401` or + `403`, and `getBearerError()` returns the Bearer `error` of the response, + or `null`. A sender that has connected before keeps retrying instead, and + a query fails, as described in + [How the client handles token failures](#how-the-client-handles-token-failures). +- **Credential unavailable**: the [token provider](#token-providers) cannot + supply a token, for example because the identity provider is unreachable. + The client contacts no endpoint. A sender connect at startup throws the + exception of the provider, such as a `TokenUnavailableException`. A query + client throws `QwpCredentialUnavailableException`, whose message starts + with `credential-unavailable:`, and `db.borrowQuery()` wraps it in a + `QueryException`. - **Malformed frames**: `QwpDecodeException` or WebSocket close with a terminal code. - **Role mismatch**: `QwpRoleMismatchException` when all endpoints report @@ -1423,8 +1714,12 @@ client and gives you a fresh one on your next query. ### Which failures are retried -- **Authentication failures** (a bad token or wrong credentials) stop - immediately on every host — retrying cannot help. +- **Authentication failures** (a bad token or wrong credentials) stop the + walk over the hosts, because every host would reject the same credential. + With a [token provider](#token-providers), a `401` first earns one retry + of the same host with a new token. Startup and queries fail, while a + sender that has connected before keeps retrying, as described in + [How the client handles token failures](#how-the-client-handles-token-failures). - **Network and availability failures** (connection refused, TLS errors, a `5xx` from the server, a mid-query drop) are treated as temporary and fed into the reconnect and failover loops. @@ -1440,7 +1735,7 @@ and failover events. It is available on **both** the facade builder sender pool) and the low-level [`Sender.builder`](#sender-low-level-primitive). -`SenderConnectionEvent.Kind` has exactly six values: +`SenderConnectionEvent.Kind` has seven values: | Kind | Meaning | |---|---| @@ -1450,6 +1745,7 @@ sender pool) and the low-level | `FAILED_OVER` | A reconnect succeeded against a **different** endpoint. | | `ENDPOINT_ATTEMPT_FAILED` | One endpoint failed during a connect walk; the client tries the next. | | `ALL_ENDPOINTS_UNREACHABLE` | Every endpoint failed this round. The sender keeps retrying — this is **not** terminal. | +| `AUTH_FAILED` | QuestDB rejected the credential with `401` or `403`, which ends the round. Terminal before the first connect. A sender that has connected before keeps retrying. | :::caution `RECONNECTED` and `FAILED_OVER` are mutually exclusive @@ -1470,6 +1766,52 @@ For the full list of connect-string keys, see the [multi-host failover](/docs/connect/clients/connect-string#failover-keys) sections of the connect string reference. +### Connection health + +A sender that rides out an outage, such as an unreachable cluster or a token +that QuestDB rejects, keeps accepting rows, so a successful `flush()` does not +mean that the sender reaches the server. Its connection health does. Reading +the health never waits for network I/O, and never contains a credential, so it +can back a health endpoint. Connection health requires Java client 1.3.11 or +later. + +```java +import io.questdb.client.ConnectionHealth; + +// every pooled sender and query client of the handle +ConnectionHealth.Aggregate health = db.health(); +long since = health.getOldestOutageSinceEpochMillis(); +if (since != ConnectionHealth.NONE) { + // a client has not connected yet, is reconnecting, or has failed + ConnectionHealth.Failure failure = health.getLastFailure(); + System.err.printf("QuestDB unreachable since %d: %s%n", since, failure); +} +``` + +`Sender.health()` and `QwpQueryClient.health()` return the `ConnectionHealth` +of one client. Only the WebSocket (QWP) sender tracks it: senders of the other +transports throw `UnsupportedOperationException`. + +| Accessor | Description | +|----------|-------------| +| `getState()` | `CONNECTING` (not connected yet), `CONNECTED`, `RECONNECTING` (the connection was lost, and the client is connecting again; for a query client, the last connect failed, and the next query tries again), `FAILED` (the client will not connect again), or `CLOSED`. | +| `getLastConnectedAtEpochMillis()` | When the client last connected, or `ConnectionHealth.NONE` (`-1`). | +| `getOutageSinceEpochMillis()` | When the current outage started, or `NONE` while connected. A client that has not connected yet has been in an outage since it was created. | +| `getFailedRounds()` | How many connect rounds, walks over the endpoints in `addr`, have failed since the outage started. | +| `getLastFailure()` | The last failed connect round, or `null`. It is kept after the client connects again. | + +A `ConnectionHealth.Failure` names its class with `getFailureClass()`: +`CREDENTIAL_UNAVAILABLE` (the token provider could not supply a token), +`AUTH_REJECTED` (QuestDB answered `401` or `403`, which `getStatusCode()` +returns), `ROLE_REJECTED` (no endpoint had the required role), `TRANSPORT` +(no endpoint could be reached), or `OTHER`. `getMessage()` and +`getEpochMillis()` return its description and its time. + +`ConnectionHealth.Aggregate` returns the number of clients in a state with +`count(State)`, the number of clients with `total()`, the oldest outage with +`getOldestOutageSinceEpochMillis()`, and the most recent failure with +`getLastFailure()`. + ## Concurrency `QuestDB` is the only thread-safe handle in the library. `Sender` and @@ -1594,6 +1936,7 @@ Common WebSocket-specific options: | `compression` | `raw` | Egress batch compression (`raw`, `zstd`, `auto`). | | `compression_level` | `1` | Egress zstd level (`1`–`22`, clamped server-side to `1`–`9`). | | `client_id` | unset | Egress client label, sent as the `X-QWP-Client-Id` header; set in production so support can correlate sessions. | +| `token_provider` | unset | Token provider that requests and renews the bearer token, `wss` only: `azure`, configured with `azure_resource`, `azure_credential`, and `azure_client_id`. See [Token providers](#token-providers). | Pool keys (read by the `QuestDB` facade; the two clients accept and ignore them): diff --git a/documentation/connect/compatibility/rest-api.md b/documentation/connect/compatibility/rest-api.md index 033060ff2..467f565d6 100644 --- a/documentation/connect/compatibility/rest-api.md +++ b/documentation/connect/compatibility/rest-api.md @@ -887,3 +887,43 @@ curl -G --data-urlencode "query=SELECT 1;" \ Refer to the [user management](/docs/security/rbac/#user-management) page to learn more on how to generate a REST API token. + +### HTTP sessions + +A client can authenticate once, then send a session cookie instead of its +credentials, as the Web Console does. To open a session, add `session=true` to +an authenticated request: QuestDB returns the session ID in the `qdb_session` +cookie. A later request that carries the cookie and no `Authorization` header +runs as the user who opened the session. To close the session, add +`session=false` to a request. + +```bash +# authenticate with the credentials in QDB_USER and QDB_PASSWORD, open a +# session, and store its cookie in cookies.txt +curl -G -c cookies.txt -u "$QDB_USER:$QDB_PASSWORD" \ + --data-urlencode "query=SELECT 1;" \ + --data-urlencode "session=true" \ + http://localhost:9000/api/v1/sql/execute + +# send the cookie instead of the credentials +curl -G -b cookies.txt -c cookies.txt \ + --data-urlencode "query=SELECT current_user();" \ + http://localhost:9000/api/v1/sql/execute +``` + +QuestDB Open Source opens sessions only when `http.user` and `http.password` +are set. A session ends when it has been idle for +[`http.session.timeout`](/docs/configuration/http-server/#httpsessiontimeout), +30 minutes by default. QuestDB also replaces the session ID from time to time, +so store the cookie that each response sets, as `-c` does above. + +Since QuestDB 10.1.0 and QuestDB Enterprise 4.1.0, a request that carries both +the cookie and an `Authorization` header is authenticated with the header. +When QuestDB rejects the credential, the request fails with +`401 Unauthorized`, and the session ends. Earlier versions served such a +request from the session, so a wrong password or an expired token kept working +for as long as the cookie did. Only the requests that QuestDB authorizes keep a +session alive, but a refused request with `session=false` still closes it. In +QuestDB Enterprise 4.1.0 and later, a session also ends when the credential +that opened it is revoked, as described in +[HTTP sessions](/docs/security/rbac/#http-sessions) of the RBAC reference. diff --git a/documentation/connect/wire-protocols/qwp-client-behavior.md b/documentation/connect/wire-protocols/qwp-client-behavior.md index c4e73a3d5..01c651bc2 100644 --- a/documentation/connect/wire-protocols/qwp-client-behavior.md +++ b/documentation/connect/wire-protocols/qwp-client-behavior.md @@ -234,6 +234,8 @@ surface — this matrix shows where each lives. | --- | :---: | :---: | :---: | | `addr` | ✅ | ✅ `address()/port()` | via conn string | | `username`/`password`/`token` | ✅ | ✅ | via conn string | +| `token_provider`/`azure_*` (Java) | ✅ | via conn string | via conn string | +| `HttpTokenProvider` (Java) | ❌ | ✅ `httpTokenProvider()` | ✅ `httpTokenProvider()` | | `tls_verify`/`tls_roots` | ✅ | ✅ | via conn string | | `auth_timeout_ms` | ✅ | ✅ | via conn string | | `initial_connect_retry` | ✅ | ✅ `initialConnectMode()` | via conn string | @@ -283,7 +285,7 @@ here. | 2 | `reconnect_max_duration_millis` is named as if it governs reconnection, but a running sender never consults it — it bounds only the blocking initial connect. | Candidate (naming) | | 3 | `failover` sounds like it covers startup but only affects post-connect query `execute()`. Queries have no async/lazy initial connect at all. | Candidate | | 4 | No first-class write-only facade: a write-only user must still supply a query config and remember `query_pool_min=0`, or use `lazy_connect=true`. | Candidate | -| 5 | A single endpoint returning `401`/`403` is treated as cluster-wide terminal and aborts the whole endpoint walk, even at startup, even if other endpoints would accept the credentials. | Intended (documented), revisit | +| 5 | A single endpoint returning `401`/`403` is treated as cluster-wide and ends the whole endpoint walk, even at startup, even if other endpoints would accept the credentials. What follows depends on the phase; see [Authentication failures and token providers](#authentication-failures-and-token-providers). | Intended (documented), revisit | | 6 | Query `serverInfoTimeoutMs` has no config key, so a facade query client cannot tune it. | Candidate | | 7 | The simplest API (`fromConfig` + async) has the worst error visibility — terminal async failures surface only on later producer calls or at `close()`. | Candidate | | 8 | `SenderProgressHandler` has no builder setter on either surface; it must be installed post-construction via `QwpWebSocketSender.setProgressHandler`. | Candidate | @@ -344,10 +346,13 @@ With `initial_connect_retry=async`: - Terminal errors go to a configured `SenderErrorHandler`; without one they surface on later producer calls or at close-time. -A sender in async mode does not give up because time passed. What ends it is a -**terminal** condition — authentication rejection, a durable-ack capability -mismatch, or the poison-frame detector — or the producer hitting -`sf_max_total_bytes` and exhausting `sf_append_deadline_millis` on `append()`. +A sender in async mode does not give up because time passed. It ends only on +a **terminal** condition: an authentication rejection or a durable-ack +capability mismatch, the poison-frame detector, or the producer hitting +`sf_max_total_bytes` and exhausting `sf_append_deadline_millis` on +`append()`. ⚠ The Java client treats an authentication rejection or a +durable-ack capability mismatch as terminal only before the sender first +connects, and retries them afterwards. ### Reconnect and outage handling @@ -380,6 +385,51 @@ and apply back-pressure to the producer rather than dropping data. ::: +⚠ In the Java client, a running sender also retries authentication failures. +Other clients treat them as terminal. See +[Authentication failures and token providers](#authentication-failures-and-token-providers). + +### Authentication failures and token providers + +A client sends its credential only on the WebSocket upgrade request, and never +authenticates an established connection again. A client with a token provider, +such as the [Java client](/docs/connect/clients/java/#token-providers), asks +the provider for the credential at the start of every connect round: the +initial connect, every reconnect and failover, every orphan-drain connect, and +every query client connect. When the provider fails, the round ends without +contacting any endpoint, as a **credential-unavailable** failure, which the +provider classifies as retryable or permanent. An exception from a provider +that the application supplies counts as permanent, unless it is a token +failure marked as retryable. + +When an upgrade that presented a token from a provider receives `401`, without +a Bearer `error` or with `error="invalid_token"`, the client tells the provider +that the token was rejected, asks it for a token again, and, if the token +changed, retries the same endpoint once, without backoff. A `403` never +triggers this retry, and neither does a fixed credential. When the retry also +fails, or the token did not change, the phase decides what happens: + +| Phase | Credential unavailable, retryable | Credential unavailable, permanent | `401` / `403` | +| --- | --- | --- | --- | +| Initial connect, `initial_connect_retry=off` | fail startup | fail startup | fail startup | +| Initial connect, `on` / `sync` | retry within `reconnect_max_duration_millis` | fail startup | fail startup | +| Initial connect, `async` | retry indefinitely | retry indefinitely | terminal, reported to the error handler | +| Sender that has connected | retry indefinitely | retry indefinitely | retry indefinitely | +| Orphan drain, token provider | retry indefinitely | retry indefinitely | ride out, then set the slot aside | +| Orphan drain, fixed credential | n/a | n/a | set the slot aside | +| Query | fail the query | fail the query | fail the query | + +*Retry indefinitely* means with capped exponential backoff for as long as the +sender is open. No setting bounds it, because store-and-forward must not end a +live producer. The client reports each failed round to the error handler as a +retriable `SECURITY_ERROR`, and in its connection health, so an application +that prefers a hard failure can close the sender itself. A query that fails +names the failure class in its error, and the next query connects again. + +⚠ This is the behaviour of the Java client 1.3.11 and later. The other +clients send fixed credentials only, and treat a `401` or `403` as terminal in +every phase. + ### Ingest endpoint walk (`addr=a:9000,b:9000,...`) | Per-endpoint result | Sender behavior | @@ -389,7 +439,7 @@ and apply back-pressure to the producer rather than dropping data. | TLS session/certificate failure | transport error; try next endpoint | | HTTP upgrade timeout / non-auth transport error | try next endpoint | | `421` with `X-QuestDB-Role: REPLICA` | role reject; try next endpoint | -| `401` / `403` auth failure | **terminal**; do not try later endpoints ⚠ | +| `401` / `403` auth failure | stop the walk; do not try later endpoints ⚠. What follows depends on the phase: see [Authentication failures and token providers](#authentication-failures-and-token-providers) | | durable-ack requested but unsupported | terminal mismatch | | successful write upgrade | bind this endpoint | | all endpoints fail transport | throw / retry per initial/reconnect mode | @@ -417,7 +467,7 @@ Query initial-connect endpoint matrix: | --- | --- | | DNS / TCP / TLS failure | record transport error; try next endpoint | | HTTP upgrade timeout | transport error; try next endpoint | -| HTTP `401` / `403` | **terminal** `QwpAuthFailedException`; do not try later ⚠ | +| HTTP `401` / `403` | Java first retries the same endpoint once after a `401`, with a new token from a token provider; otherwise **terminal** `QwpAuthFailedException`; do not try later ⚠ | | HTTP `421` + role header | role reject; try next endpoint | | upgrade ok but no `SERVER_INFO` before timeout | transport error; try next | | `SERVER_INFO` role ≠ `target` | role reject; try next endpoint | @@ -439,7 +489,9 @@ With `failover=on`: - Bounded by `failover_max_attempts` (default `8`, incl. original) **and** `failover_max_duration_ms` (default `30000`; `0` disables the duration cap). - Backoff: `failover_backoff_initial_ms=50`, `failover_backoff_max_ms=1000`. -- Auth failure during failover reconnect is terminal and reported to the handler. +- Auth failure during failover reconnect fails the query and is reported to the + handler, after the same-endpoint retry with a new token that a `401` earns + with a token provider. The next query connects again. With `failover=off`, a transport failure is reported to the handler with no reconnect/replay. @@ -465,6 +517,9 @@ reconnect/replay. | server down, reconnect duration set, no mode | `reconnect_max_duration_millis=...` | **synchronous** retry; build blocks ⚠ | | server down, async | `initial_connect_retry=async` | build returns; I/O thread retries | | server returns `401`/`403` | any mode | terminal auth failure; no endpoint continuation | +| token provider cannot supply a token | `off` | build throws the provider's exception | +| token provider cannot supply a token | `on` / `sync` | retryable failure: retried within `reconnect_max_duration_millis`; permanent failure: build throws | +| token provider cannot supply a token | `async` | build returns; I/O thread retries | | server appears later, any delay | `initial_connect_retry=async` | buffered frames sent and ACKed — the retry loop has no deadline | | server never appears, buffer fills | async + `sf_max_total_bytes` reached | `append()` blocks, then throws after `sf_append_deadline_millis`; the sender itself stays alive and keeps retrying | @@ -555,5 +610,14 @@ source states the contract directly: > converts it into the durable-ack capability-gap budget. Neither bounds this > loop's steady-state reconnect. -`QwpAuthFailedException` and `WebSocketUpgradeException` raised inside the loop -are terminal across all endpoints. Everything else is retried. +Each connect attempt walks the endpoints in `addr`. A `QwpAuthFailedException` +ends the walk at once, because every endpoint would reject the same +credential. A `WebSocketUpgradeException`, a non-101 upgrade response without +a role hint, does not: the walk tries the remaining endpoints, and raises it +only when none of them accepts the connection. When another endpoint +role-rejected the upgrade in the same walk, the walk raises a role mismatch +instead, which the loop retries. Raised inside the loop, both exceptions are +terminal for a sender that has not connected yet, and for an orphan drainer, +which first rides out repeated `401` or `403` responses when its credential +comes from a token provider. A sender that has connected retries them, like +everything else, and reports each one to its error handler. diff --git a/documentation/connect/wire-protocols/qwp-egress-websocket.md b/documentation/connect/wire-protocols/qwp-egress-websocket.md index 53d66107b..056ccd4fc 100644 --- a/documentation/connect/wire-protocols/qwp-egress-websocket.md +++ b/documentation/connect/wire-protocols/qwp-egress-websocket.md @@ -796,8 +796,11 @@ section of the connect string reference. The shared failover primitives Key behaviors: -- Authentication errors are terminal at any host; the reconnect loop does not - continue past them. +- An authentication error at any host fails the query; the reconnect loop does + not continue past it. A Java client with a token provider first retries the + same host once after a `401`, with a new token, and its next query connects + again. See + [Authentication failures and token providers](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-failures-and-token-providers). - A `CANCEL` acknowledged with `QUERY_ERROR(CANCELLED)` routes through the normal error path, not the transport-error path, so it never triggers failover. diff --git a/documentation/connect/wire-protocols/qwp-ingress-websocket.md b/documentation/connect/wire-protocols/qwp-ingress-websocket.md index 1eeee6101..8c0716ae7 100644 --- a/documentation/connect/wire-protocols/qwp-ingress-websocket.md +++ b/documentation/connect/wire-protocols/qwp-ingress-websocket.md @@ -148,6 +148,13 @@ Supported methods: A failed authentication results in a `401` or `403` HTTP response before the WebSocket connection is established. No QWP-level auth handshake exists. +The credential is checked only on the upgrade request: QuestDB never +authenticates an established connection again, so a connection stays open +when its token expires. A client with an expiring token must present a valid +one on its next connect, such as a reconnect or a failover. The +[Java client](/docs/connect/clients/java/#token-providers) can request and +renew tokens with a token provider for this. + ## Client lifecycle The end-to-end shape of a QWP client session, before the encoding details: @@ -1167,8 +1174,13 @@ Key behaviors: every host's zone tier is equivalent and selection is based on health state only. The `zone=` connect-string key is accepted but silently ignored, so a connect string shared with egress clients works unchanged on ingress. -- **Authentication errors are terminal** at any host (`401`/`403`). The - reconnect loop does not continue past them. +- **Authentication errors end the endpoint walk** at any host (`401`/`403`): + the client does not try the other hosts. Startup fails. In most clients, + the reconnect loop does not continue past them either. A Java sender that + has connected keeps retrying instead, and a Java client with a token + provider first retries the same host once after a `401`, with a new token. + See + [Authentication failures and token providers](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-failures-and-token-providers). - **`421 + X-QuestDB-Role`** is a role reject: transient if the role is `PRIMARY_CATCHUP`, topology-level otherwise. - **All other upgrade errors are transient** and feed into the reconnect loop, diff --git a/documentation/deployment/azure.md b/documentation/deployment/azure.md index 846726fce..533a22469 100644 --- a/documentation/deployment/azure.md +++ b/documentation/deployment/azure.md @@ -365,6 +365,10 @@ QuestDB Enterprise adds production features for Azure: For EntraID integration, see the [Microsoft EntraID OIDC guide](/docs/security/oidc/#microsoft-entraid). +Applications that run on Azure can authenticate as a managed identity or a +service principal, without a user. The Java client requests and renews their +Entra ID tokens with `token_provider=azure`. See +[Managed identities and service principals](/docs/security/oidc/#managed-identities-and-service-principals). See [Enterprise Quick Start](/docs/getting-started/enterprise-quick-start/) for setup. diff --git a/documentation/high-availability/client-failover/concepts.md b/documentation/high-availability/client-failover/concepts.md index be95b64a9..2d63d04f1 100644 --- a/documentation/high-availability/client-failover/concepts.md +++ b/documentation/high-availability/client-failover/concepts.md @@ -238,6 +238,14 @@ identically across the cluster, so a credential failure against one node is a credential failure against all of them. Retrying would spam every peer's audit log without recovering. +The Java client relaxes this in two cases. With a token provider, a `401` +first earns one retry of the same host with a new token. A Java sender that +has connected before also treats a rejected credential like an outage: it +keeps retrying with backoff, buffering rows, and reports each failed round to +its error handler, so ingestion resumes once QuestDB accepts the credential +again, such as after a token renewal. See +[Authentication failures and token providers](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-failures-and-token-providers). + If your deployment has per-host credentials, that is unsupported and outside the failover model — split the workload into one connect string per credential. diff --git a/documentation/operations/upgrade.md b/documentation/operations/upgrade.md index 60f21f07a..d6d568c3b 100644 --- a/documentation/operations/upgrade.md +++ b/documentation/operations/upgrade.md @@ -101,6 +101,53 @@ Others, such as accounts that triggered role switches through `SYSTEM ADMIN` on [Failover operator](/docs/security/rbac/#failover-operator) and [Database administrator](/docs/security/rbac/#database-administrator). +### Authentication changes in 4.1.0 + +QuestDB Enterprise 4.1.0 checks credentials more strictly. Before you upgrade, +check how these changes affect your users and clients: + +- **HTTP sessions end with their credential.** A request that carries a + session cookie and a credential that QuestDB rejects fails with + `401 Unauthorized`, and ends the session, instead of being served from the + session. A session also ends when its password changes, its REST token is + dropped or expires, or its user is disabled or dropped. This applies to + passwords and REST tokens, not only to OIDC. See + [HTTP sessions](/docs/security/rbac/#http-sessions). +- **Expired OIDC tokens are rejected.** With + `acl.oidc.groups.encoded.in.token=true`, QuestDB rejects a token that has no + `exp` claim, or whose `exp` has passed, with 60 seconds of allowed clock + skew. Earlier versions never checked the expiry, so a token kept working + after it expired. A client that sends a fixed token, such as + `token=` in a connect string, stops working once that token + expires, and an orphan drainer that presents an expired token sets its + store-and-forward slot aside, and reports data loss, instead of draining + it. Such a client must switch to a new token before the old one expires, + or use a + [token provider](/docs/connect/clients/java/#token-providers) in the Java + client. See [Token validation](/docs/security/oidc/#token-validation). +- **OIDC tokens must come from the expected issuer.** With + `acl.oidc.groups.encoded.in.token=true`, QuestDB rejects a token whose `iss` + claim is missing or is not the expected issuer, and refuses to start without + an expected issuer, such as with `acl.oidc.host` and no `acl.oidc.issuer`. + With Microsoft Entra ID, v1.0 access tokens fail the check. See + [Token validation](/docs/security/oidc/#token-validation). +- **Web Console users need a refresh token.** The session no longer keeps a + Web Console logged in after its token expires. Without a refresh token, the + user is logged out at that point. With Microsoft Entra ID, include + `offline_access` in `acl.oidc.scope`. See + [Web Console token renewal](/docs/security/oidc/#web-console-token-renewal). +- **Claim lists need every node on 4.1.0.** `acl.oidc.sub.claim` and + `acl.oidc.groups.claim` accept comma-separated lists of claim names. Earlier + versions read a list as one claim name, and reject every OIDC login, so in a + replicated cluster, upgrade every node before you set a list. See + [Fallback claim lists](/docs/security/oidc/#fallback-claim-lists). +- **Stricter claims.** A `null` or empty claim counts as missing, so a login + that earlier versions accepted with the principal `null`, or with a group + named `null`, is rejected. QuestDB also refuses to start with an empty + `acl.oidc.sub.claim`, or with a claim listed in both settings: earlier + versions started with them, then rejected every OIDC login. See + [Startup validation](/docs/security/oidc/#startup-validation). + ### QuestDB Enterprise BYOC For QuestDB Enterprise BYOC customers, QuestDB manages the upgrade process. The diff --git a/documentation/security/oidc.mdx b/documentation/security/oidc.mdx index fb10a470f..82bda08a3 100644 --- a/documentation/security/oidc.mdx +++ b/documentation/security/oidc.mdx @@ -1,6 +1,6 @@ --- title: OpenID Connect (OIDC) Integration -description: Configure OpenID Connect (OIDC) integration with external Identity Providers for SSO authentication in QuestDB Enterprise Web Console. +description: Set up OpenID Connect (OIDC) in QuestDB Enterprise for Web Console SSO, service authentication with Entra ID managed identities, token validation, and troubleshooting. --- import Screenshot from "@theme/Screenshot"; @@ -21,6 +21,19 @@ Identity Provider. Specific installation steps depend on the type of the provider. +:::warning QuestDB Enterprise 4.1.0 + +QuestDB Enterprise 4.1.0 checks tokens and HTTP sessions more strictly. With +`acl.oidc.groups.encoded.in.token=true`, it rejects a token that has expired +or whose issuer is not the expected one, and refuses to start with +`acl.oidc.host` unless `acl.oidc.issuer` is set. A request whose token +QuestDB rejects fails with `401 Unauthorized`, even with a valid session +cookie. See +[Authentication changes in 4.1.0](/docs/operations/upgrade/#authentication-changes-in-410) +before you upgrade. + +::: + ## Architecture overview Altogether, the architecture appears as such: @@ -580,6 +593,14 @@ with Sender.from_conf(conf) as sender: sender.dataframe(df, table_name="foo", at="ts") ``` +A token that a client requests itself expires, and the client keeps sending it +unchanged. A client that runs longer than its token lives must switch to a new +token before QuestDB stops accepting the old one, as described in +[Sessions and token expiry](#sessions-and-token-expiry). The +[Java client](/docs/connect/clients/java/#token-providers) can request and +renew its tokens itself with a token provider. For Azure services, see +[Managed identities and service principals](#managed-identities-and-service-principals). + ## OIDC for the PGWire endpoint If the @@ -668,10 +689,9 @@ message. If we take the example used earlier, we will see that the message contains a claim called `groups`. This name is configurable in QuestDB. -If the groups claim is missing or it is an empty list, the user cannot access -the database. - -Although the user is authenticated, they have no permissions at all. +If the groups claim is missing or holds no group, QuestDB rejects the login. +To read the principal and the groups from different claims for different +kinds of tokens, see [User and group claims](#user-and-group-claims). The user has to have at least the `HTTP` permission to be able to successfully login via the [Web Console](/docs/getting-started/web-console/overview/). @@ -697,6 +717,10 @@ There may be a slight delay due to the User Info Cache. QuestDB will use the cached information until it becomes stale, and gets updated. +With `acl.oidc.groups.encoded.in.token=true`, QuestDB reads the groups from +the token itself, so a change takes effect when the client presents a new +token. + ::: The same stands for changes made to the user's status within the Identity @@ -715,6 +739,291 @@ by QuestDB. They could be displayed in the [Web Console](/docs/getting-started/web-console/overview/), or appear in the logs, for example. +## User and group claims + +QuestDB takes the principal of an external user from the claim that +[`acl.oidc.sub.claim`](/docs/configuration/oidc/#acloidcsubclaim) names, `sub` +by default, and the groups of the user from the claim that +[`acl.oidc.groups.claim`](/docs/configuration/oidc/#acloidcgroupsclaim) names. +It reads both from the User Info response or, with +`acl.oidc.groups.encoded.in.token=true`, from the token. + +The principal identifies the external user: `current_user()` returns it, the +Web Console displays it, and the server log records it. QuestDB keeps the +groups of the latest login of each principal, so users who share a principal +share their permissions. Pick a claim whose value the Identity Provider gives +to one user or service only, such as a username or an object ID, and avoid +display names, such as the `name` claim. + +### Fallback claim lists + +In QuestDB Enterprise 4.1.0 and later, both settings accept a comma-separated +list of claim names in priority order, so that one configuration accepts +tokens of different shapes. With Microsoft Entra ID, for example, the ID +tokens of users carry `preferred_username` and `groups`, while the app-only +tokens of +[managed identities and service principals](#managed-identities-and-service-principals) +carry only `oid` and `roles`: + +```ini title="server.conf" +# the ID tokens of users carry preferred_username, app-only tokens only oid +acl.oidc.sub.claim=preferred_username,oid +# app-only tokens carry roles, the ID tokens of users carry groups +acl.oidc.groups.claim=roles,groups +``` + +QuestDB picks the claims as follows: + +- **The list order decides.** QuestDB takes the first claim on the list that + carries a value, wherever the claims appear in the token or the User Info + response. +- **Missing, `null`, and empty claims fall through.** A claim that is missing, + `null`, or an empty string falls through to the next claim on the list, and + so does a groups claim that holds no group name, such as an empty array. + QuestDB skips `null` and empty group names, and reads the string `"null"` as + `null`. +- **Groups are not combined.** QuestDB takes every group from one claim. With + `roles,groups`, a user whose token carries a `roles` claim gets only the + QuestDB groups that the roles map to. +- **Claim shapes.** Use a string for the principal, and an array of strings + or a single string for the groups. QuestDB also accepts numbers and + booleans as their text values. Unsupported containers, such as an array in + a principal claim or an object in a listed claim, fail the login even when + a claim earlier on the list carries a value. +- **Only top-level claims count.** QuestDB ignores nested claims, and the + claims that the lists do not name, whatever their shape. + +When none of the listed claims carries a value, QuestDB rejects the login, as +described in +[Missing principal or groups claims](#missing-principal-or-groups-claims). + +:::warning Replicated clusters + +Versions before 4.1.0 read the whole value of each setting as one claim name, +and reject every OIDC login when it holds a list. Upgrade every node of a +replicated cluster before you set a list. + +::: + +### Startup validation + +With OIDC enabled, QuestDB refuses to start when either setting is empty, +when a setting lists a claim twice, or when both settings list the same claim. +An empty `acl.oidc.sub.claim` does not fall back to the default `sub`. The +error names the setting and the claim: + +```text +required property is not set: acl.oidc.groups.claim +claim is listed more than once in acl.oidc.sub.claim [claim=oid] +claim cannot be listed in both acl.oidc.sub.claim and acl.oidc.groups.claim [claim=roles] +``` + +QuestDB trims the spaces around claim names, and skips empty entries. + +## Token validation + +With `acl.oidc.groups.encoded.in.token=true`, QuestDB validates tokens itself, +instead of asking the User Info endpoint of the provider. This applies to +every JWT that QuestDB accepts in this mode: the ID tokens of Web Console +users, and the tokens of other clients, such as the app-only access tokens of +Microsoft Entra ID. QuestDB Enterprise 4.1.0 and later accept a token only +when: + +- its signature matches a signing key from the JWKS endpoint of the provider; +- it carries the `sub`, `aud`, and `exp` claims, and the `iss` claim unless + issuer validation is disabled; +- it has not expired, with 60 seconds of allowed clock skew; +- its `iss` claim is a single string that equals the + [expected issuer](#the-expected-issuer); +- its `aud` claim contains + [`acl.oidc.audience`](/docs/configuration/oidc/#acloidcaudience), the client + ID by default; +- its `sub` claim is not empty, whatever `acl.oidc.sub.claim` names. + +QuestDB caches the result for +[`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl), 30 seconds +by default, so a token can stay accepted for that long after it expires, on +top of the clock skew. In User Info mode, the default, QuestDB checks none of +this itself: the User Info endpoint of the provider accepts or rejects the +token. + +### The expected issuer + +The expected issuer is the `issuer` field of the discovery document at +[`acl.oidc.configuration.url`](/docs/configuration/oidc/#acloidcconfigurationurl). +QuestDB does not derive it from the URL. To pin it, set +[`acl.oidc.issuer`](/docs/configuration/oidc/#acloidcissuer), which takes +precedence over the discovery document. QuestDB compares the issuer exactly +and case-sensitively, including the path and any trailing slash. + +Set `acl.oidc.issuer` in these cases: + +- QuestDB uses `acl.oidc.host` and the endpoint settings instead of a + discovery document. In token mode, QuestDB refuses to start without an + expected issuer. +- The discovery document names a template instead of an issuer, as the + multi-tenant endpoints of Microsoft Entra ID do. Set the issuer of your + tenant, never a template or a wildcard. + +The check stops QuestDB from accepting the tokens of another tenant, when a +provider signs the tokens of several tenants with the same keys. Earlier +versions don't check the `iss` claim, so they accept a token of any issuer +that a trusted key signs for the expected audience. + +With Microsoft Entra ID, the issuer depends on the version of the token: +`https://login.microsoftonline.com//v2.0` for v2.0 tokens, which the +v2.0 discovery document names, and `https://sts.windows.net//` for +v1.0 tokens. With the v2.0 discovery document, Web Console users sign in +through the v2.0 endpoints, so their ID tokens are v2.0 tokens. The access +tokens that services request are v1.0 tokens, unless the QuestDB application +asks for v2.0 tokens, as described in +[Managed identities and service principals](#managed-identities-and-service-principals). + +For a provider whose tokens carry no `iss` claim, disable the check with +[`acl.oidc.issuer.validation.enabled=false`](/docs/configuration/oidc/#acloidcissuervalidationenabled). +QuestDB then accepts the tokens of any issuer that the provider signs for the +expected audience, and logs an advisory at startup. + +## Sessions and token expiry + +QuestDB checks a token when a request or a new connection presents it. An +established connection, such as a QWP or PGWire connection, stays open after +its token expires. Every new connection and reconnect presents a token again, +so a client that runs longer than its tokens live must present new ones, as +the [Java client does with a token provider](/docs/connect/clients/java/#token-providers). + +The Web Console opens an HTTP session when the user logs in, identified by the +`qdb_session` cookie, and also sends its token with every request. In QuestDB +Enterprise 4.1.0 and later, a request that carries both the cookie and a token +is authenticated with the token. When QuestDB rejects the token, the request +fails with `401 Unauthorized`, and the session ends. Earlier versions serve +such a request from the session, so an expired or rejected token keeps working +for as long as the cookie does. + +QuestDB also rejects a token that it cannot check, because the Identity +Provider is unreachable or answers with an error, once `acl.oidc.cache.ttl` +has passed since the provider last checked it. In User Info mode, this affects +every token. In token mode, QuestDB keeps the signing keys that it has +downloaded, so it affects only a token signed with a key that QuestDB has not +downloaded yet. An outage of the provider can therefore stop the queries of +Web Console users, or log them out, as described below. A WebSocket +connection that a browser opened with the session cookie stays open when the +session ends. + +For how sessions treat passwords and REST tokens, see +[HTTP sessions](/docs/security/rbac/#http-sessions). + +### Web Console token renewal + +The Web Console sends a token with every request: the access token in User +Info mode, or the ID token with `acl.oidc.groups.encoded.in.token=true`. It +renews its tokens with the refresh token that the provider issued at login. +Without a refresh token, the user is logged out once QuestDB stops accepting +the token of the login. Some providers, such as Microsoft Entra ID, issue a +refresh token only for the `offline_access` scope, which +[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) must then include. + +In QuestDB Enterprise 4.1.0 and later, the Web Console renews its tokens as +follows: + +- **When.** Before a request, when the token expires within 30 seconds. The + Web Console reads the expiry of an ID token from its `exp` claim, and the + expiry of an access token from the `expires_in` field of the token + response. The request waits up to 10 seconds for the new tokens. +- **ID tokens are required.** With `acl.oidc.groups.encoded.in.token=true`, + every token response must include an ID token. Without one, the Web Console + logs the user out with **Missing ID token**. Configure the provider to + return an ID token for the refresh token grant, and include `openid` in + `acl.oidc.scope`. +- **A rejected token.** When QuestDB answers `401 Unauthorized`, for example + because the clock of the browser is behind, the Web Console renews its + tokens and sends the request again, once. It logs the user out when QuestDB + rejects the new token too. +- **A refused renewal.** When the provider refuses to renew the tokens, for + example because the user is disabled or the refresh token has expired, the + Web Console logs the user out, and the login page shows the reason that the + provider gives. +- **A failed renewal.** When the provider does not answer within 10 seconds, + cannot be reached, or answers with `429` or a `5xx` status, the Web Console + keeps the user logged in, and shows a warning with a **Sign in again** + button. While the current token is valid, requests use it, and later + requests retry the renewal, with a delay that grows from 5 to 30 seconds + between attempts. Once the token has expired, the Web Console does not send + it: a query fails with + `Could not renew your SSO session; the request was not executed`. Run it + again after a short wait to retry the renewal, or sign in again. + +## Troubleshooting + +### Missing principal or groups claims + +When the user information carries none of the listed principal claims, or +none of the listed groups claims, QuestDB rejects the login: HTTP clients +receive `401 Unauthorized`, and PGWire clients `invalid username/password`. +The server log shows both lists and the user information that QuestDB +received: + +```text +Failed to find required claims [subClaims=[preferred_username,oid], groupsClaims=[roles,groups], userInfo={...}] +``` + +Compare the claims in `userInfo` with the lists. A provider leaves out the +claims that no requested scope covers: Microsoft Entra ID, for example, sends +`preferred_username` only for the `profile` scope. Versions before 4.1.0 log +the two claim names instead: +`Failed to find required claims [claims=sub,groups userInfo=...]`. + +### Token validation errors + +In token mode, QuestDB rejects a token that fails +[token validation](#token-validation) the same way, and the server log gives +the reason: + +```text +Request failed [errorMsg=Failed to decode JWT token Error(InvalidIssuer)] +Request failed [errorMsg=Failed to decode JWT token Error(MissingRequiredClaim("iss"))] +Request failed [errorMsg=Failed to decode JWT token Error(InvalidAudience)] +Request failed [errorMsg=Failed to decode JWT token Error(ExpiredSignature)] +Request failed [errorMsg=Failed to decode JWT token Error(MissingRequiredClaim("exp"))] +``` + +- `InvalidIssuer`: the `iss` claim is not the expected issuer. Compare the + `iss` claim of a token with `acl.oidc.issuer`, or with the `issuer` field of + the discovery document. With Microsoft Entra ID, this usually means a v1.0 + access token, or a token of another tenant. +- `MissingRequiredClaim("iss")`: the token carries no `iss` claim. See + [The expected issuer](#the-expected-issuer). +- `InvalidAudience`: the `aud` claim does not contain `acl.oidc.audience`. + The token was requested for another application or, with Microsoft Entra + ID, it is a v1.0 access token. +- `ExpiredSignature`: the token has expired. The client must present a new + token. For the Web Console, check that it receives a refresh token, as + described in [Web Console token renewal](#web-console-token-renewal). + +### Web Console sign-in errors + +When the Web Console cannot complete a sign-in, the login page shows the +reason. QuestDB logs nothing, because the browser exchanges the authorization +code at the token endpoint of the provider directly: + +- `The identity provider did not respond within 10 seconds`: the token + endpoint did not answer in time. +- `Could not reach the identity provider to complete the sign-in`: the browser + could not call the token endpoint. Usually, the provider does not allow the + origin of the Web Console, such as `https://questdb.example.com:9000`, in its + CORS settings. +- `The identity provider returned an invalid token response`, or + `The identity provider returned an unexpected response (HTTP )`: + the token endpoint answered with something other than a token response, + such as the error page of a proxy. +- **Missing ID token**: with `acl.oidc.groups.encoded.in.token=true`, the + provider returned no ID token. Include `openid` in `acl.oidc.scope`. +- Other messages usually come from the provider, such as the error for a + redirect URI that the provider does not accept. + +The network tab of the developer tools of the browser shows the request to +the token endpoint and the response of the provider. + ## Configuration options For all OIDC-related configuration options of QuestDB, see @@ -724,7 +1033,9 @@ For all OIDC-related configuration options of QuestDB, see ## Active Directory -The following sections are guides for setting up single sign-on (SSO) with various OAuth2 providers. +The following sections are guides for setting up single sign-on (SSO) with +various OAuth2 providers, and for authenticating Azure services with +Microsoft Entra ID. ### PingFederate @@ -1077,6 +1388,10 @@ If all has been wired up well, then login will succeed. This document sets up SSO authentication for the [QuestDB Web Console](/docs/getting-started/web-console/overview/) in [Microsoft EntraID](https://www.microsoft.com/en-gb/security/business/identity-access/microsoft-entra-id), formerly known as Azure AD. +Azure services that authenticate without a user, as a managed identity or a +service principal, build on this setup, as described in +[Microsoft Entra ID managed identities and service principals](#managed-identities-and-service-principals). + :::tip @@ -1260,8 +1575,8 @@ The below should be set in QuestDB's `server.conf`: # enable OIDC acl.oidc.enabled=true -# the claim contains the username or user id -acl.oidc.sub.claim=name +# the claim contains the user's sign-in name, which needs the profile scope +acl.oidc.sub.claim=preferred_username # the claim contains the user's group memberships acl.oidc.groups.claim=groups @@ -1337,6 +1652,141 @@ GRANT SELECT ON table1, table2 to groupName; To test, head to the Web Console and login. If all has been wired up well, then login will succeed, and the user -will have the access granted to them. +will have the access granted to them. If the login fails, the login page or +the server log gives the reason, as described in +[Troubleshooting](#troubleshooting). + +### Microsoft Entra ID managed identities and service principals {#managed-identities-and-service-principals} + +Azure services, such as an ingestion job on a virtual machine or in Azure +Kubernetes Service, can authenticate to QuestDB without a user. They request +an app-only access token from Entra ID, as a managed identity or a service +principal. An app-only token carries no `preferred_username` and no `groups` +claim: it carries the object ID of the service in `oid`, and the app roles +assigned to the service in `roles`. QuestDB Enterprise 4.1.0 and later accept +these tokens next to the ID tokens of users, with +[fallback claim lists](#fallback-claim-lists). + +This setup builds on the QuestDB application and the +[QuestDB configuration](#questdb-configuration) of the +[Microsoft Entra ID guide](#microsoft-entraid) above. QuestDB reads the +principal and the roles of a service from its token, and validates the token +itself, so OIDC must run in token mode, with +`acl.oidc.groups.encoded.in.token=true`, as described in +[Token validation](#token-validation). + +First, configure the QuestDB application in Entra ID: + +1. Under _Expose an API_, set the _Application ID URI_, such as + `api://8de84b90-1ea5-4e41-9e84-dba860aa01a6`. Services request their + tokens for this URI. +2. Under _App roles_, create a role for each kind of access, such as a role + with the value `QuestDB.Ingest`, and set its _Allowed member types_ to + _Applications_. Users then never carry the role, so their tokens keep + their groups, which `roles,groups` would otherwise replace. +3. In the _Manifest_, set `requestedAccessTokenVersion` to `2`, called + `accessTokenAcceptedVersion` in the legacy manifest. The access tokens of + services then carry the application ID in their `aud` claim, and the v2.0 + issuer, which QuestDB expects, as described in + [The expected issuer](#the-expected-issuer). + +Then assign the role to the service. For a service principal, add the role +under _API permissions_ of its own app registration, as an application +permission of the QuestDB application, and grant admin consent. A managed +identity has no app registration: assign the role through Microsoft Graph, +for example with the Azure CLI, using the object IDs of the managed identity +and of the enterprise application of QuestDB, and the ID of the app role: + +```bash title="Assign the QuestDB.Ingest role to a managed identity" +MI="" +APP="" +ROLE="" +az rest --method POST \ + --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$MI/appRoleAssignments" \ + --body "{\"principalId\": \"$MI\", \"resourceId\": \"$APP\", \"appRoleId\": \"$ROLE\"}" +``` + +In `server.conf`, keep the [QuestDB configuration](#questdb-configuration) +of the Entra ID guide, and replace its two claim settings with fallback +lists. These are the settings that the tokens of services depend on: + +```ini title="server.conf" +acl.oidc.enabled=true +# QuestDB validates the tokens itself, and reads the claims from them +acl.oidc.groups.encoded.in.token=true +# the v2.0 discovery document of the tenant names the expected issuer +acl.oidc.configuration.url=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/v2.0/.well-known/openid-configuration +# the application ID, also the expected audience of the tokens +acl.oidc.client.id=8de84b90-1ea5-4e41-9e84-dba860aa01a6 +# users have preferred_username, services only oid +acl.oidc.sub.claim=preferred_username,oid +# services have roles, users have groups +acl.oidc.groups.claim=roles,groups +``` + +Keep the settings that only the Web Console uses, such as +`acl.oidc.redirect.uri` and `acl.oidc.scope`, as they are. + +The principal of a service is then its object ID, which `current_user()` +returns. Map each app role to a QuestDB group, and grant the group the +permissions that the service needs. QWP over WebSocket, which the clients +use for ingestion, requires the `HTTP` endpoint permission: + +```questdb-sql title="Allow the services with the QuestDB.Ingest role to ingest into fx_trades" +CREATE GROUP fxIngest WITH EXTERNAL ALIAS 'QuestDB.Ingest'; +GRANT HTTP TO fxIngest; +GRANT INSERT ON fx_trades TO fxIngest; +``` + +Ingestion cannot create a table or add a column for an external user, so +create the table, with every column that the service writes, in advance, as a +database user such as the built-in admin: + +```questdb-sql title="Create the table that the service ingests into" +CREATE TABLE fx_trades ( + timestamp TIMESTAMP, + symbol SYMBOL, + side SYMBOL, + price DOUBLE, + quantity DOUBLE +) TIMESTAMP(timestamp) PARTITION BY DAY; +``` + +The [Java client](/docs/connect/clients/java/#microsoft-entra-id) requests the +token of the service and renews it by itself, with `token_provider=azure` and +the `questdb-client-azure` artifact. The token provider requires the `wss` +schema, so QuestDB must serve HTTP over +[TLS](/docs/security/tls/#enabling-tls-for-http-server-rest-api): + +```java title="Ingest into fx_trades as the managed identity of the host" +import java.time.Instant; + +import io.questdb.client.QuestDB; +import io.questdb.client.Sender; + +try (QuestDB db = QuestDB.connect( + "wss::addr=questdb.example.com:9000;" + + "token_provider=azure;" + + "azure_resource=api://8de84b90-1ea5-4e41-9e84-dba860aa01a6;" + + "azure_credential=managed_identity;")) { + try (Sender sender = db.borrowSender()) { + sender.table("fx_trades") + .symbol("symbol", "EURUSD") + .symbol("side", "buy") + .doubleColumn("price", 1.0842) + .doubleColumn("quantity", 100000.0) + .at(Instant.now()); + } +} +``` + +Other clients take a fixed token: request it with the Azure Identity library +for the scope `api:///.default`, and create a new client with +a new token before the old one expires. + +QuestDB reads the roles from the token, so a role that you assign to or remove +from a service takes effect when the service presents a new token. Azure +caches the tokens of managed identities for around 24 hours. To cut off a +service at once, revoke the permissions of its QuestDB group.
diff --git a/documentation/security/rbac.md b/documentation/security/rbac.md index 52cefca0c..660cd553f 100644 --- a/documentation/security/rbac.md +++ b/documentation/security/rbac.md @@ -576,6 +576,66 @@ ALTER USER username DROP TOKEN TYPE REST 'token_value_here'; -- Drop specific t Removing all authentication methods (password and tokens) effectively disables the user - they can no longer connect to the database. +### HTTP sessions + +The Web Console, and any HTTP client that sends `session=true`, opens an HTTP +session, identified by the `qdb_session` cookie, as described in +[HTTP sessions](/docs/connect/compatibility/rest-api/#http-sessions) of the +REST API reference. In QuestDB Enterprise 4.1.0 and later, a session ends with +the credential that opened it, or that it checked last, even if the client +sends only the cookie: + +- when the password is changed, even to the same value, or removed; +- when the REST token is dropped or expires; +- when the user or service account is disabled or dropped; +- for the built-in admin, when a configuration reload changes the admin + credentials. + +A request that carries a credential that QuestDB rejects fails with +`401 Unauthorized`, and ends the session. A session reuses the check of a +password for [`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl), +30 seconds by default, but checks a REST token on every request, which keeps +extending a token created with `REFRESH`. Only the requests that QuestDB +authorizes extend a session. A request refused with `401`, with `429`, or with +`403` for a disabled user or a user without the `HTTP` permission does not, +while a query that fails on a missing table permission still does. For OIDC +tokens, see +[Sessions and token expiry](/docs/security/oidc/#sessions-and-token-expiry). + +When a user logs in to the Web Console with a password, the Web Console +creates a REST token for the user, valid for one day and extended while in +use, and sends the token with every later request. A password change +therefore ends the session that the password opened, but not the Web Console +access: the next request authenticates with the token, and opens a new +session. The same holds for the built-in admin. To end the Web Console access +of a user, drop the REST tokens of the user, which works for the built-in +admin too, or disable or drop the user: + +```questdb-sql title="End the Web Console logins of a user" +ALTER USER fx_analyst DROP TOKEN TYPE REST; +``` + +This also drops the REST tokens that the other clients of the user use. + +:::caution Replicated clusters + +The node that disables or drops a user ends the sessions of the user at once. +A replica ends them when they are next used, so a session on a replica that is +not used while the user is disabled survives if the user is enabled again. The REST token of the Web Console lives in the +memory of the node that issued it, and is not replicated, so +`DROP TOKEN TYPE REST` removes it only on the node that runs the statement. +Run the statement on every node that the user logged in to, the primary first: +while the user has persistent REST tokens, a replica refuses it until the drop +on the primary has replicated. For the same reason, `DROP USER` leaves the +tokens that a replica issued to the user. Such a token authenticates a new +user that you create with the same name on that replica, and so does a session +that the replica opened with the token, unless the session was used while the +user did not exist. After you create a user with the name of a dropped one, +drop the REST tokens of the user on every replica that the dropped user logged +in to. + +::: + ### Viewing information ```questdb-sql