diff --git a/README.md b/README.md index b3870a783a..e85b133ecb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ docker-compose up ## Contributing -You'll need to sign a quick CLA. +You will need to sign a quick CLA. Pretty standard, [check it out](https://cla-assistant.io/questdb/documentation). diff --git a/documentation/changelog.mdx b/documentation/changelog.mdx index 9046996dd9..3facae9c5c 100644 --- a/documentation/changelog.mdx +++ b/documentation/changelog.mdx @@ -12,17 +12,56 @@ This page tracks significant updates to the QuestDB documentation. ### New +- [OIDC device flow](/docs/security/oidc/device-flow/) - Human sign-in from Java, Python, Rust, C, and C++, with provider discovery, checked ingestion examples, silent refresh, reauthentication, and optional token persistence +- [User and group claims](/docs/security/oidc/#user-and-group-claims) - Fallback claim lists of QuestDB Enterprise 4.0.2, how QuestDB picks a claim, and the startup checks +- [Token validation](/docs/security/oidc/#token-validation) - What QuestDB checks in a token, the expected issuer, and when QuestDB stops accepting an expired token +- [Web Console token renewal](/docs/security/oidc/#web-console-token-renewal) - When the Web Console renews its tokens, the ID-token requirement in token mode, how transient renewal failures pause queries without logging users out, and which errors end the session +- [Troubleshooting OIDC logins](/docs/security/oidc/#troubleshooting-oidc-logins) - The log lines of rejected logins and what they mean +- [Upgrade to 4.0.2](/docs/security/oidc/#upgrade-to-402) - How earlier versions differ, and what to check when you upgrade +- [Where QuestDB reads the user information](/docs/security/oidc/#where-questdb-reads-the-user-information) - The User Info endpoint compared with reading the claims from the token +- [Group changes, sessions, and revoking access](/docs/security/oidc/#group-changes-sessions-and-revoking-access) - When a change in the identity provider takes effect, and how to cut off access +- [Tables created by external users](/docs/security/oidc/#tables-created-by-external-users) - Create tables in advance, or with `OWNED BY` and a QuestDB group of the user +- [Token lifetime](/docs/security/oidc/#token-lifetime) - Switch a long-running client to a new token without losing rows +- [Rotating tokens](/docs/connect/clients/java/#rotating-tokens) - The Java client requests and renews bearer tokens with `token_provider=azure` or a `RefreshingTokenProvider` +- [Connection health](/docs/connect/clients/java/#connection-health) - The Java client reports whether it reaches the server, and its last failure - [Databento connector](/docs/integrations/other/databento/) - The Databento page now covers the QuestDB Databento connector, a standalone process that streams Databento Live feeds and runs Historical backfills into QuestDB over QWP, replacing the earlier Python client walkthrough. Covers TOML configuration with multiple datasets per process, the default table and column mapping for each schema, `TIMESTAMP_NS` timestamps and order book arrays, checkpointed at-least-once recovery with a backfill procedure for expired replay, and Prometheus metrics. The page moves to the new Connectors group in the Connect section - [ALTER LIVE VIEW RESUME WAL](/docs/query/sql/alter-live-view-resume-wal/) - New reference page for resuming a live view whose own WAL was suspended after an error, and how to find suspended live views through `wal_tables()` ### Reference +- [`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint) - Documented the optional device endpoint, discovery-document behavior, and client discovery through `/settings` +- [`acl.oidc.sub.claim`](/docs/configuration/oidc/#acloidcsubclaim) and [`acl.oidc.groups.claim`](/docs/configuration/oidc/#acloidcgroupsclaim) - Documented fallback claim lists and their startup validation +- [`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.pkce.required`](/docs/configuration/oidc/#acloidcpkcerequired) - Corrected the name of the property, previously documented as `acl.oidc.pkce.enabled` +- [`acl.oidc.state.required`](/docs/configuration/oidc/#acloidcstaterequired) and [`acl.oidc.public.keys.expiry`](/docs/configuration/oidc/#acloidcpublickeysexpiry) - Documented both properties, including JWKS refresh timing, outage delays, and retained signing keys +- [`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl) - Documented what QuestDB caches, distinguished provider checks from JWT revalidation in token mode, and explained 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 +- [Token providers](/docs/connect/clients/connect-string/#token-providers) - The `token_provider` and `azure_*` keys of the Java client - 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 - Added the `checkpoint_seal_failures` column to [`live_views()`](/docs/query/functions/meta/#live_views) and corrected the `lag_seqtxn`, `last_processed_seqtxn`, `lag_micros`, and `writer_stall_micros` descriptions - Corrected the defaults of [`mat.view.refresh.worker.count`](/docs/configuration/materialized-views/#matviewrefreshworkercount) and [`wal.apply.worker.count`](/docs/configuration/wal/#walapplyworkercount), and documented that `mat.view.refresh.worker.count=0` disables materialized view refresh +### Guides + +- [Microsoft Entra ID](/docs/security/oidc/entra-id/) - Moved to its own page, and added app-only tokens for Azure managed identities and service principals +- [PingFederate](/docs/security/oidc/pingfederate/) - Moved to its own page, added the QuestDB configuration with ROPC disabled unless explicitly enabled, and corrected the directory that PingFederate reads users from + ### Updated +- [Java](/docs/connect/clients/java/#oidc-device-flow), [Python](/docs/connect/clients/python/#oidc-device-flow), [Rust](/docs/connect/clients/rust/#oidc-device-flow), and [C/C++](/docs/connect/clients/c-and-cpp/#oidc-device-flow) clients - Added device-flow provider integration and distinguished silent renewal from fixed-token replacement +- [OpenID Connect (OIDC)](/docs/security/oidc/) - Split into an overview and provider guides, with a minimal configuration and an upgrade warning for QuestDB Enterprise 4.0.2 +- [Mapping user permissions](/docs/security/oidc/#mapping-user-permissions) - Corrected that a login without groups is rejected, and added the endpoint permission of each interface +- [Non-interactive clients](/docs/security/oidc/#non-interactive-clients) - Which token each client sends, with client credentials and password grant examples over QWP +- [OIDC for PGWire](/docs/security/oidc/#oidc-for-the-pgwire-endpoint) - Require certificate and hostname verification in the bearer-token example, with a trusted CA certificate bundle +- [Managing authentication](/docs/security/rbac/#managing-authentication) - HTTP sessions of QuestDB Enterprise 4.0.2 end with the credential that opened them +- [Java client](/docs/connect/clients/java/#ingestion-errors) - Corrected the error policies, and that the `QuestDB` handle accepts an error handler +- [Java token providers](/docs/connect/clients/java/#rotating-tokens) - Clarified certificate credential configuration, query authentication exceptions, token-wait budgets, which `401` responses trigger refresh, and which connection-string settings must be redacted before logging +- [Client failover](/docs/high-availability/client-failover/concepts/#authentication-is-cluster-wide) - Distinguished authentication rejection during startup, established Java ingestion, query reconnects, and orphan recovery +- [Connection strings](/docs/connect/clients/connect-string/#reconnect-keys) - Corrected pooled initial-retry settings and clarified when lazy pools begin connecting in Java, C/C++, Python, and Rust +- [Python](/docs/connect/clients/python/#the-pool), [C/C++](/docs/connect/clients/c-and-cpp/#connecting), and [Rust](/docs/connect/clients/rust/#connecting) clients - Corrected that the pool connects when it is created, unless `lazy_connect=on` +- [Python client](/docs/connect/clients/python/#flushing-and-acknowledgements) - Corrected that a terminal server rejection raises from `flush(wait=True)` +- [Client token rotation](/docs/security/oidc/#token-lifetime) - Corrected how the Python, C/C++, .NET, Go, and Rust clients switch to a new token +- [`CREATE VIEW`](/docs/query/sql/create-view/#owned-by-enterprise), [`CREATE LIVE VIEW`](/docs/query/sql/create-live-view/#owned-by-clause), and [`CREATE MATERIALIZED VIEW`](/docs/query/sql/create-mat-view/#owned-by-clause) - Documented the owner that an external user must name - [Live views](/docs/concepts/live-views/) - Corrected the [supported window functions](/docs/concepts/live-views/#supported-window-functions) to match QuestDB 10.0.1: the statistical functions, EMA, and VWEMA run only in anchored windows, `ksum` does not support `RANGE` frames, `lag` needs a bounded frame or an anchor, and `VARCHAR`, `UUID`, `LONG256`, `DECIMAL`, `BINARY`, and array partition keys are rejected. Added a [rolling standard deviation](/docs/concepts/live-views/#rolling-standard-deviation) recipe for Bollinger bands, rewrote [Freshness](/docs/concepts/live-views/#freshness) to reflect that interval-filtered reads, aggregates, and `ASOF JOIN` see unflushed rows while `LATEST ON` does not, and added the `UPDATE` and `REBASE WAL` invalidation triggers, base WAL retention, and replica freshness. Documented that an out-of-order base row rewrites view rows already emitted, that `view_status` stays `active` while the view's own WAL is suspended, and what to change in `SHOW CREATE LIVE VIEW` output before replaying it - [CREATE LIVE VIEW](/docs/query/sql/create-live-view/) - Fixed the `OWNED BY` example, which requires a parenthesized query, documented the designated-timestamp projection requirement and the remaining anchored-window rules, and expanded the error reference with verified messages. Added a grammar for anchored windows and [examples](/docs/query/sql/create-live-view/#examples) of a `RANGE` frame with a `WHERE` filter and an anchored session window - [Live view configuration](/docs/configuration/live-views/) - Corrected the descriptions of the in-memory buffer growth budget, partition compaction threshold, flush retry budgets, and refresh memory limit diff --git a/documentation/configuration/http-server.md b/documentation/configuration/http-server.md index cb8971ee49..c736ba003b 100644 --- a/documentation/configuration/http-server.md +++ b/documentation/configuration/http-server.md @@ -81,6 +81,19 @@ 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, the `qdb_session` cookie, by sending `session=true`, as the Web +Console does at login. Every request that QuestDB authorizes with the session +restarts the timer, so a session in use does not expire. In QuestDB +Enterprise, a session also ends when the credential that opened it is +revoked, as described in +[Managing authentication](/docs/security/rbac/#managing-authentication). + ### http.user - **Default**: N/A diff --git a/documentation/configuration/oidc.md b/documentation/configuration/oidc.md index e0c5932bdc..ca7b544762 100644 --- a/documentation/configuration/oidc.md +++ b/documentation/configuration/oidc.md @@ -1,6 +1,6 @@ --- title: OpenID Connect (OIDC) -description: Configuration settings for OpenID Connect integration in QuestDB Enterprise. +description: "Reference for the acl.oidc.* settings of QuestDB Enterprise: provider endpoints, TLS, PKCE, ROPC, token audience and issuer, and claim lists." --- :::note @@ -14,7 +14,9 @@ Management. The database can be integrated with any OAuth2/OIDC Identity Provider (IdP). For detailed information about OIDC, see the -[OpenID Connect (OIDC) integration guide](/docs/security/oidc). +[OpenID Connect (OIDC) integration guide](/docs/security/oidc). For complete +provider setups, see the [Microsoft Entra ID](/docs/security/oidc/entra-id/) +and [PingFederate](/docs/security/oidc/pingfederate/) guides. ## General @@ -26,6 +28,16 @@ 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 is this value, or a list that contains it. The setting takes a +single value. Keep the default: the ID tokens that the Web Console sends carry +the client ID, so another value makes Web Console logins fail. See +[Token validation](/docs/security/oidc/#token-validation). + +With `acl.oidc.groups.encoded.in.token=false`, QuestDB does not use this +setting. It treats the access token as opaque, even when it is a JWT, and +leaves its validation to the provider's User Info endpoint. + ### acl.oidc.client.id - **Default**: none @@ -42,6 +54,17 @@ enabled. URL where the OpenID Provider's configuration information can be loaded in JSON format. Should always end with `/.well-known/openid-configuration`. +QuestDB downloads the document when it starts, and takes the provider's +endpoints from it. Since QuestDB Enterprise 4.0.2, it also takes the expected +issuer of the tokens from the `issuer` field of the document, unless +[`acl.oidc.issuer`](#acloidcissuer) is set. QuestDB does not start when it +cannot download or parse the document. The error starts with +`Unable to download OIDC provider configuration from` or +`Unable to parse OIDC provider configuration`. To let QuestDB start while the +provider is unreachable, set [`acl.oidc.host`](#acloidchost) and the +[endpoint settings](#endpoints) instead. QuestDB refuses to start when both +this setting and `acl.oidc.host` are set. + ### acl.oidc.enabled - **Default**: `false` @@ -55,8 +78,17 @@ configuration options must also be set. - **Default**: none - **Reloadable**: no -OIDC provider hostname. Required when OIDC is enabled, unless the OIDC -configuration URL is set. +OIDC provider hostname. Required when OIDC is enabled, unless +[`acl.oidc.configuration.url`](#acloidcconfigurationurl) is set. The two +settings cannot be combined: QuestDB refuses to start when both are set. With +`acl.oidc.host`, QuestDB starts even when the provider is unreachable, and only +OIDC logins fail until the provider is reachable again. + +Without a discovery document, QuestDB has no issuer of the tokens to read. With +`acl.oidc.groups.encoded.in.token=true`, QuestDB Enterprise 4.0.2 and later +therefore refuse to start unless [`acl.oidc.issuer`](#acloidcissuer) is set, or +[`acl.oidc.issuer.validation.enabled`](#acloidcissuervalidationenabled) is +`false`. ### acl.oidc.http.timeout @@ -65,6 +97,67 @@ configuration URL is set. OIDC provider HTTP request timeout in milliseconds. +### acl.oidc.issuer + +- **Default**: none (the `issuer` of the discovery document) +- **Reloadable**: no + +The issuer that the `iss` claim of a token must equal when +`acl.oidc.groups.encoded.in.token` is `true`, in QuestDB Enterprise 4.0.2 and +later. The comparison is exact and case-sensitive, and includes the path and +any trailing slash. 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. + +Set it when: + +- QuestDB uses [`acl.oidc.host`](#acloidchost) and the endpoint settings, + which provide no issuer. +- The discovery document names a template instead of an issuer, such as + `https://login.microsoftonline.com/{tenantid}/v2.0`. Set the issuer of your + tenant, not a template or a wildcard. +- The discovery document names another issuer than the tokens, because the + provider puts the host name of each request into the issuer, such as + Keycloak without a fixed hostname, and QuestDB reaches the provider through + another URL than the clients. Set the issuer that the tokens carry. The Web + Console still sends browsers to the endpoints of that discovery document, so + for Web Console logins, give the provider a fixed public hostname instead, + as described in [The expected issuer](/docs/security/oidc/#the-expected-issuer). + +With issuer validation enabled, QuestDB refuses to start when it has no +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 +``` + +Not used with `acl.oidc.groups.encoded.in.token=false`, or with +[`acl.oidc.issuer.validation.enabled=false`](#acloidcissuervalidationenabled). +See [The expected issuer](/docs/security/oidc/#the-expected-issuer). + +### 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.0.2 and +later. When `false`, QuestDB neither requires the `iss` claim nor compares it, +even when [`acl.oidc.issuer`](#acloidcissuer) is set or the discovery document +provides an issuer, and starts without an expected issuer. It still checks the +signature, the audience, the expiry, and the subject of the token, and logs an +advisory at startup. + +Without the check, QuestDB accepts the tokens of any issuer or tenant that the +provider signs with the same keys for the expected audience. Keep the default, +unless the provider issues tokens without an `iss` claim. + +Accepts `true` or `false`, in any letter case. QuestDB refuses to start with +any other value, an empty value included, even when OIDC is disabled: +`invalid configuration value [key=acl.oidc.issuer.validation.enabled, value=...]`. +See [The expected issuer](/docs/security/oidc/#the-expected-issuer). + ### acl.oidc.port - **Default**: `443` @@ -89,6 +182,16 @@ location where it was loaded from (`window.location.href`). The OIDC server asks consent for the scopes listed in this property. The scope `openid` is mandatory and must always be included. +The Web Console requests these scopes when the user logs in. Add +`offline_access` if the provider issues a refresh token only for that scope, +such as Microsoft Entra ID. Without a refresh token, the Web Console cannot +renew its tokens, and on QuestDB Enterprise 4.0.2 and later, it logs the user +out once QuestDB stops accepting the token of the login, about an hour after +the login with Microsoft Entra ID. Until then, a user who is disabled in the +provider can keep access. See +[Web Console token renewal](/docs/security/oidc/#web-console-token-renewal) in +the OIDC guide. + ## Authentication flows ### acl.oidc.pg.token.as.password.enabled @@ -100,13 +203,21 @@ When enabled, the PGWire endpoint supports OIDC authentication. The OAuth2 token should be sent in the password field, while the username field should contain the string `_sso`, or left empty if that is an option. -### acl.oidc.pkce.enabled + + +### acl.oidc.pkce.required - **Default**: `true` - **Reloadable**: no -Enables or disables PKCE for the Authorization Code Flow. This should always -be enabled in production. The Web Console is not fully secure without it. +Whether the Web Console uses PKCE (Proof Key for Code Exchange) in the +Authorization Code Flow. This should always be enabled in production. The Web +Console is not fully secure without it. + +`acl.oidc.pkce.enabled` is not a QuestDB setting. QuestDB reports it as an +invalid setting at startup and does not apply its value, and with +[`config.validation.strict=true`](/docs/configuration/overview/#configvalidationstrict) +it refuses to start. Use `acl.oidc.pkce.required` instead. ### acl.oidc.ropc.flow.enabled @@ -116,6 +227,22 @@ be enabled in production. The Web Console is not fully secure without it. Enables or disables the Resource Owner Password Credentials flow. When enabled, this flow must also be configured in the OIDC Provider. +### acl.oidc.state.required + +- **Default**: `false` +- **Reloadable**: no + +Whether the Web Console sends a random `state` parameter in the authorization +request of the Authorization Code Flow, and rejects the redirect back from the +OIDC Provider when it does not carry the same `state`. This ties the redirect +to a login that the same browser started, which protects against cross-site +request forgery on the login redirect. + +Enable it when your security policy or your provider requires the `state` +parameter. The PKCE check, which +[`acl.oidc.pkce.required`](#acloidcpkcerequired) enables by default, already +ties the authorization code to the browser that started the login. + ## Endpoints ### acl.oidc.authorization.endpoint @@ -126,14 +253,56 @@ enabled, this flow must also be configured in the OIDC Provider. OIDC Authorization Endpoint. The default value should work for the Ping Identity Platform. +### acl.oidc.device.authorization.endpoint + +- **Default**: none +- **Reloadable**: no + +The provider's device authorization endpoint, used by the +[Java, Python, Rust, C, and C++ device-flow helpers](/docs/security/oidc/device-flow/). +With [`acl.oidc.host`](#acloidchost) and manual endpoint configuration, set +this to the provider's endpoint path. QuestDB combines it with the configured +host, port, and TLS mode. There is no provider-independent default. + +With [`acl.oidc.configuration.url`](#acloidcconfigurationurl), QuestDB reads +`device_authorization_endpoint` from the provider's discovery document +instead. A missing, empty, or blank value leaves this endpoint unset and does +not prevent startup. An invalid nonblank endpoint is rejected like the other +discovered endpoint URLs. + +When present, QuestDB includes the resolved URL in `/settings` so clients can +discover it. The client, not QuestDB, sends device authorization and token +requests to the provider. Setting this property does not enable the grant at +the provider or enable QuestDB's ROPC flow. + ### acl.oidc.public.keys.endpoint - **Default**: `/pf/JWKS` - **Reloadable**: no JSON Web Key Set (JWKS) Endpoint. Provides the list of public keys used to -decode and validate ID tokens issued by the OIDC Provider. The default value -should work for the Ping Identity Platform. +decode and validate the tokens issued by the OIDC Provider, such as ID tokens, +when `acl.oidc.groups.encoded.in.token` is `true`. The default value should +work for the Ping Identity Platform. + +### acl.oidc.public.keys.expiry + +- **Default**: `120000` +- **Reloadable**: no + +The interval, in milliseconds, before QuestDB refreshes the signing keys +that it downloaded from the JWKS endpoint. After startup, QuestDB refreshes +the keys during authentication, not on a background timer. It also refreshes +them when a token names a key ID that it does not know, such as a key that +the OIDC Provider has just started to use. + +This is a refresh interval, not a hard lifetime for cached keys. If a refresh +fails, QuestDB retains its previously downloaded keys and can still validate +tokens signed with them. The authentication request that performs the +refresh may wait for a slow or unreachable provider until the HTTP request +timeout, [`acl.oidc.http.timeout`](#acloidchttptimeout), expires, even when +its signing key is already cached. The timeout is 30 seconds by default. See +[Troubleshooting OIDC logins](/docs/security/oidc/#troubleshooting-oidc-logins). ### acl.oidc.token.endpoint @@ -196,37 +365,121 @@ which it connects. ## User and group claims +QuestDB reads the principal and the group memberships of an external user from +the user information: the User Info endpoint's response or, when +`acl.oidc.groups.encoded.in.token` is `true`, the payload of a JWT, such as an +ID token. `acl.oidc.sub.claim` and `acl.oidc.groups.claim` each take a single +claim name or, since QuestDB Enterprise 4.0.2, a comma-separated list of claim +names in priority order. For how QuestDB picks the claims, with examples, see +[User and group claims](/docs/security/oidc/#user-and-group-claims) in the +OIDC guide. For how versions before 4.0.2 differ, see +[Versions before 4.0.2](/docs/security/oidc/#versions-before-402). + ### 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 user information of a valid token, in +milliseconds: the User Info endpoint's response or, when +`acl.oidc.groups.encoded.in.token` is `true`, the result of validating the +token. This setting controls how often a token is validated again and the user +information refreshed. With `acl.oidc.groups.encoded.in.token=true`, the user +information comes from the token itself, so a change to it takes effect when +the client presents a new token. For username and password logins through the +ROPC flow, QuestDB caches the result of the login for this long, then requests +a new token from the provider, so with either setting, changes take effect +after this time. + +Since QuestDB Enterprise 4.0.2, this also sets how long an HTTP session reuses +the check of a password that QuestDB verifies itself, the password of a +database user or of the built-in admin, even when OIDC is disabled. A request +that carries the session cookie and the same password is checked again once +this time has passed since the session last checked it. Changing or removing +the password ends the session at once, as described in +[Managing authentication](/docs/security/rbac/#managing-authentication). +The session does not reuse the check of any other credential. QuestDB checks a +REST token on every request that carries it, and an OIDC token or an ROPC login +through the cache described above. Once a cache entry expires, User Info mode +calls the provider's User Info endpoint again, and ROPC requests a new token +from the provider in either mode. For bearer tokens in token mode, QuestDB +instead revalidates the JWT against signing keys and rereads its existing +claims. It does not ask the provider about the user's current status or group +memberships; updated group claims require the client to present a new token. +See [When QuestDB stops accepting a token](/docs/security/oidc/#when-questdb-stops-accepting-a-token). ### 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 group name. Required when OIDC +is enabled. + +Since QuestDB Enterprise 4.0.2, accepts a comma-separated list of claims in +priority order, in the form `claimName[,claimName ...]`, such as +`roles,groups`. Spaces around claim names are trimmed, and empty entries are +skipped. QuestDB takes the groups from the first claim on the list that holds +at least one group name, and does not combine groups from several claims. A login is rejected when none of the listed claims +holds a group name. With OIDC enabled, QuestDB refuses to start when the list +names a claim twice, or names a claim that `acl.oidc.sub.claim` also lists. + +On earlier versions, a list makes every OIDC login fail. With +`acl.oidc.groups.encoded.in.token=true`, earlier versions also require a +`groups` array in the token, whatever this setting names. + +See [How QuestDB picks a claim](/docs/security/oidc/#how-questdb-picks-a-claim) +and [Startup validation](/docs/security/oidc/#startup-validation). ### 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 +instead of calling the User Info endpoint: the ID token, which the Web Console +sends and QuestDB obtains itself in the ROPC flow, or a token that a client +presents, such as an Entra ID app-only access token. QuestDB validates the +token itself, as described in +[Token validation](/docs/security/oidc/#token-validation). Set to `true` if the +OIDC Provider encodes group memberships directly into the token. + +Since QuestDB Enterprise 4.0.2, QuestDB also rejects expired tokens, tokens +without an `exp` claim, and, unless +[`acl.oidc.issuer.validation.enabled`](#acloidcissuervalidationenabled) is +`false`, tokens whose `iss` claim is not the expected issuer. Earlier versions +check neither the `exp` nor the `iss` claim of the token. ### 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 user's principal, such as +a username, an email address, or an object ID. Displayed in the Web Console, +returned by `current_user()`, and logged for audit purposes. Pick a claim that +identifies one user or service, and that the provider never gives to anyone +else: QuestDB keeps one external user per principal and replaces its groups at +every login, so users who share a principal share permissions. Avoid display +names, such as the `name` claim. See +[Choose the principal claim](/docs/security/oidc/#choose-the-principal-claim). + +For username and password logins through the ROPC flow, the principal is the +username that the client sends instead. QuestDB still requires one of the +listed claims to carry a value, but does not use that value. + +Since QuestDB Enterprise 4.0.2, accepts a comma-separated list of claims in +priority order, in the form `claimName[,claimName ...]`, such as +`preferred_username,oid`. Spaces around claim names are trimmed, and empty +entries are skipped. QuestDB takes the principal from the first claim on the +list that holds a non-empty value. A login is +rejected when none of the listed claims holds one. With OIDC enabled, QuestDB +refuses to start when the value is empty, when the list names a claim twice, or +when it names a claim that `acl.oidc.groups.claim` also lists. An empty value +does not fall back to the default `sub`. + +On earlier versions, a list makes every OIDC login fail. + +See [How QuestDB picks a claim](/docs/security/oidc/#how-questdb-picks-a-claim) +and [Startup validation](/docs/security/oidc/#startup-validation). diff --git a/documentation/connect/clients/c-and-cpp.md b/documentation/connect/clients/c-and-cpp.md index 9b82a307ca..2dae962e87 100644 --- a/documentation/connect/clients/c-and-cpp.md +++ b/documentation/connect/clients/c-and-cpp.md @@ -251,8 +251,8 @@ threads, close it at shutdown. You don't open connections yourself: **borrow** a lease (a sender or reader), use it on one thread, then **return** it (recycles the connection) or **drop** it (retires it). In C++ the borrowed sender wrapper and pooled reader return on -destruction; `drop_on_return()` forces a drop. The pool connects lazily, and -reconnects and fails over on its own. +destruction; `drop_on_return()` forces a drop. The pool opens its first +connections when you create it, and reconnects and fails over on its own. ### Which borrow? @@ -271,14 +271,19 @@ the schema server-side. The connect string uses a QWP/WebSocket scheme: `ws` / `wss`. For auth and TLS keys, see the -[connect string reference](/docs/connect/clients/connect-string/). The pool is -**lazy**: `questdb_db_connect` (C) and the `questdb::pool` constructor (C++) -parse and validate the string but perform no blocking network I/O, so auth / -TLS / connect errors surface from the first borrow, not at construction. The -one exception is the on-disk send queue (`sf_dir`, see -[Durability and backpressure](#durability-and-backpressure)): at connect the -pool reopens any queue directories a previous run left holding unacknowledged -rows, then reconnects and resends them in the background. +[connect string reference](/docs/connect/clients/connect-string/). By default, +the pool connects **eagerly**: `questdb_db_connect` (C) and the +`questdb::pool` constructor (C++) parse and validate the string, then open the +warm minimum of connections (`sender_pool_min` senders and `query_pool_min` +readers, one of each by default) before they return, so auth / TLS / connect +errors surface from `questdb_db_connect` or the constructor. With +[`lazy_connect=on`](/docs/connect/clients/connect-string/#pool-keys), they +perform no blocking network I/O: senders connect in the background, readers +connect on the first borrow, and the errors surface from those operations +instead. With the on-disk send queue (`sf_dir`, see +[Durability and backpressure](#durability-and-backpressure)), the pool also +reopens, at connect, any queue directories a previous run left holding +unacknowledged rows, then reconnects and resends them in the background. @@ -320,21 +325,53 @@ wss::addr=db.example.com:9000;token=your_bearer_token; # bearer token (E rejects it. - **`auth_timeout_ms`** (default 15000) bounds the WebSocket upgrade. -Because the pool connects lazily, a bad credential surfaces as -`line_sender_error_auth_error` from the **first borrow** on each connection. -Handle it there, not at `connect` (see -[Which errors mean what](#which-errors-mean-what)). - -### Unsupported auth paths - -The client supports only HTTP basic auth and static bearer-token auth. The -following are **not** supported: - -| Path | Status | Workaround | +Because the pool connects eagerly by default, a bad credential surfaces as +`line_sender_error_auth_error` from `questdb_db_connect` or the `questdb::pool` +constructor. With `lazy_connect=on`, it surfaces later: from a reader's first +borrow, or, because senders connect in the background, from a sender's wait or +a later call on it. A connection that the pool opens later, such as a +reconnect, can also fail authentication, for example after the token expires +(see [Which errors mean what](#which-errors-mean-what)). + +### OIDC device flow + +With matching OIDC-enabled headers and library, the client can acquire and +refresh tokens for a human user through the OAuth 2.0 Device Authorization +Grant. The user authorizes a displayed code in a browser on any device; the +application does not collect their password. + +- **C++:** include ``, build an auth object with + `questdb::oidc::builder::from_questdb(url).build()`, call `auth.sign_in()`, + then construct `questdb::pool{conf, auth}`. +- **C:** include ``, build an auth with + `questdb_oidc_builder_from_questdb` and `questdb_oidc_builder_build`, then + call `questdb_oidc_auth_sign_in`. Initialize `questdb_db_connect_options`, + set its `oidc_auth` field, and pass it to `questdb_db_connect_ex`. + +The pool retains shared auth state and resolves a current token on each +sender/reader connection or reconnect. Provider calls can refresh silently but +never display a prompt. Sign in before starting the pool, and do not combine +the provider with `token=`, `username`, or `password`. + +For complete checked-error examples, provider setup, client-build availability, +persistence, and reauthentication, see the +[C++](/docs/security/oidc/device-flow/#cpp) and +[C](/docs/security/oidc/device-flow/#c) device-flow examples. For standalone +senders, use C++ `opts::oidc_auth(auth)` or C +`line_sender_opts_oidc_auth(options, auth, &error)`. + +### Authentication support {#unsupported-auth-paths} + +The client supports HTTP basic auth, static bearer tokens, and OIDC device flow: + +| Path | Status | Usage | |---|---|---| -| OIDC token acquisition or in-band refresh | Not supported. The client does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC; see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...`, and rebuild the pool when the token nears expiry. | +| OIDC device acquisition and refresh | Supported with a shared device auth object. | Sign in explicitly, then attach the provider to the pool or standalone sender. See [Device flow](#oidc-device-flow). | | Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss`. See the connect-string reference's [TLS section](/docs/connect/clients/connect-string/#tls). | -| Token rotation mid-session | Not supported. Credentials are presented once during the WebSocket upgrade and are not re-sent. | On token expiry, close the pool and build a fresh one with the new token. | +| Changing a fixed `token=` value in place | Not supported; a device-flow provider can renew tokens without replacing the pool. | With a fixed token, build a new pool before expiry and wait for the old senders' acknowledgements before closing it. With `sf_dir`, flush and close the old pool first, then build the new one with the same `sender_id` so it can replay retained rows. See [Token lifetime](/docs/security/oidc/#token-lifetime). | + +Device flow is a human login. For unattended Azure services, see +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). ## Headers @@ -1591,7 +1628,8 @@ int main() // Enterprise; the connect fails against a server without replication. "request_durable_ack=on;"}; - // First borrow opens the connection: give it the failover budget. + // If the pool has to open a connection, retry it within the + // failover budget. auto sender = pool.borrow_sender_with_retry(pool.reconnect_max_duration_ms()); @@ -1640,7 +1678,8 @@ int main(void) db = questdb_db_connect(conf, strlen(conf), &err); if (!db) goto on_error; - /* First borrow opens the connection: give it the failover budget. */ + /* If the pool has to open a connection, retry it within the failover + budget. */ sender = questdb_db_borrow_sender_with_retry( db, questdb_db_reconnect_max_duration_ms(db), &err); if (!sender) goto on_error; diff --git a/documentation/connect/clients/connect-string.md b/documentation/connect/clients/connect-string.md index b07e59c063..1949812d26 100644 --- a/documentation/connect/clients/connect-string.md +++ b/documentation/connect/clients/connect-string.md @@ -147,13 +147,18 @@ wss::addr=node-a:443,node-b:443;target=replica;zone=eu-west-1a; ### Tolerate a slow or restarting server at startup ``` -ws::addr=node-a:9000;reconnect_max_duration_millis=120000; +ws::addr=node-a:9000;query_pool_min=0;initial_connect_retry=on;reconnect_max_duration_millis=120000; ``` -The 2-minute reconnect budget covers both the *first* connect and any -subsequent reconnect: setting any explicit `reconnect_*` key implicitly -turns on `initial_connect_retry`. See -[Ingress reconnect](#reconnect-keys). +The sender retries its first connection synchronously for up to 2 minutes. +The explicit `initial_connect_retry=on` also enables this in the pooled +facade of the C, C++, Python, and Rust clients, where `reconnect_*` keys +alone do not enable initial retries. `query_pool_min=0` prevents the query +pool's separate, fail-fast startup connection, so this example configures +ingest startup only. Once connected, the sender retries transport outages +indefinitely; the 2-minute budget does not limit subsequent reconnects. +For non-blocking pool startup, use [`lazy_connect=on`](#pool-keys) instead. +See [Ingress reconnect](#reconnect-keys). ## Recipes {#recipes} @@ -167,6 +172,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` | — | @@ -178,8 +184,9 @@ caveats), follow the section links from the [Key index](#key-index). | Run multiple senders sharing one `sf_dir` | ingress | `sf_dir`, `sender_id` | unique `sender_id` per sender | | Orphan recovery for crashed senders | ingress | `drain_orphans=on` | `max_background_drainers` | | End-to-end durable acknowledgement | ingress | `request_durable_ack=on` | `durable_ack_keepalive_interval_millis` | -| Tune ingress reconnect backoff | ingress | — | `reconnect_initial_backoff_millis`, `reconnect_max_backoff_millis`, `reconnect_max_duration_millis` (any of these also implies `initial_connect_retry=on`) | -| Force fail-fast on initial connect | ingress | `initial_connect_retry=off` | overrides the implicit promotion from any explicit `reconnect_*` key | +| Tune ingress reconnect backoff | ingress | — | `reconnect_initial_backoff_millis`, `reconnect_max_backoff_millis`, `reconnect_max_duration_millis`; pooled C/C++/Python/Rust clients need an explicit initial retry mode | +| Force fail-fast on initial connect | ingress | `initial_connect_retry=off` | overrides implicit promotion where supported; see [Ingress reconnect](#reconnect-keys) | +| Retry initial ingest connection synchronously | ingress | `initial_connect_retry=on` | `reconnect_max_duration_millis`; use `query_pool_min=0` to skip separate query startup | | Retry initial connect in background | ingress | `initial_connect_retry=async` | `reconnect_*` | | Fast `close()` without drain | ingress | `close_flush_timeout_millis=0` | — | | Disable per-query egress failover | egress | `failover=off` | — | @@ -235,6 +242,55 @@ 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. +For interactive human sign-in, the Java, Python, Rust, C, and C++ clients also +accept a programmatically supplied +[OIDC device-flow provider](/docs/security/oidc/device-flow/). Sign in explicitly +and attach the auth object or callback to the client; do not copy its token +into a fixed `token=` string. Device flow is not a `token_provider=oidc` +connect-string option, and must not be combined with static credentials. + +### Token providers {#token-providers} + +*Applies to: ingress and egress. The connect-string provider keys below are +Java-only, 1.3.10 or later.* + +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 it instead of +a fixed `token` for tokens that expire, such as the tokens of a Microsoft Entra +ID managed identity or service principal: + +``` +wss::addr=node-a:9000,node-b:9000;token_provider=azure;azure_resource=api://;azure_credential=managed_identity; +``` + +- `token_provider`: the provider. `azure` requests tokens from Microsoft Entra + ID 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 to + the client. +- `azure_resource`: required with `token_provider=azure`. The Application ID + URI, such as `api://`, or the application ID of the QuestDB + application in Entra ID. The client requests the scope + `/.default`. +- `azure_credential`: the Azure credential that requests the token. `default`, + the default, is `DefaultAzureCredential`, a discovery chain meant for + development. `managed_identity`, `workload_identity`, and `environment`, a + service principal configured with the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, + and `AZURE_CLIENT_SECRET` environment variables or a certificate, use that + credential alone, which is recommended in production. +- `azure_client_id`: the client ID of a user-assigned managed identity or of a + workload identity. Cannot be combined with `azure_credential=environment`. + +The Azure provider keys contain no secret. You can keep the connect string in +`QDB_CLIENT_CONF`. Redact any other sensitive settings, such as +`tls_roots_password`, before logging it. Clients built from equivalent connect +strings share one provider in a process. See +[Rotating tokens](/docs/connect/clients/java/#rotating-tokens) in the Java +client reference, and +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals) +for the Entra ID setup. + ## TLS {#tls} *Applies to: ingress and egress.* @@ -607,16 +663,15 @@ architecture is that a producer survives an arbitrarily long outage. - `reconnect_initial_backoff_millis` — initial wait between reconnect attempts. Backoff grows exponentially up to `reconnect_max_backoff_millis`. - Default: `100`. Setting this enables `initial_connect_retry=on` implicitly; - see below. + Default: `100`. See the implicit-promotion rules below for its effect on + initial connect. - `reconnect_max_backoff_millis` — cap on per-attempt backoff. - Default: `5000` (5 s). Setting this enables - `initial_connect_retry=on` implicitly; see below. + Default: `5000` (5 s). See the implicit-promotion rules below. - `reconnect_max_duration_millis` — time budget for the **blocking sync initial connect** (`initial_connect_retry=on`): once exceeded, the constructor gives up and returns the error. The running loop and the `async` initial connect never consult it. Default: `300000` (5 min). - Setting this enables `initial_connect_retry=on` implicitly; see below. + See the implicit-promotion rules below. - `initial_connect_retry` — whether the client retries the initial connect attempt on failure. - `off` (default, alias `false`) — fail fast on initial connect failure. @@ -626,13 +681,16 @@ architecture is that a producer survives an arbitrarily long outage. the background indefinitely, surfacing only genuine terminal failures (auth reject, durable-ack mismatch) via the error inbox. - **Implicit promotion.** Setting any explicit `reconnect_*` key without - also choosing an `initial_connect_retry` mode promotes - `initial_connect_retry` to `on` automatically, so the budget also covers - the *first* connect attempt — without the promotion, - `reconnect_max_duration_millis` would be inert. To keep fail-fast - behaviour on the first connect while still tuning the backoff, set - `initial_connect_retry=off` explicitly; the explicit setting wins. + **Implicit promotion.** In Java, .NET, and Go, setting any explicit + `reconnect_*` key without also choosing an `initial_connect_retry` mode + promotes the initial mode to `on`. In C, C++, Python, and Rust, this + promotion applies only to a standalone sender, not to the pooled facade. + Set `initial_connect_retry=on` explicitly for blocking initial retries + in those pools, or use `lazy_connect=on` for non-blocking startup. Query + connections have separate fail-fast startup behavior; set + `query_pool_min=0` to skip their prewarm. An explicit + `initial_connect_retry=off` always wins over implicit promotion, letting + you tune reconnect backoff while retaining fail-fast sender startup. - `close_flush_timeout_millis` — `close()` blocks up to this many milliseconds waiting for buffered frames to drain. Set to `0` or `-1` for fast close (skip the drain). **The default differs by client**: `60000` @@ -642,9 +700,65 @@ 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. +Authentication rejection is terminal before a sender's first successful +connection, after any eligible token-provider refresh and retry. Established +Java senders instead keep retrying server upgrade `401`/`403` rejections with +capped backoff while retaining buffered rows, even with fixed credentials. +Rust, C, and C++ builds containing +[c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183) +and Python builds containing +[py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133) +share this established-sender policy. Older native releases may still treat +authentication rejection as terminal after the first connection. + +Native clients emit `auth_failed` connection events, and an acknowledgement +wait that times out identifies the rejection. Applications requiring a hard +failure must monitor the outage and close the sender themselves. This retry +policy covers server upgrade rejections, not a closed or misconfigured token +provider; those native provider failures remain terminal. Query authentication +rejections remain terminal for the connection attempt or query after any +eligible token retry, rather than starting an indefinite recovery loop. + +A durable-ACK mismatch or non-101 upgrade response without a role hint remains +terminal during native reconnect. Java senders that have already connected +retry these rejections; at initial connect they are terminal. See +[How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) +for Java's token-provider refresh and retry rules. + +In Java, a protocol-version mismatch is transient, including at startup. +With `initial_connect_retry=on`, the client retries it within +`reconnect_max_duration_millis`; with `async`, it retries in the background +indefinitely. A sender that has already connected also retries version +mismatches indefinitely, allowing a rolling upgrade to settle. + +Java orphan drainers have a separate policy: + +- Authentication rejection with a fixed credential, and non-retriable upgrade + rejection, fail immediately. +- With a token provider, `401`/`403` rejections receive bounded retries with + fresh credentials before the drainer quarantines the slot. +- A durable-ACK capability mismatch receives bounded retries to allow a + rolling upgrade to settle before the drainer quarantines the slot. +- Role rejections while all reachable servers are replicas are transient: + the drainer keeps retrying rather than quarantining the slot. + +For the Java token-rejection thresholds and reporting, see +[How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures). + +Native orphan drainers in the builds listed above also distinguish fixed and +rotating credentials: + +- A rejected fixed credential retires the slot immediately for the current + session. +- With a token provider, the drainer retries until at least six rejections have + persisted for `min(reconnect_max_duration_millis, 300000)` milliseconds, or + until 256 rejections occur without acknowledgement progress. Other failure + types reset the persistence timer but not the rejection count; new + acknowledgement progress resets both. + +Retiring a native slot records `.last_error` and stops draining it for the +rest of the session, without writing Java's `.failed` sentinel. Unacknowledged +rows remain on disk, and the next session tries the slot again. ### Egress failover {#egress-failover} @@ -773,10 +887,21 @@ explicit setter always wins over the string. (30 min). - `housekeeper_interval_ms` — how often the housekeeper checks for idle and over-age connections. Default: `5000`. -- `lazy_connect` — when `on`, the pool defers opening its first connection - until the first borrow, so construction succeeds against a server that is - down. This is the supported way to tolerate a server that starts after your - application. Default: `off`. +- `lazy_connect` — when `on`, construction does not wait for a connection, + so the application can start while the server is down. + - Java can prewarm its configured minimum senders asynchronously before + the first borrow. This also lets the application start while the + identity provider is unreachable when a token provider requests its + first token. + - C, C++, Python, and Rust skip sender prewarming: a clean pool creates + senders on first borrow, then connects them asynchronously. Recovery + of existing disk-backed slots can start before the first borrow. + - In these clients, query connections open on first use, and + `lazy_connect` cannot be combined with `query_pool_min` above `0`, or + with an `initial_connect_retry` other than `async`: the client rejects + the configuration. + + Default: `off`. ## Error handling {#error-handling} @@ -859,6 +984,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) | @@ -876,7 +1004,7 @@ description and behaviour notes. | `failover_max_attempts` | int | `8` | [Egress failover](#reconnect-keys) | | `failover_max_duration_ms` | int (ms) | `30000` | [Egress failover](#reconnect-keys) | | `init_buf_size` | size | `65536` (64 KiB) | [Buffer sizing](#buffer) | -| `initial_connect_retry` | enum (`off` / `on` / `async`) | `off` (auto-promoted to `on` when any explicit `reconnect_*` key is set) | [Ingress reconnect](#reconnect-keys) | +| `initial_connect_retry` | enum (`off` / `on` / `async`) | `off` (auto-promoted to `on` when any explicit `reconnect_*` key is set, except in the pooled facade of the C, C++, Python, and Rust clients) | [Ingress reconnect](#reconnect-keys) | | `initial_credit` | int (bytes) | `0` (unbounded) | [Query client keys](#egress-keys) | | `housekeeper_interval_ms` | int (ms) | `5000` | [Connection pool](#pool-keys) | | `idle_timeout_ms` | int (ms) | `60000` (`0` ⇒ infinite) | [Connection pool](#pool-keys) | @@ -918,6 +1046,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/dotnet.md b/documentation/connect/clients/dotnet.md index 3922011fce..9ffda36c45 100644 --- a/documentation/connect/clients/dotnet.md +++ b/documentation/connect/clients/dotnet.md @@ -228,9 +228,9 @@ that purpose; left unset, it inherits `auth_timeout_ms`. | Path | Status | Workaround | |---|---|---| -| OIDC token acquisition or in-band refresh | Not supported by this client. It does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC — see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...` above, and rebuild the sender / query client when the token nears expiry. | +| OIDC token acquisition or in-band refresh | Not supported by this client. It does not negotiate with an identity provider and has no callback to refresh a token mid-session. | QuestDB itself supports OIDC; see [OpenID Connect](/docs/security/oidc/#non-interactive-clients) for which token to send. Acquire the token out-of-band from your IdP, pass it via `token=...` above, and rebuild the sender / query client when the token nears expiry. For Azure services, see [Microsoft Entra ID managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). | | Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss://`. See the connect-string reference for the canonical statement. | -| Token rotation mid-session | Not supported. Credentials are presented once during the WebSocket upgrade and are not re-sent. | On token expiry, `await sender.DisposeAsync()` and build a fresh sender with the new token. The same applies to `QueryClient`. | +| Token rotation mid-session | Not supported. The client sends the credentials in the WebSocket upgrade of every connection that it opens, including reconnects, and cannot switch to a new token while it runs. | Before the token expires, build a new sender with a new token, call `SendAsync()` on the old sender, because dispose does not send, then `await` its `DisposeAsync()`. With `sf_dir`, call `SendAsync()` on the old sender and dispose of it first, then build the new one with the same `sender_id`: two senders cannot hold the same store-and-forward slot, and the new sender replays what the old one left on disk. Replace a `QueryClient` the same way: build the new one, then dispose of the old one. On QuestDB Enterprise 4.0.2 and later, or with the User Info endpoint, QuestDB rejects the token on new connections shortly after it expires; see [Token lifetime](/docs/security/oidc/#token-lifetime). | ### Production example (TLS + token + multi-host) diff --git a/documentation/connect/clients/go.md b/documentation/connect/clients/go.md index 5a2e424117..c857d58c44 100644 --- a/documentation/connect/clients/go.md +++ b/documentation/connect/clients/go.md @@ -360,9 +360,21 @@ db, err := qdb.Connect(ctx, The token is sent as an `Authorization: Bearer YOUR_BEARER_TOKEN` header on both the ingress and egress WebSocket upgrades. It is a **static credential**: the client sends exactly the string you pass and never refreshes or renews it. -Acquire it out of band — QuestDB Enterprise issues bearer tokens through its -[OpenID Connect flow](/docs/security/oidc/) — and manage its lifetime yourself. -When the token expires or is rotated, construct a new handle with the new token. +Acquire it out of band, for example from the identity provider of the QuestDB Enterprise +[OpenID Connect flow](/docs/security/oidc/#non-interactive-clients), and manage its lifetime yourself. +For Azure services, see +[Microsoft Entra ID managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). +Before the token expires, construct a new handle with a new token, wait until +QuestDB has acknowledged what the old handle sent (`FlushAndGetSequence` and +`AwaitAckedFsn`), then close the old one: its final flush is best-effort and +bounded by `close_flush_timeout_millis`. With `sf_dir`, flush and close the old +handle first, then +construct the new one with the same `sender_id`: two handles cannot hold the +same store-and-forward slot, and the new handle replays what the old one left +on disk. The client sends the token on every connection that it opens, +including reconnects. On QuestDB Enterprise 4.0.2 and later, or with the User +Info endpoint, QuestDB rejects it on new connections shortly after it expires; +see [Token lifetime](/docs/security/oidc/#token-lifetime). An expired or rejected token surfaces as an authentication failure (see [Connection-level errors](#connection-level-errors)). It is mutually exclusive with `username`/`password`. diff --git a/documentation/connect/clients/java.md b/documentation/connect/clients/java.md index de86d92505..83a87c0905 100644 --- a/documentation/connect/clients/java.md +++ b/documentation/connect/clients/java.md @@ -44,6 +44,13 @@ Key capabilities: async [`Completion`](#completion) handles, and byte-credit flow control. - **Failover**: multi-endpoint connections with automatic reconnect across rolling upgrades and primary migrations. +- **OIDC device sign-in**: authenticate a human user from a CLI, desktop + application, or remote process without collecting a password. See + [OIDC device flow](#oidc-device-flow). +- **Token renewal**: with a token provider, such as `token_provider=azure` for + Microsoft Entra ID managed identities and service principals, the client + requests bearer tokens and renews them before they expire. See + [Rotating tokens](#rotating-tokens). :::tip Legacy transports @@ -234,14 +241,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 with a provider that supplies the bearer token, such as a `RefreshingTokenProvider`. See [Rotating tokens](#rotating-tokens). | | `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` | Connection health of every pooled sender and query client. See [Connection health](#connection-health). | | `close()` | `void` | Shut down pools and disconnect every underlying client. Idempotent. | A `QuestDB` instance is safe to share across threads. Borrows and query @@ -335,6 +344,7 @@ Builder methods: | `errorHandler(SenderErrorHandler)` | — | Async ingest rejections from across the whole sender pool, delivered on the senders' I/O threads. | | `connectionListener(SenderConnectionListener)` | — | Connect and disconnect events across the sender pool. | | `drainerListener(BackgroundDrainerListener)` | — | Store-and-forward background drainer events. | +| `httpTokenProvider(HttpTokenProvider)` | — | Supplies the bearer token of every connection, such as from a `RefreshingTokenProvider`. Cannot be combined with `token`, `username`/`password`, or `token_provider` in the configuration. See [Rotating tokens](#rotating-tokens). | `fromConfig(...)` is required; `build()` throws `IllegalStateException("configuration is required; call fromConfig()")` without @@ -395,7 +405,279 @@ try (QuestDB db = QuestDB.connect( 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`. +`username`/`password`. The client sends the same token on every connection that +it opens, so once QuestDB stops accepting the token, new connections, +reconnects, and failovers fail. QuestDB Enterprise 4.0.2 and later, and the +User Info endpoint, reject a token shortly after it expires; see +[Token lifetime](/docs/security/oidc/#token-lifetime). For tokens that expire, +use a [token provider](#rotating-tokens). + +### OIDC device flow + +`OidcDeviceAuth` in the core `questdb-client` artifact signs a user in with the +OAuth 2.0 Device Authorization Grant. It displays a verification URL and code; +the user can authorize in a browser on another device. No Azure adapter is +required. Sign in explicitly, then pass the provider to the handle: + +```java +import io.questdb.client.QuestDB; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; + +try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000")) { + auth.signIn(); + try (QuestDB db = QuestDB.connect( + "wss::addr=questdb.example.com:9000;", auth::getToken)) { + // Both sender and query connections use the shared provider. + } +} +``` + +`getToken()` can refresh silently but never prompts. Request `offline_access` +if the provider needs that scope to issue a refresh token. Without a usable +cached or refreshable credential, call `signIn()` again outside transport +callbacks. Do not copy its result into a fixed `token=` string. Close the +handle before closing the auth object. + +For provider setup, a complete ingestion example, token persistence, and +reauthentication, see [OIDC device flow](/docs/security/oidc/device-flow/#java). +For unattended service identities rather than a human login, use the rotating +token providers below. + +### Rotating tokens + +The tokens of an identity provider expire, for example after one to 24 hours +with Microsoft Entra ID. A **token provider** requests tokens from the identity +provider instead of a fixed `token`, caches them, and renews them in the +background at about half of their lifetime, so every connection, reconnect, +and failover presents a valid token. A connection that is already open is not +authenticated again. Token providers require Java client 1.3.10 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 +`token_provider=azure`: + + + + ( + + {` + org.questdb + questdb-client-azure + ${release.name} +`} + + )} /> + + + ( + + {`implementation 'org.questdb:questdb-client-azure:${release.name}'`} + + )} /> + + + +```java +try (QuestDB db = QuestDB.connect( + "wss::addr=db.example.com:9000;" + + "token_provider=azure;" + + "azure_resource=api://;" + + "azure_credential=managed_identity;")) { + // ... +} +``` + +| Key | Description | +|-----|-------------| +| `token_provider` | `azure`. Requires the `wss` schema. Cannot be combined with `token`, `username`/`password`, or a provider passed to `httpTokenProvider(...)`. | +| `azure_resource` | Required. The Application ID URI, such as `api://`, or the application ID of the QuestDB application in Entra ID. The client requests the scope `/.default`. | +| `azure_credential` | The Azure credential that requests the token: `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`. | + +`azure_credential=default` uses `DefaultAzureCredential`, a discovery chain +meant for development: a service principal configured in environment +variables, a workload identity, a managed identity, 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 it starts. In +production, name the credential: `managed_identity`, `workload_identity`, or `environment`, a +service principal configured with `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and +`AZURE_CLIENT_SECRET` or a certificate. A managed identity named this way +retries an unreachable endpoint, and fails fast when the identity is not +assigned to the host. + +The Azure provider keys contain no secret. You can keep the connect string in +`QDB_CLIENT_CONF`. Redact any other sensitive settings, such as +`tls_roots_password`, before logging it. + +Every sender, query client, and pooled connection built from equivalent connect +strings shares one provider in a process: one thread that renews the token, +and one request to Entra ID at a time. The provider stays open for 60 seconds +after the last client that uses it closes, so that a client built soon after +reuses its token. For the keys, see +[Token providers](/docs/connect/clients/connect-string/#token-providers) in the +connect string reference. For the Entra ID setup and the QuestDB configuration, +see +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + +Certificates configured through Azure's environment variables can use +`azure_credential=environment`. To construct or customize an Azure credential +in code instead, such as the certificate credential below, wrap it in an +`AzureTokenSource`, and pass it 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(); +``` + +#### Other identity providers + +Wrap a `TokenSource`, the function that requests a token from your identity +provider, in a `RefreshingTokenProvider`, and 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: request a token from your + // identity provider, such as with the client credentials grant + try { + MyToken token = requestToken(); + 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(); +} +``` + +`requestToken()` and `MyToken` 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 cannot throw checked exceptions: +catch them, as the example does with the `IOException` of an HTTP client, and +throw a `TokenUnavailableException` instead, which is unchecked. + +A source reports a failure by throwing `TokenUnavailableException.retryable(...)` +for network trouble, throttling, or a `5xx` response, or +`TokenUnavailableException.permanent(...)` for a missing or wrong +configuration, such as a wrong client secret. Pass the `Retry-After` of a +throttled response with `retryable(message, retryAfterMillis)`. Any other +exception counts as retryable. Don't put a token or a raw response body into +the message. The provider keeps retrying with backoff, whatever the +classification, and keeps handing out the current token while it is still +valid. + +The provider requests its first token as soon as it is built. +`awaitReady(timeoutMillis)` returns `true` once it holds a valid one, and +`false` when the timeout passes, also after a permanent failure. To fail fast +at startup, for example on a wrong client secret, check `getLastFailure()`: + +```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()` and `getTokenExpiresAtEpochMillis()` report the +state of the provider too, such as for a health check. + +The provider hands out a token only while more than 60 seconds of its lifetime +are left. A token that arrives with less counts as a failed request, whose +error names `handout_floor`. If your identity provider issues tokens that +short, lower the floor with `handoutFloorMillis(...)` on the builder. A +connection that needs a token waits up to 30 seconds when the provider holds no +usable token, set with `coldWaitMillis(...)`. After a refreshable `401`, +`onTokenRejected()` waits up to 5 seconds for a refresh, set with +`forcedWaitMillis(...)`. The client then calls `getToken()` again. If the cached +token became unusable during the forced-refresh wait, this call can add the +30-second cold wait, for up to 35 seconds of provider waiting with the defaults, +excluding network time. + +#### How the client handles token failures + +- When QuestDB rejects a token with `401` and the response has no Bearer + `error` or has `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. The provider requests a new token for a rejected one at most + once every 30 seconds. With `token_provider=azure`, Azure Identity can + return its cached token; an unchanged token does not earn this immediate + retry. A `403` or any other Bearer error, such as `insufficient_scope`, + does not trigger this refresh/retry. These rules govern the one-time + token refresh, not the established sender's reconnect loop described below. +- At startup, a sender with `initial_connect_retry=off`, the default, fails + when it cannot get a token within 30 seconds. 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 `QuestDB` handle also connects `query_pool_min` query clients, one by + default, when it starts, and a query client that cannot get a token fails + without retrying. `QuestDB.connect()` and `build()` therefore fail while the + identity provider is unreachable, whatever `initial_connect_retry` says. To + start anyway, set `lazy_connect=true`: senders then connect in the + background, as with `initial_connect_retry=async`, and query clients on the + first query. `lazy_connect=true` rejects an explicit `query_pool_min` above + `0`, and an `initial_connect_retry` other than `async`. +- Once a sender has connected, it keeps retrying when it cannot get a token or + QuestDB rejects one, with any credential, not only with a token provider. It + buffers rows in [store-and-forward](#store-and-forward) meanwhile, and + reports each failure in its [connection health](#connection-health) and to + the error handler, as a `SECURITY_ERROR` with the `RETRIABLE` policy, whose + message starts with `ws-upgrade-failed:` or `credential-unavailable:`. It + 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. + Without `sf_dir`, closing it discards the rows that QuestDB has not + acknowledged. +- An orphan drainer, enabled with `drain_orphans=on`, gives up instead. When + QuestDB keeps rejecting its credential, it sets the slot aside behind a + `.failed` file and reports `DATA_LOSS`. With a fixed credential, this happens + at once. With a token provider, it happens when either condition is met: + - At least six authentication rejections have occurred, and the rejections + have persisted for at least the smaller of + `reconnect_max_duration_millis` and 5 minutes. + - There have been 256 authentication rejections in one episode without ACK + progress, regardless of the persistence interval. + + ACK progress resets the episode, including the rejection count and + persistence clock. Intervening failures of another kind reset only the + persistence clock, not the count. The rows stay on disk. +- A query whose client has to connect, and cannot get a token, fails. The next + query tries again. ### HTTP basic auth @@ -856,27 +1138,51 @@ ws::addr=localhost:9000;sf_dir=/var/lib/questdb/sf;request_durable_ack=on; `flush()` returns once the batch is handed to the send engine, not once the server has acknowledged it. ACKs arrive asynchronously on the I/O loop. To -bridge between publish and acknowledgement, every published frame is assigned a -frame sequence number (FSN). `flushAndGetSequence()` returns -the highest FSN the call published, and `awaitAckedFsn(targetFsn, -timeoutMillis)` blocks until the server has acknowledged up to that FSN: +wait for all rows published by a QWP sender, call `drain(timeoutMillis)` while +that sender is borrowed. It flushes any buffered rows, then waits for the +server to acknowledge all published frames, including frames published by an +earlier automatic flush: + +```java +try (Sender sender = db.borrowSender()) { + sender.table("trades").doubleColumn("price", 42.0).atNow(); + if (!sender.drain(10_000)) { + throw new IllegalStateException("Timed out waiting for QuestDB acknowledgements"); + } +} +``` + +A `false` result means the acknowledgement wait timed out, not that delivery +succeeded. It does not cancel the published frames: the sender may still +deliver them later. + +For per-call correlation, every published frame has a frame sequence number +(FSN). `flushAndGetSequence()` returns the highest FSN published by that call, +and `awaitAckedFsn(targetFsn, timeoutMillis)` waits for acknowledgement up to +that FSN: ```java try (Sender sender = db.borrowSender()) { sender.table("trades").doubleColumn("price", 42.0).atNow(); long fsn = sender.flushAndGetSequence(); if (fsn >= 0 && !sender.awaitAckedFsn(fsn, 10_000)) { - // timed out waiting for the server ACK + throw new IllegalStateException("Timed out waiting for frame " + fsn); } } ``` +This FSN example waits only for frames published by its explicit flush. A +return value of `-1` means that call published nothing; it does **not** mean +previously auto-flushed rows were acknowledged. Use `drain()` for the +all-published-rows barrier. + Related accessors: | Method | Returns | |--------|---------| -| `flushAndGetSequence()` | Highest FSN published by this call. `-1` if nothing was published. | -| `getAckedFsn()` | Highest FSN the server has acknowledged. `-1` if no batch has been published yet. | +| `drain(timeoutMillis)` | Flushes buffered rows and waits for all published frames. `true` if acknowledged, `false` on timeout. | +| `flushAndGetSequence()` | Highest FSN published by this call. `-1` if nothing was published by this call. | +| `getAckedFsn()` | Highest FSN the server has acknowledged. `-1` if no frame has been acknowledged yet. | | `awaitAckedFsn(fsn, timeoutMillis)` | Block until `getAckedFsn()` reaches `fsn`, or the timeout elapses. | When `request_durable_ack=on` is set, `getAckedFsn()` advances after the @@ -885,9 +1191,10 @@ FSN span is reported on `SenderError.getFromFsn()` / `getToFsn()` for rejected batches, so the value returned by `flushAndGetSequence()` is also the correlation key for async error reports. -These methods are no-ops on transports that do not track frame sequence -numbers (HTTP, TCP, UDP): `flushAndGetSequence()` and `getAckedFsn()` -return `-1`, and `awaitAckedFsn()` returns immediately. +On transports that do not track frame sequence numbers (HTTP, TCP, UDP), +`flushAndGetSequence()` flushes and returns `-1`, `getAckedFsn()` returns `-1`, +and `awaitAckedFsn()` returns immediately. On those transports, `drain()` +flushes and returns `true` without a frame-acknowledgement wait. ## Querying with `Query` and `Completion` @@ -1235,16 +1542,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 from store-and-forward), `RETRIABLE_OTHER` (the same, on the next endpoint), `TERMINAL` (the next API call throws `LineSenderServerException`, and the rejected rows stay in store-and-forward), or `ABANDONED` (with `DATA_LOSS` only: the rows can never be sent, and stay on disk under `getQuarantinedPath()`) | | 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 +1560,15 @@ 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 +By default, schema mismatches, parse errors, security errors such as a missing +grant, and protocol violations are `TERMINAL`. Write errors, internal errors, +dictionary gaps, and unknown categories are `RETRIABLE`, and a node that cannot +serve writes is `RETRIABLE_OTHER`. A connected sender also reports each +reconnect whose credential QuestDB rejects, or for which it cannot get a token, +as a `RETRIABLE` `SECURITY_ERROR`, as described in +[How the client handles token failures](#how-the-client-handles-token-failures). + +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 +1634,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. Terminal across all endpoints for the first connect of a + sender and for a query client. A synchronous sender connect reports + `LineSenderException`. If `db.borrowQuery()` creates a query client and its + connection fails, it throws `QueryException` with the connection failure as + its cause. A failed reconnect during query execution is reported to the + handler's `onError(...)`, and `Completion.await()` throws `QueryException`. + A sender that has connected before keeps retrying instead, as described in + [How the client handles token failures](#how-the-client-handles-token-failures). + With a [token provider](#how-the-client-handles-token-failures), a `401` can + trigger a refresh only when the response has no Bearer `error` or has + `error="invalid_token"`. The client retries the same endpoint once only if + the provider returns a different token. +- **Credential unavailable**: the [token provider](#rotating-tokens) cannot + supply a token, for example because the identity provider is unreachable. + The client contacts no endpoint, and handles the failure as described in + [How the client handles token failures](#how-the-client-handles-token-failures). - **Malformed frames**: `QwpDecodeException` or WebSocket close with a terminal code. - **Role mismatch**: `QwpRoleMismatchException` when all endpoints report @@ -1424,7 +1752,14 @@ 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. + immediately on every host for a query and for the first connect of a sender, + because retrying cannot help. With a + [token provider](#how-the-client-handles-token-failures), a `401` can trigger + a refresh only when the response has no Bearer `error` or has + `error="invalid_token"`. The client retries the same host once only if the + provider returns a different token. A sender that has already connected + keeps retrying with any credential, 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. @@ -1470,6 +1805,49 @@ 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 rides out an outage, such as an unreachable cluster or a token that +QuestDB rejects, and keeps accepting rows while it retries. To find out that it +has not reached the server, check its connection health, which requires Java +client 1.3.10 or later. Health reads do not perform network I/O or wait for a +connection attempt. The `db.health()` aggregate acquires pool locks to collect +each client's snapshot; it is not lock-free. The snapshots contain no +credential, so they can back a health endpoint: + +```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); +} +``` + +The aggregate counts query clients too: a query client whose last connect +failed is in an outage until a later query connects it. + +`Sender.health()` and `QwpQueryClient.health()` return the `ConnectionHealth` +of one client. Only the WebSocket (QWP) sender tracks it; the other transports +throw `UnsupportedOperationException`. + +| Accessor | Description | +|----------|-------------| +| `getState()` | `CONNECTING` (no successful connect 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` (terminal), or `CLOSED`. | +| `getLastConnectedAtEpochMillis()` | Time of the last successful connect, or `ConnectionHealth.NONE` (`-1`). | +| `getOutageSinceEpochMillis()` | Start of the current outage, or `NONE` while connected. | +| `getFailedRounds()` | Failed connect rounds, walks over the endpoints in `addr` that ended without a connection, since the outage started. | +| `getLastFailure()` | The last failed connect round, kept after the connection recovers, or `null`. `getFailureClass()` returns `CREDENTIAL_UNAVAILABLE`, `AUTH_REJECTED` (with the status in `getStatusCode()`), `ROLE_REJECTED`, `TRANSPORT`, or `OTHER`, and `getMessage()` and `getEpochMillis()` return the message and the time. | + +`ConnectionHealth.Aggregate` counts the connections in each state with +`count(State)`, and returns 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 +1972,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 | Bearer-token provider that requests and renews the token, `wss` only: `azure`, with `azure_resource`, `azure_credential`, and `azure_client_id`. See [Rotating tokens](#rotating-tokens). | Pool keys (read by the `QuestDB` facade; the two clients accept and ignore them): diff --git a/documentation/connect/clients/python.md b/documentation/connect/clients/python.md index be8d79aa24..b0e40bf8f5 100644 --- a/documentation/connect/clients/python.md +++ b/documentation/connect/clients/python.md @@ -91,10 +91,14 @@ db = questdb.connect("ws::addr=localhost:9000;") `connect()` accepts only `ws`/`wss` configuration strings; ILP schemes such as `http::` or `tcp::` belong to the [legacy Sender](#legacy-ilp-clients). -It parses and validates the string without opening a connection: the first -`sender()`, `dataframe()`, or `query()` call opens one, and later calls -reuse pooled connections. Servers without QWP support fail during the -WebSocket upgrade; see +It parses and validates the string, then opens the warm minimum of +connections before it returns (see [The pool](#the-pool)), so an unreachable +server or rejected credentials raise from `connect()`. Later calls reuse +pooled connections. With +[`lazy_connect=on`](/docs/connect/clients/connect-string/#pool-keys), +`connect()` returns without connecting, and the first `sender()`, +`dataframe()`, or `query()` call opens a connection. Servers without QWP +support fail during the WebSocket upgrade; see [protocol versioning](/docs/connect/wire-protocols/overview/#versioning). Instead of a configuration string, `connect()` also takes the equivalent @@ -143,13 +147,17 @@ token = questdb.connect( ``` Authentication happens during the WebSocket upgrade, before any data frames -are exchanged. Bad credentials raise `QuestDBErrorCode.AuthError` from the -first operation that needs the connection, not from `connect()`. Queries and -`dataframe()` calls raise it directly. Row senders connect in the -background, so a fire-and-forget `flush()` can return before the upgrade -fails; the error then surfaces from `flush(wait=True)`, `wait()`, or the -next call on the lease — and immediately through the -[connection listener](#failover-and-errors) as an `AuthFailed` event. +are exchanged. By default, `connect()` opens its first connections before it +returns (see [The pool](#the-pool)), so bad credentials raise +`QuestDBErrorCode.AuthError` from `connect()` itself. With +[`lazy_connect=on`](/docs/connect/clients/connect-string/#pool-keys), +`connect()` returns without connecting, and bad credentials raise from the +first operation that needs the connection instead. Queries and `dataframe()` calls +raise it directly. Row senders connect in the background, so a +fire-and-forget `flush()` can return before the upgrade fails. The error then +surfaces from `flush(wait=True)`, `wait()`, or the next call on the lease, and +immediately through the [connection listener](#failover-and-errors) as an +`AuthFailed` event. With `wss`, the default combines the bundled `webpki` root store with the operating-system certificate store. Override it with configuration keys: @@ -164,16 +172,48 @@ operating-system certificate store. Override it with configuration keys: See the [connect string reference](/docs/connect/clients/connect-string/) for the full grammar. -### Unsupported auth paths +### OIDC device flow -The client supports only HTTP basic auth and static bearer-token auth. The -following are **not** supported: +With an OIDC-enabled client build, `questdb.auth.OidcDeviceAuth` signs a human +user in from a terminal or notebook, including a remote kernel. The user opens +a verification URL and enters a code; the application does not collect their +password. Sign in explicitly, then share the provider with the pool: -| Path | Status | Workaround | +```python +import questdb +from questdb.auth import OidcDeviceAuth + +with OidcDeviceAuth.from_questdb("https://questdb.example.com:9000") as auth: + auth.sign_in() + with questdb.connect( + "wss::addr=questdb.example.com:9000;", oidc_auth=auth + ) as db: + # Senders and queries obtain current tokens from the same auth state. + pass +``` + +`auth.token()` and transport callbacks never prompt. They return a cached or +silently refreshed token, or raise `OidcInteractionRequired` when an explicit +`sign_in()` is needed. For a standalone ILP/HTTP sender, pass `oidc_auth=auth` +to `Sender.from_conf(...)`. Do not combine it with static credentials or close +the auth provider while attached transports are still in use. + +See [OIDC device flow](/docs/security/oidc/device-flow/#python) for client-build +availability, provider prerequisites, a complete ingestion example, persistence, +and reauthentication. Device flow requires a person; for unattended Azure +services, see +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + +### Authentication support {#unsupported-auth-paths} + +The client supports HTTP basic auth, static bearer tokens, and device-flow +authentication through a shared provider: + +| Path | Status | Usage | | --- | --- | --- | -| OIDC token acquisition or in-band refresh | Not supported. The client does not negotiate with an identity provider and cannot refresh a token mid-session. | QuestDB itself supports OIDC; see [OpenID Connect](/docs/security/oidc/). Acquire an access token out-of-band from your IdP, pass it via `token=...`, and rebuild the handle when the token nears expiry. | +| OIDC device acquisition and refresh | Supported with `OidcDeviceAuth`. | Sign in explicitly and pass `oidc_auth=auth`. Refresh requires a refresh token from the provider. See [Device flow](#oidc-device-flow). | | Mutual TLS (client certificates) | Not supported. The QuestDB server does not negotiate client certificates regardless of client. | Use bearer-token auth over `wss`. | -| Token rotation mid-session | Not supported. The handle keeps the credentials it was built with and presents them on every connection it opens — including reconnects and failover, so an expired token also breaks mid-session reconnection. | On token expiry, close the handle and build a fresh one with the new token. | +| Changing a fixed `token=` value in place | Not supported; this restriction does not apply to `oidc_auth`. | Before a fixed token expires, build a fresh handle with a new token, then close the old one once its senders have flushed with `flush(wait=True)`. Closing a handle discards rows not acknowledged within `close_flush_timeout_millis`. With `sf_dir`, flush and close the old handle first, then build the new one with the same `sender_id` so it can replay the retained rows. See [Token lifetime](/docs/security/oidc/#token-lifetime). | ## The pool @@ -183,10 +223,15 @@ borrows a connection, opening one on demand, and returns it when the lease or result closes; when every slot is busy, a borrow waits for a free one (see [Pool settings](#pool-settings)). -`connect()` performs no blocking network I/O. `dataframe()`, `query()`, -and `server_info()` connect on first use; `sender()` connects in the -background, so call `flush(wait=True)` or `wait()` to surface connection and -delivery errors. +By default, `connect()` opens the warm minimum of connections before it +returns: `sender_pool_min` sender and `query_pool_min` reader connections, one +of each by default (see [Pool settings](#pool-settings)). An unreachable +server or bad credentials therefore raise from `connect()`. With +[`lazy_connect=on`](/docs/connect/clients/connect-string/#pool-keys), +`connect()` performs no blocking network I/O: `dataframe()`, `query()`, and +`server_info()` connect on first use, and `sender()` connects in the +background. In both modes, call `flush(wait=True)` or `wait()` to surface +connection and delivery errors from row senders. ### Choose an API @@ -297,45 +342,6 @@ QWP cannot preserve nulls for `BOOLEAN`, `BYTE`, or `SHORT`. An absent value in one of those columns is received as `false` or `0`; use a wider nullable type when the distinction matters. -:::note Auto-flush - -The pooled sender auto-flushes by default at 1,000 rows, after 100 ms, or when -its estimated encoded size reaches 90% of the current QWP frame limit. Until -the server advertises that limit, the byte threshold is 8 MiB (or the lower -local queue limit). The interval is checked only by `row()`; there is no -background timer that flushes an idle buffer. - -Override the thresholds with `auto_flush_rows`, `auto_flush_interval`, and -`auto_flush_bytes`. Set `auto_flush_bytes=off` to disable only the byte trigger, -or `auto_flush=off` to disable all row-triggered publishing. Auto-flush does not -wait for an acknowledgement; errors propagate from `row()`. - -::: - -`flush()` publishes the buffered rows to the local queue and returns; -delivery continues in the background. `flush(wait=True)` additionally blocks -until the server acknowledges everything published on this lease, and -`wait(timeout_millis)` is the standalone barrier with an explicit no-progress -timeout (`0` means no deadline; `wait()` returns immediately when the lease -published nothing). The barrier raises only on a terminal connection -failure: server rejections are pushed to the pool's -[rejection handler](#server-rejections) — logged by default — rather than -raised from the wait. Closing the lease (leaving the `with` block) flushes -remaining rows without waiting. - -The pooled lease has no `transaction()`, no `new_buffer()` or `Buffer`, -and no manual progress pump; those exist only on the standalone -[legacy `Sender`](#legacy-ilp-clients). The rest of the ws delivery -surface is on the lease: `flush_and_get_fsn()` / -`flush_and_keep_and_get_fsn()` return the published frame's sequence -number, `await_acked_fsn(fsn, timeout_millis)` waits for its -acknowledgement, `published_fsn()` / `acked_fsn()` report progress without -blocking, and `poll_error()` / `error_events_dropped()` pull the -rejections recorded since the lease was borrowed. FSNs are watermarks of -the lease's pooled connection — use them while the lease is held; they are -not portable across leases. Use `auto_flush=off` when -`flush_and_get_fsn()` must define exact application batches. - ### Multi-dimensional arrays A numpy array keeps its shape on the wire, so an N-dimensional array lands in an @@ -376,6 +382,50 @@ np.array([[1, 2], [3, 4]], dtype=np.int64) Cast first with `arr.astype(np.float64)` when your source data is `float32` or an integer type. +### Flushing and acknowledgements + +`flush()` publishes the buffered rows to the local queue and returns; +delivery continues in the background. `flush(wait=True)` additionally blocks +until the server acknowledges everything published on this lease, and +`wait(timeout_millis)` is the standalone barrier with an explicit no-progress +timeout (`0` means no deadline; `wait()` returns immediately when the lease +published nothing). Every server rejection goes to the pool's +[rejection handler](#server-rejections), logged by default, and the +store-and-forward queue replays the retriable ones. The barrier raises on a +terminal failure: a terminal connection failure, or a terminal rejection, such +as a schema mismatch or a missing grant, which latches the connection and +raises `QuestDBServerRejectionError`. A terminal rejection that arrives after +the barrier returned raises from the next call on the lease. Closing the lease +(leaving the `with` block) flushes remaining rows without waiting. + +:::note Auto-flush + +The pooled sender auto-flushes by default at 1,000 rows, after 100 ms, or when +its estimated encoded size reaches 90% of the current QWP frame limit. Until +the server advertises that limit, the byte threshold is 8 MiB (or the lower +local queue limit). The interval is checked only by `row()`; there is no +background timer that flushes an idle buffer. + +Override the thresholds with `auto_flush_rows`, `auto_flush_interval`, and +`auto_flush_bytes`. Set `auto_flush_bytes=off` to disable only the byte trigger, +or `auto_flush=off` to disable all row-triggered publishing. Auto-flush does not +wait for an acknowledgement; errors propagate from `row()`. + +::: + +The pooled lease has no `transaction()`, no `new_buffer()` or `Buffer`, +and no manual progress pump; those exist only on the standalone +[legacy `Sender`](#legacy-ilp-clients). The rest of the ws delivery +surface is on the lease: `flush_and_get_fsn()` / +`flush_and_keep_and_get_fsn()` return the published frame's sequence +number, `await_acked_fsn(fsn, timeout_millis)` waits for its +acknowledgement, `published_fsn()` / `acked_fsn()` report progress without +blocking, and `poll_error()` / `error_events_dropped()` pull the +rejections recorded since the lease was borrowed. FSNs are watermarks of +the lease's pooled connection: use them while the lease is held, because they +are not portable across leases. Use `auto_flush=off` when +`flush_and_get_fsn()` must define exact application batches. + ## DataFrame ingestion Use `db.dataframe()` when the data already lives in columns. Each call @@ -643,8 +693,10 @@ background delivery continues, so retry `wait()` rather than flushing the same rows again. `flush(wait=True)` and `wait()` observe the accepted (`Ok`) acknowledgement: -the server took responsibility for the frames. They are pure barriers — -data-fate notification is a separate channel, the +the server took responsibility for the frames. They raise on a terminal +failure, including a terminal rejection, as described in +[Flushing and acknowledgements](#flushing-and-acknowledgements). Every +rejection, terminal or retriable, also goes to the [rejection handler](#server-rejections). A durable-level wait (waiting for object-storage upload on Enterprise deployments) is not exposed on the pooled Python API. The `request_durable_ack=on` connect key is accepted, and @@ -739,12 +791,26 @@ uses these keys: | `reconnect_initial_backoff_millis` | `100` | | `reconnect_max_backoff_millis` | `5000` | -Authentication failures, protocol-version failures, and terminal data -rejections are not retried. When a `flush(wait=True)` or `wait()` makes no -progress within the budget it raises `QuestDBErrorCode.FailoverRetry`: the -rows remain queued and background delivery continues, so retry the wait or -extend the budget — do not rebuild and flush the same rows, which would -duplicate them once the original frames land. +Protocol-version failures and terminal data rejections are not retried. +Authentication rejection is terminal before a sender's first successful +connection and for query initial connects and reconnects, after any eligible +token-provider refresh and retry. + +In builds containing +[py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133), +established senders instead retry server upgrade `401`/`403` rejections +indefinitely with capped backoff, retaining buffered rows even with fixed +credentials. They emit `auth_failed` connection events. Applications requiring +a hard stop must monitor the outage and [close the pool](#closing). Older +builds may still treat these rejections as terminal. A closed or misconfigured +token provider remains terminal. See +[Authentication rejection and credential recovery](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-rejection-and-credential-recovery). + +When a `flush(wait=True)` or `wait()` makes no progress within the budget it +raises `QuestDBErrorCode.FailoverRetry`: the rows remain queued and background +delivery continues, so retry the wait or extend the budget. Do not rebuild +and flush the same rows, which would duplicate them once the original frames +land. To observe connection state, pass a listener when connecting. It receives one `ConnectionEvent` per state transition — `kind` @@ -807,10 +873,17 @@ The `db.error_events_delivered` and `db.error_events_dropped` properties report totals. Use the handler for dead-lettering, alerting, and metrics. Terminal -rejections additionally latch the connection: the next call on the affected -sender lease raises `QuestDBServerRejectionError`, and the pool retires the -connection instead of lending it out again. Producer-side abort logic -belongs with that raised error, not in the handler. +rejections additionally latch the connection: `flush(wait=True)` or `wait()` +on the affected sender lease raises `QuestDBServerRejectionError`, or, when +the rejection arrives after that call returned, the next call on the lease +raises it. The pool retires the connection instead of lending it out again. +Producer-side abort logic belongs with that raised error, not in the handler. + +Without `sf_dir`, the rejected rows are dropped. With `sf_dir`, they stay in +the store-and-forward slot, and the next connection that takes over the slot +sends them again before any later batch. Later batches then fail the same way +until QuestDB accepts the rejected rows, for example once you grant a missing +permission, so don't send the rows again. ### Reader failover @@ -857,7 +930,7 @@ Codes you will most often dispatch on: | `InvalidTimestamp` | Bad `at` value (wrong type, `NaT`). | Pass `TimestampNanos`, a timezone-aware `datetime`, or `ServerTimestamp`. | | `FailoverRetry` | `flush(wait=True)` or `wait()` made no progress within the budget. | Retry the wait; do not re-send the rows. | | `FailoverWouldDuplicate` | Mid-stream failover on an `iter_*` consumer or `polars.DataFrame(result)`. | Discard partial state and rerun the query. | -| `ServerRejection` | Terminal server rejection (schema mismatch, parse error, ...) latched by the connection; raised by the next call on the affected lease. Every rejection, terminal or retriable, is also delivered to the [rejection handler](#server-rejections). | Inspect `sender_error`; fix the data or the schema. | +| `ServerRejection` | Terminal server rejection (schema mismatch, parse error, security error such as a missing grant, ...) latched by the connection; raised by `flush(wait=True)` or `wait()` on the affected lease, or by its next call when the rejection arrives later. Every rejection, terminal or retriable, is also delivered to the [rejection handler](#server-rejections). | Inspect `sender_error`; fix the data, the schema, or the grant. With `sf_dir`, don't send the rows again: they stay on disk and are sent first (see [Server rejections](#server-rejections)). | | `ProtocolVersionError` | The server lacks a negotiated capability (for example durable ACK). | Drop the option or upgrade the server. | `QuestDBServerRejectionError` marks a terminal server rejection; its diff --git a/documentation/connect/clients/rust.md b/documentation/connect/clients/rust.md index 20fb11dbb2..95a8e23c19 100644 --- a/documentation/connect/clients/rust.md +++ b/documentation/connect/clients/rust.md @@ -125,16 +125,23 @@ use questdb::QuestDb; let db = QuestDb::connect("ws::addr=localhost:9000;")?; ``` -`QuestDb::connect` parses the connect string and doesn't normally perform any -blocking network I/O. The exception to that happens when store-and-forward -durability is in use, and the client detects unsent data left over from a -previously crashed client. The pool may reopen the dirty slots at construction -and start replaying them in the background. - -The ingestion sender creates its local producer on first borrow and connects its +By default, `QuestDb::connect` parses the connect string, then opens the warm +minimum of connections before it returns: `sender_pool_min` senders and +`query_pool_min` readers, one of each by default. An unreachable server, +rejected credentials, or a TLS error therefore fails `connect()` itself. +Senders honour `initial_connect_retry` while they connect, and readers fail +fast. With store-and-forward on disk, the pool also reopens the slots that a +previous client left holding unsent data, and replays them in the background. + +With [`lazy_connect=on`](/docs/connect/clients/connect-string/#pool-keys), +`connect()` performs no blocking network I/O, and `query_pool_min` defaults to +`0`. It rejects an explicit `query_pool_min` above `0`, and an +`initial_connect_retry` other than `async`. A borrowed sender then creates its +local producer at once and connects its delivery runner in the background, so it can queue data while the server is -temporarily unavailable. Reader errors and direct Arrow/Polars transport errors -surface from the borrow or during operation rather than from `connect()`. +unavailable, and reader errors surface from the first borrow. In both modes, +direct Arrow/Polars transport errors surface from the borrow or during +operation rather than from `connect()`. ## Authentication and TLS @@ -152,6 +159,69 @@ let token = QuestDb::connect( )?; ``` +A `token` is a static credential: the client sends it on every connection that +it opens, including reconnects, and never renews it. Acquire it from the +identity provider of the QuestDB Enterprise +[OpenID Connect flow](/docs/security/oidc/#non-interactive-clients), and for +Azure services, see +[Microsoft Entra ID managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). +Before the token expires, connect a new `QuestDb` with a new token, wait until +QuestDB has acknowledged what the old senders sent, with +`wait(AckLevel::Ok, timeout)`, then close the old handle. With `sf_dir`, close +the old handle first, then connect the new one with the same `sender_id`: two +handles cannot hold the same store-and-forward slot, and the new handle replays +what the old one left on disk. On QuestDB Enterprise 4.0.2 and later, or with +the User Info endpoint, QuestDB rejects the token on new connections shortly +after it expires; see [Token lifetime](/docs/security/oidc/#token-lifetime). + +### OIDC device flow + +With an OIDC-enabled client build and the `oidc` feature, use +`questdb::oidc::OidcDeviceAuth` for an interactive human login. It displays a +verification URL and code that the user authorizes on any device. Sign in +explicitly, then pass a provider closure to +`QuestDb::connect_with_token_provider` so both ingestion and query connections +can obtain a current token: + +```text title="Cargo.toml" +[dependencies] +questdb-rs = { version = "7", features = ["oidc"] } +``` + +```rust +use std::sync::Arc; +use questdb::{oidc::OidcDeviceAuth, QuestDb}; + +fn main() -> questdb::Result<()> { + let auth = Arc::new( + OidcDeviceAuth::from_questdb("https://questdb.example.com:9000").build()?, + ); + auth.sign_in()?; + let db = QuestDb::connect_with_token_provider( + "wss::addr=questdb.example.com:9000;", + { + let auth = Arc::clone(&auth); + move || auth.token() + }, + )?; + // Borrow senders and readers from db; they share the auth state. + drop(db); + Ok(()) +} +``` + +`token()` can refresh silently but never starts an interactive sign-in. +Without a usable cached or refreshable credential it returns an OIDC +`InteractionRequired` error; call `sign_in()` outside transport callbacks. +The closure's `Arc` keeps the auth object alive for the pool. Do not also set +static credentials in the configuration string. + +See [OIDC device flow](/docs/security/oidc/device-flow/#rust) for client-build +availability, a complete ingestion example, discovery, persistence, and +reauthentication. The `oidc` feature is not enabled by default. + +### TLS + With the default crate features, the TLS root set is `webpki_roots`. Other choices have feature requirements: @@ -770,17 +840,20 @@ and is not part of that thread-bound borrow list. | Key | Default | Guidance | | --- | --- | --- | -| `sender_pool_min` | `1` | Warm minimum of ingestion connections retained once they have been opened. Set it near steady concurrent use. | +| `sender_pool_min` | `1` | Warm minimum of ingestion connections, opened by `connect()` unless `lazy_connect=on`. Set it near steady concurrent use. | | `sender_pool_max` | `4` | Ingestion pool growth cap. Set it at or above peak concurrent sender borrows. | -| `query_pool_min` | `1` | Warm minimum of reader connections retained once they have been opened. | +| `query_pool_min` | `1`, or `0` with `lazy_connect=on` | Warm minimum of reader connections, opened by `connect()` unless `lazy_connect=on`. | | `query_pool_max` | `4` | Reader pool growth cap. Set it at or above peak concurrent reader borrows. | | `acquire_timeout_ms` | `5000` | How long a borrow waits for a returned connection once its pool is at its cap. `0` fails fast. | | `idle_timeout_ms` | `60000` | Idle lifetime for connections above the warm minimum. | | `pool_reap` | `auto` | Use `manual` only when your application will call `reap_idle()`. | | `initial_connect_retry` | `off` | `on`/`sync` retries initial connection synchronously; `async` starts background retry. | +| `lazy_connect` | `off` | `on` makes `connect()` open no connections, so that it tolerates a server that is down at startup. See [Connecting](#connecting). | -Setting an ingress `reconnect_*` key without explicitly setting -`initial_connect_retry` promotes the initial mode to synchronous retry. See the +For a standalone `Sender`, setting an ingress `reconnect_*` key without +explicitly setting `initial_connect_retry` promotes the initial mode to +synchronous retry. A pool, and so `QuestDb::connect`, honours only an +explicitly set `initial_connect_retry`. See the [connect string reference](/docs/connect/clients/connect-string/) for the full grammar and all limits. @@ -851,9 +924,21 @@ ingestion retry budget uses these keys: | `reconnect_initial_backoff_millis` | `100` | | `reconnect_max_backoff_millis` | `5000` | -Authentication failures, protocol-version failures, and terminal data -rejections are not retried. A terminal writer is retired when its borrow is -dropped; there is no pooled `must_close()` or async-error polling API. +Protocol-version failures and terminal data rejections are not retried. A +terminal writer is retired when its borrow is dropped; there is no pooled +`must_close()` or async-error polling API. Authentication rejection is terminal +before a sender's first successful connection and for query initial connects +and reconnects, after any eligible token-provider refresh and retry. + +In builds containing +[c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183), +established senders instead retry server upgrade `401`/`403` rejections +indefinitely with capped backoff, retaining buffered rows even with fixed +credentials. They emit `auth_failed` connection events. Applications requiring +a hard stop must monitor the outage and [close the pool](#closing). Older +builds may still treat these rejections as terminal. A closed or misconfigured +token provider remains terminal. See +[Authentication rejection and credential recovery](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-rejection-and-credential-recovery). Reader failover has a separate policy: diff --git a/documentation/connect/compatibility/ilp/overview.md b/documentation/connect/compatibility/ilp/overview.md index ccec3ed661..699ff6711f 100644 --- a/documentation/connect/compatibility/ilp/overview.md +++ b/documentation/connect/compatibility/ilp/overview.md @@ -441,6 +441,11 @@ GRANT add column, insert ON all tables TO ingest_user; -- grants permissions to GRANT add column, insert ON table1, table2 TO ingest_user; -- grants permissions to add columns and insert data to specific tables ``` +Ingestion fails when it has to create a table or add a column for an external +user, who logs in through [OpenID Connect](/docs/security/oidc/). Create the +tables in advance, with every column that the client sends. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). + Read more setup details in the [Enterprise quickstart](/docs/getting-started/enterprise-quick-start/#4-ingest-data-influxdb-line-protocol) and the [role-based access control](/docs/security/rbac/) guides. diff --git a/documentation/connect/wire-protocols/qwp-client-behavior.md b/documentation/connect/wire-protocols/qwp-client-behavior.md index c4e73a3d5e..9c7f5e24f3 100644 --- a/documentation/connect/wire-protocols/qwp-client-behavior.md +++ b/documentation/connect/wire-protocols/qwp-client-behavior.md @@ -137,11 +137,13 @@ query execution after a connection exists. This naming trips people up | `on` / `true` / `sync` | `SYNC` | retry loop on caller thread, bounded by `reconnect_max_duration_millis` (blocks) | | `async` | `ASYNC` | returns immediately; I/O thread retries in background | -**Default resolution ⚠:** if you don't set `initial_connect_retry` explicitly but -you *do* set any `reconnect_*` knob, the mode becomes `SYNC` — so a "resilience" -knob silently turns startup into a multi-minute **blocking** retry. If no -`reconnect_*` knob is set either, the mode is `OFF`. Always set -`initial_connect_retry` explicitly to avoid this ([sharp edge #1](#known-sharp-edges)). +**Default resolution ⚠:** in Java, setting any `reconnect_*` knob without an +explicit `initial_connect_retry` promotes the mode to `SYNC`, so tuning +reconnects can turn startup into a multi-minute **blocking** retry. In C, C++, +Python, and Rust, this promotion applies to standalone senders only; their +pooled facades require an explicit initial mode. With no promotion or explicit +mode, the default is `OFF`. Set `initial_connect_retry` explicitly to avoid +ambiguity ([sharp edge #1](#known-sharp-edges)). ### Facade prewarm @@ -279,11 +281,11 @@ here. | # | Sharp edge | Status | | --- | --- | --- | -| 1 | `initial_connect_retry` is implicitly promoted to `SYNC` when any `reconnect_*` knob is set — a resilience knob silently makes startup block. | Candidate | +| 1 | Java and standalone C/C++/Python/Rust senders promote `initial_connect_retry` to `SYNC` when any `reconnect_*` knob is set; pooled C/C++/Python/Rust clients do not. Set the initial mode explicitly. | Candidate | | 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 | Authentication rejection ends the current endpoint walk. Java startup, established senders, queries, and orphan drainers have different [recovery policies](#authentication-rejection-and-credential-recovery). | Intended; client- and lifecycle-specific | | 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,14 @@ 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. Before its first +successful connection, authentication rejection (after any token-provider retry) +or a durable-ack capability mismatch is terminal. After a sender has connected, +recovery depends on the rejection and client build; see +[Authentication rejection and credential recovery](#authentication-rejection-and-credential-recovery). +The poison-frame detector can still terminate a sender. If the buffer reaches +`sf_max_total_bytes`, `append()` blocks and then throws after +`sf_append_deadline_millis`; the sender itself keeps retrying. ### Reconnect and outage handling @@ -356,13 +362,17 @@ wall-clock give-up and no budget-exhaustion event. Backoff grows from `reconnect_initial_backoff_millis` to `reconnect_max_backoff_millis` and stays there; the loop rotates through the endpoints in `addr` as it goes. -`reconnect_max_duration_millis` has exactly two consumers, neither of which is -the steady-state loop: +`reconnect_max_duration_millis` bounds startup and specific Java orphan-recovery +budgets, not the steady-state foreground loop: -1. The **blocking sync initial connect** (`initial_connect_retry=on` / `sync`), - which gives up and throws when the budget expires. -2. The background drainer's **durable-ack capability-gap budget**, used when an - orphan slot repeatedly lands on a node that cannot serve durable acks. +1. The **blocking sync initial connect** (`initial_connect_retry=on` / `sync`) + gives up and throws when the budget expires. +2. The Java background drainer uses it as a **durable-ack capability-gap budget** + when an orphan repeatedly lands on a node that cannot serve durable acks. +3. With a token provider, the Java drainer also derives a bounded authentication + rejection window from it. See + [How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) + for the time and attempt thresholds. The practical consequence: what bounds your tolerance of a long outage is **buffer capacity, not time**. In SF mode that is `sf_max_total_bytes` against @@ -380,6 +390,42 @@ and apply back-pressure to the producer rather than dropping data. ::: +### Authentication rejection and credential recovery + +A `401`/`403` ends the current endpoint walk rather than continuing to other +hosts with the rejected credential. This does not necessarily end the sender's +lifetime. + +Java and native clients with the token-provider changes below can first retry a +`401` once on the same endpoint when there is no Bearer `error` or when +`error="invalid_token"`, but only if the provider supplies a different token. +A `403` or another Bearer error, such as `insufficient_scope`, does not trigger +this refresh/retry. + +Here, **native clients** means Rust, C, and C++ builds containing +[c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183), and +Python builds containing +[py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133), +which uses that native core. After any eligible token retry: + +| Lifecycle | Java | Native clients | +| --- | --- | --- | +| Sender before its first successful connection | Terminal, including with `initial_connect_retry=async`. | Terminal, including with `initial_connect_retry=async`. | +| Established foreground sender | Retry with backoff indefinitely while retaining buffered rows, even with fixed credentials. Report through connection health and the error handler. | Retry with capped backoff indefinitely while retaining buffered rows, even with fixed credentials. Emit `auth_failed` connection events; an acknowledgement timeout names the rejection. | +| Query initial connect or execution-time reconnect | Terminal for that connection attempt or query; no continuation to later endpoints. | Terminal for that connection attempt or query; no continuation to later endpoints. | +| Orphan drainer with fixed credentials | Quarantine the slot immediately behind a `.failed` file. | Retire the slot immediately for the current session and record `.last_error`. | +| Orphan drainer with a token provider | Retry under a bounded time/attempt policy, then quarantine if rejections persist. | Retry under a bounded time/attempt policy, then retire the slot for the current session and record `.last_error`. No `.failed` sentinel is written; the next session tries the slot again. | + +Older native releases may still treat authentication rejection as terminal for +established senders. See +[Ingress reconnect](/docs/connect/clients/connect-string/#reconnect-keys) +for the native drainer policy, and +[How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) +for Java refresh limits, orphan thresholds, and reporting. The established +sender's reconnect loop is independent of the one-time token refresh. Retrying +a server's `401`/`403` does not make a closed or misconfigured native token +provider recoverable; those provider failures remain terminal. + ### Ingest endpoint walk (`addr=a:9000,b:9000,...`) | Per-endpoint result | Sender behavior | @@ -389,11 +435,11 @@ 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 ⚠ | -| durable-ack requested but unsupported | terminal mismatch | +| `401` / `403` auth failure | End the current walk after any token-provider retry; subsequent recovery depends on [client build and lifecycle](#authentication-rejection-and-credential-recovery). | +| durable-ack requested but unsupported | Terminal at startup; established Java foreground senders retry, while orphan drainers use a bounded capability-gap budget. | | successful write upgrade | bind this endpoint | | all endpoints fail transport | throw / retry per initial/reconnect mode | -| all endpoints role-reject as replicas | `QwpRoleMismatchException` | +| all endpoints role-reject as replicas | OFF startup throws `QwpRoleMismatchException`; SYNC startup retries within its budget; ASYNC startup and running senders retry in the background. | ### Query client initial connect @@ -417,7 +463,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 may first retry the same endpoint with a refreshed token; otherwise terminal `QwpAuthFailedException`, with no later endpoints tried. | | 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 +485,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 rejection during failover reconnect is terminal and reported to the + handler after any eligible same-endpoint token-provider retry. See + [Authentication rejection and credential recovery](#authentication-rejection-and-credential-recovery). With `failover=off`, a transport failure is reported to the handler with no reconnect/replay. @@ -464,7 +512,7 @@ reconnect/replay. | server down, default mode | no `reconnect_*`, no async | one attempt; build throws | | 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 | +| server returns `401`/`403` | any mode | terminal at startup after any eligible same-endpoint token-provider retry; no continuation to later endpoints | | 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 | @@ -479,7 +527,7 @@ reconnect/replay. | upgrades but no `SERVER_INFO` | Yes | after `serverInfoTimeoutMs` (builder only) | | primary/standalone while `target=replica` | Yes | role mismatch | | `421` role reject | Yes | try next | -| `401`/`403` | **No** | auth treated as cluster-wide terminal ⚠ | +| `401`/`403` | **No** | Java may retry the same endpoint after eligible token refresh; a remaining rejection is terminal. | | broken shared TLS/trust store | No | every endpoint fails | | all endpoints down | No | `all QWP endpoints unreachable` | | reachable but none match `target` | No | `QwpRoleMismatchException` | @@ -555,5 +603,9 @@ 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. +Before the first successful connection, `QwpAuthFailedException` (after any +eligible token-provider retry) and non-role `WebSocketUpgradeException` failures +are terminal. An established foreground sender instead retries these failures +with backoff while retaining buffered data. Orphan drainers return control to +their owner, which applies its separate quarantine policy. See +[Authentication rejection and credential recovery](#authentication-rejection-and-credential-recovery). diff --git a/documentation/connect/wire-protocols/qwp-egress-websocket.md b/documentation/connect/wire-protocols/qwp-egress-websocket.md index 53d66107b9..7f3d078a52 100644 --- a/documentation/connect/wire-protocols/qwp-egress-websocket.md +++ b/documentation/connect/wire-protocols/qwp-egress-websocket.md @@ -796,8 +796,13 @@ 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. +- Authentication rejections stop the endpoint walk rather than trying other + hosts. With a token provider, Java can first retry the same host once if a + refreshable `401` produces a different token. A `403` or a Bearer error + other than `invalid_token` does not trigger that refresh/retry. If the + retry is unavailable or fails, the authentication rejection is terminal + for the query. See + [How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures). - 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 4680474016..af8e5e9956 100644 --- a/documentation/connect/wire-protocols/qwp-ingress-websocket.md +++ b/documentation/connect/wire-protocols/qwp-ingress-websocket.md @@ -143,7 +143,7 @@ Supported methods: [HTTP basic authentication](/docs/connect/compatibility/rest-api/#http-basic-authentication). - **Token-based auth** (Enterprise only): see [Authentication via token in QuestDB Enterprise](/docs/connect/compatibility/rest-api/#authentication-via-token-in-questdb-enterprise). -- **OIDC** (Enterprise only): see [OpenID Connect](/docs/security/oidc/). +- **OIDC** (Enterprise only): see [OpenID Connect](/docs/security/oidc/#non-interactive-clients). A failed authentication results in a `401` or `403` HTTP response before the WebSocket connection is established. No QWP-level auth handshake exists. @@ -1159,15 +1159,30 @@ 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 rejections depend on the client and lifecycle.** In Java, + a token provider can retry a refreshable `401` once on the same host with a + different token. If that retry is unavailable or fails, authentication + rejection is terminal before the sender's first successful connection. + Once a foreground Java sender has connected, it instead keeps buffering + and retrying rejected credentials with backoff, even with a fixed + credential. Rust, C, and C++ builds containing + [c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183), + and Python builds containing + [py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133), + also retry server upgrade `401`/`403` indefinitely after the first successful + connection, retaining buffered rows. Initial authentication rejection remains + terminal after any eligible token retry. Older native releases may still + treat reconnect authentication rejection as terminal. Orphan drainers have + separate bounded policies, including different Java and native slot-retirement + behavior. See + [Authentication rejection and credential recovery](/docs/connect/wire-protocols/qwp-client-behavior/#authentication-rejection-and-credential-recovery). - **`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, - including `404`, `426`, `503`, generic 4xx/5xx, TCP/TLS failures, - mid-stream send/recv errors, and an upgrade response that advertises a QWP - version outside the client's supported range (per-endpoint, so a host on a - rolling upgrade does not lock the client out of compatible peers). +- **Transport errors are transient**, including TCP/TLS failures and + mid-stream send/recv errors. For other HTTP upgrade rejections, version + mismatches, and durable-ACK capability rejections, the client and lifecycle + determine whether a failed endpoint round is terminal or retried. See + [Ingress reconnect](/docs/connect/clients/connect-string/#reconnect-keys). :::note Enterprise diff --git a/documentation/deployment/azure.md b/documentation/deployment/azure.md index edd2c8b496..0a74607205 100644 --- a/documentation/deployment/azure.md +++ b/documentation/deployment/azure.md @@ -342,10 +342,14 @@ QuestDB Enterprise adds production features for Azure: - **Blob Storage replication** - Continuous backup for durability - **Cold storage** - Move old partitions to Blob Storage, query on-demand - **High availability** - Automatic failover across instances -- **EntraID SSO** - Single sign-on with Microsoft Entra ID - -For EntraID integration, see the -[Microsoft EntraID OIDC guide](/docs/security/oidc/#microsoft-entraid). +- **Entra ID SSO** - Single sign-on with Microsoft Entra ID + +For Entra ID integration, see the +[Microsoft Entra ID guide](/docs/security/oidc/entra-id/). +Since QuestDB Enterprise 4.0.2, Azure services that run as a managed identity +or a service principal can also authenticate to QuestDB with Entra ID app-only +tokens. See +[Managed identities and service principals](/docs/security/oidc/entra-id/#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 be95b64a9b..c8ad927b88 100644 --- a/documentation/high-availability/client-failover/concepts.md +++ b/documentation/high-availability/client-failover/concepts.md @@ -187,7 +187,7 @@ will not help. | Condition | Why terminal | |---|---| -| HTTP `401` / `403` on upgrade | Credentials are cluster-wide; retrying floods server logs without recovery. | +| HTTP `401` / `403` during initial connect or query reconnect, after any token-provider retry | The rejected credential stops the endpoint walk. Established senders and orphan drainers have [client-specific recovery policies](#authentication-is-cluster-wide). | | Server-status reject (SF) | Application-layer reject; replay reproduces the same response. | ### Topology — handled inside the round @@ -232,14 +232,46 @@ when at least one peer is healthy." ## Authentication is cluster-wide -A `401` or `403` on the HTTP upgrade is terminal — the client does not retry -other hosts. The assumption is that auth credentials are configured -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. - -If your deployment has per-host credentials, that is unsupported and outside -the failover model — split the workload into one connect string per credential. +Credentials are assumed to be configured identically across the cluster. +A `401` or `403` therefore ends the current endpoint walk rather than +continuing to other hosts with the rejected credential. Whether the client +retries later depends on the client and lifecycle: + +- With a token provider, Java can first retry the same host once when a + refreshable `401` produces a different token. A `403` or a Bearer error + other than `invalid_token` does not trigger that refresh/retry. +- Before a sender's first successful connection, or when a query client + connects or reconnects, any remaining authentication rejection is terminal. +- An established foreground Java sender instead keeps buffering rows and + retrying with backoff, even with a fixed credential. It reports the outage + through connection health and the error handler. +- Rust, C, and C++ builds containing + [c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183), + and Python builds containing + [py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133), + also retry server upgrade `401`/`403` indefinitely on established senders, + retaining buffered rows. They emit `auth_failed` connection events, and + acknowledgement timeouts identify the rejection. A closed or misconfigured + native token provider remains terminal. Older native releases may still + treat server authentication rejection as terminal after the first connection. +- Applications that need a hard failure must monitor the established sender's + outage and close it themselves; an acknowledgement timeout does not stop its + reconnect loop. +- A Java orphan drainer fails immediately on a rejected fixed credential. + With a token provider, it retries under a bounded policy before + quarantining the slot behind a `.failed` file; the rows stay on disk. +- Native orphan drainers in the builds above also stop immediately on a + rejected fixed credential and retry rotating credentials under a bounded + policy. They record `.last_error` and retire the slot for the current session + without writing a `.failed` sentinel; the next session tries it again. + +See [Ingress reconnect](/docs/connect/clients/connect-string/#reconnect-keys) +for native drainer thresholds, and +[How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) +for Java refresh rules, drainer thresholds, and reporting. + +Per-host credentials are outside the failover model. Split such workloads +into one connect string per credential. ## Next steps diff --git a/documentation/high-availability/failover.md b/documentation/high-availability/failover.md index 3b153faaa8..5ebca4c0fe 100644 --- a/documentation/high-availability/failover.md +++ b/documentation/high-availability/failover.md @@ -284,8 +284,9 @@ The [minimal HTTP server](/docs/operations/logging-metrics/#minimal-http-server) on port 9003 exposes the same switch to external coordinators. It accepts the same credentials as the main HTTP server: HTTP basic authentication, a [REST token](/docs/connect/compatibility/rest-api/#authentication-via-token-in-questdb-enterprise) -as `Authorization: Bearer`, or an OIDC access token. The principal needs the -`HTTP` endpoint permission and, for the switch, `SWITCH ROLE`. With access +as `Authorization: Bearer`, or an +[OIDC token](/docs/security/oidc/#non-interactive-clients). The principal needs +the `HTTP` endpoint permission and, for the switch, `SWITCH ROLE`. With access control disabled no credentials are needed. TLS for this port is configured with the `http.min.tls.*` settings on the [TLS](/docs/configuration/tls/) page. diff --git a/documentation/high-availability/store-and-forward/operating-and-tuning.md b/documentation/high-availability/store-and-forward/operating-and-tuning.md index 9b69e434ca..fcc8bce59c 100644 --- a/documentation/high-availability/store-and-forward/operating-and-tuning.md +++ b/documentation/high-availability/store-and-forward/operating-and-tuning.md @@ -225,7 +225,13 @@ and exits when the orphan's `ackedFsn ≥ publishedFsn`. - **Reconnect budget exhausted.** Drainer writes `.failed` with reason, releases the lock, exits. -- **Auth-terminal upgrade error.** Same. +- **Authentication rejection.** In Java, a fixed credential that is rejected + fails the drainer immediately. With a token provider, `401`/`403` + rejections receive bounded retries before the drainer writes `.failed` + and exits. This differs from an established foreground Java sender, + which keeps retrying. See + [How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures) + for the retry thresholds and reporting. - **Irrecoverable corruption.** Same. `.failed` slots are excluded from auto-drain on subsequent scans — diff --git a/documentation/query/sql/acl/create-service-account.md b/documentation/query/sql/acl/create-service-account.md index ce9230ec9b..b61b21b443 100644 --- a/documentation/query/sql/acl/create-service-account.md +++ b/documentation/query/sql/acl/create-service-account.md @@ -50,7 +50,9 @@ clause is present, in which case the permission is granted to the user or group specified in the clause. The `OWNED BY` clause cannot be omitted if the service account is created by -an external user, because permissions cannot be granted to them. +an external user, because permissions cannot be granted to them. The owner must +then be one of the QuestDB groups that the external user belongs to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). ## Examples diff --git a/documentation/query/sql/alter-table-add-column.md b/documentation/query/sql/alter-table-add-column.md index 546e0a0e26..a258675875 100644 --- a/documentation/query/sql/alter-table-add-column.md +++ b/documentation/query/sql/alter-table-add-column.md @@ -38,7 +38,10 @@ However, if the `OWNED BY` clause is used, the permissions instead go to the user, group, or service account named in that clause. The `OWNED BY` clause cannot be omitted if the column is added by an external -user, because permissions cannot be granted to them. +user, who logs in through OIDC, because permissions cannot be granted to them. +The owner must then be one of the QuestDB groups that the external user belongs +to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). ## Examples diff --git a/documentation/query/sql/create-live-view.md b/documentation/query/sql/create-live-view.md index 17e09e2ecd..420fe2c5a8 100644 --- a/documentation/query/sql/create-live-view.md +++ b/documentation/query/sql/create-live-view.md @@ -400,8 +400,13 @@ owner receives these permissions instead. ### OWNED BY clause -Assign ownership to a user, group, or service account. The query must be wrapped -in parentheses: +Assign ownership to a user, group, or service account. The `OWNED BY` clause +cannot be omitted if the live view is created by an external user, who logs in +through OIDC, because permissions cannot be granted to them. The owner must +then be one of the QuestDB groups that the external user belongs to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). + +The query must be wrapped in parentheses: ```questdb-sql CREATE GROUP analysts; diff --git a/documentation/query/sql/create-mat-view.md b/documentation/query/sql/create-mat-view.md index 0dd9739535..6bf7d2cf16 100644 --- a/documentation/query/sql/create-mat-view.md +++ b/documentation/query/sql/create-mat-view.md @@ -362,7 +362,9 @@ CREATE MATERIALIZED VIEW trades_hourly AS ( :::note External users (authenticated via external identity providers) must specify the -`OWNED BY` clause when creating materialized views. +`OWNED BY` clause when creating materialized views. The owner must then be one +of the QuestDB groups that the external user belongs to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). ::: ### Permission examples diff --git a/documentation/query/sql/create-table.md b/documentation/query/sql/create-table.md index a2a4d39de3..b1cedab389 100644 --- a/documentation/query/sql/create-table.md +++ b/documentation/query/sql/create-table.md @@ -827,7 +827,10 @@ clause is used, the permissions instead go to the user, group, or service account named in that clause. The `OWNED BY` clause cannot be omitted if the table is created by an external -user, because permissions cannot be granted to them. +user, who logs in through OIDC, because permissions cannot be granted to them. +The owner must then be one of the QuestDB groups that the external user belongs +to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). ```questdb-sql CREATE GROUP analysts; diff --git a/documentation/query/sql/create-view.md b/documentation/query/sql/create-view.md index 33eacf2c68..8efde0c864 100644 --- a/documentation/query/sql/create-view.md +++ b/documentation/query/sql/create-view.md @@ -217,6 +217,12 @@ When a user creates a new view, they are automatically assigned all view level permissions with the `GRANT` option for that view. This behavior can be overridden using `OWNED BY`. +The `OWNED BY` clause cannot be omitted if the view is created by an external +user, who logs in through OIDC, because permissions cannot be granted to them. +The owner must then be one of the QuestDB groups that the external user belongs +to. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). + ```questdb-sql CREATE GROUP analysts; CREATE VIEW trades_summary AS ( diff --git a/documentation/security/oidc.mdx b/documentation/security/oidc.mdx deleted file mode 100644 index 51e5d01ab5..0000000000 --- a/documentation/security/oidc.mdx +++ /dev/null @@ -1,1342 +0,0 @@ ---- -title: OpenID Connect (OIDC) Integration -description: Configure OpenID Connect (OIDC) integration with external Identity Providers for SSO authentication in QuestDB Enterprise Web Console. ---- - -import Screenshot from "@theme/Screenshot"; -import { EnterpriseNote } from "@site/src/components/EnterpriseNote" - - - OpenID Connect (OIDC) enables SSO authentication with external Identity Providers. - - -OpenID Connect (OIDC) integrates with Identity Providers (IdP) external to -QuestDB. - -It is a convenient way to integrate QuestDB into your enterprise environment, -and it provides SSO (Single Sign-On) for the [Web Console](/docs/getting-started/web-console/overview/). - -Microsoft Active Directory and Azure AD, for example, can be turned into an -Identity Provider. - -Specific installation steps depend on the type of the provider. - -## Architecture overview - -Altogether, the architecture appears as such: - - - -We can break it down into core components. - -### Web Console - -QuestDB's interactive UI. Users must authenticate before accessing the database -via the interface. - -The [Web Console](/docs/getting-started/web-console/overview/) uses PKCE (Proof Key for Code Exchange) to -secure the authentication and authorization flow. - -In OAuth2/OIDC terms, the [Web Console](/docs/getting-started/web-console/overview/) is referred to as -the _client_, and it is assigned an identifier: the **Client Id**. - -Each application which integrates via OIDC should be given a different **Client -Id**. - -### OIDC Provider - -Typically consists of a number of modules. - -We are interested in two of them only. - -1. The _Identity Provider_ holds user identities and user information, capable - of authenticating users, and to issue an ID Token which uniquely identify - them. - -2. The _Authorization Server_ grants access to resources, such as a database, in - the form of access tokens. - -The OIDC Provider usually integrates with a number of applications which require -different access to a number of resources. - -These clients communicate with the OIDC Provider via its endpoints. - -It exposes a number of APIs, including the Authorization, Token and User Info -endpoints. - -### QuestDB - -The database, in OAuth2/OIDC terms the _protected resource_ or _resource -server_. - -Only processes requests which contain a valid access token. - -## Authentication and Authorization Flow - -The OAuth2/OIDC standard defines different ways of obtaining access and ID -tokens from the OIDC Provider, referred to as the "_flow_". - -The goal of this flow is to get the user, who is sitting in front of the Web -Console, authenticated. - -Then, it allows QuestDB to determine the user's permissions based on user -information provided by the Identity Providers. - -Specifically, the QuestDB [Web Console](/docs/getting-started/web-console/overview/) uses the -`Authorization Code Flow with PKCE` option. - -It consists of ten steps... - -### 1. Secret generation - -First the [Web Console](/docs/getting-started/web-console/overview/) generates a cryptographically strong -random secret called the _code verifier_. - -The secret is hashed using the _SHA256 algorithm_. The result is the _code -challenge_. - -After PKCE initialization the [Web Console](/docs/getting-started/web-console/overview/) requests an -_authorization code_ from the OIDC Provider. - -It calls the Authorization endpoint with a few parameters, including the: - -- **Client Id** -- requested scopes (the list of scopes are configurable, default is `openid` - only) -- code challenge -- algorithm used to generate the code challenge from the code verifier (SHA256) - -When the Authorization Server receives the request, it checks if the user has -been authenticated already: - -- If the user has a valid session, it can be provided with an authorization code - straight away, so we jump to step 4. - -- If the user does not have a valid session yet, it will be redirected to the - Identity Provider for authentication. - -```bash title="Authorization code request example" -https://oidc.provider:443/as/authorization.oauth2?client_id=questdb&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_challenge=IwZ-WuypAY3fMtvismbj1MQUe5CzMgrBa87nYcgFoLQ&code_challenge_method=S256 -``` - -### 2. Prove identity - -Next, the user must prove its identity. - -This could be a username with: - -- a password, -- an OTP -- facial recognition via a mobile app -- or anything else supported by the Identity Provider. - - - -### 3. Scope consent - -After successful authentication, the user provides consent for the requested -scopes. - -The list of scopes are configurable. - -By default the Web Console requests only the `openid` scope which is mandatory -for OIDC. - -No ID Token is issued without it. - -The OIDC provider can be configured to provide the consent automatically, -without presenting the user with an additional screen in the browser. - - - -### 4. Redirection - -Consent is granted! - -The Authorization Server redirects the user back to the -[Web Console](/docs/getting-started/web-console/overview/) with the _authorization code_: - -```bash title="Authorization code response example" -https://questdb.host:9000/?code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A -``` - -### 5. Credential request - -Now, the QuestDB [Web Console](/docs/getting-started/web-console/overview/) requests the ID and access -tokens from the Token endpoint of the OIDC Provider with the authorization code. - -It includes the Client ID and the PKCE code verifier together with the -authorization code in the request. - -The endpoint then hashes the code verifier using the method specified previously -in step 1. - -The result must match the code challenge, also provided in step 1. - -The matching code challenge proves that the token is requested by the client -which requested the authorization code, and it was not stolen: - -```bash title="Token request example" -POST https://oidc.provider:443/as/token.oauth2 HTTP/1.1 -Content-Type: application/x-www-form-urlencoded -grant_type=authorization_code&code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A&client_id=questdb&&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_verifier=uGZh4sQffXLgRna7D-jtEAkuXzp7Lm_okZXBljzP38coAD44kEheIaz7Pdh98KxYtYLZHNiQPCczQYeF -``` - -### 6. Credentials received - -If the PKCE check is passed, the Web Console receives the ID and access tokens. - -There is a third token in the response too, the refresh token. - -The refresh token is used by the Web Console to refresh the access token before -it expires. - -Without the refresh token mechanism, the user would be forced to re-authenticate -when the access token expires. - -The validity of the tokens are configurable inside the OIDC Provider. - -```json title="Token response example" -{ - "access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom", - "refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv.eg83ge", - "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I...", - "token_type": "Bearer", - "expires_in": 300 // In seconds, thus 5 minutes -} -``` - -### 7. Database access - -With the tokens, the Web Console can interact with the database. - -The access token is in the header of every request sent to QuestDB. - -> **Worried about exposing the token?** It is rather opaque and does not contain -> user details. - -To carry out permission checks, the database has to know more about the user. - -For this, QuestDB has a User Info Cache. - -If it finds a valid entry with the access token in the cache, steps 8 and 9 are -skipped: - -```bash title="Query request example" -https://questdb.host:9999/exec?query=select%20current_user() -Authorization: Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom -``` - -### 8. Find user information - -No user information in the cache, or stale information? - -QuestDB uses the access token to request user information from the OIDC -Provider's User Info endpoint. - -This call also serves as token validation. - -If the token is not real or has been expired, the User Info endpoint replies -with an error: - -```bash title="User info request example" -https://oidc.provider:443/idp/userinfo.openid -Authorization: Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom -``` - -### 9. Receive user information - -If the access token is valid, QuestDB receives the required user information -from the endpoint, then updates its cache. - -The cache improves performance, as QuestDB does not have to turn to the OIDC -Provider on every single request. - -Do note that cache expiry is configurable: - -```json title="User info response example with Active Directory groups" -{ - "sub": "externalUser", - "name": "External User", - "groups": [ - "CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev", - "CN=TestGroup2,OU=DC Users,DC=ad,DC=quest,DC=dev" - ] -} -``` - -### 10. Permission check - -With the help of the user information, QuestDB can carry out -[permission checks](#user-permissions). - -If the permission check is successful, the database will process the request, -and then sends the results back: - -```json title="Query response example" -{ - "query": "select current_user()", - "columns": [ - { - "name": "current_user", - "type": "STRING" - } - ], - "dataset": [["External User"]], - "count": 1, - "timestamp": -1 -} -``` - -## Interactive clients - -Any interactive client - a UI, Jupyter notebook, CLI - can integrate with an -OIDC provider. However, the level of support will vary between these tools. - -Interactive clients usually fall into one of the following categories: - -- Browser-based clients with support for HTTP redirects; this includes the Web - Console or any javascript UI -- Applications running in a browser without support for redirects, such as - Jupyter notebooks -- Non-browser based clients, usually some kind of command line interface (CLI) - or a standalone application, such as Microsoft Access - -### Browser-based clients - -If the tool is browser based and can handle HTTP redirects, it can implement two -possible flows to request an access token.: - -1. [Authorization Code](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) - flow **(Recommended, more secure)** - -2. [Implicit](https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth) - flow - -The Web Console implements the -[Authorization Code Flow with PKCE](https://oauth.net/2/pkce), which is a -special version of the Authorization Code flow designed for mobile apps and -single page applications. - -Regardless of which flow is used by the web or mobile application, the requested -access token can be used for authentication and authorization when communicating -with QuestDB as explained in the [above 7th step](#7-database-access). - -### Jupyter notebook - -JupyterHub can integrate with OAuth2 providers using OAuthenticator, as -described in its -[documentation](https://jupyterhub.readthedocs.io/en/stable/explanation/oauth.html). -The OAuthenticator documentation also contains -[examples](https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/index.html) -using different identity providers. - -If Jupyter notebooks are used without JupyterHub, one option for OAuth2 -integration is to use the -Resource Owner Password Credentials (ROPC) flow. -It is likely that enabling this flow in your OAuth2 provider will require -additional setup. - -:::caution - -The Resource Owner Password Credentials flow is legacy, and should be used as a last resort. - -::: - -We can use the code below to acquire an access token in our notebook: - -```python -from urllib import request, parse -import json - -url = "https://oidc.provider:443/as/token.oauth2" -data = parse.urlencode( { - "grant_type": "password", - "username": "testuser", - "password": "testpwd", - "scope": "openid", - "client_id": "testclient" -} ).encode() -req = request.Request(url=url, data=data) -req.add_header("Content-Type", "application/x-www-form-urlencoded") -with request.urlopen(req) as f: - body = f.read().decode(f.headers.get_content_charset()) - resp = json.loads(body) - access_token = resp["access_token"] -``` - -This token can be used to authenticate with QuestDB: - -```python -query = parse.urlencode({ - "query": "select current_user()" -}) -req = request.Request(f"http://localhost:9000/api/v1/sql/execute?{query}") -req.add_header("Authorization", f"Bearer {access_token}") -with request.urlopen(req) as f: - body = f.read().decode(f.headers.get_content_charset()) - resp = json.loads(body) - print(resp) -``` - -#### Externalizing credentials - -The above example saves the user's credentials into the notebook, potentially -exposing them to others. One way to improve this is to use environment variables -or files to externalize the username and password. - -Here is an example using the `dotenv` library. - -First we need to create a file named `.env` with the settings: - -```python -username=testuser -password=testpwd -``` - -Then load it in our notebook, and use it to request tokens: - -```python -from dotenv import load_dotenv -import os -from urllib import request, parse -import json - -load_dotenv() -user = os.environ.get("username") -pwd = os.environ.get("password") - -url = "https://oidc.provider:443/as/token.oauth2" -data = parse.urlencode( { - "grant_type": "password", - "username": user, - "password": pwd, - "scope": "openid", - "client_id": "testclient" -} ).encode() -req = request.Request(url=url, data=data) -req.add_header("Content-Type", "application/x-www-form-urlencoded") -with request.urlopen(req) as f: - body = f.read().decode(f.headers.get_content_charset()) - resp = json.loads(body) - access_token = resp["access_token"] -``` - -#### Enable ROPC - -The Resource Owner Password Credentials flow can be enabled in QuestDB within -`server.conf`: - -``` -acl.oidc.ropc.flow.enabled = true -``` - -> Note that the flow also has to be configured in the OAuth2/OIDC provider! - -Now we can use Basic Authentication to simplify our code. We send the -credentials to QuestDB, and the database will validate the credentials against -the OAuth2 provider. - -```python -from dotenv import load_dotenv -import os -from urllib import request -import base64 - -load_dotenv() -user = os.environ.get("username") -pwd = os.environ.get("password") - -query = parse.urlencode({ - "query": "select current_user()" -}) -req = request.Request(f"http://localhost:9000/api/v1/sql/execute?{query}") -b64credentials = base64.standard_b64encode(f"{user}:{pwd}".encode()).decode() -req.add_header("Authorization", f"Basic {b64credentials}") -with request.urlopen(req) as f: - body = f.read().decode(f.headers.get_content_charset()) - resp = json.loads(body) - print(resp) -``` - -We can also use a postgres client to connect to the database: - -:::note - -QuestDB never persists the user's credentials. - -::: - -```python -import psycopg as pg -from dotenv import load_dotenv -import os - -load_dotenv() -user = os.environ.get("username") -pwd = os.environ.get("password") - -conn_str = f"user={user} password={pwd} host=localhost port=8812 dbname=qdb" -with pg.connect(conn_str, autocommit=True) as connection: - with connection.cursor() as cur: - cur.execute("select current_user()") - records = cur.fetchall() - for row in records: - print(row) -``` - -### CLI, standalone applications - -When using CLI tools, such as `psql`, or standalone applications like Microsoft -Access, the best option may be the Resource Owner Password Credentials flow. - -The user logs in with their SSO credentials, and the server validates the -details with the OAuth2 provider: - -```shell -% psql -h localhost -p 8812 -U testuser -Password for user testuser: -psql (14.2, server 11.3) -Type "help" for help. - -testldap=> -testldap=> -``` - -## Non-interactive clients - -Non-interactive clients are usually jobs or standalone applications, such as a -client for ingesting data. It is practical to manage their credentials via an -OAuth2 provider too. - -As seen in the Jupyter notebook examples, the clients can request a token -themselves and then use it to authorise data ingestion: - -```python -import json -import os -import requests -import pandas as pd -from dotenv import load_dotenv -from questdb.ingress import Sender - -load_dotenv() -user = os.environ.get("username") -pwd = os.environ.get("password") - -token_endpoint = "https://oidc.provider:443/as/token.oauth2" -response = requests.post(token_endpoint, - data={"grant_type": "password", - "client_id": "testclient", - "username": user, - "password": pwd, - "scope": "openid"}, - headers={"Content-Type": "application/x-www-form-urlencoded"}) - -response_body = response.content.decode("utf-8") -tokens = json.loads(response_body) -access_token = tokens["access_token"] - -conf = f"http::addr=localhost:9000;token={access_token};" -with Sender.from_conf(conf) as sender: - df = pd.read_csv("data.csv") - df["ts"] = pd.to_datetime(df["ts"]) - sender.dataframe(df, table_name="foo", at="ts") -``` - -Alternatively, a user may rely on QuestDB to authenticate them via the OAuth2 -provider when the Resource Owner Password Credentials flow is enabled on the -server side: - -```python -import os -import pandas as pd -from dotenv import load_dotenv -from questdb.ingress import Sender - -load_dotenv() -user = os.environ.get("username") -pwd = os.environ.get("password") - -conf = f"http::addr=localhost:9000;username={user};password={pwd};" -with Sender.from_conf(conf) as sender: - df = pd.read_csv("data.csv") - df["ts"] = pd.to_datetime(df["ts"]) - sender.dataframe(df, table_name="foo", at="ts") -``` - -## OIDC for the PGWire endpoint - -If the -Resource Owner Password Credentials (ROPC) flow is not an option, we can still authenticate via OIDC on the PGWire endpoint. -However, in this case the client's responsibility to source the token required for authentication. -This method works wherever a Postgres client library is available, including jupyter notebooks. - -Token authentication for the PGWire endpoint should be enabled by adding the `acl.oidc.pg.token.as.password.enabled=true` setting to the server configuration. - -The token should be sent in the password field, while the username field should contain the string `_sso`, or left empty if that is an option: - -```python -import psycopg as pg - -token = "token_requested_from_the_oauth2_provider" - -conn_str = f"user=_sso password={token} host=localhost port=8812 dbname=qdb" -with pg.connect(conn_str, autocommit=True) as connection: - with connection.cursor() as cur: - cur.execute('select current_user()') - records = cur.fetchall() - for row in records: - print(row) -``` - -## User permissions - -QuestDB requires additional user information to be able to construct the user's -access list. - -As a reminder, the access list is the list of permissions that determines what -the user can and cannot do. - -QuestDB itself does not store external users, nor their passwords or any other -authentication related detail. - -External users and their authentication methods are managed by the Identity -Provider. - -Since external users are not managed by QuestDB, permissions cannot be granted -to them directly. - -Instead, the database expects a list of groups, called the _groups claim_ to be -present in the user information. - -These external group names are mapped to QuestDB's own groups. - -The access list of the external user consists of the permissions granted to -those groups: - - - -### Mapping user permissions - -The mappings between external and QuestDB groups are managed with the following -SQL commands: - -```questdb-sql title="Create a group which is mapped to an Active Directory group" -CREATE GROUP groupName WITH EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; -``` - -```questdb-sql title="Map an Active Directory group to an already existing QuestDB group" -ALTER GROUP groupName WITH EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; -``` - -```questdb-sql title="Remove an Active Directory mapping without deleting the QuestDB group" -ALTER GROUP groupName DROP EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; -``` - -See [`CREATE GROUP`](/docs/query/sql/acl/create-group/) and -[`ALTER GROUP`](/docs/query/sql/acl/alter-group/) for the full syntax. - -External users cannot be given a query memory limit directly; -`ALTER USER ... SET MEMORY LIMIT` is rejected for them. Set the limit on the -mapped QuestDB group instead; see -[memory limits](/docs/security/rbac/#memory-limits). - -QuestDB works the list of external groups out from the User Info response -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. - -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/). - -```json title="User info response example with Active Directory groups" -{ - "sub": "externalUser", - "name": "External User", - "groups": [ - "CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev", - "CN=TestGroup2,OU=DC Users,DC=ad,DC=quest,DC=dev" - ] -} -``` - -Any change made to the user's group membership in the Identity Provider, QuestDB -will adjust the user's access list. - -:::note - -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. - -::: - -The same stands for changes made to the user's status within the Identity -Provider. - -For example, a disabled user will not be kicked out of QuestDB immediately. - -The `acl.oidc.cache.ttl` config option drives how often user information should -be synchronized with the Identity Providers. - -It should be set accordingly to your organization's policies. - -Other parts of the user information, such as the `sub` and the `name` also used -by QuestDB. - -They could be displayed in the [Web Console](/docs/getting-started/web-console/overview/), or appear in -the logs, for example. - -## Configuration options - -For all OIDC-related configuration options of QuestDB, see -[Configuration](/docs/configuration/oidc/). - -
- -## Active Directory - -The following sections are guides for setting up single sign-on (SSO) with various OAuth2 providers. - -### PingFederate - -This document helps set up SSO authentication for the Web Console in -[PingFederate](https://www.pingidentity.com/en/platform/capabilities/authentication-authority/pingfederate.html). - -It is assumed that the Azure Active Directory serves as the Identity Provider -(IdP). - -#### Set up PingFederate client - -First thing first, let's pick a name for the client! - - - -The QuestDB [Web Console](/docs/getting-started/web-console/overview/) is a SPA (Single Page App). - -As a result, it cannot store safely a client secret. - -Instead it can use PKCE (Proof Key for Code Exchange) to secure the flow. - -As shown above, leave the client authentication disabled. - -We also have to white list the URL of the [Web Console](/docs/getting-started/web-console/overview/) as a redirection URL: - - - -We can instruct PingFederate to automatically authorize the scopes requested by -the [Web Console](/docs/getting-started/web-console/overview/). - -The user will not be presented the extra window asking for consent after -authentication: - - - -The [Web Console](/docs/getting-started/web-console/overview/) uses the -[Authorization Code Flow](/docs/security/oidc/#authentication-and-authorization-flow), -and refreshes tokens automatically. - -Next, enable the grant types required for this flow: - - - -We've selected: - -- Authorization Code -- Refresh Token -- Access Token Validation (Client is a Resource Server) - -After that, select the token manager for the client. - -The token manager is responsible for issuing access tokens. - -All token related settings should be configured in the token manager. - - - -Finally, enable PKCE - as shown above - and save the settings. - -#### Access Token Manager settings - -QuestDB does not require any special setup regarding the access token. - -We recommend that you do not to use shorter tokens than the default 28 -characters. - -As the QuestDB [Web Console](/docs/getting-started/web-console/overview/) refreshes the token automatically, there is no need -for long-lived tokens: - - - -We've selected: - -- Token length: 28 -- Token lifetime: 5 -- Lifetime extension policy: None -- Maximum token lifetime: Null -- Lifetime extension threshold percentage: 30 - -For the next step, we tune the Authorization Server. - -#### Authorization Server settings - -These settings relate to the authorization code, refresh token and CORS. - - - -In this section, we've entered: - -- Authorization code timeout: 60 -- Authorization code entropy: 30 -- Client secret retention period: 0 - -Next, ensure the `ROLL REFRESH TOKEN VALUES` option is selected: - - - -It is also important to whitelist the [Web Console](/docs/getting-started/web-console/overview/)'s URL on the CORS list: - - - -#### Set up a Microsoft Entra ID Data Source - -PingFederate needs a Data Source setup. - -This is a secure LDAP connection to Microsoft Entra ID, formerly known as Azure -Active Directory. - -The data source needs a: - -- name -- hostname -- port -- username and password for the LDAP connection - - - -We have given it the name EntraDS and it will be applied later. - -#### Set up a Password Credential Validator - -Now that PingFederate has an LDAP connection, we can use it for authentication. - -First, create a Password Credential Validator: - - - -We've entered: - -- Instance name: EntraPCV -- Instance ID: EntraPCV -- Selected: LDAP Username Password Credential Validator -- Parent instance: None - -Furthermore, we now declare our previously created data source (`EntraDS`): - - - -This links our data store (`EntraDS`) to our PCV (`EntraPCV`). - -#### Set up an Identity Provider - -We can use our PCV once we set up an Identity Provider. - -The IdP will be used to authenticate users against Active Directory using the -LDAP connection. - -We do this in the Type subsection: - - -Next, in the IdP Adapter section... - -Click: Add a new row to Credential Validators. - -Select the PCV (`EntraPCV`) we created. - -Optionally alter number of retries: - - - -#### Add groups to OIDC policy management - -QuestDB now needs to know about the user's AD group memberships to find their -permissions. - -Groups are passed to QuestDB inside the User Info object in a custom claim. - -This has to be added in the OpenID Connect Policy Management. - -The field is Multi-Valued, because it is a list of group names. - -Under the Attribute Contract subsection, see: - - - -Next, click to the Attribute Scopes subsection. - -Ensure `groups` is among the `openid` attributes: - - - -Onwards to the Attribute Sources & User Lookup Section. - -From this view, you can add local data stores. - -Note item `test` of type of LDAP: - - - -We created it via the following choices in Add Attribute Source: - - - -Note where we specified the Data Store (`EntraDS`). - -This is also where the directory search parameters are defined. - -Back at the Attribute Sources & User Lookup Section section, note we have set -`email`. - -The source is `LDAP (test)`, while the value is `usePrincipalName`: - - - -And finally! - -In the same Attribute Sources & User Lookup Section... - -Find `groups`. - -Note the definition of Source (`LDAP (test)`) that bridges our various parts. - -The value is `memberOf`. - - - -#### Enable Resource Owner Password Credentials (ROPC) flow - -As described in the -[OIDC operations document](/docs/security/oidc/#enable-ropc) -tools - such as `psql` - can be integrated with the OIDC provider using the ROPC flow. - -When setting this flow up, enable the Resource Owner Password Credentials flow in the -client settings. - -Next, create a Resource Owner Credentials Grant Mapping to map values obtained from -the Password Credential Validator (PCV) into the persistent grants. - -When setting this up, select the previously created LDAP Data Source and IdP Adapter, which links -to the existing PCV. - -Then select the `username` attribute of the PCV as `USER_KEY`. - -#### Confirm QuestDB mappings and login - -QuestDB requires a mapping, as laid out in the -[OIDC operations document](/docs/security/oidc/#mapping-user-permissions). - -If a given user has the HTTP permission, they will be able to now login via the -[Web Console](/docs/getting-started/web-console/overview/). - -To test, head to `http://localhost:9000` and login. - -If all has been wired up well, then login will succeed. - -
- -### Microsoft EntraId - -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. - - -:::tip - -To enlarge the images, click or tap them. - -::: - -#### Set up the client application in Entra ID - -First thing first, let's pick a name for the client! - -Then head to _Microsoft Entra Admin Center_, and register the application -under _Identity - App registrations - New registration_. - - - -The QuestDB [Web Console](/docs/getting-started/web-console/overview/) is a SPA (Single Page App). - -As a result, it cannot store safely a client secret. - -Instead, it can use PKCE (Proof Key for Code Exchange) to secure the flow. - -When registering the application, select the SPA platform. - -We also have to specify the URL of the [Web Console](/docs/getting-started/web-console/overview/) as Redirect URI. - - - -After clicking _Register_, we have created a client application with the -name _QuestDB_. - -Each application is assigned a unique id (known as Client ID in the -OAuth2 - OIDC standard). The client will identify itself with this id -when sending requests to Entra ID. - - - -We find the platform configurations under _Authentication_. This is the place where -the previously set redirect URI can be viewed and modified. We can also specify -additional redirect URIs, if necessary. - -The redirect URIs of the application are automatically eligible for the -_Authorization Code Flow with PKCE_, which is a special version of the OAuth2 standard's -Authorization Code Flow. It is specifically designed for applications where a client -secret (e.g. a password) could not be kept safely. As single page applications run in -the browser, they fall into this category. - -The redirect URIs are also added to the _CORS_ (Cross-Origin Resource Sharing) policy -of EntraID. CORS is a mechanism to allow a web page, such as the Web Console, to access -resources from a different domain than the one that served the page. In this context -this means that we let the Web Console to access Entra ID, while its origin is the -HTTP endpoint of QuestDB. - - - -If we scroll down to the bottom of this page, we can also find a section where we -can enable the _Resource Owner Password Credential Flow_. - -This OAuth2 flow is legacy, and should be enabled only if there is a requirement -of connecting to QuestDB using SSO (Single Sign-On) via clients not supporting -redirect based web flows. -This could mean a Postgres client without OAuth2 integration, such as _psql_, or -a standalone in-house client application, or could be just a jupyter notebook. - -The main issue with this flow is that the client application has to be trusted -with the user's login details. The user's credentials are passed to the -application, in this case to QuestDB, and the client application uses these -credentials to authenticate the user by forwarding them to the identity provider, -in this case to Entra ID. - -It is guaranteed that QuestDB does not store the user's credentials in any way. -They are not persisted into the database, not even in encrypted form. -The login details are treated as passthrough information. Only exception is -that server logs can contain the username, logged for audit purposes. - - - -Our next stop is the _Token configuration_, where the OAuth2/OIDC access and ID -tokens can be customized. - -Note that users can be authenticated without customized tokens, but authorization -would prove to be challenging. The user's security groups are not included -in the tokens by default. - -QuestDB can be configured to request the user's groups from the UserInfo -endpoint of the OAuth2 server, but Entra ID cannot be configured to provide -this information via the UserInfo endpoint. -Therefore, we choose to customize the tokens, QuestDB will decode and -validate the ID token, and take the group information from there. - -QuestDB authorization relies on receiving the group memberships of the user. -Entra ID groups should be mapped to QuestDB groups, and permissions can be -granted to the QuestDB groups. Detailed information about group mappings can -be found in the [OIDC integration](/docs/security/oidc/#user-permissions) -documentation. - - - -The customized tokens contain user information which cannot be accessed -without permission. User information is provided by Microsoft Graph, so -the client application needs specific permissions to access -Microsoft Graph APIs. - -These permissions can be configured under _API permissions_. It is important -to note that we will be setting _Delegated_ permissions here, meaning we -are not granting actual permissions to access user data. Instead, each user -logging into QuestDB will have to consent to accessing their user profile. - - - -By default, the _User.Read_ permission is added to the list, but what we -really need is: - - openid: to be able to issue ID tokens - - profile: to access user information - - offline_access: to be able to issue refresh tokens - -By clicking on _Microsoft Graph_ we can select and add these permissions. - - - -The _User.Read_ permission is not needed. It can be removed by clicking -on the `...` at the end of the row, and selecting _Remove permission_ from -the popup menu. - - - -With this we have finished setting up the QuestDB client application -in Entra ID, and now we can wire QuestDB and Entra ID together by -adding OIDC configuration to QuestDB. - -#### QuestDB configuration - -The below should be set in QuestDB's `server.conf`: - -```shell -# 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 group memberships -acl.oidc.groups.claim=groups - -# groups are encoded in the token -acl.oidc.groups.encoded.in.token=true - -# OIDC configuration endpoint of Entra ID -acl.oidc.configuration.url=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/v2.0/.well-known/openid-configuration - -# application ID taken from Entra ID -acl.oidc.client.id=8de84b90-1ea5-4e41-9e84-dba860aa01a6 - -# redirect URI, QuestDB's HTTP endpoint -acl.oidc.redirect.uri=http://localhost:9000 - -# OAuth scopes the user has to consent to -acl.oidc.scope=openid profile offline_access - -# enable ROPC flow -# optional, required only if ROPC is enabled in Entra ID -acl.oidc.ropc.flow.enabled=true -``` - -The application ID and the OIDC configuration endpoint's URL can be found -in the Overview of the application in Entra ID. - -The application ID is displayed right under the application's name, the -OIDC configuration endpoint is displayed on the panel which opens up when -the _Endpoints_ button is clicked. - - - -#### Map groups and grant permissions - -Now we can start QuestDB, and login with the built-in admin to create -group mappings. - -As mentioned earlier, authorization works by mapping Entra ID groups -to QuestDB groups. When the user logs in, QuestDB decodes Entra ID -group memberships from the token, then finds the QuestDB groups -mapped to them, and the user gets the permissions based on the -mapped groups. - -```questdb-sql title="Create a group which is mapped to an Entra ID group" -CREATE GROUP extUsers WITH EXTERNAL ALIAS '87654321-1234-1234-1234-123456789abc'; -``` -The above command maps the Entra ID group identified by object -id `87654321-1234-1234-1234-123456789abc` to a QuestDB group called `extUsers`. - -We should grant the necessary QuestDB endpoint permissions first -to make sure users can access the Web Console, Postgres and ILP -interfaces as required. [Read more about endpoint permissions](/docs/security/rbac/#endpoint-permissions). - -```questdb-sql title="Grant endpoint permissions" -GRANT HTTP, PGWIRE TO groupName; -``` - -Now we can grant the rest of the permissions as required. We can -grant access to tables, for example. - -```questdb-sql title="Grant database permissions" -GRANT SELECT ON table1, table2 to groupName; -``` - -#### Confirm group mappings and login - -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. - -
diff --git a/documentation/security/oidc/device-flow.mdx b/documentation/security/oidc/device-flow.mdx new file mode 100644 index 0000000000..8d37de44c6 --- /dev/null +++ b/documentation/security/oidc/device-flow.mdx @@ -0,0 +1,581 @@ +--- +title: OIDC device flow for client applications +sidebar_label: Device flow +description: "Sign in to QuestDB Enterprise from Java, Python, Rust, C, and C++ with OIDC device flow, then reuse and refresh tokens without storing a password." +--- + +The OAuth 2.0 Device Authorization Grant +([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), also called *device flow*, +lets a user sign in to a QuestDB client without giving the application their +password. The client shows a verification URL and a short code. The user +opens the URL in a browser, on the same machine or another device, and completes +the identity provider's sign-in, including MFA when required. The client polls +the provider for tokens and uses them to authenticate to QuestDB Enterprise. + +Use device flow for a notebook, CLI, or desktop application acting as a human +user, including a remote kernel or container without a local browser. The +client only needs outbound connections; it does not run a local callback server +or receive a browser redirect. Device flow still requires a person to authorize +access. For unattended services, use +[managed identities or service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals) +or the [client credentials grant](/docs/security/oidc/#client-credentials-grant) +instead. + +## Prerequisites + +- QuestDB Enterprise with [OIDC configured](/docs/security/oidc/#configuration-options), + reachable over HTTPS. If necessary, configure + [HTTP server TLS](/docs/security/tls/#enabling-tls-for-http-server-rest-api) + or a TLS-terminating proxy that supports WebSocket upgrades. +- An identity provider that supports device authorization. Enable the device + grant for the client ID used by QuestDB, and allow public-client + authentication without an application secret. Device flow does not use the + Web Console's redirect URI and does not require + [`acl.oidc.ropc.flow.enabled`](/docs/configuration/oidc/#acloidcropcflowenabled). +- The `openid` scope so the provider returns an ID token. Request + `offline_access` if the provider requires it to issue a refresh token. Set + [`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope), for example to + `openid offline_access`, and keep any additional scopes needed for group + claims. +- A user whose provider groups map to QuestDB groups with the required endpoint + and table permissions. A successful device sign-in alone does not grant + database access. See [Prepare the database](#prepare-the-database). + +:::note Client availability + +Use an OIDC-enabled client build. The Java examples use `questdb-client` +1.3.10 or later; no Azure adapter is required. The Python APIs described here +are part of [py-questdb-client#133](https://github.com/questdb/py-questdb-client/pull/133). +The Rust, C, and C++ APIs are part of +[c-questdb-client#183](https://github.com/questdb/c-questdb-client/pull/183). +Until those changes are released, use a build containing them rather than an +older package that supports only static tokens. In Rust, also enable the +`oidc` crate feature. + +::: + +### Discover the identity provider + +The helpers discover the client ID, scope, token endpoint, and +`groups.encoded.in.token` setting from the QuestDB server's public `/settings` +endpoint. QuestDB advertises the device authorization endpoint when the +provider's discovery document contains `device_authorization_endpoint`. With +manual endpoint configuration, set +[`acl.oidc.device.authorization.endpoint`](/docs/configuration/oidc/#acloidcdeviceauthorizationendpoint). +A missing device endpoint does not stop QuestDB from serving other OIDC flows, +but the client cannot start device flow without one. + +If the server does not advertise that endpoint, supply the provider's issuer +explicitly. The helper can then discover it from the issuer's +`/.well-known/openid-configuration` document: + +| Client | Issuer override | +| --- | --- | +| Java | `new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com")`, passed as the second argument to `fromQuestDB(...)` | +| Python | `OidcDeviceAuth.from_questdb(url, issuer="https://idp.example.com")` | +| Rust | `OidcDeviceAuth::from_questdb(url).issuer("https://idp.example.com").build()?` | +| C++ | `questdb::oidc::builder::from_questdb(url).issuer("https://idp.example.com").build()` | +| C | `questdb_oidc_builder_issuer(builder, issuer, issuer_len, &error)` before building the auth object | + +Use an issuer obtained from your administrator, not one inferred from an +untrusted server. The issuer override is a trust pin, not a guarantee that all +credential requests stay on the issuer's origin: + +- Endpoints read from QuestDB's `/settings` must be on the issuer's origin and + under its path. This prevents a different tenant on the same provider host + from receiving credentials. +- Endpoints obtained from the pinned issuer's own HTTPS discovery document can + be outside that origin or path, provided the document's `issuer` matches the + requested issuer. The provider's document is authoritative for those endpoints. +- Python, Rust, C, and C++ also accept a settings-sourced endpoint outside the + issuer's origin or path when the issuer's discovery document confirms that + endpoint. Java still applies the origin/path checks to settings-sourced + endpoints, even if the provider's document would confirm them. + +The device authorization and token endpoints must still share an origin. If +Java rejects a valid settings-sourced endpoint, configure trusted endpoints +explicitly with `OidcDeviceAuth.builder()` instead of weakening TLS verification. + +For Microsoft Entra ID, complete the +[Web Console and group setup](/docs/security/oidc/entra-id/#web-console-sign-in), +then enable **Allow public client flows** under the application's +**Authentication** settings. Tenant policy must also permit device code flow. +Use `openid profile offline_access` and token mode so the helpers send the ID +token, not a Microsoft Graph access token. This is a user login, not the +app-only role setup for managed identities. See +[Microsoft's device authorization documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code). + +### Configure an audience + +QuestDB's `/settings` does not advertise +[`acl.oidc.audience`](/docs/configuration/oidc/#acloidcaudience). If the provider +requires an audience request parameter to issue the token QuestDB expects, +configure it explicitly in the auth helper: + +| Client | Audience configuration | +| --- | --- | +| Java | `.audience("questdb")` on `OidcDeviceAuth.builder()` | +| Python | `audience="questdb"` in `OidcDeviceAuth.from_questdb(...)` | +| Rust | `.audience("questdb")` on the OIDC builder | +| C++ | `.audience("questdb")` on the OIDC builder | +| C | `questdb_oidc_builder_audience(builder, audience, audience_len, &error)` before building | + +Replace `questdb` with the provider's required audience. Java's discovery factory +has no audience override. When using its explicit builder, also supply trusted +`.clientId(...)`, `.deviceAuthorizationEndpoint(...)`, and `.tokenEndpoint(...)` +values, and set `.scope(...)` and `.groupsInToken(...)` to match the server. +Configure `.tlsConfig(...)` as needed for private CA trust. + +### Private CA trust + +If QuestDB or the provider uses a private certificate authority, configure +trust for the auth helper as well as for the database transport. The +connection string's `tls_roots` setting does not configure the helper that +fetches `/settings` and calls the provider: + +| Client | Auth helper configuration | +| --- | --- | +| Java | Pass `DiscoveryOptions.tlsConfig(tls)` to `fromQuestDB(...)`, where `tls` is an `io.questdb.client.ClientTlsConfiguration` with your trust store and `TLS_VALIDATION_MODE_FULL` | +| Python | Pass `ca_bundle="/path/to/ca.pem"` to `from_questdb(...)` | +| Rust | Add `.ca_bundle("/path/to/ca.pem")` to the builder | +| C++ | Add `.ca_bundle("/path/to/ca.pem")` to the builder | +| C | Call `questdb_oidc_builder_ca_bundle(builder, path, path_len, &error)` before building | + +Keep certificate and hostname verification enabled. A helper's trust settings +apply to its QuestDB discovery request and its provider requests, so the trust +store must cover both. See the client pages for the +[database transport's TLS settings](/docs/connect/clients/connect-string/#tls). + +### Prepare the database + +The examples below write the same `fx_trades` schema. As the admin, create it +before running a client: ingestion cannot create tables or columns for an +[external user](/docs/security/oidc/#tables-created-by-external-users). + +```questdb-sql title="Create the table and grant the user's mapped group access" +CREATE TABLE fx_trades ( + timestamp TIMESTAMP_NS, + symbol SYMBOL, + side SYMBOL, + price DOUBLE, + quantity DOUBLE +) TIMESTAMP(timestamp) PARTITION BY DAY; + +CREATE GROUP fxUsers WITH EXTERNAL ALIAS 'fx-users'; +GRANT HTTP TO fxUsers; +GRANT INSERT, SELECT ON fx_trades TO fxUsers; +``` + +Replace `fx-users` with a group value in the user's token or User Info response. +For Entra ID security groups, use the group's object ID. A user without a +populated groups claim is rejected, even if the device authorization succeeded. +See [Mapping user permissions](/docs/security/oidc/#mapping-user-permissions). + +## Sign in and connect + +Replace `questdb.example.com:9000` in both the discovery URL and the connection +string with your secure QuestDB endpoint. Sign in explicitly before opening +connections. Keep the auth object alive until its connections have closed. +Do not combine a provider with `token=`, `username`, or `password` in the +connection string. + +The examples use QWP for ingestion; the same shared provider supplies the query +connections in each pool. The provider returns the access token in User Info +mode and the ID token in token mode, matching the server's configuration. + +### Java + +Add the [Java client dependency](/docs/connect/clients/java/#quick-start). +`OidcDeviceAuth` is in the core `questdb-client` artifact, not +`questdb-client-azure`: + +```java +import io.questdb.client.QuestDB; +import io.questdb.client.Sender; +import io.questdb.client.cutlass.auth.OidcDeviceAuth; + +public class DeviceFlowIngest { + public static void main(String[] args) { + try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB( + "https://questdb.example.com:9000")) { + auth.signIn(); + try (QuestDB db = QuestDB.connect( + "wss::addr=questdb.example.com:9000;", auth::getToken)) { + try (Sender sender = db.borrowSender()) { + sender.table("fx_trades") + .symbol("symbol", "EURUSD") + .symbol("side", "buy") + .doubleColumn("price", 1.0842) + .doubleColumn("quantity", 100000.0) + .atNow(); + if (!sender.drain(30_000)) { + throw new IllegalStateException("Acknowledgement timed out"); + } + } + } + } + } +} +``` + +`signIn()` may prompt; `getToken()` only returns a cached or silently refreshed +token. Passing `auth::getToken` lets new connections and reconnects obtain a +current token without rebuilding the handle. For a standalone ILP/HTTP sender, +use `httpTokenProvider(auth::getToken)` instead of a fixed `httpToken(...)`. + +By default, Java prints the URL and code and attempts to open a browser. On a +remote or shared host, disable browser launch with +`-Dquestdb.client.oidc.open.browser=false`, or pass +`new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT)` +with `io.questdb.client.cutlass.auth.DeviceCodePrompt` imported. + +### Python + +Install an [OIDC-enabled Python client](#prerequisites). The device-flow helper +requires no additional OAuth Python dependency: + +```python +import questdb +from questdb import TimestampNanos +from questdb.auth import OidcDeviceAuth + +with OidcDeviceAuth.from_questdb("https://questdb.example.com:9000") as auth: + auth.sign_in() + with questdb.connect( + "wss::addr=questdb.example.com:9000;", oidc_auth=auth + ) as db: + with db.sender() as sender: + sender.row( + "fx_trades", + symbols={"symbol": "EURUSD", "side": "buy"}, + columns={"price": 1.0842, "quantity": 100000.0}, + at=TimestampNanos.now(), + ) + sender.flush(wait=True) +``` + +`sign_in()` presents the prompt in the terminal or notebook. +`auth.token()` and transport operations never prompt. Use `open_browser=False` +in `from_questdb(...)` to suppress browser launch; a notebook normally shows +the challenge in the kernel's output rather than opening a browser on the +kernel's host. For a standalone ILP/HTTP sender, pass `oidc_auth=auth` to +`questdb.Sender.from_conf("https::addr=questdb.example.com:9000;", ...)`. + +### Rust + +Enable OIDC in addition to the default transport and TLS features: + +```text title="Cargo.toml" +[dependencies] +questdb-rs = { version = "7", features = ["oidc"] } +``` + +```rust +use std::sync::Arc; + +use questdb::{ + ingress::{AckLevel, TimestampNanos}, + oidc::OidcDeviceAuth, + QuestDb, +}; + +fn main() -> questdb::Result<()> { + let auth = Arc::new( + OidcDeviceAuth::from_questdb("https://questdb.example.com:9000").build()?, + ); + auth.sign_in()?; + + let db = QuestDb::connect_with_token_provider( + "wss::addr=questdb.example.com:9000;", + { + let auth = Arc::clone(&auth); + move || auth.token() + }, + )?; + { + let mut sender = db.borrow_sender()?; + let mut buffer = sender.new_buffer(); + buffer + .table("fx_trades")? + .symbol("symbol", "EURUSD")? + .symbol("side", "buy")? + .column_f64("price", 1.0842)? + .column_f64("quantity", 100000.0)? + .at(TimestampNanos::now())?; + sender.flush_buffer_and_wait(&mut buffer, AckLevel::Ok)?; + } + Ok(()) +} +``` + +The `Arc` keeps one auth state shared by the pool's token-provider closure. +`sign_in()` is interactive; `token()` is not. For a standalone sender, use +`SenderBuilder::http_token_provider` for ILP/HTTP or +`SenderBuilder::qwp_ws_token_provider` for QWP. Set `.open_browser(false)` on +the OIDC builder when the process should only display the URL and code. + +### C++ {#cpp} + +Use matching OIDC-enabled headers and the native library from the +[C/C++ client](/docs/connect/clients/c-and-cpp/#cmake-setup): + +```cpp +#include +#include +#include + +#include +#include + +using namespace questdb::ingress::literals; + +int main() +{ + try + { + auto auth = questdb::oidc::builder::from_questdb( + "https://questdb.example.com:9000").build(); + auth.sign_in(); + questdb::pool pool{"wss::addr=questdb.example.com:9000;", auth}; + { + auto sender = pool.borrow_sender(); + auto buffer = sender.new_buffer(); + buffer.table("fx_trades"_tn) + .symbol("symbol"_cn, "EURUSD"_utf8) + .symbol("side"_cn, "buy"_utf8) + .column("price"_cn, 1.0842) + .column("quantity"_cn, 100000.0) + .at(questdb::ingress::timestamp_nanos::now()); + sender.flush_and_wait(buffer); + } + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} +``` + +The pool retains shared ownership of the auth state. Its connection callbacks +refresh silently and never run device sign-in. For a standalone sender, +configure `questdb::ingress::opts::oidc_auth(auth)`. To suppress browser launch, +set `.open_browser(false)` on the OIDC builder. + +### C + +Initialize `questdb_db_connect_options` with its initializer before setting +`oidc_auth`. Check each fallible operation and release the pool and auth handles +on both success and failure: + +```c +#include +#include +#include + +#include +#include + +int main(void) +{ + const char* url = "https://questdb.example.com:9000"; + const char* conf = "wss::addr=questdb.example.com:9000;"; + questdb_error* error = NULL; + questdb_oidc_builder* builder = NULL; + questdb_oidc_auth* auth = NULL; + questdb_db* db = NULL; + qwp_sender* sender = NULL; + line_sender_buffer* buffer = NULL; + int result = 1; + + builder = questdb_oidc_builder_from_questdb(url, strlen(url), &error); + if (!builder) goto cleanup; + auth = questdb_oidc_builder_build(builder, &error); + if (!auth) goto cleanup; + if (!questdb_oidc_auth_sign_in(auth, &error)) goto cleanup; + + questdb_db_connect_options options; + questdb_db_connect_options_init(&options, sizeof options); + options.oidc_auth = auth; + db = questdb_db_connect_ex(conf, strlen(conf), &options, &error); + if (!db) goto cleanup; + sender = questdb_db_borrow_sender(db, &error); + if (!sender) goto cleanup; + buffer = questdb_db_new_buffer(db, &error); + if (!buffer) goto cleanup; + if (!line_sender_buffer_table( + buffer, QDB_TABLE_NAME_LITERAL("fx_trades"), &error)) goto cleanup; + if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("symbol"), + QDB_UTF8_LITERAL("EURUSD"), &error)) goto cleanup; + if (!line_sender_buffer_symbol(buffer, QDB_COLUMN_NAME_LITERAL("side"), + QDB_UTF8_LITERAL("buy"), &error)) goto cleanup; + if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("price"), + 1.0842, &error)) goto cleanup; + if (!line_sender_buffer_column_f64(buffer, QDB_COLUMN_NAME_LITERAL("quantity"), + 100000.0, &error)) goto cleanup; + if (!line_sender_buffer_at_nanos( + buffer, line_sender_now_nanos(), &error)) goto cleanup; + if (!qwp_sender_flush_buffer_and_wait( + sender, buffer, qwpws_ack_level_ok, &error)) goto cleanup; + result = 0; + +cleanup: + if (error) + { + size_t length = 0; + const char* message = questdb_error_msg(error, &length); + fprintf(stderr, "%.*s\n", (int)length, message); + } + line_sender_buffer_free(buffer); + if (sender) questdb_db_return_sender(db, sender); + questdb_db_close(db); + questdb_oidc_auth_free(auth); + questdb_oidc_builder_free(builder); + questdb_error_free(error); + return result; +} +``` + +For a standalone sender, attach the auth with +`line_sender_opts_oidc_auth(options, auth, &error)`. Disable browser launch with +`questdb_oidc_builder_open_browser(builder, false, &error)` before building the +auth object. Without a custom event handler, the native client prints the +verification URL and code to standard error. + +## Token renewal and reauthentication + +Keep **interactive sign-in** separate from **token retrieval**: + +1. Run `signIn()` / `sign_in()` explicitly while a user can respond. It can + reuse a cached token or refresh silently; otherwise it starts device flow. +2. Attach the auth object or a token-provider callback to the transport. + Connection and reconnect callbacks can return cached tokens or refresh them, + but never start an interactive sign-in. +3. A refresh requires a refresh token from the provider. Request + `offline_access` where required; it does not guarantee one will be issued. +4. If the token expires without a usable refresh token, or the provider requires + user interaction again, token retrieval fails. Python raises + `OidcInteractionRequired`; Rust and C/C++ report an OIDC + `InteractionRequired` error kind; Java throws `OidcAuthException` with a + sign-in instruction. Run explicit sign-in again outside transport callbacks. + Handle any separate transport-level failure before resuming ingestion; an + authentication helper does not undo a sender's terminal state. + +Do not put `auth.token()` or `auth.signIn()` into a `token=` string and expect +renewal. That copies one token: future connections keep using it after expiry. +Use the provider integrations above. Device-flow refresh is demand-driven; it +is not the background half-life renewal of Java's +[`RefreshingTokenProvider`](/docs/connect/clients/java/#rotating-tokens). + +For SQL over PGWire, enable +[`acl.oidc.pg.token.as.password.enabled`](/docs/configuration/oidc/#acloidcpgtokenaspasswordenabled), +connect as `_sso`, and supply a current token as the password. Pull a fresh token +when opening **each new connection**. An already-open connection is not +reauthenticated on token renewal. The Python helper also provides +`questdb.auth.sqlalchemy_engine` and `questdb.auth.psycopg_connect`; the former +resolves the provider for each new pooled connection, while the latter captures +the token when connecting. Use certificate and hostname verification as in +[OIDC for PGWire](/docs/security/oidc/#oidc-for-the-pgwire-endpoint). + +### Provider timeouts + +Silent refresh performs network I/O and can block token retrieval, a flush, +or connection setup. The auth helper's HTTP timeout and waits for another +thread's refresh are separate from the transport's request and reconnect +budgets. Configure the helper's timeout for the amount of backpressure the +application can tolerate; non-interactive does not mean non-blocking. + +| Client | HTTP timeout configuration | Units | Default | +| --- | --- | --- | --- | +| Java | `.httpTimeoutMillis(30000)` on `OidcDeviceAuth.builder()` | Milliseconds | `30000` | +| Python | `timeout=30` in `OidcDeviceAuth.from_questdb(...)` | Seconds | `30` | +| Rust | `.timeout(std::time::Duration::from_secs(30))` on the OIDC builder | `Duration` | 30 seconds | +| C++ | `.timeout_ms(30000)` on the OIDC builder | Milliseconds | `30000` | +| C | `questdb_oidc_builder_timeout_ms(builder, 30000, &error)` before building | Milliseconds | `30000` | + +Timeouts must be positive and no greater than 120 seconds. They bound individual +HTTP requests or phases, not the entire interactive sign-in, which can last +until the device code expires. Java's `fromQuestDB(...)` factory uses the +30-second default and `DiscoveryOptions` has no timeout setter; use an explicit +builder with the trusted configuration described under +[Configure an audience](#configure-an-audience) to change it. + +Waiting behind another thread's silent refresh on the same auth object can take +up to **six times the HTTP timeout**, or **180 seconds by default**. This wait +budget is derived from the timeout; it is not an independent setting. Token-store +coordination and the caller's own refresh can add further waiting, so this is +not a total token-acquisition or flush deadline. Transport request and reconnect +budgets do not cap these provider waits. Size backpressure against the longer +wait, not just the per-request timeout. + +## Persisting tokens and signing out + +Tokens stay in memory by default. To resume a login after a process restart, +opt into a token store: + +| Client | Enable the bundled file store | +| --- | --- | +| Java | Pass `new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation())` to `fromQuestDB(...)`; import `io.questdb.client.cutlass.auth.FileTokenStore` | +| Python | Pass `token_store=FileTokenStore.at_default_location()` to `from_questdb(...)`; import it from `questdb.auth` | +| Rust | Add `.token_store(FileTokenStore::at_default_location()?)` to the builder; import it from `questdb::oidc` | +| C++ | Add `.default_file_token_store()` to the builder | +| C | Call `questdb_oidc_builder_default_file_token_store(builder, &error)` before building | + +The default directory is `~/.questdb/oidc-tokens/`. Python and native clients +accept an absolute directory override in `QUESTDB_CLIENT_OIDC_TOKEN_STORE_DIR`; +Java uses the JVM system property `questdb.client.oidc.token.store.dir`. +A stored refresh token lets a restarted process refresh silently rather than +prompt again, only while that credential remains valid. + +:::warning File persistence stores credentials + +The bundled file stores write **unencrypted JSON**, including long-lived refresh +tokens. Protect the directory and never commit, log, or share its contents. +On POSIX, directories and files use owner-only permissions. Python and native +file-store mutations currently require Unix; use in-memory authentication on +other platforms. Java's file store on Windows relies on the profile directory's +ACLs rather than enforcing POSIX modes. + +A store entry identifies an OIDC configuration, not a separate application +user. Use separate store directories for different people sharing one OS +account. File permissions protect against credential theft and substitution; +the file format is not an encrypted or authenticated vault. + +::: + +Clear the local cached login with Java `clearCache()`, Python `clear()`, Rust +`clear()`, C++ `clear()`, or C `questdb_oidc_auth_clear(...)`. These operations +also remove the configured persisted entry, but **do not revoke tokens at the +identity provider or terminate existing database connections**. For immediate +access removal, see +[Group changes, sessions, and revoking access](/docs/security/oidc/#group-changes-sessions-and-revoking-access). +Close transports before closing the auth provider. Explicitly closing a Python +or native auth provider also prevents its attached transports from acquiring +more tokens; recreate both provider and transports to recover. Closing releases +resources; it is not an identity-provider logout. + +## Security and troubleshooting + +- Only discover settings from a QuestDB server you trust, over HTTPS. Those + settings designate where the client sends the device code and refresh token. + The provider's credential endpoints also require HTTPS outside loopback + development environments. Configure private CA trust rather than disabling + certificate or hostname verification. The connection string's TLS settings + do not automatically configure the auth helper's HTTP client. +- Treat device codes and verification links as login challenges. Only approve a + flow you initiated, verify the provider and account before accepting it, and + do not publish the challenge in shared logs. A headless client can display + the code without launching a browser. +- If discovery cannot find the device endpoint, check the provider's discovery + document, the server's device-endpoint setting, or configure a trusted issuer + explicitly. Enabling QuestDB's ROPC setting does not enable device flow. +- If token mode reports a missing ID token, include `openid` and ensure the + provider issues ID tokens on both device authorization and refresh. Do not + substitute an access token issued for a different API. +- If device authorization succeeds but QuestDB rejects the connection, check + audience, issuer, group claims, and mapped endpoint/table grants. See + [Troubleshooting OIDC logins](/docs/security/oidc/#troubleshooting-oidc-logins). +- If the code expires or the user declines authorization, start a new explicit + sign-in only when the user is ready. A refresh failure must not cause an + automatic loop of interactive prompts. Retain unacknowledged source data; + obtaining a token does not confirm ingestion delivery. diff --git a/documentation/security/oidc/entra-id.mdx b/documentation/security/oidc/entra-id.mdx new file mode 100644 index 0000000000..d49e490549 --- /dev/null +++ b/documentation/security/oidc/entra-id.mdx @@ -0,0 +1,978 @@ +--- +title: Microsoft Entra ID (Azure AD) OIDC setup +sidebar_label: Microsoft Entra ID +description: "Set up Microsoft Entra ID (Azure AD) OIDC for QuestDB Enterprise: Web Console SSO, and app-only tokens for managed identities and service principals." +--- + +import Screenshot from "@theme/Screenshot" +import Tabs from "@theme/Tabs" +import TabItem from "@theme/TabItem" +import CodeBlock from "@theme/CodeBlock" +import { EnterpriseNote } from "@site/src/components/EnterpriseNote" + + + OpenID Connect (OIDC) enables SSO and token authentication with external Identity Providers. + + +[Microsoft Entra ID](https://www.microsoft.com/en-gb/security/business/identity-access/microsoft-entra-id), +formerly Azure Active Directory (Azure AD), can be the OpenID Connect (OIDC) +provider of QuestDB Enterprise. This guide sets up one Entra ID application for +two kinds of access: + +- [Web Console sign-in](#web-console-sign-in): users log in to the + [Web Console](/docs/getting-started/web-console/overview/) with single + sign-on (SSO), and get the permissions of the QuestDB groups that their + Entra ID security groups map to. +- [Managed identities and service principals](#managed-identities-and-service-principals): + Azure services authenticate with app-only access tokens, and get the + permissions of the QuestDB groups that their app roles map to. Requires + QuestDB Enterprise 4.0.2 or later. + +For how QuestDB uses OIDC in general, such as the login flow, the claims it +reads, and how it validates tokens, see +[OpenID Connect (OIDC)](/docs/security/oidc/). + +## Web Console sign-in + +This section sets up single sign-on for the Web Console. To also let services +that run as a managed identity or a service principal call QuestDB with +app-only tokens, complete this setup, then follow +[Managed identities and service principals](#managed-identities-and-service-principals). + +:::tip + +To enlarge the images, click or tap them. + +::: + +### Set up the client application in Entra ID + +First thing first, let's pick a name for the client! + +Then head to _Microsoft Entra Admin Center_, and register the application +under _Identity - App registrations - New registration_. + +Under _Supported account types_, select _Accounts in this organizational +directory only_. QuestDB Enterprise 4.0.2 and later accept only the tokens of +the tenant in the discovery URL of the +[QuestDB configuration](#questdb-configuration), because they check the issuer +of every token, as described in +[The expected issuer](/docs/security/oidc/#the-expected-issuer). Earlier +versions, and later ones with issuer validation disabled, do not check which +tenant issued a token, so with them, the application must not accept accounts +from other tenants. + + + +The QuestDB [Web Console](/docs/getting-started/web-console/overview/) is a SPA (Single Page App). + +As a result, it cannot store safely a client secret. + +Instead, it can use PKCE (Proof Key for Code Exchange) to secure the flow. + +When registering the application, select the SPA platform. + +We also have to specify the URL of the [Web Console](/docs/getting-started/web-console/overview/) as Redirect URI. + + + +After clicking _Register_, we have created a client application with the +name _QuestDB_. + +Each application is assigned a unique id (known as Client ID in the +OAuth2 - OIDC standard). The client will identify itself with this id +when sending requests to Entra ID. + + + +We find the platform configurations under _Authentication_. This is the place where +the previously set redirect URI can be viewed and modified. We can also specify +additional redirect URIs, if necessary. + +The redirect URIs of the application are automatically eligible for the +_Authorization Code Flow with PKCE_, which is a special version of the OAuth2 standard's +Authorization Code Flow. It is specifically designed for applications where a client +secret (e.g. a password) could not be kept safely. As single page applications run in +the browser, they fall into this category. + +The redirect URIs are also added to the _CORS_ (Cross-Origin Resource Sharing) policy +of Entra ID. CORS is a mechanism to allow a web page, such as the Web Console, to access +resources from a different domain than the one that served the page. In this context +this means that we let the Web Console to access Entra ID, while its origin is the +HTTP endpoint of QuestDB. + + + +If we scroll down to the bottom of this page, we can also find a section where we +can enable the _Resource Owner Password Credential Flow_. + +This OAuth2 flow is legacy, and should be enabled only if there is a requirement +of connecting to QuestDB using SSO (Single Sign-On) via clients not supporting +redirect based web flows. +This could mean a Postgres client without OAuth2 integration, such as _psql_, or +a standalone in-house client application, or could be just a jupyter notebook. + +The main issue with this flow is that the client application has to be trusted +with the user's login details. The user's credentials are passed to the +application, in this case to QuestDB, and the client application uses these +credentials to authenticate the user by forwarding them to the identity provider, +in this case to Entra ID. + +QuestDB does not persist the user's credentials, not even in encrypted form: +the login details are treated as passthrough information. The server logs can +contain the username, logged for audit purposes, and, when a login fails, the +claims of the user and the response of Entra ID, which can contain tokens. +Restrict access to the server logs. + + + +Our next stop is the _Token configuration_, where the OAuth2/OIDC access and ID +tokens can be customized. + +Entra ID does not include the user's security groups in the tokens by default, +and QuestDB rejects a login whose user information carries no group, so the +tokens must be customized. + +QuestDB can also request the user's groups from the User Info endpoint of the +OAuth2 server, but Entra ID cannot be configured to return them from that +endpoint. Therefore, QuestDB decodes and validates the ID token, and takes the +groups from it, with `acl.oidc.groups.encoded.in.token=true` in the +[QuestDB configuration](#questdb-configuration) below. + +QuestDB authorization relies on receiving the group memberships of the user. +Entra ID groups should be mapped to QuestDB groups, and permissions can be +granted to the QuestDB groups. Detailed information about group mappings can +be found in the [OIDC integration](/docs/security/oidc/#user-permissions) +documentation. + + + +Select _Add groups claim_, and set it up as follows: + +- Under _Select group types to include in Access, ID, and SAML tokens_, select + _Security groups_. +- Under _Customize token properties by type_, select _Group ID_ for the _ID_ + and the _Access_ token. +- Leave _Emit groups as role claims_ cleared for both. Otherwise, Entra ID + sends the groups in the `roles` claim instead of the `groups` claim that the + [QuestDB configuration](#questdb-configuration) below reads, and the `roles` + claim no longer carries the app roles that + [Managed identities and service principals](#managed-identities-and-service-principals) + relies on. + +Entra ID leaves the `groups` claim out of the tokens of a user who belongs to +more than 200 groups, and QuestDB then rejects the login. To emit only the +groups that are assigned to the application instead, see +[Missing principal or groups claims](/docs/security/oidc/#missing-principal-or-groups-claims). + +The customized tokens contain user information which cannot be accessed +without permission. User information is provided by Microsoft Graph, so +the client application needs specific permissions to access +Microsoft Graph APIs. + +These permissions can be configured under _API permissions_. It is important +to note that we will be setting _Delegated_ permissions here, meaning we +are not granting actual permissions to access user data. Instead, each user +logging into QuestDB will have to consent to accessing their user profile. + + + +By default, the _User.Read_ permission is added to the list, but what we +really need is: + - openid: to be able to issue ID tokens + - profile: to access user information + - offline_access: to be able to issue refresh tokens + +By clicking on _Microsoft Graph_ we can select and add these permissions. + + + +The _User.Read_ permission is not needed. It can be removed by clicking +on the `...` at the end of the row, and selecting _Remove permission_ from +the popup menu. + + + +With this we have finished setting up the QuestDB client application +in Entra ID, and now we can wire QuestDB and Entra ID together by +adding OIDC configuration to QuestDB. + +### QuestDB configuration + +The below should be set in QuestDB's `server.conf`: + +```ini title="server.conf" +# enable OIDC +acl.oidc.enabled=true + +# the claim contains the user's sign-in name +acl.oidc.sub.claim=preferred_username + +# the claim contains the user's group memberships +acl.oidc.groups.claim=groups + +# groups are encoded in the token +acl.oidc.groups.encoded.in.token=true + +# OIDC configuration endpoint of Entra ID +acl.oidc.configuration.url=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/v2.0/.well-known/openid-configuration + +# application ID taken from Entra ID +acl.oidc.client.id=8de84b90-1ea5-4e41-9e84-dba860aa01a6 + +# redirect URI, QuestDB's HTTP endpoint +acl.oidc.redirect.uri=http://localhost:9000 + +# OAuth scopes the user has to consent to +acl.oidc.scope=openid profile offline_access + +# optional: only for clients sending username/password credentials to QuestDB +# not required for Web Console sign-in or device flow +# acl.oidc.ropc.flow.enabled=true +``` + +`preferred_username` shows the user's sign-in name in the Web Console and in +the logs. Entra ID can change a sign-in name, and can give it to a new user +later. To identify users by an object ID that never changes, set +`acl.oidc.sub.claim=oid`. Users who log in with a username and password +through the ROPC flow, such as with `psql`, get the username that they type as +the principal instead, whatever the claim. See +[Choose the principal claim](/docs/security/oidc/#choose-the-principal-claim). + +QuestDB Enterprise 4.0.2 and later read the issuer of your tenant, +`https://login.microsoftonline.com//v2.0`, from the OIDC +configuration endpoint, and accept only the tokens that carry it. Use the +endpoint of your tenant, with the tenant ID in the URL, as above. The +endpoints of `common` and `organizations` name the template +`https://login.microsoftonline.com/{tenantid}/v2.0` instead, which no token +carries, so QuestDB would reject every login with `InvalidIssuer`. See +[The expected issuer](/docs/security/oidc/#the-expected-issuer). + +The application ID and the OIDC configuration endpoint's URL can be found +in the Overview of the application in Entra ID. + +The application ID is displayed right under the application's name, the +OIDC configuration endpoint is displayed on the panel which opens up when +the _Endpoints_ button is clicked. + + + +### Map groups and grant permissions + +Now we can start QuestDB, and login with the built-in admin to create +group mappings. + +As mentioned earlier, authorization works by mapping Entra ID groups +to QuestDB groups. When the user logs in, QuestDB decodes Entra ID +group memberships from the token, then finds the QuestDB groups +mapped to them, and the user gets the permissions based on the +mapped groups. + +```questdb-sql title="Create a group which is mapped to an Entra ID group" +CREATE GROUP extUsers WITH EXTERNAL ALIAS '87654321-1234-1234-1234-123456789abc'; +``` +The above command maps the Entra ID group identified by object +id `87654321-1234-1234-1234-123456789abc` to a QuestDB group called `extUsers`. + +We should grant the necessary QuestDB endpoint permissions first +to make sure users can access the Web Console, Postgres and ILP +interfaces as required. [Read more about endpoint permissions](/docs/security/rbac/#endpoint-permissions). + +```questdb-sql title="Grant endpoint permissions" +GRANT HTTP, PGWIRE TO extUsers; +``` + +Now we can grant the rest of the permissions as required. We can +grant access to tables, for example. + +```questdb-sql title="Grant database permissions" +GRANT SELECT ON fx_trades, core_price TO extUsers; +``` + +### Confirm group mappings and login + +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. If the login fails, the server log gives +the reason, as described in +[Troubleshooting OIDC logins](/docs/security/oidc/#troubleshooting-oidc-logins). + +:::note + +Entra ID issues the refresh token, with which the Web Console renews its +tokens, only for the `offline_access` scope in `acl.oidc.scope`. Without it, +the Web Console cannot renew its tokens, and on QuestDB Enterprise 4.0.2 and +later, it logs users out when the ID token of their login expires, one hour +after the login by default. Entra ID also limits the refresh tokens of +single-page applications to +[24 hours](https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens), +so the Web Console logs users out once a day. While their Entra ID session is +valid, logging in again usually does not ask for their credentials. See +[Web Console token renewal](/docs/security/oidc/#web-console-token-renewal). + +::: + +## Device flow for users + +For Java, Python, Rust, C, or C++ applications acting as a human user, use +[OIDC device flow](/docs/security/oidc/device-flow/) rather than collecting +the user's password. The client displays a URL and code, and the user signs +in through Entra ID in a browser on any device. It works from remote notebook +kernels and applications without a local browser callback. + +After the Web Console and group setup above, enable **Allow public client +flows** in the application's **Authentication** settings, and ensure tenant +policy permits device code flow. Keep `openid profile offline_access` in +`acl.oidc.scope` and token mode enabled. The client uses QuestDB's client ID +and presents the ID token, whose group claims map to the same QuestDB groups +as the Web Console user's claims. Requesting `offline_access` lets Entra ID +issue a refresh token, subject to its policies. + +This flow needs no client secret, Web Console redirect, or QuestDB ROPC +setting. A person must authorize the initial login, and sign in again when +silent refresh is no longer possible. For services that must run without a +person, use the managed-identity or service-principal flow below instead. + +## Managed identities and service principals + +Azure services that run as a managed identity or a service principal can +authenticate to QuestDB with app-only access tokens from Entra ID, next to the +users who log in to the Web Console. Each service gets the permissions of the +QuestDB groups that its app roles map to. + +:::warning Requires QuestDB Enterprise 4.0.2 + +Accepting app-only tokens requires QuestDB Enterprise 4.0.2 or later. On +earlier versions, the configuration below rejects every OIDC login, user logins +included. + +::: + +This setup builds on the Web Console setup above: the application registered in +[Set up the client application in Entra ID](#set-up-the-client-application-in-entra-id), +and the [QuestDB configuration](#questdb-configuration). + +App-only tokens identify the caller by the object ID of its service principal, +in the `oid` claim, and list the +[app roles](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps) +assigned to it in the `roles` claim. They carry no `name` or +`preferred_username` claim. If the service principal is a member of a security +group, its token can also carry a `groups` claim, depending on the group claims +that the QuestDB application emits, as set up under _Token configuration_ in +[Set up the client application in Entra ID](#set-up-the-client-application-in-entra-id). +Do not rely on it: grant permissions to services through app roles. + +QuestDB treats the service as an external user, not as a QuestDB +[service account](/docs/security/rbac/#users-and-service-accounts), so +permissions cannot be granted to it directly. With the configuration below, +when its token carries no role but carries a `groups` claim, the service gets +the permissions of its Entra ID security groups instead. See +[Restrict access to app roles](#restrict-access-to-app-roles). + +### Add app roles and v2.0 tokens in Entra ID {#configure-the-application-in-entra-id} + +1. Make sure that the application is single-tenant, as set up in + [Set up the client application in Entra ID](#set-up-the-client-application-in-entra-id). + An app role has the same value in every tenant that uses the application, + so with a multitenant registration, another tenant could assign the role to + its own service principals. QuestDB rejects their tokens, because their + issuer names the other tenant, as described in + [The expected issuer](/docs/security/oidc/#the-expected-issuer). With + `acl.oidc.issuer.validation.enabled=false`, it would map them to the same + QuestDB groups. +2. Under _Expose an API_, set the Application ID URI, which defaults to + `api://`. Services request their tokens for the scope + `/.default`. A token requested for any other API, such + as Microsoft Graph, carries that API in `aud`, and QuestDB rejects it. +3. Make the application issue v2.0 access tokens: in its _Manifest_, set + `api.requestedAccessTokenVersion` to `2`. The legacy manifest format calls + this property `accessTokenAcceptedVersion`. The `aud` claim of a v2.0 token + always carries the application ID, and its `iss` claim carries + `https://login.microsoftonline.com//v2.0`, the issuer of the ID + tokens. [Token validation](/docs/security/oidc/#token-validation) checks + both. A v1.0 token, the default for applications that accept + organizational accounts only, carries the issuer + `https://sts.windows.net//`, which QuestDB rejects with + `InvalidIssuer`, and can carry the Application ID URI in `aud`. Web Console + logins are not affected, because the Web Console presents ID tokens. +4. Under _App roles_, define a role for each service, with the _Applications_ + member type. A role of its own, mapped to a QuestDB group of its own, lets + you cut off one service without affecting the others, as described in + [Change or revoke access](#change-or-revoke-access). The examples below use + the value `QuestDB.FxIngest`, for a service that ingests FX trades. +5. Assign a role to each managed identity or service principal that needs + access. Assign the role to the identity itself: Entra ID does not add a role + assigned to a group to the tokens of the service principals in that group. + + - For the service principal of an app registration, open that registration, + not the QuestDB one. Under _API permissions_, select _Add a permission_, + _APIs my organization uses_, the QuestDB application, and + _Application permissions_. Add the role, and grant admin consent. + - For a managed identity, assign the role through Microsoft Graph, as + described in + [Assign a managed identity to an application role](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-assign-app-role-managed-identity). + +:::warning + +Complete these steps before a managed identity requests its first token for +QuestDB. Azure caches managed identity tokens for +[around 24 hours](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations), +and you cannot make it issue a new token before the cached one expires. A +token that the service requested before the role assignment or the manifest +change carries no role, or the issuer and the audience of a v1.0 token, and +QuestDB rejects it until Azure issues a new one. + +::: + +### Accept app-only tokens in QuestDB {#configure-questdb} + +On QuestDB Enterprise 4.0.2 or later, replace the two claim settings of the +[QuestDB configuration](#questdb-configuration) with fallback lists, and +restart QuestDB. Earlier versions read each setting as one claim name, so the +lists below make every OIDC login fail. Keep +`acl.oidc.groups.encoded.in.token=true`, so that QuestDB validates app-only +tokens against the signing keys of Entra ID, the same way as ID tokens: + +```ini title="server.conf" +# unchanged: QuestDB validates the tokens itself and reads the claims from them +acl.oidc.groups.encoded.in.token=true + +# user tokens carry preferred_username, app-only tokens carry oid +acl.oidc.sub.claim=preferred_username,oid + +# app-only tokens carry roles, user tokens carry groups +acl.oidc.groups.claim=roles,groups +``` + +User logins still take the principal from `preferred_username` and the groups +from `groups`. Their tokens carry no `roles` claim, as long as the app roles of +the QuestDB application admit applications only. If an app role also admits +users and groups, the tokens of the users who hold it carry a `roles` claim, +and QuestDB takes their groups from `roles` alone, without their security +groups. App-only tokens take the +principal from `oid` and the groups from `roles`, even when they also carry a +`groups` claim. QuestDB rejects a token that carries neither a role nor a +group. See +[How QuestDB picks a claim](/docs/security/oidc/#how-questdb-picks-a-claim). + +If your QuestDB configuration sets `acl.oidc.sub.claim=oid`, as described in +[Choose the principal claim](/docs/security/oidc/#choose-the-principal-claim), +keep it: app-only tokens carry `oid` too, so only `acl.oidc.groups.claim` +needs a list. + +### Map app roles to QuestDB groups + +Ingestion fails when it has to create a table or add a column for the service, +because the service is an external user. See +[Tables created by external users](/docs/security/oidc/#tables-created-by-external-users). +As the admin, create the tables that the service writes to, with every column +that it sends: + +```questdb-sql title="Create fx_trades before the service writes to it" +CREATE TABLE fx_trades ( + timestamp TIMESTAMP_NS, + symbol SYMBOL, + side SYMBOL, + price DOUBLE, + quantity DOUBLE +) TIMESTAMP(timestamp) PARTITION BY DAY; +``` + +Map each app role to a QuestDB group by the role's value, the same way as an +Entra ID group, and grant the group what the service needs. A service that +ingests over HTTP or [QWP](/docs/connect/wire-protocols/qwp-ingress-websocket/) +needs the `HTTP` endpoint permission and `INSERT` on its tables: + +```questdb-sql title="Map the app role of the service to a group that ingests into fx_trades" +CREATE GROUP fxIngest WITH EXTERNAL ALIAS 'QuestDB.FxIngest'; +GRANT HTTP TO fxIngest; +GRANT INSERT ON fx_trades TO fxIngest; +``` + +A service that queries its tables also needs `SELECT` on them, and a service +that connects to the +[PGWire endpoint](/docs/security/oidc/#oidc-for-the-pgwire-endpoint) also +needs the `PGWIRE` endpoint permission. See +[Endpoint permissions](/docs/security/rbac/#endpoint-permissions) for the other +endpoints. + +### Restrict access to app roles + +Unless _Assignment required_ is enabled for the QuestDB application, any +service principal in the tenant can get a token for QuestDB. A token without a +role falls back to the `groups` claim, so a service principal in a mapped +security group can get the permissions of that group without an app role, if +its token carries the group. To make +an app role the only way for a service to get permissions, do one of the +following: + +- Keep managed identities and service principals out of the security groups + that are mapped to QuestDB groups. +- Enable _Assignment required_ in the _Properties_ of the QuestDB application, + under _Enterprise applications_. Users then need an assignment too, directly + or through a group, to log in to the Web Console. Assigning a group requires + Microsoft Entra ID P1 or P2, and gives access only to the direct members of + the group, not to the members of its nested groups. Entra ID also stops + asking users for consent, so + [grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) + to the QuestDB application, or users cannot log in. + +### Request a token in the service + +The service requests its token for the QuestDB application, for the scope +`/.default`, as the managed identity of the Azure resource +that it runs on, or as a service principal. The +[Java client](#with-the-java-client) requests the token and renews it by +itself. With the [Python client](#with-the-python-client), the service requests +the token, and switches to a new token before the old one expires. + +#### With the Java client + +With `token_provider=azure`, the +[Java client](/docs/connect/clients/java/#rotating-tokens) requests the token +from Entra ID by itself, and renews it in the background at about half of its +lifetime, so every connection that it opens presents a valid token. Add the +`questdb-client-azure` artifact, which brings the Azure Identity library, at +the same version as `questdb-client`, 1.3.10 or later: + + + + ( + + {` + org.questdb + questdb-client-azure + ${release.name} +`} + + )} /> + + + ( + + {`implementation 'org.questdb:questdb-client-azure:${release.name}'`} + + )} /> + + + +`token_provider=azure` requires a TLS-secured `wss::` endpoint. Before running +this example, configure +[HTTP server TLS](/docs/security/tls/#enabling-tls-for-http-server-rest-api) +or a TLS-terminating proxy that supports WebSocket upgrades. Replace +`questdb.example.com:9000` with the secure host and port reachable from your +service. For a private CA, configure the +[Java trust store](/docs/connect/clients/java/#tls-with-custom-trust-store). +If enabling TLS changes the Web Console URL, update its registered redirect +URI in Entra ID and `acl.oidc.redirect.uri` to match. + +```java title="Ingest into fx_trades with an app-only token" +import io.questdb.client.QuestDB; +import io.questdb.client.Sender; + +public class FxIngest { + public static void main(String[] args) { + String conf = "wss::addr=questdb.example.com:9000;" + + "token_provider=azure;" + // Application ID URI of the QuestDB application + + "azure_resource=api://8de84b90-1ea5-4e41-9e84-dba860aa01a6;" + // the managed identity of the Azure resource + + "azure_credential=managed_identity;"; + try (QuestDB db = QuestDB.builder() + .fromConfig(conf) + // batches that QuestDB rejects, such as for a missing grant + .errorHandler(error -> System.err.printf("rejected: %s %s%n", + error.getCategory(), error.getServerMessage())) + .build()) { + try (Sender sender = db.borrowSender()) { + sender.table("fx_trades") + .symbol("symbol", "EURUSD") + .symbol("side", "buy") + .doubleColumn("price", 1.0842) + .doubleColumn("quantity", 100000.0) + .atNow(); + } + } + } +} +``` + +- `azure_resource` is the Application ID URI that you set under + _Expose an API_, as described in + [Configure the application in Entra ID](#configure-the-application-in-entra-id), + or the application ID. The client requests the scope + `/.default`. +- `azure_credential=managed_identity` requests the token as the managed + identity of the Azure resource. For a user-assigned managed identity, also + set `azure_client_id` to the client ID of the identity. Otherwise, the client + uses the system-assigned identity. For a workload identity in Azure + Kubernetes Service, use `workload_identity`. For a service principal, use + `environment`, which reads `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and + `AZURE_CLIENT_SECRET` or a certificate from the environment. Without + `azure_credential`, the client uses `DefaultAzureCredential`, a discovery + chain meant for development, which does not ride out an outage of the + managed identity endpoint at startup. +- The Azure provider keys contain no secret. You can keep the connect string in + the `QDB_CLIENT_CONF` environment variable. Redact any other sensitive + settings, such as `tls_roots_password`, before logging it. +- When QuestDB rejects a token with `401` and the response has no Bearer + `error` or has `error="invalid_token"`, the client asks Azure Identity for a + new token, at most once every 30 seconds. If it gets a different one, it + retries the same server once. Azure Identity can return its cached token; + an unchanged token does not earn this immediate retry. A `403` or any + other Bearer error, such as `insufficient_scope`, does not trigger this + refresh/retry. Independently of this one-time retry, a sender that has + connected keeps buffering rows while it cannot get a token or QuestDB + rejects it, and reports each failure to the error handler and in its + [connection health](/docs/connect/clients/java/#connection-health). +- When the handle starts, it waits up to 30 seconds for the first token, and + fails when Entra ID cannot issue one. To start while Entra ID or QuestDB is + unreachable, add `lazy_connect=true` to the connect string, as described in + [How the client handles token failures](/docs/connect/clients/java/#how-the-client-handles-token-failures). + +The client keeps using the same `QuestDB` handle across token renewals, so a +service can keep the rows that QuestDB has not acknowledged on disk without the +switch between handles that the Python examples below need: add `sf_dir` to the +connect string, such as `sf_dir=/var/lib/fx-ingest/sf;`, as described in +[Store-and-forward](/docs/connect/clients/java/#store-and-forward). Closing a +borrowed sender flushes its rows and returns it to the pool, and closing the +handle waits up to `close_flush_timeout_millis`, 60 seconds by default, for +QuestDB to acknowledge them. To confirm that all rows published by a sender +have reached QuestDB, call `sender.drain(timeoutMillis)` while the sender is +borrowed, and treat a `false` result as a timeout, not a delivery confirmation. +This also waits for rows published by an earlier automatic flush, as described +in [Awaiting acknowledgements](/docs/connect/clients/java/#awaiting-acknowledgements). + +#### With the Python client + +With the +[Azure Identity library](https://learn.microsoft.com/en-us/python/api/overview/azure/identity-readme) +for Python, `DefaultAzureCredential` requests the token as the managed identity +of the Azure resource that the service runs on, or as a service principal set +up in environment variables. For a user-assigned managed identity, set the +`AZURE_CLIENT_ID` environment variable to the client ID of the identity. +Otherwise, the credential uses the system-assigned identity. + +`DefaultAzureCredential` tries several credentials in turn, and is meant for +development, as with the Java client. In production, use the credential of the +environment that the service runs in instead, such as +`ManagedIdentityCredential(client_id="")` for a user-assigned +managed identity, or `EnvironmentCredential()` for a service principal. The +examples work with any of them. + +The examples use the QWP API of the `questdb` package, version 5.0 or later. +Install the libraries with +`python3 -m pip install -U questdb azure-identity pandas`. pandas is only +needed for `to_pandas()` in [Verify the setup](#verify-the-setup): + +```python title="Ingest into fx_trades with an app-only token" +import questdb +from azure.identity import DefaultAzureCredential +from questdb import TimestampNanos + +# Application ID URI of the QuestDB application, followed by /.default +scope = "api://8de84b90-1ea5-4e41-9e84-dba860aa01a6/.default" +credential = DefaultAzureCredential() +token = credential.get_token(scope) + +conf = f"wss::addr=questdb.example.com:9000;token={token.token};" +with questdb.connect(conf) as db: + with db.sender() as sender: + sender.row( + "fx_trades", + symbols={"symbol": "EURUSD", "side": "buy"}, + columns={"price": 1.0842, "quantity": 100000.0}, + at=TimestampNanos.now(), + ) + sender.flush(wait=True) +``` + +`flush(wait=True)` returns when the server has acknowledged the rows. The +client passes every rejection to the error handler of the handle, which logs it +by default. A rejection that resending cannot fix, such as a missing `INSERT` +grant on the table or a value that does not match the column type, is +terminal: `flush(wait=True)` also raises `QuestDBServerRejectionError`, and the +pool retires the connection. See +[Server rejections](/docs/connect/clients/python/#server-rejections). + +The client presents the same token on every connection that it opens: the +first one, the additional connections of its pool, and the connections that +replace a failed one. Shortly after the token expires, QuestDB rejects these +new connections, as described in +[When QuestDB stops accepting a token](/docs/security/oidc/#when-questdb-stops-accepting-a-token). +A long-running service must therefore switch to a new token before the old +one expires. `token.expires_on` is a Unix timestamp. + +The short example above keeps its store-and-forward queue in memory. Closing +that handle can discard unacknowledged rows, so replacing an in-memory handle +does not guarantee lossless recovery across an outage and token expiry. For +pending rows to survive handle replacement, use +[`sf_dir`](/docs/connect/clients/connect-string/#sf-keys) and retain the same +`sender_id`. Two handles cannot hold the same disk slot: release every sender +lease and close the old handle before opening its replacement. The new handle +replays the rows left on disk. + +The credential caches the token in memory, and requests a new one when the +cached token expires within 5 minutes, or earlier when the token's refresh +time has passed, so calling `get_token()` often is cheap. The following +single-producer example looks for a new token in the last 10 minutes of the +old one. It retries token acquisition and transient connection failures, +including at startup. While token acquisition or reconnection is retrying, +it pauses ingestion. + +After publishing a batch once with `flush()`, the example calls `wait(5000)`. +This is a five-second no-progress timeout, not a wall-clock deadline. On +`FailoverRetry`, the rows stay queued; the example returns to the outer loop +so that renewal remains reachable. It does not submit the timed-out batch +again. If the old token expires during the wait and the connection fails with +`AuthError`, it releases the lease and replaces the handle with a current +token. Other authentication errors and terminal server rejections stop the +example instead of being retried indefinitely. + +```python title="Rotate tokens with disk-backed store-and-forward" +import time + +import questdb +from azure.core.exceptions import AzureError +from azure.identity import DefaultAzureCredential +from questdb import QuestDBError, QuestDBErrorCode, TimestampNanos + +SCOPE = "api://8de84b90-1ea5-4e41-9e84-dba860aa01a6/.default" +ADDR = "questdb.example.com:9000" +SF_CONF = "sf_dir=/var/lib/fx-ingest/sf;sender_id=fx-ingest;" +REFRESH_MARGIN_SECONDS = 600 +RETRY_SECONDS = 5 +WAIT_TIMEOUT_MILLIS = 5000 +TRANSIENT_CONNECT_ERRORS = { + QuestDBErrorCode.CouldNotResolveAddr, + QuestDBErrorCode.SocketError, + QuestDBErrorCode.ConnectTimeout, + QuestDBErrorCode.FailoverRetry, +} + +credential = DefaultAzureCredential() + + +def on_rejection(error): + print("rejected:", error.category.tag, error.message) + + +def get_token_with_retry(): + while True: + try: + return credential.get_token(SCOPE) + except AzureError as e: + print("token request failed, retrying:", e) + time.sleep(RETRY_SECONDS) + + +def connect_with_retry(): + while True: + # Obtain a current token on every attempt: the outage may outlast it. + token = get_token_with_retry() + conf = ( + f"wss::addr={ADDR};token={token.token};{SF_CONF}" + "sender_pool_min=1;sender_pool_max=1;query_pool_min=0;" + "auto_flush=off;close_flush_timeout_millis=1000;" + ) + try: + return token, questdb.connect(conf, error_handler=on_rejection) + except QuestDBError as e: + expired = time.time() >= token.expires_on + if e.code not in TRANSIENT_CONNECT_ERRORS and not ( + e.code is QuestDBErrorCode.AuthError and expired + ): + raise + print("connect failed, retrying:", e) + time.sleep(RETRY_SECONDS) + + +def renew(token, db): + new_token = get_token_with_retry() + if new_token.token == token.token: + return token, db + # No sender lease is held here. Release the slot before reopening it. + db.close() + return connect_with_retry() + + +def next_trades(): + # Replace with your source of trades and its acknowledgement policy. + time.sleep(1) + return [("EURUSD", "buy", 1.0842, 100000.0)] + + +token, db = connect_with_retry() +try: + while True: + if time.time() > token.expires_on - REFRESH_MARGIN_SECONDS: + token, db = renew(token, db) + replace_handle = False + with db.sender() as sender: + for symbol, side, price, quantity in next_trades(): + sender.row( + "fx_trades", + symbols={"symbol": symbol, "side": side}, + columns={"price": price, "quantity": quantity}, + at=TimestampNanos.now(), + ) + # Publish once. Do not rebuild this batch after a wait timeout. + sender.flush() + try: + sender.wait(WAIT_TIMEOUT_MILLIS) + except QuestDBError as e: + if e.code is QuestDBErrorCode.FailoverRetry: + print("no acknowledgement progress; rows remain on disk") + elif ( + e.code is QuestDBErrorCode.AuthError + and time.time() >= token.expires_on + ): + replace_handle = True + else: + raise + if replace_handle: + # The lease has been returned; the replacement replays its rows. + db.close() + token, db = connect_with_retry() +finally: + db.close() +``` + +After a wait timeout, the example can queue later batches while the server is +unreachable. Store-and-forward is bounded: if the disk queue fills, +publication can fail, and this example stops. Size and monitor the queue for +your outage budget. Store-and-forward protects only rows already accepted by +the local queue; a real trade source also needs a policy for retaining and +acknowledging records not yet published to it. See +[Delivery and durability](/docs/connect/clients/python/#delivery-and-durability). + +A terminal rejection raises `QuestDBServerRejectionError` and ends the loop; +the `finally` block closes the handle. The rejected batch stays on disk and +is replayed before later batches when the service restarts with the same +`sender_id`. Fix the cause, such as a missing grant, before restarting. Do not +submit the rejected batch again. + +Keep `sf_dir` on storage that survives service or container replacement. The +Python client's disk queue protects against a client-process crash, not a +host crash or power loss. Replay is at-least-once, so the table can receive +rows that it has already stored. For the rules that apply to every client, +including how to avoid duplicates with `DEDUP UPSERT KEYS`, see +[Token lifetime](/docs/security/oidc/#token-lifetime). + +See [Authentication and TLS](/docs/connect/clients/python/#authentication-and-tls) +for the client options. For the +[PGWire endpoint](/docs/security/oidc/#oidc-for-the-pgwire-endpoint), enable +`acl.oidc.pg.token.as.password.enabled`, and send the token as the password of +the `_sso` user. + +### Verify the setup + +`current_user()` returns the principal that QuestDB sees for the service: the +object ID of its service principal, from the `oid` claim. For a managed +identity, that is the _Object (principal) ID_ that the Azure portal shows for +the identity, not its client ID. Run the query from the service while it is +connected: + +```python title="Check the principal of the service" +# db is an open handle, such as the one in the examples above +with db.query("SELECT current_user()") as result: + print(result.to_pandas()) +``` + +With the Java client, run the query with `db.borrowQuery()`, as described in +[Querying with Query and Completion](/docs/connect/clients/java/#querying-with-query-and-completion). + +If the service cannot connect, the server log gives the reason, as described in +[Troubleshooting OIDC logins](/docs/security/oidc/#troubleshooting-oidc-logins). +For a token that carries neither a role nor a group, the log lists the claims +that the token carries. + +### Change or revoke access + +QuestDB reads the roles from the token, so a change to the roles of a service +takes effect when QuestDB accepts a new token of the service. Azure caches +managed identity tokens for around 24 hours, so a role assigned to, or removed +from, a managed identity can take hours to reach QuestDB. See the +[managed identity best practices](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations). + +Removing a role does not cut off the connections that the service has already +opened. They stay open when their token expires. If the next token of the +service carries neither a role nor a group, QuestDB rejects it, and the open +connections keep the groups of the last token that QuestDB accepted. To cut a +service off, revoke the permissions of its QuestDB group. This takes effect at +once, on open connections too, for every service that holds the app role, +which is why each service gets an app role and a QuestDB group of its own: + +```questdb-sql title="Cut off the service that ingests into fx_trades" +REVOKE INSERT ON fx_trades FROM fxIngest; +REVOKE HTTP FROM fxIngest; +``` + +After the revoke, QuestDB rejects the next batch of the service. In the Python +examples above, `flush(wait=True)` or `wait()` raises +`QuestDBServerRejectionError`, and the examples stop. Without `sf_dir`, the +rejected rows are lost. With `sf_dir`, they stay on disk, and land once you +grant the permissions again and the service connects with the same +`sender_id`. In the Java client, the rejection reaches the error handler of +the `QuestDB` handle as a terminal +`SECURITY_ERROR`, the next call on the borrowed sender throws +`LineSenderServerException`, and the pool replaces the sender on its next +borrow. + +Granting the permissions again restores access on the connections that are +still open, and for every token of the service that QuestDB still accepts. See +[Group changes, sessions, and revoking access](/docs/security/oidc/#group-changes-sessions-and-revoking-access). diff --git a/documentation/security/oidc/index.mdx b/documentation/security/oidc/index.mdx new file mode 100644 index 0000000000..3ac7d7bbcd --- /dev/null +++ b/documentation/security/oidc/index.mdx @@ -0,0 +1,1882 @@ +--- +title: OpenID Connect (OIDC) Integration +slug: /security/oidc +description: "Configure OIDC in QuestDB Enterprise: Web Console SSO, token authentication for clients and services, claim mapping, token validation, and troubleshooting." +--- + +import Screenshot from "@theme/Screenshot"; +import { EnterpriseNote } from "@site/src/components/EnterpriseNote" + + + OpenID Connect (OIDC) enables SSO and token authentication with external Identity Providers. + + +OpenID Connect (OIDC) connects QuestDB Enterprise to an external Identity +Provider (IdP), such as Microsoft Entra ID or PingFederate. Users sign in to +the [Web Console](/docs/getting-started/web-console/overview/) with single +sign-on (SSO), and applications and services authenticate with tokens from the +same provider, over HTTP, QWP, or PGWire. External users are not created in +QuestDB: they get the permissions of the QuestDB groups that their groups in +the Identity Provider map to. + +Start with the [minimal configuration](#configuration-options), then choose +[where QuestDB reads the user information](#where-questdb-reads-the-user-information): +the provider's User Info endpoint, or the token that the client sends. Then +[map the provider's groups to QuestDB groups](#mapping-user-permissions) and +grant endpoint and table permissions before testing access. To accept user +and service tokens with one configuration, see +[Fallback claim lists](#fallback-claim-lists). For rejected logins, see +[Troubleshooting OIDC logins](#troubleshooting-oidc-logins). For step-by-step +setup, see the guides for [Microsoft Entra ID](/docs/security/oidc/entra-id/), +which also covers Azure managed identities and service principals, and +[PingFederate](/docs/security/oidc/pingfederate/). To sign a human in from a +notebook, CLI, or application without a browser callback, see +[OIDC device flow for clients](/docs/security/oidc/device-flow/), with Java, +Python, Rust, C, and C++ examples. + +:::warning Upgrading to QuestDB Enterprise 4.0.2 + +QuestDB Enterprise 4.0.2 validates tokens more strictly, and when it rejects +the credential of a request, it no longer serves the request from its HTTP +session. With `acl.oidc.groups.encoded.in.token=true`, it rejects expired +tokens and tokens whose issuer is not the expected one, and refuses to start +with `acl.oidc.host` unless `acl.oidc.issuer` is set. Web Console users without +a refresh token are logged out when their token expires. See +[Upgrade to 4.0.2](#upgrade-to-402) before you upgrade. + +::: + +## Configuration options + +A minimal configuration enables OIDC, points QuestDB at the provider, and +names the client ID and the groups claim: + +```ini title="server.conf" +# enable OIDC +acl.oidc.enabled=true + +# the OIDC discovery document of the provider, which QuestDB downloads at +# startup; alternatively, set acl.oidc.host and the endpoint settings +acl.oidc.configuration.url=https://idp.example.com/.well-known/openid-configuration + +# the client ID that the provider assigned to QuestDB +acl.oidc.client.id=questdb + +# required, no default: the claim that carries the groups +acl.oidc.groups.claim=groups + +# optional, sub by default: the claim that carries the principal +# acl.oidc.sub.claim=sub + +# optional, false by default: read the claims from a JWT, such as the ID token, +# instead of calling the User Info endpoint; required for Microsoft Entra ID +# acl.oidc.groups.encoded.in.token=true + +# optional, openid by default: scopes for the Web Console and device-flow +# clients that discover their settings from QuestDB; add offline_access if +# the provider requires it to issue refresh tokens, such as Microsoft Entra ID +# acl.oidc.scope=openid offline_access +``` + +QuestDB downloads the discovery document when it starts, and does not start +when it cannot download or parse it. The error starts with +`Unable to download OIDC provider configuration from` or +`Unable to parse OIDC provider configuration`. To let QuestDB start while the +provider is unreachable, set `acl.oidc.host` and the +[endpoint settings](/docs/configuration/oidc/#endpoints) instead: QuestDB then +starts without the provider, and only OIDC logins fail until the provider is +reachable again. QuestDB refuses to start when both +`acl.oidc.configuration.url` and `acl.oidc.host` are set. + +With `acl.oidc.host` and `acl.oidc.groups.encoded.in.token=true`, also set +[`acl.oidc.issuer`](/docs/configuration/oidc/#acloidcissuer): there is no +discovery document to read the issuer of the tokens from, so QuestDB Enterprise +4.0.2 and later refuse to start without it, unless issuer validation is +disabled. See [The expected issuer](#the-expected-issuer). + +For all OIDC-related configuration options of QuestDB, see +[Configuration](/docs/configuration/oidc/). For complete examples, see the +QuestDB configuration in the +[Microsoft Entra ID](/docs/security/oidc/entra-id/#questdb-configuration) and +[PingFederate](/docs/security/oidc/pingfederate/#questdb-configuration) guides. + +## Where QuestDB reads the user information + +`acl.oidc.groups.encoded.in.token` decides where QuestDB reads the principal +and the groups of an external user from, and with it, which token a client +sends and what QuestDB checks. This page calls the two settings _User Info +mode_, the default, and _token mode_, `acl.oidc.groups.encoded.in.token=true`: + +| | User Info mode (default) | Token mode (`acl.oidc.groups.encoded.in.token=true`) | +| --- | --- | --- | +| Claims come from | the provider's User Info response for the token | the payload of a JWT: the ID token for Web Console and ROPC logins, or the token that a client sends | +| The Web Console sends | the access token | the ID token | +| Other clients send | an access token | for a user, an ID token requested with QuestDB's client ID; for a service, an access token whose `aud` contains `acl.oidc.audience` | +| The token is validated by | the provider's User Info endpoint | QuestDB: signature, `aud`, `sub`, and in QuestDB Enterprise 4.0.2 and later, `exp` and `iss` | +| `acl.oidc.audience` | not used | must be in the `aud` claim | +| `acl.oidc.issuer` | not used | the `iss` claim must equal it; defaults to the issuer of the discovery document (4.0.2 and later) | +| A change to the groups applies | after `acl.oidc.cache.ttl` | when the client presents a new token | +| Suits providers that | return the groups from the User Info endpoint, such as PingFederate | put the groups into the tokens, such as Microsoft Entra ID, and issue tokens that services request for themselves | + +The setting applies to every client, the Web Console included. Microsoft Entra +ID does not return the groups from its User Info endpoint, so with Entra ID, +set `acl.oidc.groups.encoded.in.token=true`: with the default, QuestDB rejects +every login. A provider that returns the groups only from its User Info +endpoint needs the default, unless you also configure it to put the groups into +the ID token. + +For which token each client sends, see +[Non-interactive clients](#non-interactive-clients). For what QuestDB checks, +see [Token validation](#token-validation), and for when a change in the +Identity Provider takes effect, see +[Group changes, sessions, and revoking access](#group-changes-sessions-and-revoking-access). + +## Architecture overview + +Altogether, the architecture appears as such: + + + +We can break it down into core components. + +### Web Console + +QuestDB's interactive UI. Users must authenticate before accessing the database +via the interface. + +The [Web Console](/docs/getting-started/web-console/overview/) uses PKCE (Proof Key for Code Exchange) to +secure the authentication and authorization flow. + +In OAuth2/OIDC terms, the [Web Console](/docs/getting-started/web-console/overview/) is referred to as +the _client_, and it is assigned an identifier: the **Client Id**. + +Each application which integrates via OIDC should be given a different **Client +Id**, except clients that send ID tokens to QuestDB: they request them with +QuestDB's client ID, as described in +[Non-interactive clients](#non-interactive-clients). + +### OIDC Provider + +Typically consists of a number of modules. + +We are interested in two of them only. + +1. The _Identity Provider_ holds user identities and user information, capable + of authenticating users, and to issue an ID Token which uniquely identify + them. + +2. The _Authorization Server_ grants access to resources, such as a database, in + the form of access tokens. + +The OIDC Provider usually integrates with a number of applications which require +different access to a number of resources. + +These clients communicate with the OIDC Provider via its endpoints. + +It exposes a number of APIs, including the Authorization, Token and User Info +endpoints. + +### QuestDB + +The database, in OAuth2/OIDC terms the _protected resource_ or _resource +server_. + +Only processes requests which contain a valid token: an access token, or, in +token mode, a JWT such as an ID token. + +## Authentication and Authorization Flow + +The OAuth2/OIDC standard defines different ways of obtaining access and ID +tokens from the OIDC Provider, referred to as the "_flow_". + +The goal of this flow is to get the user, who is sitting in front of the Web +Console, authenticated. + +Then, it allows QuestDB to determine the user's permissions based on user +information provided by the Identity Providers. + +Specifically, the QuestDB [Web Console](/docs/getting-started/web-console/overview/) uses the +`Authorization Code Flow with PKCE` option. + +It consists of ten steps... + +### 1. Secret generation + +First the [Web Console](/docs/getting-started/web-console/overview/) generates a cryptographically strong +random secret called the _code verifier_. + +The secret is hashed using the _SHA256 algorithm_. The result is the _code +challenge_. + +After PKCE initialization the [Web Console](/docs/getting-started/web-console/overview/) requests an +_authorization code_ from the OIDC Provider. + +It calls the Authorization endpoint with a few parameters, including the: + +- **Client Id** +- requested scopes (the list of scopes are configurable, default is `openid` + only) +- code challenge +- algorithm used to generate the code challenge from the code verifier (SHA256) + +When the Authorization Server receives the request, it checks if the user has +been authenticated already: + +- If the user has a valid session, it can be provided with an authorization code + straight away, so we jump to step 4. + +- If the user does not have a valid session yet, it will be redirected to the + Identity Provider for authentication. + +```bash title="Authorization code request example" +https://oidc.provider:443/as/authorization.oauth2?client_id=questdb&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_challenge=IwZ-WuypAY3fMtvismbj1MQUe5CzMgrBa87nYcgFoLQ&code_challenge_method=S256 +``` + +### 2. Prove identity + +Next, the user must prove its identity. + +This could be a username with: + +- a password, +- an OTP +- facial recognition via a mobile app +- or anything else supported by the Identity Provider. + + + +### 3. Scope consent + +After successful authentication, the user provides consent for the requested +scopes. + +The list of scopes are configurable. + +By default the Web Console requests only the `openid` scope which is mandatory +for OIDC. + +No ID Token is issued without it. + +The OIDC provider can be configured to provide the consent automatically, +without presenting the user with an additional screen in the browser. + + + +### 4. Redirection + +Consent is granted! + +The Authorization Server redirects the user back to the +[Web Console](/docs/getting-started/web-console/overview/) with the _authorization code_: + +```bash title="Authorization code response example" +https://questdb.host:9000/?code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A +``` + +### 5. Credential request + +Now, the QuestDB [Web Console](/docs/getting-started/web-console/overview/) requests the ID and access +tokens from the Token endpoint of the OIDC Provider with the authorization code. + +It includes the Client ID and the PKCE code verifier together with the +authorization code in the request. + +The endpoint then hashes the code verifier using the method specified previously +in step 1. + +The result must match the code challenge, also provided in step 1. + +The matching code challenge proves that the token is requested by the client +which requested the authorization code, and it was not stolen: + +```bash title="Token request example" +POST https://oidc.provider:443/as/token.oauth2 HTTP/1.1 +Content-Type: application/x-www-form-urlencoded +grant_type=authorization_code&code=1L344XEY5XRka1j4ySNa8bVQSLf71as9uGLEuv_A&client_id=questdb&&redirect_uri=https%3A%2F%2Fquestdb.host%3A9000&code_verifier=uGZh4sQffXLgRna7D-jtEAkuXzp7Lm_okZXBljzP38coAD44kEheIaz7Pdh98KxYtYLZHNiQPCczQYeF +``` + +### 6. Credentials received + +If the PKCE check is passed, the Web Console receives the ID and access tokens. + +The response can carry a third token too, the refresh token, with which the +Web Console renews its tokens before they expire, as described in +[Web Console token renewal](#web-console-token-renewal). Some providers, such +as Microsoft Entra ID, issue it only when the Web Console requests the +`offline_access` scope, which +[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) sets. + +The validity of the tokens is configurable inside the OIDC Provider. + +```json title="Token response example" +{ + "access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom", + "refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv.eg83ge", + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I...", + "token_type": "Bearer", + "expires_in": 300 // In seconds, thus 5 minutes +} +``` + +### 7. Database access + +With the tokens, the Web Console can interact with the database. + +The access token is in the header of every request sent to QuestDB. In token +mode, the Web Console sends the ID token instead, as described in step 8. + +:::note + +Treat the token as a credential, and serve QuestDB over +[TLS](/docs/security/tls/). An access token can be opaque, but an ID token is a +signed JWT that anyone who holds it can decode, including the user's name and +groups. Restrict access to the server logs too: when QuestDB rejects a login, +it can log the token, the response of the provider, or the claims of the user. +See [Troubleshooting OIDC logins](#troubleshooting-oidc-logins). + +::: + +To carry out permission checks, the database has to know more about the user. + +For this, QuestDB has a User Info Cache. + +If it finds a valid entry with the access token in the cache, steps 8 and 9 are +skipped: + +```bash title="Query request example" +https://questdb.host:9999/exec?query=select%20current_user() +Authorization: Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom +``` + +### 8. Find user information + +No user information in the cache, or stale information? + +QuestDB uses the access token to request user information from the OIDC +Provider's User Info endpoint. + +This call also serves as token validation. + +If the token is not real or has been expired, the User Info endpoint replies +with an error: + +```bash title="User info request example" +https://oidc.provider:443/idp/userinfo.openid +Authorization: Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom +``` + +In token mode, the Web Console sends the ID token instead of the access token, +and QuestDB skips this call. It validates the ID token itself, as described in +[Token validation](#token-validation), and reads the user information from the +token's payload. + +### 9. Receive user information + +If the access token is valid, QuestDB receives the required user information +from the endpoint, then updates its cache. + +The cache improves performance, as QuestDB does not have to turn to the OIDC +Provider on every single request. + +Do note that cache expiry is configurable: + +```json title="User info response example with Active Directory groups" +{ + "sub": "externalUser", + "name": "External User", + "groups": [ + "CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev", + "CN=TestGroup2,OU=DC Users,DC=ad,DC=quest,DC=dev" + ] +} +``` + +### 10. Permission check + +With the help of the user information, QuestDB can carry out +[permission checks](#user-permissions). + +If the permission check is successful, the database will process the request, +and then sends the results back: + +```json title="Query response example" +{ + "query": "select current_user()", + "columns": [ + { + "name": "current_user", + "type": "STRING" + } + ], + "dataset": [["externalUser"]], + "count": 1, + "timestamp": -1 +} +``` + +## Interactive clients + +Any interactive client - a UI, Jupyter notebook, CLI - can integrate with an +OIDC provider. However, the level of support will vary between these tools. + +Interactive clients usually fall into one of the following categories: + +- Browser-based clients with support for HTTP redirects; this includes the Web + Console or any javascript UI +- Applications running in a browser without support for redirects, such as + Jupyter notebooks +- Non-browser based clients, usually some kind of command line interface (CLI) + or a standalone application, such as Microsoft Access + +### Browser-based clients + +If the tool is browser based and can handle HTTP redirects, it can implement two +possible flows to request an access token.: + +1. [Authorization Code](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) + flow **(Recommended, more secure)** + +2. [Implicit](https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth) + flow + +The Web Console implements the +[Authorization Code Flow with PKCE](https://oauth.net/2/pkce), which is a +special version of the Authorization Code flow designed for mobile apps and +single page applications. + +Regardless of which flow is used by the web or mobile application, the requested +token can be used for authentication and authorization when communicating with +QuestDB as explained in the [above 7th step](#7-database-access): the access +token, or in token mode, the ID token. + +### Device flow for client applications + +For a notebook, CLI, or application acting as a human user, the +[Java, Python, Rust, C, and C++ device-flow helpers](/docs/security/oidc/device-flow/) +run the OAuth 2.0 Device Authorization Grant. The client displays a URL and a +code, and the user signs in with the identity provider on any device. No +password or browser redirect passes through the application. Sign in +explicitly, then attach the shared auth provider so connections can obtain +cached or silently refreshed tokens without prompting from background threads. + +This is not an unattended service flow. Use the +[client credentials grant](#client-credentials-grant) or an Azure +[managed identity](/docs/security/oidc/entra-id/#managed-identities-and-service-principals) +for jobs that must start without a person present. + +### Jupyter notebook + +The Python client's [device-flow helper](/docs/security/oidc/device-flow/#python) +works in local and remote kernels, including notebooks without JupyterHub. +It supports explicit sign-in and silent refresh without collecting the user's +SSO password. Prefer it to the legacy ROPC example below when the provider +supports device flow. + +JupyterHub can integrate with OAuth2 providers using OAuthenticator, as +described in its +[documentation](https://jupyterhub.readthedocs.io/en/stable/explanation/oauth.html). +The OAuthenticator documentation also contains +[examples](https://oauthenticator.readthedocs.io/en/latest/tutorials/provider-specific-setup/index.html) +using different identity providers. + +If Jupyter notebooks are used without JupyterHub, one option for OAuth2 +integration is to use the +Resource Owner Password Credentials (ROPC) flow. +It is likely that enabling this flow in your OAuth2 provider will require +additional setup. + +:::caution + +The Resource Owner Password Credentials flow is legacy, and should be used as a last resort. + +::: + +We can use the code below to acquire an access token in our notebook: + +```python +from urllib import request, parse +import json + +url = "https://oidc.provider:443/as/token.oauth2" +data = parse.urlencode( { + "grant_type": "password", + "username": "testuser", + "password": "testpwd", + "scope": "openid", + "client_id": "questdb" +} ).encode() +req = request.Request(url=url, data=data) +req.add_header("Content-Type", "application/x-www-form-urlencoded") +with request.urlopen(req) as f: + body = f.read().decode(f.headers.get_content_charset()) + resp = json.loads(body) + access_token = resp["access_token"] +``` + +This token can be used to authenticate with QuestDB. In token mode, use the ID +token, `resp["id_token"]`, instead. [Non-interactive clients](#non-interactive-clients) explains which +token to send: + +```python +query = parse.urlencode({ + "query": "select current_user()" +}) +req = request.Request(f"http://localhost:9000/api/v1/sql/execute?{query}") +req.add_header("Authorization", f"Bearer {access_token}") +with request.urlopen(req) as f: + body = f.read().decode(f.headers.get_content_charset()) + resp = json.loads(body) + print(resp) +``` + +#### Externalizing credentials + +The above example saves the user's credentials into the notebook, potentially +exposing them to others. One way to improve this is to use environment variables +or files to externalize the username and password. + +Here is an example using the `dotenv` library. + +First we need to create a file named `.env` with the settings: + +```python +username=testuser +password=testpwd +``` + +Then load it in our notebook, and use it to request tokens: + +```python +from dotenv import load_dotenv +import os +from urllib import request, parse +import json + +load_dotenv() +user = os.environ.get("username") +pwd = os.environ.get("password") + +url = "https://oidc.provider:443/as/token.oauth2" +data = parse.urlencode( { + "grant_type": "password", + "username": user, + "password": pwd, + "scope": "openid", + "client_id": "questdb" +} ).encode() +req = request.Request(url=url, data=data) +req.add_header("Content-Type", "application/x-www-form-urlencoded") +with request.urlopen(req) as f: + body = f.read().decode(f.headers.get_content_charset()) + resp = json.loads(body) + access_token = resp["access_token"] +``` + +#### Enable ROPC + +The Resource Owner Password Credentials flow can be enabled in QuestDB within +`server.conf`: + +``` +acl.oidc.ropc.flow.enabled = true +``` + +> Note that the flow also has to be configured in the OAuth2/OIDC provider! + +Now we can use Basic Authentication to simplify our code. We send the +credentials to QuestDB, and the database will validate the credentials against +the OAuth2 provider. + +```python +from dotenv import load_dotenv +import os +from urllib import request, parse +import base64 +import json + +load_dotenv() +user = os.environ.get("username") +pwd = os.environ.get("password") + +query = parse.urlencode({ + "query": "select current_user()" +}) +req = request.Request(f"http://localhost:9000/api/v1/sql/execute?{query}") +b64credentials = base64.standard_b64encode(f"{user}:{pwd}".encode()).decode() +req.add_header("Authorization", f"Basic {b64credentials}") +with request.urlopen(req) as f: + body = f.read().decode(f.headers.get_content_charset()) + resp = json.loads(body) + print(resp) +``` + +We can also use a postgres client to connect to the database: + +:::note + +QuestDB never persists the user's credentials. + +::: + +```python +import psycopg as pg +from dotenv import load_dotenv +import os + +load_dotenv() +user = os.environ.get("username") +pwd = os.environ.get("password") + +conn_str = f"user={user} password={pwd} host=localhost port=8812 dbname=qdb" +with pg.connect(conn_str, autocommit=True) as connection: + with connection.cursor() as cur: + cur.execute("select current_user()") + records = cur.fetchall() + for row in records: + print(row) +``` + +### CLI, standalone applications + +For applications that can integrate a client auth helper, use +[device flow](/docs/security/oidc/device-flow/) to sign a user in without +collecting their password. A tool that accepts only PGWire credentials can +connect as `_sso` with an externally obtained device-flow token as the +password; see [OIDC for PGWire](#oidc-for-the-pgwire-endpoint). + +For tools that cannot integrate token acquisition, such as an unmodified +`psql` login, the legacy Resource Owner Password Credentials flow is another +option when explicitly enabled by the administrator and provider. The user +logs in with their SSO credentials, and the server validates them with the +OAuth2 provider: + +```shell +% psql -h localhost -p 8812 -U testuser +Password for user testuser: +psql (14.2, server 11.3) +Type "help" for help. + +testldap=> +testldap=> +``` + +## Non-interactive clients + +This section covers jobs and services acting as themselves, or integrations +using the legacy password grant. For a person signing in from a client +application, see [Device flow](/docs/security/oidc/device-flow/) instead. + +Non-interactive clients are usually jobs or standalone applications, such as a +client for ingesting data. It is practical to manage their credentials via an +OAuth2 provider too. Such a client requests a token from the provider itself, +and sends it to QuestDB as a bearer token, for example in the +[`token` setting](/docs/connect/clients/connect-string/#auth) of the client's +configuration string, over ILP/HTTP or QWP. The Java client can also request +the token and renew it by itself, with a +[token provider](/docs/connect/clients/java/#rotating-tokens). + +Which token to send depends on where QuestDB reads the user information from, +and on who the client authenticates as: + +- In User Info mode, QuestDB sends the token to the provider's User Info + endpoint, so send the access token. Some providers refuse tokens issued to + applications at that endpoint, so to accept tokens that services obtain for + themselves, use token mode. The setting applies to every client: the Web + Console then sends the ID token, and QuestDB reads the principal and the + groups from it. A provider that returns the groups only from its User Info + endpoint, as set up in the + [PingFederate guide](/docs/security/oidc/pingfederate/), must also put them + into the ID token, or QuestDB rejects every Web Console login. +- In token mode, QuestDB validates the token itself, so send a JWT that passes + [Token validation](#token-validation): + - For a user, send the ID token, because the provider often issues the + access token for another audience. The `aud` claim of an ID token is the + client that requested it, so request the token with QuestDB's client ID, + the value of `acl.oidc.client.id`. + - For a service that authenticates as itself, such as with the client + credentials grant, send the access token that the provider issues to it + for QuestDB. Configure the provider so that the `aud` claim of the token + contains the value of `acl.oidc.audience`, which defaults to QuestDB's + client ID, and so that its `iss` claim is the expected issuer, as + described in [The expected issuer](#the-expected-issuer). For Microsoft + Entra ID, see + [Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + +Tokens that the provider issues to a service often carry the principal and the +groups under different claim names than the tokens of users. To accept both +kinds of token with one configuration, list the claims of both, which requires +QuestDB Enterprise 4.0.2 or later. See +[Fallback claim lists](#fallback-claim-lists). + +The examples below ingest into `fx_trades` over QWP with the +[Python client](/docs/connect/clients/python/). Ingestion fails when it has to +create a table or add a column for an external user, so create the table in +advance, with every column that the client sends. See +[Tables created by external users](#tables-created-by-external-users). The +examples connect over TLS with `wss::`; against a server without TLS, use +`ws::`. + +As the admin, create the table, and grant the QuestDB group that the client's +groups map to the `HTTP` endpoint permission and `INSERT` on the table: + +```questdb-sql title="Create fx_trades and let the client's group ingest into it" +CREATE TABLE fx_trades ( + timestamp TIMESTAMP_NS, + symbol SYMBOL, + side SYMBOL, + price DOUBLE, + quantity DOUBLE +) TIMESTAMP(timestamp) PARTITION BY DAY; + +CREATE GROUP fxIngest WITH EXTERNAL ALIAS 'fx-ingest'; +GRANT HTTP TO fxIngest; +GRANT INSERT ON fx_trades TO fxIngest; +``` + +Replace `fx-ingest` with a group or role name that the client's token carries, +as described in [Mapping user permissions](#mapping-user-permissions). + +### Client credentials grant + +A service that authenticates as itself requests an access token with the +client credentials grant, using its own client ID and secret, and sends the +token to QuestDB. This example assumes token mode, and a provider that issues +the token as a JWT whose `aud` claim contains the value of +`acl.oidc.audience`, and whose `iss` claim is the expected issuer: + +```python +import os + +import questdb +import requests +from questdb import TimestampNanos + +token_endpoint = "https://oidc.provider:443/as/token.oauth2" +response = requests.post( + token_endpoint, + data={ + "grant_type": "client_credentials", + "client_id": os.environ["CLIENT_ID"], + "client_secret": os.environ["CLIENT_SECRET"], + # the scope of the QuestDB API, if the provider needs one, such as + # api:///.default for Microsoft Entra ID; requests + # leaves the field out when QUESTDB_SCOPE is not set + "scope": os.environ.get("QUESTDB_SCOPE"), + }, + timeout=10, +) +response.raise_for_status() +access_token = response.json()["access_token"] + +conf = f"wss::addr=questdb.example.com:9000;token={access_token};" +with questdb.connect(conf) as db: + with db.sender() as sender: + sender.row( + "fx_trades", + symbols={"symbol": "EURUSD", "side": "buy"}, + columns={"price": 1.0842, "quantity": 100000.0}, + at=TimestampNanos.now(), + ) + sender.flush(wait=True) +``` + +The service gets the permissions of the QuestDB groups that the groups or roles +in its token map to, as described in +[Mapping user permissions](#mapping-user-permissions). For Microsoft Entra ID, +see +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals), +which requests the token without a client secret. + +### Resource Owner Password Credentials grant + +The following example requests a token for a user with the Resource Owner +Password Credentials grant, then ingests over QWP. It requests the token with +QuestDB's client ID, `questdb` in the +[minimal configuration](#configuration-options): + +```python +import os + +import questdb +import requests +from dotenv import load_dotenv +from questdb import TimestampNanos + +load_dotenv() +user = os.environ.get("username") +pwd = os.environ.get("password") + +token_endpoint = "https://oidc.provider:443/as/token.oauth2" +response = requests.post( + token_endpoint, + data={ + "grant_type": "password", + "client_id": "questdb", + "username": user, + "password": pwd, + "scope": "openid", + }, + timeout=10, +) +response.raise_for_status() +tokens = response.json() +access_token = tokens["access_token"] + +conf = f"wss::addr=questdb.example.com:9000;token={access_token};" +with questdb.connect(conf) as db: + with db.sender() as sender: + sender.row( + "fx_trades", + symbols={"symbol": "GBPUSD", "side": "sell"}, + columns={"price": 1.2634, "quantity": 250000.0}, + at=TimestampNanos.now(), + ) + sender.flush(wait=True) +``` + +In token mode, send the ID token instead of the access token: + +```python +conf = f"wss::addr=questdb.example.com:9000;token={tokens['id_token']};" +``` + +Alternatively, a user may rely on QuestDB to authenticate them via the OAuth2 +provider when the Resource Owner Password Credentials flow is enabled on the +server side. The client then sends the username and password instead of a +token, and the principal is the username, as described in +[User and group claims](#user-and-group-claims): + +```python +import os + +import questdb +from dotenv import load_dotenv +from questdb import TimestampNanos + +load_dotenv() +user = os.environ.get("username") +pwd = os.environ.get("password") + +conf = f"wss::addr=questdb.example.com:9000;username={user};password={pwd};" +with questdb.connect(conf) as db: + with db.sender() as sender: + sender.row( + "fx_trades", + symbols={"symbol": "GBPUSD", "side": "sell"}, + columns={"price": 1.2634, "quantity": 250000.0}, + at=TimestampNanos.now(), + ) + sender.flush(wait=True) +``` + +### Token lifetime + +A client configured with a fixed token, such as with the `token` setting of +its configuration string, sends that token on every connection that it opens +and never refreshes it. A connection that is already open, such as a QWP or +PGWire connection, stays open when its token expires. Every HTTP request and +every new connection, including a reconnect, a failover, and an additional +connection of a client's pool, authenticates with the token again, and fails +once QuestDB stops accepting the token, as described in +[When QuestDB stops accepting a token](#when-questdb-stops-accepting-a-token). + +The [Java client](/docs/connect/clients/java/#rotating-tokens) can renew the +token by itself instead: with a token provider, such as `token_provider=azure` +for Microsoft Entra ID, it requests a new token in the background before the +current one expires, and presents it on every connection that it opens after +that. + +The [device-flow helpers](/docs/security/oidc/device-flow/#token-renewal-and-reauthentication) +for Java, Python, Rust, C, and C++ can also supply rotating tokens without +rebuilding the client. They refresh on demand when the provider has issued a +usable refresh token, and require explicit user sign-in again when it has not. + +Without a provider, a long-running client configured with a fixed token must +switch before that token expires: request a new token, create a new client +with it, wait until the old client has delivered what it sent, then close the +old client. A QWP client that +is closed discards the rows that QuestDB has not acknowledged within +`close_flush_timeout_millis`, unless it keeps them on disk with `sf_dir`, so +wait for the acknowledgement first, for example with `flush(wait=True)` in the +[Python client](/docs/connect/clients/python/#flushing-and-acknowledgements). + +A client that keeps its +[store-and-forward](/docs/connect/clients/connect-string/#sf-keys) data on +disk, with `sf_dir`, must switch in the other order. Two clients cannot hold +the same slot, so a new client with the `sender_id` of the old client fails to +start while the old client is open. Flush and close the old client first, then +create the new client with the new token and the same `sender_id`. The new +client replays what the old client left on disk, as described in +[Sender restart and replay](/docs/connect/clients/connect-string/#sender-restart-and-replay). +The old client is closed by then, so when the new client fails to start, for +example while QuestDB is unreachable, retry it instead of falling back to the +old client, and request a current token for each attempt, because an outage +can outlast the token. +[Request a token in the service](/docs/security/oidc/entra-id/#request-a-token-in-the-service) +shows disk-backed token rotation in Python. + +Replay is at-least-once: after a reconnect or a switch to a new client, +QuestDB can receive rows that it has already stored. To avoid duplicates, +declare [`DEDUP UPSERT KEYS`](/docs/concepts/deduplication/) on the table, +covering the identity of a row, such as a trade ID. See +[Delivery semantics](/docs/concepts/delivery-semantics/). + +## OIDC for the PGWire endpoint + +If the +Resource Owner Password Credentials (ROPC) flow is not an option, clients +can still authenticate on the PGWire endpoint with OIDC, using a token that they +request from the provider themselves. This works with any PostgreSQL client +library, including in Jupyter notebooks. + +Enable token authentication for the PGWire endpoint in the server +configuration: + +```ini title="server.conf" +acl.oidc.pg.token.as.password.enabled=true +``` + +The client sends the token in the password field, and the string `_sso` in the +username field, or leaves the username empty if it can. QuestDB validates the +token the same way as over HTTP, and takes the principal and the groups from +it, not from the username field. Send the token that an HTTP client would send: + +- In User Info mode, the default, an access token. +- In token mode, for a user, an ID token requested with QuestDB's client ID, + and for a service, an access token whose `aud` claim contains + `acl.oidc.audience`, and whose `iss` claim is the + [expected issuer](#the-expected-issuer). QuestDB rejects a token for another + audience, such as a user's access token for another API, and logs + `InvalidAudience`, or `InvalidIssuer` when the token also comes from another + issuer. + +See [Non-interactive clients](#non-interactive-clients). Like a password, the +token crosses the network in the clear unless the connection uses +[TLS](/docs/security/tls/#enabling-tls-for-pgwire), so require TLS and verify +the server's identity on the client. Use `sslmode=verify-full` to check both +the certificate chain and that the certificate matches the `host` name. +`sslmode=require` encrypts the connection but does not require hostname +verification. Set `sslrootcert` to a trusted CA certificate bundle on the +client, including your private CA if the server uses one: + +```python +import psycopg as pg + +# a token that the client requested from the provider, as described above +token = "token_requested_from_the_oauth2_provider" + +conn_str = ( + f"user=_sso password={token} host=questdb.example.com port=8812 " + "dbname=qdb sslmode=verify-full " + "sslrootcert=/path/to/trusted-ca.pem" +) +with pg.connect(conn_str, autocommit=True) as connection: + with connection.cursor() as cur: + cur.execute('select current_user()') + records = cur.fetchall() + for row in records: + print(row) +``` + +## User permissions + +QuestDB requires additional user information to be able to construct the user's +access list. + +As a reminder, the access list is the list of permissions that determines what +the user can and cannot do. + +QuestDB itself does not persist external users, nor their passwords or any +other authentication related detail. It keeps the groups of each external +user's latest login in memory only, as described in +[User and group claims](#user-and-group-claims). + +External users and their authentication methods are managed by the Identity +Provider. + +Since external users are not managed by QuestDB, permissions cannot be granted +to them directly. + +Instead, the database expects a list of groups, called the _groups claim_ to be +present in the user information. + +These external group names are mapped to QuestDB's own groups. + +The access list of the external user consists of the permissions granted to +those groups: + + + +### Mapping user permissions + +The mappings between external and QuestDB groups are managed with the following +SQL commands: + +```questdb-sql title="Create a group which is mapped to an Active Directory group" +CREATE GROUP groupName WITH EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; +``` + +```questdb-sql title="Map an Active Directory group to an already existing QuestDB group" +ALTER GROUP groupName WITH EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; +``` + +```questdb-sql title="Remove an Active Directory mapping without deleting the QuestDB group" +ALTER GROUP groupName DROP EXTERNAL ALIAS 'CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev'; +``` + +See [`CREATE GROUP`](/docs/query/sql/acl/create-group/) and +[`ALTER GROUP`](/docs/query/sql/acl/alter-group/) for the full syntax. + +External users cannot be given a query memory limit directly; +`ALTER USER ... SET MEMORY LIMIT` is rejected for them. Set the limit on the +mapped QuestDB group instead; see +[memory limits](/docs/security/rbac/#memory-limits). + +QuestDB works the list of external groups out from the +[user information](#user-and-group-claims), such as this User Info response: + +```json title="User info response example with Active Directory groups" +{ + "sub": "externalUser", + "name": "External User", + "groups": [ + "CN=TestGroup1,OU=DC Users,DC=ad,DC=quest,DC=dev", + "CN=TestGroup2,OU=DC Users,DC=ad,DC=quest,DC=dev" + ] +} +``` + +The groups are in a claim called `groups`. This name is configurable in +QuestDB, and since QuestDB Enterprise 4.0.2, QuestDB can fall back to other +claims when the user information does not carry it, or when it holds no group +name. See [User and group claims](#user-and-group-claims). + +If the groups claim is missing or holds no group name, QuestDB rejects the +login. See [Troubleshooting OIDC logins](#troubleshooting-oidc-logins). + +An external alias must equal a group name in the user information exactly, +including its case. The group names depend on the provider: for example, the +distinguished names of Active Directory groups, as above, or, with Microsoft +Entra ID, the object IDs of security groups and the values of app roles, as +described in +[Map groups and grant permissions](/docs/security/oidc/entra-id/#map-groups-and-grant-permissions) +and +[Map app roles to QuestDB groups](/docs/security/oidc/entra-id/#map-app-roles-to-questdb-groups). + +If none of the user's groups is mapped to a QuestDB group, the user is +authenticated, but has no permissions at all. + +Grant each mapped QuestDB group the +[endpoint permission](/docs/security/rbac/#endpoint-permissions) of every +interface that its users connect to, and the permissions on the tables that +they use: + +- `HTTP` for the [Web Console](/docs/getting-started/web-console/overview/), + the REST API, ILP/HTTP, and QWP over WebSocket. +- `PGWIRE` for the [PGWire endpoint](#oidc-for-the-pgwire-endpoint). + +```questdb-sql title="Let the members of a mapped group log in and query fx_trades" +GRANT HTTP, PGWIRE TO groupName; +GRANT SELECT ON fx_trades TO groupName; +``` + +### Group changes, sessions, and revoking access + +QuestDB keeps the groups of the last token that it accepted for each external +user, and every session and connection of the user runs with them. QuestDB +picks up a change to the user's groups or status in the Identity Provider, +such as a disabled user, when it checks a token of the user again: + +- In User Info mode, when the cached user information expires. + [`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl), 30 + seconds by default, controls how long QuestDB caches it. Set it according to + your organization's policies. +- In token mode, when the client presents a new token. QuestDB reads the groups + from the token instead of asking the Identity Provider, so refreshing the + cache does not pick up changes, and the old token keeps its groups until + QuestDB stops accepting it. + +For a username and password login through the [ROPC flow](#enable-ropc), +QuestDB requests a new token from the provider itself once the cached result +expires, so in both modes, changes take effect after `acl.oidc.cache.ttl`. + +A token that QuestDB rejects does not change the groups. Connections that are +already open, such as PGWire and QWP connections, and HTTP sessions that +receive requests without a token, keep running with the groups of the last +token that QuestDB accepted for the user, as described in +[When QuestDB stops accepting a token](#when-questdb-stops-accepting-a-token). +In the Web Console, a disabled user is logged out when the provider refuses to +renew the tokens, or when QuestDB stops accepting the token of the login, as +described in [Web Console token renewal](#web-console-token-renewal). + +To cut off access immediately, revoke the permissions of the QuestDB groups +that the user's groups map to. This takes effect at once for every session and +connection, including those of other users who map to the same groups. + +### Tables created by external users + +When a user creates a table or adds a column, QuestDB grants that user all +permissions on it. Permissions cannot be granted to external users, so: + +- Ingestion, over ILP/HTTP or QWP, fails when it has to create a table or add a + column for an external user. QuestDB rolls back a new table, but a new column + stays in the table even though the request fails. Create the tables in + advance, with every column that the client sends. +- An external user that creates a table or adds a column with SQL must name + the owner with `OWNED BY`: one of the QuestDB groups that its groups map to, + as set up in [Mapping user permissions](#mapping-user-permissions), not the + name of a group in the Identity Provider. See + [`CREATE TABLE`](/docs/query/sql/create-table/#owned-by) and + [`ALTER TABLE ADD COLUMN`](/docs/query/sql/alter-table-add-column/#owned-by). +- The same applies when an external user creates a view, a materialized view, + a live view, or a service account. See + [`CREATE VIEW`](/docs/query/sql/create-view/#owned-by-enterprise), + [`CREATE MATERIALIZED VIEW`](/docs/query/sql/create-mat-view/#owned-by-clause), + [`CREATE LIVE VIEW`](/docs/query/sql/create-live-view/#owned-by-clause), and + [`CREATE SERVICE ACCOUNT`](/docs/query/sql/acl/create-service-account/). + +## User and group claims + +:::note + +Fallback claim lists, and the rules and startup checks that come with them, +require QuestDB Enterprise 4.0.2 or later. For how earlier versions read the +claims, see [Versions before 4.0.2](#versions-before-402). + +::: + +QuestDB reads two values from the user information of an external user: the +principal, which identifies the user, and the groups the user belongs to. The +user information is: + +- By default, the response of the provider's User Info endpoint. +- In token mode, the payload of a JWT: the ID token, for Web Console logins and + in the ROPC flow, or the token that a client presents, such as an Entra ID + app-only access token. + +Two settings name the claims that QuestDB reads: + +- [`acl.oidc.sub.claim`](/docs/configuration/oidc/#acloidcsubclaim) names the + claim that carries the principal, `sub` by default. The principal identifies + the user in the [Web Console](/docs/getting-started/web-console/overview/), + in the server logs, and in the result of `current_user()`. +- [`acl.oidc.groups.claim`](/docs/configuration/oidc/#acloidcgroupsclaim) + names the claim that carries the groups, as an array of group names or as a + single group name. It has no default and must be set when OIDC is enabled. + +QuestDB rejects the login when the user information carries no principal or no +group. + +A username and password login through the [ROPC flow](#enable-ropc), over HTTP +basic authentication or PGWire, is the exception to the principal claim: its +principal is the username that the client sends. QuestDB still requires one of +the claims that `acl.oidc.sub.claim` lists to carry a value, but does not use +that value. + +### Choose the principal claim + +QuestDB keeps one in-memory external user for each principal, and every login +replaces the groups of that user. Two people who resolve to the same principal +therefore share one QuestDB user, and a session that is already open, such as +a PGWire connection, can run with the groups of whoever logged in last. Pick a +claim that identifies one user or service, and that the provider never gives +to anyone else: + +- An object ID, such as `oid` in Microsoft Entra ID, never changes and is never + reused, but the Web Console, the logs, and `current_user()` then show an + opaque ID. +- A username or an email address, such as `preferred_username` in Microsoft + Entra ID, is readable, but the provider can change it, and can give the name + of a former user to a new one. Microsoft + [documents `preferred_username` as mutable](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). + Use such a claim only if your organization never reuses names. + +Never use a display name, such as the `name` claim, because different people +can share one. Changing the claim on an existing deployment gives every user a +new principal. Permissions stay the same, because they come from the groups. + +Avoid principals that can equal the name of a QuestDB user, service account, +or group. Once such an external user has logged in, statements that look the +name up, such as `SHOW PERMISSIONS`, fail with `Ambiguous user name`. + +The claim does not apply to username and password logins through the ROPC +flow, which use the username that the client sends as the principal. A person +who logs in to the Web Console, and with a username and password to the PGWire +endpoint, appears as two external users, unless the claim carries the same +username. + +### Fallback claim lists + +In QuestDB Enterprise 4.0.2 and later, `acl.oidc.sub.claim` and +`acl.oidc.groups.claim` accept a comma-separated list of claim names in +priority order, so one configuration can accept tokens that carry the principal +and the groups under different claim names. Earlier versions read the whole +value as one claim name, so a list makes every OIDC login fail. + +Tokens issued to users and tokens issued to applications often differ this +way. For example, Microsoft Entra ID user tokens, configured as in the +[Entra ID guide](/docs/security/oidc/entra-id/#web-console-sign-in), carry +`preferred_username` and `groups`, while its +[app-only tokens](/docs/security/oidc/entra-id/#managed-identities-and-service-principals) +carry `oid` and `roles`: + +```ini title="server.conf" +# Entra ID puts the groups and the app roles into the tokens +acl.oidc.groups.encoded.in.token=true + +# user tokens carry preferred_username, app-only tokens carry oid +acl.oidc.sub.claim=preferred_username,oid + +# app-only tokens carry roles, user tokens carry groups +acl.oidc.groups.claim=roles,groups +``` + +QuestDB takes the principal from the first claim on the list that carries a +non-empty value, and the groups from the first claim on the list that carries +at least one group name. With the configuration above, QuestDB reads these two +token payloads, shortened to the relevant claims, as follows: + +```json title="Entra ID user token payload" +{ + "sub": "AAAAAAAAAAAAAAAAAAAAAIkzqFVrSaSaFHy782bbtaQ", + "oid": "6f2d8c4e-31a7-4b5e-9c0d-8e1f2a3b4c5d", + "preferred_username": "jane.smith@example.com", + "groups": ["87654321-1234-1234-1234-123456789abc"] +} +``` + +```json title="Entra ID app-only token payload" +{ + "sub": "4c9a4e3b-0d6f-4f43-9a8e-27d2b1f0c5a1", + "oid": "4c9a4e3b-0d6f-4f43-9a8e-27d2b1f0c5a1", + "roles": ["QuestDB.FxIngest"] +} +``` + +| Token | Principal | Groups | +| -------- | --------------------------------------------------- | -------------------------------- | +| User | `jane.smith@example.com`, from `preferred_username` | `87654321-...`, from `groups` | +| App-only | `4c9a4e3b-...`, from `oid` | `QuestDB.FxIngest`, from `roles` | + +`roles` comes before `groups` because an app-only token can also carry a +`groups` claim, and QuestDB takes the groups from the first claim on the list +that holds one. For the same reason, a user token that carries a `roles` claim +takes its groups from `roles` alone, and the groups in its `groups` claim no +longer count. Entra ID puts no `roles` claim into user tokens as long as the +app roles of the QuestDB application admit applications only. With any +provider, make sure that the first claim on the list appears only in the +tokens that should take their groups from it. See +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + +### How QuestDB picks a claim + +In QuestDB Enterprise 4.0.2 and later, the following rules decide which claim +QuestDB uses, for a single claim name and for a list: + +- The order of the list decides, not the order of the claims in the token. + With the configuration in [Fallback claim lists](#fallback-claim-lists), a + token that carries both `roles` and `groups` gets its groups from `roles` + alone. QuestDB does not combine groups from several claims. +- A claim that is missing, `null`, or empty falls through to the next claim on + the list. Empty means an empty string, or an empty array for a groups claim. + The string `"null"` counts as missing too. +- Group names that are `null` or empty are skipped. A groups claim that holds + nothing else falls through to the next claim. +- Only top-level claims count. A claim nested in another claim is ignored, + such as the `roles` that Keycloak puts inside its `realm_access` claim. To + use such values, configure the provider to emit them as a top-level claim. + In Keycloak, for example, add a _User Realm Role_ mapper with the + _Token Claim Name_ `roles` to the client of the service that requests the + token. Don't add it to the client that the Web Console uses, the one that + `acl.oidc.client.id` names: every Keycloak user has the default realm roles, + so with `roles,groups`, the tokens of Web Console users would take their + groups from `roles`. +- Claim names are case-sensitive. +- A listed claim must have the expected shape: a single value, such as a + string, for the principal, and a single value or a flat array of values for + the groups. A listed claim of any other shape, such as an array in a + principal claim or an object in a groups claim, fails the login, even when a + claim earlier on the list carries a value. +- Claims that are not listed are ignored, whatever their shape. + +With the configuration in [Fallback claim lists](#fallback-claim-lists), +QuestDB reads these token payloads, shortened to the relevant claims, as +follows: + +| Claims in the token | Result | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `"preferred_username": ""`, `"oid": "4c9a4e3b-..."` | Principal `4c9a4e3b-...`, from `oid`: the empty `preferred_username` falls through | +| `"roles": []`, `"groups": ["87654321-..."]` | Groups `87654321-...`, from `groups`: the empty `roles` array falls through | +| `"roles": [null, ""]`, `"groups": ["87654321-..."]` | Groups `87654321-...`, from `groups`: `roles` holds no group name once `null` and `""` are skipped | +| `"roles": ["QuestDB.FxIngest"]`, `"groups": {"id": "x"}` | The login fails with `unexpected input format`: `groups` is listed and holds an object, even though `roles` carries a group | + +In token mode, QuestDB validates the token before it applies these rules. See +[Token validation](#token-validation). + +### Startup validation + +In QuestDB Enterprise 4.0.2 and later, with OIDC enabled, QuestDB checks both +settings at startup. Spaces around claim +names are trimmed and empty entries are skipped, so +`preferred_username, oid,,sub` reads as `preferred_username,oid,sub`. QuestDB +refuses to start when: + +- `acl.oidc.groups.claim` is not set, or either setting is empty: + `required property is not set: acl.oidc.groups.claim`, or the same message + for `acl.oidc.sub.claim`. An empty `acl.oidc.sub.claim` does not fall back + to the default `sub`. +- A setting lists the same claim twice: + `claim is listed more than once in acl.oidc.sub.claim [claim=oid]`. +- Both settings list the same claim: + `claim cannot be listed in both acl.oidc.sub.claim and acl.oidc.groups.claim [claim=oid]`. + +## Token validation + +In token mode, QuestDB validates the token itself before it reads any claim: + +- The token must be a JWT whose `kid` header names one of the signing keys that + the provider publishes. +- Its `aud` claim must match + [`acl.oidc.audience`](/docs/configuration/oidc/#acloidcaudience), which + defaults to the client ID. The setting takes a single value. A token whose + `aud` claim is a list is accepted when the list contains that value. +- Its `iss` claim must be the expected issuer, by default the issuer of the + provider's discovery document. Versions before 4.0.2 do not check `iss`. See + [The expected issuer](#the-expected-issuer). +- It must carry an `exp` claim that has not passed. QuestDB allows 60 seconds + of clock skew. Versions before 4.0.2 do not check `exp`. See + [Versions before 4.0.2](#versions-before-402). +- It must carry a non-empty `sub` claim, whatever claims `acl.oidc.sub.claim` + lists. + +In User Info mode, the default, QuestDB expects an access token, and treats it +as opaque, even when it is a JWT: QuestDB does not +decode the token, and leaves its validation to the provider. It sends the token +to the provider's User Info endpoint, which validates it and returns the user +information. `acl.oidc.audience` and `acl.oidc.issuer` do not apply. See +[Find user information](#8-find-user-information). + +### The expected issuer + +In token mode, QuestDB Enterprise 4.0.2 and later accept tokens from a single +issuer, the expected issuer: + +- [`acl.oidc.issuer`](/docs/configuration/oidc/#acloidcissuer), when it is + set. +- Otherwise, the `issuer` field of the discovery document that QuestDB + downloads from `acl.oidc.configuration.url` at startup. QuestDB does not + derive the issuer from the URL. + +The `iss` claim of a token must be a single string that equals the expected +issuer exactly. The comparison is case-sensitive, and includes the path and any +trailing slash. QuestDB rejects a token without an `iss` claim, or with another +issuer, like a token that fails any other check, and logs the reason, as +described in [Troubleshooting OIDC logins](#troubleshooting-oidc-logins). A +provider that serves several tenants, such as Microsoft Entra ID, puts the +tenant into the issuer, so QuestDB accepts only the tokens of the tenant that +the expected issuer names. + +Set `acl.oidc.issuer` when: + +- QuestDB uses `acl.oidc.host` and the endpoint settings instead of a + discovery document. QuestDB then has no issuer to read, and refuses to start + with this error, which also stops QuestDB when the discovery document has no + 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 + ``` + +- The discovery document names a template instead of an issuer, such as + `https://login.microsoftonline.com/{tenantid}/v2.0` in the discovery + documents of the `common` and `organizations` endpoints of Microsoft Entra + ID. No token carries a template, so QuestDB rejects every token. Set the + issuer of your tenant instead. +- The provider puts the host name of each request into the issuer, such as + Keycloak without a fixed hostname, and QuestDB reaches the provider through + another URL than the clients, such as an internal one. The discovery document + that QuestDB downloads then names another issuer than the tokens. Set the + issuer that the tokens carry. This fixes token validation only: the Web + Console sends browsers to the authorization and token endpoints of the same + discovery document, so Web Console logins fail unless browsers can reach the + internal URL too. For the Web Console, give the provider a fixed public + hostname, such as with the `hostname` option of Keycloak, and let QuestDB + reach the provider at that hostname, for example through DNS that resolves it + to an internal address. The discovery document then names the issuer of the + tokens, and `acl.oidc.issuer` is not needed. + +For example, to validate the tokens of a Keycloak realm with `acl.oidc.host` +instead of a discovery document: + +```ini title="server.conf" +acl.oidc.enabled=true +acl.oidc.client.id=questdb +acl.oidc.groups.claim=groups +acl.oidc.groups.encoded.in.token=true + +# the provider and its endpoints, instead of acl.oidc.configuration.url +acl.oidc.host=keycloak.example.com +acl.oidc.authorization.endpoint=/realms/markets/protocol/openid-connect/auth +acl.oidc.token.endpoint=/realms/markets/protocol/openid-connect/token +acl.oidc.userinfo.endpoint=/realms/markets/protocol/openid-connect/userinfo +acl.oidc.public.keys.endpoint=/realms/markets/protocol/openid-connect/certs + +# required without a discovery document: the iss claim of the tokens +acl.oidc.issuer=https://keycloak.example.com/realms/markets +``` + +`acl.oidc.issuer` takes a single value, so every token that QuestDB accepts +must carry the same issuer. The issuer can depend on the kind of token, not just on +the provider. Microsoft Entra ID puts +`https://login.microsoftonline.com//v2.0` into v2.0 tokens, and +`https://sts.windows.net//` into v1.0 tokens. With the v2.0 +discovery URL of the +[Entra ID guide](/docs/security/oidc/entra-id/#questdb-configuration), the ID +tokens of Web Console users are v2.0 tokens, but the access tokens for the +QuestDB application are v1.0 tokens, unless its manifest requests v2.0 access +tokens. To accept both the ID tokens of +Web Console users and the access tokens of services, make the provider issue +access tokens with the issuer of its ID tokens. For Entra ID, see +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + +For a provider that issues tokens without an `iss` claim, turn the check off: + +```ini title="server.conf" +acl.oidc.issuer.validation.enabled=false +``` + +With +[`acl.oidc.issuer.validation.enabled=false`](/docs/configuration/oidc/#acloidcissuervalidationenabled), +QuestDB ignores the `iss` claim, needs no expected issuer to start, and still +checks the signature, the audience, the expiry, and the subject. It logs this +advisory at startup: + +```text +OIDC JWT issuer validation is disabled; tokens from other issuers or tenants may authenticate when signing keys and audience are shared +``` + +Without the check, QuestDB accepts any token that the provider's keys sign for +the expected audience, so make sure that no one outside your organization can +obtain tokens for the QuestDB application. With Microsoft Entra ID, register +the application as single-tenant, as described in +[Set up the client application in Entra ID](/docs/security/oidc/entra-id/#set-up-the-client-application-in-entra-id). + +### When QuestDB stops accepting a token + +QuestDB checks a token when a request or a new connection authenticates with +it, and caches the result for up to +[`acl.oidc.cache.ttl`](/docs/configuration/oidc/#acloidccachettl), 30 seconds +by default: + +- In token mode, QuestDB Enterprise 4.0.2 and later accept a token for up to + 60 seconds, the allowed clock skew, plus `acl.oidc.cache.ttl` after it + expires. After that, they reject it on every request and new connection, + including reconnects. Earlier versions do not check whether the token has + expired. +- In User Info mode, the provider rejects an expired token. QuestDB asks the + provider again once the user information that it cached for the token + expires. + +QuestDB also rejects a token that it cannot check, because the provider cannot +be reached or answers with an error: + +- In User Info mode, once `acl.oidc.cache.ttl` has passed since QuestDB last + asked the provider about the token. +- In token mode, only a token signed with a key that QuestDB has not + downloaded yet: QuestDB validates tokens with the signing keys that it has + already downloaded. These keys are retained if a refresh fails, but + refreshing can delay authentication; see + [JWKS refresh behavior](/docs/configuration/oidc/#acloidcpublickeysexpiry). +- Username and password logins through the [ROPC flow](#enable-ropc) fail in + both modes once `acl.oidc.cache.ttl` has passed since QuestDB last checked + them, because QuestDB checks them with the provider's token endpoint. + +For ordinary HTTP requests in QuestDB Enterprise 4.0.2 and later, a token +that QuestDB rejects, or cannot check, causes `401 Unauthorized`, even with a +valid session cookie, and ends that HTTP session. An outage of the provider +can therefore log users out of the Web Console. Versions before 4.0.2 accept +such a request through its session instead. + +A browser QWP WebSocket upgrade that supplies a credential through the +WebSocket credential subprotocol is handled separately: a rejected credential +fails the upgrade without falling back to the session cookie. This path does +not create, rotate, or destroy an HTTP session, so the failed upgrade does not +end an existing cookie session. + +Some access outlives the token: + +- Connections that are already open, such as PGWire and QWP connections, stay + open when their token expires, and keep running with the groups of the last + token that QuestDB accepted for the user. A WebSocket connection that a + browser opened with the cookie of an HTTP session also stays open when the + session ends. +- An HTTP session, such as the one that the Web Console opens at login, + accepts the requests that carry its cookie without a token. QuestDB checks + an OIDC token only when a request carries it, so it does not end such a + session when the token expires. The session ends when the client logs out, + or after it has been idle for + [`http.session.timeout`](/docs/configuration/http-server/#httpsessiontimeout), + 30 minutes by default. Every request that QuestDB accepts restarts the idle + timer. A session that the Web Console does not end, for example because the + browser was closed, stays valid for any client that reuses its cookie + without a token, for as long as that client stays active. + +For how the Web Console renews its token, see +[Web Console token renewal](#web-console-token-renewal). For what other +clients must do before their token expires, see +[Token lifetime](#token-lifetime). + +### Web Console token renewal + +The Web Console sends its token with every query: the access token in User +Info mode, or the ID token in token mode. It renews its tokens only with a +refresh token. Some providers, such as Microsoft Entra ID, issue one only for +the `offline_access` scope, which +[`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope) sets. + +With a refresh token, the Web Console requests new tokens before it sends a +query, when the token that it sends expires within 30 seconds. It reads the +expiry of the ID token from the token's `exp` claim, and the expiry of the +access token from the `expires_in` field of the token response. It does not +renew a token whose expiry it does not know. When the clock of the browser is +behind, QuestDB can reject the token as expired first: the Web Console then +renews its tokens and sends the request again. + +In token mode, every refresh response must include an `id_token`, even if it +contains a new access token. If the ID token is missing, the Web Console logs +the user out with **Missing ID token** instead of keeping the previous ID +token. Configure the provider to issue ID tokens for the refresh-token grant, +and ensure `acl.oidc.scope` includes `openid`. + +- When the provider refuses to issue new 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. +- When token renewal times out, the Web Console cannot reach the provider, + or the provider answers with `429` or a `5xx` status, the Web Console keeps + the login and warns the user. While the current token is valid, queries + continue to use it. Subsequent query attempts retry renewal with a growing + delay of up to 30 seconds between attempts. If the token expires before + renewal succeeds, the Web Console pauses queries instead of sending the + expired token. A query blocked by renewal is reported as not sent and has + not executed on the database. Transient failures do not log the user out, + however many attempts fail. Once renewal succeeds, queries can run again. + +Without a refresh token, the Web Console keeps sending the token of the login +after it has expired. QuestDB Enterprise 4.0.2 and later then reject the token, +in token mode with `Failed to decode JWT token Error(ExpiredSignature)` in the +log, answer with `401 Unauthorized`, and end the session, and the Web Console +logs the user out. With Microsoft Entra ID, whose ID tokens are valid for one +hour by default, this happens about an hour after the login. Earlier versions +keep accepting the requests of the Web Console, as described in +[Versions before 4.0.2](#versions-before-402). + +## Troubleshooting OIDC logins + +### 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 receive `invalid username/password`. The +server log contains `Failed to find required claims`, followed by both lists +and the user information QuestDB received: + +```text +Failed to find required claims [subClaims=[preferred_username,oid], groupsClaims=[roles,groups], userInfo={...}] +``` + +Compare the claims in `userInfo` with the two lists to find the claim names +that your provider uses. A listed claim of an unexpected shape, such as an +object in a groups claim, is logged as +`Request failed [error=unexpected input format ...]` instead. Versions before +4.0.2 log `Failed to find required claims [claims=sub,groups userInfo=...]`, +with the two configured claim names. + +With Microsoft Entra ID, a user who belongs to more than 200 groups, nested +groups included, gets no `groups` claim in the token, so QuestDB rejects the +login unless another listed claim carries groups. To avoid this, emit only the +groups that are assigned to the application: in the _Token configuration_ of +the QuestDB application, select _Groups assigned to the application_, then +assign the mapped groups to the application under _Enterprise applications_. +The token then carries only the assigned groups that the user is a direct +member of, so a user who is in a mapped group only through a nested group +loses that group. Assigning groups to an application requires Microsoft Entra +ID P1 or P2. + +### Token validation errors + +A token that fails [token validation](#token-validation) is rejected the same +way, but the server log gives the reason instead of the claim lists, for +example: + +```text +Request failed [errorMsg=Failed to decode JWT token Error(InvalidAudience)] +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(MissingRequiredClaim("exp"))] +Request failed [errorMsg=Failed to decode JWT token Error(ExpiredSignature)] +Request failed [errorMsg=Failed to decode JWT token Error(InvalidSignature)] +Request failed [errorMsg=Unable to find public key with key id [...]] +``` + +QuestDB checks the signature of the token first, then that the token carries +the `sub`, `aud`, and `exp` claims, and with issuer validation, the `iss` +claim, then the expiry, the issuer, and the audience, in this order, and logs +the first check that fails. For example, an expired token without an `iss` +claim logs `MissingRequiredClaim("iss")`. + +`InvalidAudience` usually means that the token was requested for another API, +or, with issuer validation disabled, that Entra ID issued a v1.0 access token. + +`InvalidIssuer` means that the `iss` claim of the token is not the expected +issuer, and `MissingRequiredClaim("iss")` that the token carries no `iss` +claim, as described in [The expected issuer](#the-expected-issuer). With +Microsoft Entra ID, `InvalidIssuer` usually means a v1.0 access token, whose +issuer is `https://sts.windows.net//`, or a token of another +tenant. When every login fails with it, compare the `iss` claim of a token +with the expected issuer: `acl.oidc.issuer`, or the `issuer` field of the +discovery document. If the discovery document names a template rather than +your tenant, or another host than the `iss` claim of the tokens, set +`acl.oidc.issuer`. + +For a v1.0 access token of Entra ID, make the application issue v2.0 tokens, as +described in +[Managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). +Don't set `acl.oidc.audience` to the Application ID URI, or `acl.oidc.issuer` +to the issuer of v1.0 tokens, to work around it: each setting takes a single +value, and the ID tokens of Web Console users carry the application ID and the +v2.0 issuer, so their logins would fail. + +`ExpiredSignature` means that the token has expired, and +`MissingRequiredClaim("exp")` that it carries no `exp` claim, which QuestDB +Enterprise 4.0.2 and later require. A client configured with a fixed token must +switch to a new token before it expires, as described in +[Token lifetime](#token-lifetime). For Web Console users, `ExpiredSignature` +usually means that the Web Console has no refresh token. See +[Web Console token renewal](#web-console-token-renewal). + +`InvalidSignature` means that the signature of the token does not match the +signing key that its `kid` header names. + +`Unable to find public key with key id` means that none of the signing keys +that the provider publishes matches the token. QuestDB downloads the keys again +when they are older than +[`acl.oidc.public.keys.expiry`](/docs/configuration/oidc/#acloidcpublickeysexpiry), +and when it meets a key ID that it does not know, so either another provider +issued the token, or QuestDB cannot reach the provider's keys, which it logs as +`Unable to download public keys from OIDC provider`. A token whose header names +no key ID logs `Key id is missing from JWT token header`. + +QuestDB also rejects a token whose `sub` claim is missing or empty, whatever +claims `acl.oidc.sub.claim` lists. It then logs a JSON error that names `sub`, +or `Sub is not set in token`. + +In token mode, QuestDB accepts only a JWT. It rejects a token that is not one, +such as an opaque access token, and logs: + +```text +Request failed [errorMsg=Invalid token format, cannot find header separator, expected format of JWT:
..] +``` + +A token with a single `.` logs `cannot find signature separator` instead. Send +a JWT, as described in [Non-interactive clients](#non-interactive-clients). + +In User Info mode, the provider's User Info endpoint rejects a token that is +invalid or expired, and QuestDB rejects the login. + +### Requests denied after a successful login + +If the login succeeds but every request is denied, check that at least one of +the user's groups is mapped to a QuestDB group that has the endpoint +permission of the interface: `HTTP` for the Web Console, the REST API, +ILP/HTTP, and QWP, or `PGWIRE` for the PGWire endpoint. See +[Mapping user permissions](#mapping-user-permissions). + +### Ingestion cannot create a table or add a column + +If the login succeeds but ingestion fails when it creates a table or adds a +column, create the table and its columns in advance: ingestion cannot create a +table or add a column for an external user. The error contains +`Cannot grant permissions to an external user`. When ingestion adds a column, +the client can receive `WAL writer is distressed and cannot be used any more` +instead, and the column stays in the table. See +[Tables created by external users](#tables-created-by-external-users). + +## Versions before 4.0.2 + +QuestDB Enterprise 4.0.2 changes how QuestDB validates tokens, reads claims, +and handles HTTP sessions. For each change, the list below gives how earlier +versions behave, and what to check when you upgrade. + +### Upgrade to 4.0.2 {#upgrade-to-402} + +- **Token expiry.** In token mode, earlier versions do not check the `exp` + claim, so they accept expired tokens and tokens without an `exp` claim. + QuestDB Enterprise 4.0.2 rejects them, as described in + [When QuestDB stops accepting a token](#when-questdb-stops-accepting-a-token). + A client configured with a fixed token must switch to a new token before the + old one expires, as described in [Token lifetime](#token-lifetime). The Web + Console renews its tokens only with a refresh token: to keep users logged + in, add the scope that the provider issues refresh tokens for, such as + `offline_access` with Microsoft Entra ID, to + [`acl.oidc.scope`](/docs/configuration/oidc/#acloidcscope). See + [Web Console token renewal](#web-console-token-renewal). +- **Issuer.** In token mode, earlier versions do not check the `iss` claim, so + they accept the tokens of any issuer that the provider's keys sign for the + expected audience, such as the tokens of another Microsoft Entra ID tenant. + QuestDB Enterprise 4.0.2 rejects tokens whose `iss` claim is missing or is + not the expected issuer, including Microsoft Entra ID v1.0 access tokens + with the v2.0 discovery URL, and refuses to start when it has no expected + issuer, such as with `acl.oidc.host`. Set `acl.oidc.issuer`, or, for a + provider whose tokens carry no `iss` claim, + `acl.oidc.issuer.validation.enabled=false`. See + [The expected issuer](#the-expected-issuer). +- **HTTP sessions.** When an ordinary HTTP request carries a session cookie + and a token that they reject, or cannot check because the provider is + unreachable, earlier versions accept the request through the session, so + the Web Console keeps working with a token that they no longer accept, + until its session expires. QuestDB Enterprise 4.0.2 rejects the request with + `401 Unauthorized` and ends the session, so an outage of the provider can log + users out of the Web Console. A browser QWP upgrade using the credential + subprotocol instead fails without using or destroying the cookie session. + See [When QuestDB stops accepting a token](#when-questdb-stops-accepting-a-token). +- **Claim lists.** Earlier versions read the whole value of each setting as a + single claim name, so a claim list makes every OIDC login fail. In a + replicated cluster, upgrade every node before you change a setting to a + claim list. +- **Groups in tokens.** In token mode, earlier versions reject any token that + does not carry a `groups` array, whatever `acl.oidc.groups.claim` names. + QuestDB Enterprise 4.0.2 reads the claims that the setting lists. +- **`null` and empty claims.** Earlier versions read a `null` claim as the + value `null`: every account whose principal claim is `null` logs in as the + same principal, `null`, and `null` and empty group names count as groups. + QuestDB Enterprise 4.0.2 rejects such logins, unless another listed claim + carries a value. See [How QuestDB picks a claim](#how-questdb-picks-a-claim). +- **Unlisted claims.** Earlier versions reject a login when a claim that is + not listed holds an array of objects or nested arrays. QuestDB Enterprise + 4.0.2 ignores the claims that are not listed, whatever their shape. +- **Startup checks.** Earlier versions start with an empty + `acl.oidc.sub.claim`, or with the same claim in both settings, and then + reject every OIDC login. QuestDB Enterprise 4.0.2 refuses to start instead, + and also when a setting lists the same claim twice. See + [Startup validation](#startup-validation). + +
+ +## Identity provider guides {#active-directory} + +### Microsoft Entra ID {#microsoft-entraid} + +The [Microsoft Entra ID guide](/docs/security/oidc/entra-id/) sets up single +sign-on for the Web Console, and app-only tokens for Azure services that run as +a managed identity or a service principal. + +Continue with a setup step: + +- + Set up the client application in Entra ID + +- + QuestDB configuration + +- + Map groups and grant permissions + +- + Confirm group mappings and login + + +### PingFederate + +The [PingFederate guide](/docs/security/oidc/pingfederate/) sets up single +sign-on for the Web Console, with PingFederate authenticating users against an +Active Directory domain, such as Microsoft Entra Domain Services, over secure +LDAP. + +Continue with a setup step: + +- + Set up PingFederate client + +- + Access Token Manager settings + +- + Authorization Server settings + +- + Set up an Active Directory data source + +- + Set up a Password Credential Validator + +- + Set up an Identity Provider + +- + Add groups to OIDC policy management + +- + Enable Resource Owner Password Credentials (ROPC) flow + +- + Confirm QuestDB mappings and login + diff --git a/documentation/security/oidc/pingfederate.mdx b/documentation/security/oidc/pingfederate.mdx new file mode 100644 index 0000000000..0c65812e2b --- /dev/null +++ b/documentation/security/oidc/pingfederate.mdx @@ -0,0 +1,400 @@ +--- +title: PingFederate OIDC setup +sidebar_label: PingFederate +description: "Set up Web Console single sign-on for QuestDB Enterprise with PingFederate as the OIDC provider, authenticating users over LDAP." +--- + +import Screenshot from "@theme/Screenshot" +import { EnterpriseNote } from "@site/src/components/EnterpriseNote" + + + OpenID Connect (OIDC) enables SSO and token authentication with external Identity Providers. + + +This guide sets up single sign-on (SSO) for the QuestDB Enterprise +[Web Console](/docs/getting-started/web-console/overview/) with +[PingFederate](https://www.pingidentity.com/en/platform/capabilities/authentication-authority/pingfederate.html) +as the OpenID Connect (OIDC) provider. PingFederate authenticates users against +an Active Directory domain over secure LDAP (LDAPS), and passes their group +memberships to QuestDB in the User Info response. QuestDB maps those groups to +its own groups to grant permissions. The domain in this guide is a +[Microsoft Entra Domain Services](https://learn.microsoft.com/en-us/entra/identity/domain-services/overview) +managed domain with secure LDAP enabled, because Microsoft Entra ID itself has +no LDAP endpoint. An on-premises Active Directory works the same way. + +For how QuestDB uses OIDC in general, such as the login flow and how it maps +groups to permissions, see [OpenID Connect (OIDC)](/docs/security/oidc/). + +This guide covers Web Console logins, and username and password logins through +the ROPC flow, with QuestDB reading the groups from the User Info endpoint of +PingFederate. It does not set up tokens that services request for themselves: +those usually need QuestDB to read the groups from a JWT instead, as described +in [Non-interactive clients](/docs/security/oidc/#non-interactive-clients). + +## Set up PingFederate client + +First thing first, let's pick a name for the client! + + + +The QuestDB [Web Console](/docs/getting-started/web-console/overview/) is a SPA (Single Page App). + +As a result, it cannot store safely a client secret. + +Instead it can use PKCE (Proof Key for Code Exchange) to secure the flow. + +As shown above, leave the client authentication disabled. + +We also have to white list the URL of the [Web Console](/docs/getting-started/web-console/overview/) as a redirection URL: + + + +We can instruct PingFederate to automatically authorize the scopes requested by +the [Web Console](/docs/getting-started/web-console/overview/). + +The user will not be presented the extra window asking for consent after +authentication: + + + +The [Web Console](/docs/getting-started/web-console/overview/) uses the +[Authorization Code Flow](/docs/security/oidc/#authentication-and-authorization-flow), +and refreshes tokens automatically. + +Next, enable the grant types required for this flow: + + + +We've selected: + +- Authorization Code +- Refresh Token +- Access Token Validation (Client is a Resource Server) + +After that, select the token manager for the client. + +The token manager is responsible for issuing access tokens. + +All token related settings should be configured in the token manager. + + + +Finally, enable PKCE - as shown above - and save the settings. + +## Access Token Manager settings + +QuestDB does not require any special setup regarding the access token. + +We recommend that you do not to use shorter tokens than the default 28 +characters. + +As the QuestDB [Web Console](/docs/getting-started/web-console/overview/) refreshes the token automatically, there is no need +for long-lived tokens: + + + +We've selected: + +- Token length: 28 +- Token lifetime: 5 +- Lifetime extension policy: None +- Maximum token lifetime: Null +- Lifetime extension threshold percentage: 30 + +For the next step, we tune the Authorization Server. + +## Authorization Server settings + +These settings relate to the authorization code, refresh token and CORS. + + + +In this section, we've entered: + +- Authorization code timeout: 60 +- Authorization code entropy: 30 +- Client secret retention period: 0 + +Next, ensure the `ROLL REFRESH TOKEN VALUES` option is selected: + + + +It is also important to whitelist the [Web Console](/docs/getting-started/web-console/overview/)'s URL on the CORS list: + + + +## Set up an Active Directory data source {#set-up-a-microsoft-entra-id-data-source} + +PingFederate needs a Data Source setup. + +This is a secure LDAP connection to the Active Directory domain, here a +Microsoft Entra Domain Services managed domain. + +The data source needs a: + +- name +- hostname +- port +- username and password for the LDAP connection + + + +We have given it the name EntraDS and it will be applied later. + +## Set up a Password Credential Validator + +Now that PingFederate has an LDAP connection, we can use it for authentication. + +First, create a Password Credential Validator: + + + +We've entered: + +- Instance name: EntraPCV +- Instance ID: EntraPCV +- Selected: LDAP Username Password Credential Validator +- Parent instance: None + +Furthermore, we now declare our previously created data source (`EntraDS`): + + + +This links our data store (`EntraDS`) to our PCV (`EntraPCV`). + +## Set up an Identity Provider + +We can use our PCV once we set up an Identity Provider. + +The IdP will be used to authenticate users against Active Directory using the +LDAP connection. + +We do this in the Type subsection: + + +Next, in the IdP Adapter section... + +Click: Add a new row to Credential Validators. + +Select the PCV (`EntraPCV`) we created. + +Optionally alter number of retries: + + + +## Add groups to OIDC policy management + +QuestDB now needs to know about the user's AD group memberships to find their +permissions. + +Groups are passed to QuestDB inside the User Info object in a custom claim. + +This has to be added in the OpenID Connect Policy Management. + +The field is Multi-Valued, because it is a list of group names. + +Under the Attribute Contract subsection, see: + + + +Next, click to the Attribute Scopes subsection. + +Ensure `groups` is among the `openid` attributes: + + + +Onwards to the Attribute Sources & User Lookup Section. + +From this view, you can add local data stores. + +Note item `test` of type of LDAP: + + + +We created it via the following choices in Add Attribute Source: + + + +Note where we specified the Data Store (`EntraDS`). + +This is also where the directory search parameters are defined. + +Back at the Attribute Sources & User Lookup Section section, note we have set +`email`. + +The source is `LDAP (test)`, while the value is `userPrincipalName`: + + + +And finally! + +In the same Attribute Sources & User Lookup Section... + +Find `groups`. + +Note the definition of Source (`LDAP (test)`) that bridges our various parts. + +The value is `memberOf`. + + + +## Enable Resource Owner Password Credentials (ROPC) flow + +As described in the +[OIDC operations document](/docs/security/oidc/#enable-ropc) +tools - such as `psql` - can be integrated with the OIDC provider using the ROPC flow. + +When setting this flow up, enable the Resource Owner Password Credentials flow in the +client settings. + +Next, create a Resource Owner Credentials Grant Mapping to map values obtained from +the Password Credential Validator (PCV) into the persistent grants. + +When setting this up, select the previously created LDAP Data Source and IdP Adapter, which links +to the existing PCV. + +Then select the `username` attribute of the PCV as `USER_KEY`. + +## QuestDB configuration + +Set the following in QuestDB's `server.conf`. QuestDB's default endpoint paths +match PingFederate, so set the host of PingFederate, not the endpoints: + +```ini title="server.conf" +# enable OIDC +acl.oidc.enabled=true + +# the host of PingFederate; also set acl.oidc.port if it is not 443 +acl.oidc.host=pingfederate.example.com + +# the client ID set up in Set up PingFederate client +acl.oidc.client.id=questdb + +# the claim set up in Add groups to OIDC policy management +acl.oidc.groups.claim=groups + +# optional: uncomment only if you enabled ROPC in PingFederate +# acl.oidc.ropc.flow.enabled=true +``` + +QuestDB reads the groups from the User Info response, so keep the default, +`acl.oidc.groups.encoded.in.token=false`. It reads the principal from the +`sub` claim by default. To read it from another claim of the User Info +response, set `acl.oidc.sub.claim`. Users who log in with a username and +password through the ROPC flow, such as with `psql`, get the username that +they type as the principal instead. See +[Choose the principal claim](/docs/security/oidc/#choose-the-principal-claim). +For the other settings, such as the TLS settings that a private certificate +authority needs, see [Configuration](/docs/configuration/oidc/). + +## Confirm QuestDB mappings and login + +QuestDB requires a mapping, as laid out in the +[OIDC operations document](/docs/security/oidc/#mapping-user-permissions). + +If a given user has the HTTP permission, they will be able to now login via the +[Web Console](/docs/getting-started/web-console/overview/). + +To test, head to `http://localhost:9000` and login. + +If all has been wired up well, then login will succeed. diff --git a/documentation/security/rbac.md b/documentation/security/rbac.md index 62dd034475..633946811c 100644 --- a/documentation/security/rbac.md +++ b/documentation/security/rbac.md @@ -57,11 +57,11 @@ Control *what data* users can access: Control *how* users can connect: -| Permission | Protocol | Use case | -| ---------- | ------------------------------- | ------------------------------------------ | -| `HTTP` | REST API, Web Console, ILP/HTTP | Interactive users, web applications | -| `PGWIRE` | PostgreSQL Wire Protocol | SQL clients, BI tools, programmatic access | -| `ILP` | InfluxDB Line Protocol (TCP) | High-throughput data ingestion | +| Permission | Protocol | Use case | +| ---------- | --------------------------------------------------- | ------------------------------------------ | +| `HTTP` | REST API, Web Console, ILP/HTTP, QWP over WebSocket | Interactive users, web applications | +| `PGWIRE` | PostgreSQL Wire Protocol | SQL clients, BI tools, programmatic access | +| `ILP` | InfluxDB Line Protocol (TCP) | High-throughput data ingestion | ```questdb-sql -- User can connect via PostgreSQL protocol only (not web console) @@ -248,7 +248,7 @@ GRANT SWITCH ROLE TO failover_bot; -- SWITCH ROLE, SWITCH STATUS, the endpoint ### Users and service accounts -QuestDB has two types of principals: +QuestDB has two types of local principals: - **Users**: For human individuals. Can belong to multiple groups and inherit permissions from them. Cannot be assumed by others. @@ -263,6 +263,15 @@ CREATE SERVICE ACCOUNT app_account WITH PASSWORD 'pwd'; Names must be unique across all users, service accounts, and groups. +QuestDB Enterprise also accepts external users: people and services that log +in through an [OpenID Connect provider](/docs/security/oidc/), such as users +who sign in to the Web Console with Microsoft Entra ID, and, since QuestDB +Enterprise 4.0.2, Azure services that run as a Microsoft Entra ID managed +identity. QuestDB does not persist external users, and permissions cannot be +granted to them directly. They get the permissions of the QuestDB groups that +their external groups map to. See +[User permissions](/docs/security/oidc/#user-permissions). + #### Why service accounts? Service accounts provide **clean, testable application access**: @@ -324,7 +333,7 @@ dropped, all members lose the permissions they inherited from that group. width={745} /> -QuestDB supports three authentication methods: +QuestDB supports three local authentication methods: | Method | Use case | Endpoints | | ------------------ | ------------------------ | ------------------------- | @@ -332,6 +341,13 @@ QuestDB supports three authentication methods: | **JWK Token** | ILP ingestion | InfluxDB Line Protocol | | **REST API Token** | Programmatic REST access | REST API | +QuestDB Enterprise can also authenticate external users and services with +tokens from an OpenID Connect provider, such as Microsoft Entra ID. They get +their permissions from the QuestDB groups that their external groups map to. +See [OpenID Connect (OIDC)](/docs/security/oidc/#user-permissions), and for +Azure services on QuestDB Enterprise 4.0.2 or later, +[Microsoft Entra ID managed identities and service principals](/docs/security/oidc/entra-id/#managed-identities-and-service-principals). + Users can have multiple authentication methods enabled simultaneously: ```questdb-sql @@ -360,11 +376,11 @@ to protect credentials in transit. Before a user can connect, they need endpoint permissions: -| Permission | Allows access to | -| ---------- | -------------------------------------- | -| `HTTP` | REST API, Web Console, ILP over HTTP | -| `PGWIRE` | PostgreSQL Wire Protocol (port 8812) | -| `ILP` | InfluxDB Line Protocol TCP (port 9009) | +| Permission | Allows access to | +| ---------- | -------------------------------------------------------- | +| `HTTP` | REST API, Web Console, ILP over HTTP, QWP over WebSocket | +| `PGWIRE` | PostgreSQL Wire Protocol (port 8812) | +| `ILP` | InfluxDB Line Protocol TCP (port 9009) | ```questdb-sql -- Typical setup for an interactive user @@ -559,6 +575,27 @@ 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. +In QuestDB Enterprise 4.0.2 and later, an HTTP session ends with the +credential that opened it, or the one that it checked last: when the password +is changed or removed, when the REST token is dropped or expires, or when the +user or service account is disabled or dropped, even if the client sends only +the session cookie. A session of the built-in admin ends when a configuration +reload changes `acl.admin.password`. A request that carries a credential that +QuestDB rejects fails with `401 Unauthorized`, and ends the session whose +cookie it carries. A replica ends the sessions of a disabled or dropped user or +service account only when they are next used, so a session that is not used +while the user is disabled survives if the user is enabled again. + +The Web Console logs in with the password, then sends a REST token that it +creates at login with every later request. The token is valid for one day, and +is extended while in use. A password change ends the session of the Web +Console, but not the token, which opens a new session. To log a user out of +the Web Console, drop the user's REST tokens with +`ALTER USER username DROP TOKEN TYPE REST`, which also drops the REST tokens +that the user's other clients use. To cut the user off from every client, +disable the user. For external users, see +[Group changes, sessions, and revoking access](/docs/security/oidc/#group-changes-sessions-and-revoking-access). + ### Viewing information ```questdb-sql diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 2fc0114310..9782e82a45 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -730,9 +730,35 @@ module.exports = { label: "Role-Based Access Control (RBAC)", }, { - id: "security/oidc", - type: "doc", + type: "category", label: "OpenID Connect (OIDC)", + collapsed: true, + link: { + type: "doc", + id: "security/oidc/index", + }, + items: [ + { + id: "security/oidc/index", + type: "ref", + label: "Overview", + }, + { + id: "security/oidc/device-flow", + type: "doc", + label: "Device flow", + }, + { + id: "security/oidc/entra-id", + type: "doc", + label: "Microsoft Entra ID", + }, + { + id: "security/oidc/pingfederate", + type: "doc", + label: "PingFederate", + }, + ], }, { type: "doc", diff --git a/plugins/fetch-java-client-release/index.test.cjs b/plugins/fetch-java-client-release/index.test.cjs new file mode 100644 index 0000000000..0a5f29f973 --- /dev/null +++ b/plugins/fetch-java-client-release/index.test.cjs @@ -0,0 +1,170 @@ +const assert = require("node:assert/strict") +const { EventEmitter } = require("node:events") +const fs = require("node:fs") +const { createRequire } = require("node:module") +const path = require("node:path") +const test = require("node:test") +const vm = require("node:vm") +const ts = require("typescript") +const { resolveJavaClientVersion } = require("../../shared/java-client-version") + +const root = path.resolve(__dirname, "../..") +const minimum = "1.3.10" + +// Load the actual plugin/converter with isolated HTTP mocks, without network I/O. +function loadModule(relativePath, mocks = {}, globals = {}) { + const filename = path.join(root, relativePath) + const localRequire = createRequire(filename) + const source = fs.readFileSync(filename, "utf8") + const code = filename.endsWith(".ts") + ? ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }).outputText + : source + const module = { exports: {} } + vm.runInNewContext( + code, + { + module, + exports: module.exports, + __dirname: path.dirname(filename), + require: (id) => + Object.hasOwn(mocks, id) ? mocks[id] : localRequire(id), + console: { error() {}, warn() {} }, + ...globals, + }, + { filename }, + ) + return module.exports +} + +test("release tags resolve to stable versions at or above the minimum", () => { + const cases = [ + [undefined, minimum], + [null, minimum], + [42, minimum], + ["", minimum], + ["latest", minimum], + ["invalid", minimum], + ["v1.0.1", minimum], + ["1.3.8", minimum], + ["1.3.9", minimum], + ["1.3.10", minimum], + ["v1.3.10", minimum], + ["v1.3.11", "1.3.11"], + ["1.4.0", "1.4.0"], + ["v1.10.0", "1.10.0"], + ["2.0.0", "2.0.0"], + ["v1.3.10-rc.1", minimum], + ["v2.0.0-SNAPSHOT", minimum], + ] + for (const [tag, expected] of cases) { + assert.equal(resolveJavaClientVersion(tag), expected, `tag: ${tag}`) + } +}) + +const response = (body) => ({ ok: true, json: async () => body }) +const scenarios = [ + ["older release", async () => response({ tag_name: "v1.3.8" }), minimum], + ["minimum release", async () => response({ tag_name: "v1.3.10" }), minimum], + ["newer release", async () => response({ tag_name: "v1.3.11" }), "1.3.11"], + ["missing tag", async () => response({}), minimum], + ["invalid tag", async () => response({ tag_name: 42 }), minimum], + [ + "prerelease tag", + async () => response({ tag_name: "v2.0.0-rc.1" }), + minimum, + ], + ["HTTP failure", async () => ({ ok: false, status: 503 }), minimum], + [ + "network failure", + async () => { + throw new Error("offline") + }, + minimum, + ], + [ + "malformed JSON", + async () => ({ + ok: true, + json: async () => { + throw new SyntaxError("invalid JSON") + }, + }), + minimum, + ], +] + +for (const nativeFetch of [true, false]) { + for (const [label, fetchStub, expected] of scenarios) { + test(`HTML plugin (${nativeFetch ? "native fetch" : "node-fetch"}): ${label}`, async () => { + const { default: plugin } = loadModule( + "plugins/fetch-java-client-release/index.ts", + { "node-fetch": fetchStub }, + { fetch: nativeFetch ? fetchStub : undefined }, + ) + const instance = plugin() + const content = await instance.loadContent() + assert.equal(content.name, expected) + let published + await instance.contentLoaded({ + content, + actions: { + setGlobalData: (data) => { + published = data + }, + }, + }) + assert.equal(published.release.name, expected) + }) + } +} + +for (const [label, tag, expected] of [ + ["older release", "v1.3.8", minimum], + ["minimum release", "v1.3.10", minimum], + ["newer release", "v1.3.11", "1.3.11"], + ["missing tag", undefined, minimum], + ["network failure", null, minimum], +]) { + test(`raw Markdown keeps core and Azure dependencies aligned: ${label}`, async () => { + const https = { + get(_url, _options, callback) { + const request = new EventEmitter() + queueMicrotask(() => { + if (tag === null) { + request.emit("error", new Error("offline")) + } else { + const res = new EventEmitter() + callback(res) + res.emit("data", JSON.stringify({ tag_name: tag })) + res.emit("end") + } + }) + return request + }, + } + const { convertAllComponents } = loadModule( + "plugins/raw-markdown/convert-components.js", + { https }, + ) + const filename = path.join(root, "documentation/connect/clients/java.md") + const converted = await convertAllComponents( + fs.readFileSync(filename, "utf8"), + path.dirname(filename), + path.join(root, "documentation"), + ) + const versions = [ + ...converted.matchAll(/([^<]+)<\/version>/g), + ].map((match) => match[1]) + assert.deepEqual(versions, [expected, expected]) + for (const artifact of ["questdb-client", "questdb-client-azure"]) { + assert.ok(converted.includes(`org.questdb:${artifact}:${expected}`)) + } + assert.ok(!converted.includes("${release.name}")) + }) +} diff --git a/plugins/fetch-java-client-release/index.ts b/plugins/fetch-java-client-release/index.ts index b440c350e6..fdd1cbd40c 100644 --- a/plugins/fetch-java-client-release/index.ts +++ b/plugins/fetch-java-client-release/index.ts @@ -1,12 +1,16 @@ import type { Plugin } from '@docusaurus/types' import nodeFetch from 'node-fetch' +import { + MINIMUM_JAVA_CLIENT_VERSION, + resolveJavaClientVersion, +} from '../../shared/java-client-version' type Release = { name: string } const DEFAULT_RELEASE: Release = { - name: '1.0.1', + name: MINIMUM_JAVA_CLIENT_VERSION, } async function fetchLatestJavaClientRelease(): Promise { @@ -27,8 +31,9 @@ async function fetchLatestJavaClientRelease(): Promise { } const data = await response.json() - const tagName = (data as { tag_name?: string }).tag_name ?? '' - return { name: tagName.replace(/^v/, '') || DEFAULT_RELEASE.name } + return { + name: resolveJavaClientVersion((data as { tag_name?: string }).tag_name), + } } catch (error) { console.error('Failed to fetch latest Java client release:', error) return DEFAULT_RELEASE @@ -47,8 +52,9 @@ async function fetchLatestJavaClientRelease(): Promise { } const data = await response.json() - const tagName = (data as { tag_name?: string }).tag_name ?? '' - return { name: tagName.replace(/^v/, '') || DEFAULT_RELEASE.name } + return { + name: resolveJavaClientVersion((data as { tag_name?: string }).tag_name), + } } catch (error) { console.error('Failed to fetch latest Java client release:', error) return DEFAULT_RELEASE diff --git a/plugins/raw-markdown/convert-components.js b/plugins/raw-markdown/convert-components.js index fc576b441f..87279f6493 100644 --- a/plugins/raw-markdown/convert-components.js +++ b/plugins/raw-markdown/convert-components.js @@ -2,6 +2,7 @@ const fs = require("fs") const path = require("path") const clients = require("../../shared/clients.json") const ilpClients = require("../../shared/ilp_clients.json") +const { resolveJavaClientVersion } = require("../../shared/java-client-version") /** * Parses JSX props from a string like: alt="test" height={300} src="..." @@ -160,7 +161,7 @@ function convertDocButton(content) { } /** - * Converts and to markdown table + * Converts and to labeled Markdown blocks */ function convertTabs(content) { return replaceComponent(content, 'Tabs', (match) => { @@ -194,8 +195,8 @@ function convertTabs(content) { tabItems.push({ value, content }) } - // Convert to sections with headings instead of table - // This handles complex content better (nested tables, code blocks, etc.) + // Labels must not change the surrounding heading hierarchy: tabs can + // appear at any depth, with prose and sibling sections following them. let sections = "\n\n" for (const valueObj of valuesArray) { @@ -203,7 +204,7 @@ function convertTabs(content) { const tabItem = tabItems.find(item => item.value === valueObj.value) const content = tabItem ? tabItem.content.trim() : "" - sections += `### ${label}\n\n${content}\n\n` + sections += `**${label}**\n\n${content}\n\n` } return sections @@ -321,7 +322,8 @@ async function fetchReleaseVersion() { } async function fetchJavaClientVersion() { - return fetchGitHubRelease("questdb/java-questdb-client", { stripV: true }) + const tag = await fetchGitHubRelease("questdb/java-questdb-client", { stripV: true }) + return resolveJavaClientVersion(tag) } function convertInterpolateReleaseData(content, releaseVersion) { diff --git a/plugins/raw-markdown/convert-components.test.cjs b/plugins/raw-markdown/convert-components.test.cjs new file mode 100644 index 0000000000..f687f69edc --- /dev/null +++ b/plugins/raw-markdown/convert-components.test.cjs @@ -0,0 +1,169 @@ +const assert = require("node:assert/strict") +const { EventEmitter } = require("node:events") +const fs = require("node:fs") +const { createRequire } = require("node:module") +const path = require("node:path") +const test = require("node:test") +const vm = require("node:vm") + +// Exercise the real converter without fetching release data over the network. +function loadConverter() { + const filename = path.join(__dirname, "convert-components.js") + const localRequire = createRequire(filename) + const https = { + get(_url, _options, callback) { + const request = new EventEmitter() + queueMicrotask(() => { + const response = new EventEmitter() + callback(response) + response.emit("data", JSON.stringify({ tag_name: "v1.3.10" })) + response.emit("end") + }) + return request + }, + } + const module = { exports: {} } + vm.runInNewContext( + fs.readFileSync(filename, "utf8"), + { + module, + exports: module.exports, + require: (id) => (id === "https" ? https : localRequire(id)), + console, + }, + { filename }, + ) + return module.exports +} + +test("OIDC raw Markdown preserves usable legacy provider-step links", async () => { + const { convertAllComponents, removeImports, normalizeNewLines } = + loadConverter() + const docsRoot = path.resolve(__dirname, "../../documentation") + const filename = path.join(docsRoot, "security/oidc/index.mdx") + const converted = normalizeNewLines( + removeImports( + await convertAllComponents( + fs.readFileSync(filename, "utf8"), + path.dirname(filename), + docsRoot, + ), + ), + ) + const anchors = new Map() + for (const [, attributes, label] of converted.matchAll( + /]*)>([\s\S]*?)<\/a>/g, + )) { + const id = attributes.match(/\bid="([^"]+)"/)?.[1] + const href = attributes.match(/\bhref="([^"]+)"/)?.[1] + if (id) { + assert.ok(label.trim(), `link label for ${id}`) + assert.ok(!anchors.has(id), `unique legacy anchor ${id}`) + anchors.set(id, href) + } + } + + const providerSteps = { + "entra-id": [ + "set-up-the-client-application-in-entra-id", + "questdb-configuration", + "map-groups-and-grant-permissions", + "confirm-group-mappings-and-login", + ], + pingfederate: [ + "set-up-pingfederate-client", + "access-token-manager-settings", + "authorization-server-settings", + "set-up-a-microsoft-entra-id-data-source", + "set-up-a-password-credential-validator", + "set-up-an-identity-provider", + "add-groups-to-oidc-policy-management", + "enable-resource-owner-password-credentials-ropc-flow", + "confirm-questdb-mappings-and-login", + ], + } + for (const [provider, steps] of Object.entries(providerSteps)) { + for (const id of steps) { + assert.equal( + anchors.get(id), + `/docs/security/oidc/${provider}/#${id}`, + `legacy anchor ${id} links to the provider setup step`, + ) + } + } + assert.doesNotMatch(converted, /<\/?Link\b/) +}) + +test("nested dependency tabs preserve the surrounding heading hierarchy", async () => { + const { convertAllComponents, normalizeNewLines } = loadConverter() + const source = `### Request a token + +#### With the Java client + + + + +\`\`\`xml +questdb-client-azure +\`\`\` + + + + +\`\`\`text +implementation 'org.questdb:questdb-client-azure:1.3.10' +\`\`\` + + + + +Java ingestion instructions. + +#### With the Python client + +Python ingestion instructions. +` + const docsRoot = path.resolve(__dirname, "../../documentation") + const converted = normalizeNewLines( + await convertAllComponents(source, docsRoot, docsRoot), + ) + assert.deepEqual(converted.match(/^#{1,6} .+$/gm), [ + "### Request a token", + "#### With the Java client", + "#### With the Python client", + ]) + assert.match( + converted, + /\*\*Maven\*\*\s+```xml\nquestdb-client-azure<\/dependency>\n```/, + ) + assert.match( + converted, + /\*\*Gradle\*\*\s+```text\nimplementation 'org.questdb:questdb-client-azure:1\.3\.10'\n```/, + ) + assert.match(converted, /Java ingestion instructions\./) + assert.match(converted, /Python ingestion instructions\./) + assert.doesNotMatch(converted, /<\/?(?:Tabs|TabItem)\b/) +}) + +for (const page of ["security/oidc/entra-id.mdx", "connect/clients/java.md"]) { + test(`${page} keeps dependency labels out of its Markdown outline`, async () => { + const { convertAllComponents } = loadConverter() + const docsRoot = path.resolve(__dirname, "../../documentation") + const filename = path.join(docsRoot, page) + const source = fs.readFileSync(filename, "utf8") + const converted = await convertAllComponents( + source, + path.dirname(filename), + docsRoot, + ) + assert.deepEqual( + converted.match(/^#{1,6} .+$/gm), + source.match(/^#{1,6} .+$/gm), + ) + assert.match(converted, /\*\*Maven\*\*/) + assert.match(converted, /\*\*Gradle\*\*/) + assert.match(converted, /questdb-client-azure<\/artifactId>/) + assert.match(converted, /1\.3\.10<\/version>/) + assert.doesNotMatch(converted, /\$\{release\.name\}|<\/?(?:Tabs|TabItem)\b/) + }) +} diff --git a/scripts/generate-llms-files.js b/scripts/generate-llms-files.js index a1968ba778..17f531129c 100644 --- a/scripts/generate-llms-files.js +++ b/scripts/generate-llms-files.js @@ -109,7 +109,7 @@ function processForLlmsTxt(items, indent = 0, isTopLevel = false) { } result += '\n' - } else if (item.type === 'doc') { + } else if (item.type === 'doc' || item.type === 'ref') { const docId = item.id const docPath = path.join('./documentation', docId + '.md') const { title, description } = extractFrontmatter(docPath) @@ -171,8 +171,12 @@ function generateLlmsFiles() { console.log(` - Size: ${(llmsOutput.length / 1024).toFixed(2)} KB`) } -try { - generateLlmsFiles() -} catch (error) { - console.error('Error generating llms files:', error) -} \ No newline at end of file +if (require.main === module) { + try { + generateLlmsFiles() + } catch (error) { + console.error('Error generating llms files:', error) + } +} + +module.exports = { processForLlmsTxt } diff --git a/scripts/generate-llms-files.test.cjs b/scripts/generate-llms-files.test.cjs new file mode 100644 index 0000000000..8b21da40ea --- /dev/null +++ b/scripts/generate-llms-files.test.cjs @@ -0,0 +1,38 @@ +const assert = require("node:assert/strict") +const test = require("node:test") +const { processForLlmsTxt } = require("./generate-llms-files") +const { docs } = require("../documentation/sidebars") + +const overviewId = "security/oidc/index" +const overviewUrl = "https://questdb.com/docs/security/oidc.md" + +// A reference has the same metadata and canonical URL as a normal doc entry. +test("doc references preserve labels, descriptions, and custom slugs", () => { + const entry = { id: overviewId, label: "Overview" } + const output = processForLlmsTxt([{ ...entry, type: "ref" }]) + + assert.equal(output, processForLlmsTxt([{ ...entry, type: "doc" }])) + assert.ok(output.startsWith(`- [Overview](${overviewUrl}): `)) + assert.ok(output.includes("Configure OIDC in QuestDB Enterprise:")) +}) + +test("unlabelled references use the document title", () => { + const output = processForLlmsTxt([{ type: "ref", id: overviewId }]) + + assert.ok( + output.startsWith( + `- [OpenID Connect (OIDC) Integration](${overviewUrl}): `, + ), + ) +}) + +test("the real sidebar includes the canonical OIDC overview exactly once", () => { + const output = processForLlmsTxt(docs, 0, true) + + assert.equal(output.split(overviewUrl).length - 1, 1) + assert.ok(output.includes(` - [Overview](${overviewUrl}): Configure OIDC`)) + for (const guide of ["device-flow", "entra-id", "pingfederate"]) { + const url = `https://questdb.com/docs/security/oidc/${guide}.md` + assert.equal(output.split(url).length - 1, 1, guide) + } +}) diff --git a/shared/java-client-version.js b/shared/java-client-version.js new file mode 100644 index 0000000000..ca9814af98 --- /dev/null +++ b/shared/java-client-version.js @@ -0,0 +1,31 @@ +// Token-provider examples require this version of both Java client artifacts. +const MINIMUM_JAVA_CLIENT_VERSION = "1.3.10" + +/** + * Resolve a stable release tag without falling below the documented API version. + * @param {unknown} tag + * @returns {string} + */ +function resolveJavaClientVersion(tag) { + const version = typeof tag === "string" ? tag.replace(/^v/, "") : "" + if (!/^\d+\.\d+\.\d+$/.test(version)) { + return MINIMUM_JAVA_CLIENT_VERSION + } + + const parts = version.split(".").map(Number) + const minimum = MINIMUM_JAVA_CLIENT_VERSION.split(".").map(Number) + for (let i = 0; i < minimum.length; i++) { + if (parts[i] > minimum[i]) { + return version + } + if (parts[i] < minimum[i]) { + return MINIMUM_JAVA_CLIENT_VERSION + } + } + return version +} + +module.exports = { + MINIMUM_JAVA_CLIENT_VERSION, + resolveJavaClientVersion, +}