v26.4.2
v26.4.2
A deactivated directory refuses SCIM requests
A directory sync directory that was deactivated answered every SCIM request with 200 and an empty body, and ignored it, so the
IdP believed its sync worked while none of its changes were kept. It now answers with a 403 SCIM error,
Directory is deactivated, so the IdP reports the sync as failing. The secret is checked first: a request with a wrong secret is
still answered 401, so only the IdP that holds it learns the directory is deactivated. The Google Workspace sync no longer syncs
a deactivated directory.
See Directory Sync API.
Breaking changes
An IdP that still provisions into a deactivated directory now gets a 403 for every request, and reports provisioning errors,
where it got 200 before. If you deactivated a directory to pause it quietly, turn off provisioning in the IdP as well, or
activate the directory again.
Microsoft Entra ID retries the refused changes, and quarantines the provisioning job once enough of them fail; it catches up on its own if the directory is activated again within four weeks, but disables the job after four weeks in quarantine, after which an admin has to restart provisioning in Entra. Okta keeps them as failed tasks, to retry from its Tasks page.
Google Workspace directory authorization now requires a per-directory token
Starting the Google consent flow for a Google Workspace directory installs credentials on that directory. Until now the authorize
endpoint accepted any request that named a directory id. It now also requires the directory's google_authorization_token, a new
secret that every Google directory holds.
The google_authorization_url returned for a directory, and shown in the Admin Portal and on setup links, is now a complete link
that carries the directory id and the token. Copy and share it as it is. Directories created before this release receive a token
the first time they are read.
Breaking changes
- Authorization URLs built by hand from
google_authorization_urlplus adirectoryIdquery parameter no longer work. Use the URL exactly as the API or the Admin Portal returns it. generateAuthorizationUrlin the@boxyhq/saml-jacksonpackage now takes atokenalongsidedirectoryId.- Completing the consent screen replaces the directory's token, and so does setting
google_access_tokenorgoogle_refresh_tokenthrough the directory API. An authorization URL shared earlier stops working once the directory has been authorized; read the directory again to get the current one.
Polis metrics carry an outcome and a duration, and cover directory sync
Every Polis operation now reports one counter and one duration histogram, in seconds, with the same attributes. The counter
carries outcome, either success or error, so an error ratio can be computed per protocol and login type, and an attribute
that could not be determined before a request failed is reported as unknown instead of being left out.
Directory sync is now covered: dsync.request records every SCIM request by resource, method and response status, and
webhook.delivery records every webhook delivery with its response status or transport error, with event_type telling directory
sync's user and group events from the connection lifecycle events.
Every point carries app, which is polis, and version, as the Ory Go services' metrics do, and the duration histograms use
the same latency buckets as those services, so one dashboard query fits both.
The database pool is reported through the OpenTelemetry semantic conventions, db.client.connection.count by state,
db.client.connection.max and db.client.connection.pending_requests, read at collection time rather than every 30 seconds.
See Observability for Enterprise SSO and Observability for Directory Sync for the full list.
Breaking changes
Polis metrics no longer carry a service prefix: jackson.oauth.authorize is now oauth.authorize, and so on, with the service
named by the app attribute, as on the Ory Go services. The meter is named polis. Update dashboards and alerts to the new
names, for example oauth_authorize_total{app="polis"} in place of jackson_oauth_authorize_total. One counter is also renamed:
jackson.dsync.events_batch.failed is now dsync.event_batch.failed, in the singular as the other directory sync event metrics
are. The documentation had for some time listed the metrics under a polis. prefix that Polis never emitted; that prefix is gone
too.
The .error counters are gone. Their values are the outcome="error" series of the request counter of the same name:
jackson.oauth.authorize.error becomes oauth.authorize{outcome="error"}, and so on. The request counters now carry protocol,
login_type and outcome; the sum over outcome equals the previous total. Update dashboards and alerts that reference the
.error names or that summed the request counters without grouping by outcome.
The database pool gauges are replaced by the OpenTelemetry ones: jackson.db.connections.max is db.client.connection.max,
jackson.db.connections.waiting is db.client.connection.pending_requests, and jackson.db.connections.total and .idle are
both db.client.connection.count, split by db.client.connection.state. The old .idle is the
db_client_connection_state="idle" series; the old .total counted idle and in-use connections together, so it is the sum over
both states, sum(db_client_connection_count) rather than the db_client_connection_state="used" series alone. The db_name
attribute is now db.client.connection.pool.name.
Reject userinfo access tokens sent in the URL
The userinfo endpoint accepts the access token either in the Authorization header or in an access_token query parameter. A
token in the URL ends up in browser history, proxy and access logs, monitoring pipelines and referrer headers, which is why RFC
6750 discourages the query form and RFC 9700 forbids clients from using it.
Set OPENID_USERINFO_REJECT_QUERY_TOKEN=true to accept the token only from the Authorization header. Requests that carry it in
the URL then fail with a 400 invalid_request error instead of returning the profile. The default stays false, so nothing
changes until you enable it — turn it on once you have confirmed your clients send the token in the header.
Error responses from the endpoint now also carry the WWW-Authenticate challenge and the error and error_description fields
that OAuth defines, alongside the message field they returned before.
What you must do
A token the endpoint cannot accept — malformed, unknown, expired, or one it cannot decrypt — now returns 401 with
error: "invalid_token". It returned 403 before, and a token it could not decrypt returned 500. Check any client or monitor
that treats the userinfo response by status code: OpenID Connect clients that follow the specification already handle 401, but
code written against the old 403 needs updating. The message field keeps its previous value, so anything reading the body is
unaffected.
SCIM PUT /Groups/{id} now applies group members
A SCIM PUT on a group replaces its membership with the members in the request, and an empty members list clears it. Until
now PUT only renamed the group, so identity providers that push memberships with PUT, such as Okta apps created with the App
Integration Wizard, left groups empty. A PUT without members keeps the current membership. Each member added or removed sends
a group.user_added or group.user_removed webhook event, as PATCH does.
Upgrading
The first PUT that Okta sends for a group after the upgrade adds every member of that group, one at a time, within that request,
and sends a group.user_added event for each. For a group with thousands of members, the request can take longer than Okta waits,
and Okta reports the push as failed. The members added before then are kept, and the next push of that group adds only those still
missing, so retry the push from the Okta app's Push Groups tab until it succeeds. Expect a burst of group.user_added events
at your webhook endpoint while groups fill. To spread the load, push large groups one at a time.
Breaking changes
group.updated events now always carry raw.members: []. Before, a PUT that included members stored that list in the group's
attributes, so group.updated and GET /Groups repeated it back even though no member was added. Track membership with the
group.user_added and group.user_removed events instead, or read it from the paginated
group members endpoint,
GET /api/v1/dsync/groups/{groupId}/members. A SCIM GET /Groups/{id} lists members only with includeMembers=true, and only
for groups within DSYNC_MAX_INLINE_GROUP_MEMBERS, 500 by default.
SSO connection webhooks carry the client secret on sso.created only
Every SSO connection webhook, sso.created, sso.activated, sso.deactivated and sso.deleted, carried the connection's
clientSecret. The secret is a live credential: it authenticates the token exchange for the connection and authorizes updating or
deleting it. It is now sent on sso.created only, where an application first learns of a connection, for example one a customer
created through a setup link. The other events name the connection by its clientID.
See Webhook events.
Breaking changes
sso.activated, sso.deactivated and sso.deleted no longer include data.clientSecret. An application that reads it from
those events should keep the secret from sso.created, or fetch the connection through the API.
Batched directory sync webhooks reach DynamoDB-backed endpoints in order
With webhook batching (DSYNC_WEBHOOK_BATCH_SIZE) on DynamoDB, which lists the queue by key rather than by when events were
queued, a directory's events could reach its webhook out of order. Queued events now get IDs that sort in the order they were
queued, so DynamoDB reads the queue oldest first, as the other databases do. Events already queued before the upgrade keep their
old IDs and may be delivered out of order once.
Choose the SAML connection for single logout
/api/logout accepts a clientID parameter that names the SAML connection the user signed in through. You can send it with
tenant and product, or on its own.
When a tenant and product have several connections, Polis now picks the connection to log out through as follows:
- OIDC connections are skipped.
- If exactly one SAML connection supports single logout, Polis uses it.
- If more than one supports single logout, Polis responds with
400 Bad Requestuntil you passclientID. Before, Polis used whichever connection it read first, which could send the logout to an identity provider the user never signed in with.
Deleting a directory deletes its queued webhook events
With webhook batching (DSYNC_WEBHOOK_BATCH_SIZE), deleting a directory removed its users, groups and webhook event logs but left
the events still queued for its webhook, until the next batch run found the directory gone and deleted them. They are now deleted
with the directory. Each is recorded as a polis.dsync.webhook.dropped warning with the reason directory_deleted and its event
ID, as dropped events are, so a change that will never reach the webhook can still be told apart.
Directory sync webhooks accept any 2xx response and retry on a schedule
When directory sync events are batched, only a 200 response counted as a delivery. An endpoint that answered 201, 202, or
204 was treated as failing: its events stayed queued and the same batch was posted to it again and again. Any 2xx response now
counts as a delivery, and webhook event logs mark those deliveries as delivered.
A batch that does fail is now retried on a schedule rather than on every pass of the queue. The delay starts at 30 seconds,
doubles with each attempt, carries jitter, and settles at one hour between attempts. Set DSYNC_WEBHOOK_BATCH_RETRY_INTERVAL to
change the starting delay. Events that are waiting for their next attempt are skipped, so one connection whose endpoint is
unreachable no longer holds up the events of your other connections.
A failed batch is also one request to your endpoint now rather than four in quick succession. The HTTP client used to retry three more times on its own, three seconds apart, before the queue heard about the failure -- attempts that neither waited for the delay above nor stopped at a response the queue treats as final.
Within a connection, events are still delivered in the order they were queued: an event that is waiting to be retried holds back the events queued behind it for that connection, and only that connection. That ordering relies on the database returning queued events in the order they were written. Every engine does except DynamoDB, where the queue is keyed by a random id, so events for one connection can reach your endpoint out of order there.
An endpoint that answers 400 or 422 has read the payload and will not take it, so those events are moved to a dead-letter
store straight away rather than retried, and the events queued behind them carry on. Every other response, including 401, 403,
404, 413, 429, and anything 5xx, is retried. If an endpoint answers 413 because the batch is too large for it, lower
DSYNC_WEBHOOK_BATCH_SIZE and the queued events go out in smaller requests.
Events are still kept until they are delivered. If you would rather drop events that cannot be delivered, set
DSYNC_WEBHOOK_BATCH_MAX_RETRIES to the number of retries you want; an event that uses up that budget moves to the dead-letter
store, where it is kept for inspection and is never sent again.
No action is required.
Group members can be paged past the first page on DynamoDB
GET /api/v1/dsync/groups/{groupId}/members now returns the pageToken for the next page. On DynamoDB, which pages by token, a
group's members could only be read up to the first page.
Identity Federation logout finds the upstream connection from the SessionIndex
SAML responses from an Identity Federation app now carry a SessionIndex that names the upstream connection the user signed in
through. When the service provider sends that SessionIndex back in its LogoutRequest, as SAML single logout expects, Polis ends
the session at that connection. The service provider no longer needs to send idp_hint or the id_token when the app has more
than one upstream connection.
Sign-ins made before this release carry the old SessionIndex, so logging them out still needs idp_hint or the id_token.
Identity Federation logout works with more than one upstream connection
When an Identity Federation app has more than one upstream connection, single logout no longer sends the user to the identity provider selection page, which could not complete a logout. Polis now ends the session at:
- the connection named by
idp_hint, if set. Both bindings accept it in the query string, and HTTP-POST also accepts it as a form field; - otherwise, the app's only active OIDC connection;
- otherwise, the OIDC connection that issued the
id_tokenin the LogoutRequest'sExtensions.
If none of these settles it, Polis rejects the logout request. Include the id_token in the LogoutRequest to avoid this.
Polis exports traces through OpenTelemetry
Polis now exports traces beside its metrics. Set OTEL_EXPORTER_OTLP_ENDPOINT, or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, and each
request to Polis opens a request span named after the route, continuing a trace that arrives in a traceparent header, with a
span for each Polis operation (oauth.authorize and so on) carrying the attributes its metric carries, outcome included, so a
failing or slow series can be followed to the traces behind it. A failure sets the span status and records the error. The calls
Polis makes to identity providers and webhooks, and its database queries, are client spans, and outgoing requests carry the trace
on. Exported spans carry no request target, and the URLs of the calls Polis makes keep only their scheme and host, with the path,
credentials and query values replaced by REDACTED, so tokens, authorization codes and state values do not reach the trace
backend. Spans are reported by the Ory Polis service, as the other Ory services name their tracers; metrics keep polis as
their service name, so their Prometheus job label is unchanged.
The exporter is configured the way the metrics exporter is, with the traces-specific variables taking precedence;
OTEL_TRACES_EXPORTER=none turns traces off while the endpoint keeps serving metrics. The sampler decides which of those traces
are recorded and exported: it follows OTEL_TRACES_SAMPLER, parent-based always-on by default, so set a ratio on a busy
deployment.
A deployment that already sets OTEL_EXPORTER_OTLP_ENDPOINT for metrics starts exporting a trace for every request when it
upgrades, to the same collector. Make sure the collector accepts traces, or set OTEL_TRACES_EXPORTER=none to keep them off. With
a valid POLIS_LICENSE_KEY, the same endpoint also turns on logs; set OTEL_LOGS_EXPORTER=none to keep those off.
See Observability for Enterprise SSO and the environment variables.
Polis keeps syncing Google Workspace directories when one fails
A Google Workspace sync run stopped at the first directory that failed, such as one whose Google grant was revoked, and the
directories after it were not synced until a later run reached them, if one did. Each directory is now synced on its own: a
failure is logged, naming the directory, and recorded as a failed polis.dsync.sync, and the run goes on to the next directory.
The run also logs how many directories failed. A sync stopped during shutdown no longer begins another directory.
Polis logs every change to SSO connections and directories through OpenTelemetry
Every change to an SSO connection or a directory is now an OpenTelemetry log record, named after the webhook event:
polis.connection.sso.created, .activated, .deactivated and .deleted, and the same for polis.connection.dsync. Updates,
which have no webhook event, are polis.connection.sso.updated and polis.connection.dsync.updated, with the names of the fields
they changed, never their values. A change is logged whether or not a webhook is configured, and connection.event counts them by
event type.
A connection's records carry its tenant, product, client ID, name, protocol, whether it is active, and the IdP it trusts, by
provider and issuer; a directory's carry its ID, name and type, whether it is active, and whether it has a webhook. They never
carry a secret or a webhook URL. The latest record of each connection therefore answers which connections a tenant has, with which
IdP, and whether they are on. Like the other Polis logs, these need a valid POLIS_LICENSE_KEY.
Each change, and each run of the directory sync webhook batch job, is also a span, connection.sso.created or dsync.event_batch
say, so its records and webhook deliveries share a trace ID even where no request span is around them.
The span and log record of a lifecycle webhook delivery, sent to WEBHOOK_URL and counted as the event_type="connection" series
of webhook.delivery, now name the event and the connection or directory it was about.
A lifecycle webhook that fails no longer fails the API request that made the change. The change was already saved, but the request returned an error, so the caller could not tell that it had succeeded. The request now succeeds, and the failure is logged and recorded as a failed delivery. The webhook is not sent again after the transport's own retries.
See Connection changes.
Polis logs every directory sync change through OpenTelemetry
Every change directory sync makes to a user, a group or a group's members is now an OpenTelemetry log record, named after the
event the webhook receives, such as polis.dsync.user.created or polis.dsync.group.user_added. A change is logged whether or
not the directory has a webhook. Like the sign-in logs, these need a valid POLIS_LICENSE_KEY. Each record carries the directory,
with its tenant, product, ID, name and type, and the user or group it changed: the user's ID, email address, user name, full name
and whether they are active, and the group's ID and name. A change carries the trace of the SCIM request that made it, so a record
leads to that request and its outcome.
The records also carry the SCIM request body the IdP sent and the event as the webhook receives it. Set
DSYNC_TELEMETRY_REDACT=true to leave these out; the IDs, names and email addresses are logged either way. The Helm chart redacts
them by default, through monitoring.dsync.redact.enabled.
SCIM requests now say which directory they were for and, when they fail, why: the error's scimType, such as uniqueness, or its
status code, as error.type, which failed requests also carry on their metrics. A request to a deactivated directory, which Polis
accepts and ignores, is marked as such. Each Google Workspace sync of a directory is recorded as dsync.sync, with its outcome
and duration, and leads to the changes it made. dsync.event counts the changes by event type.
See Observability for Directory Sync.
Polis logs every sign-in and sign-out through OpenTelemetry
Polis now exports OpenTelemetry logs beside its metrics and traces, as an Enterprise feature: logs are exported only with a valid
POLIS_LICENSE_KEY, while metrics and traces need none. Every operation emits one log record when it ends, successful or not,
with the attributes of its metric and span and the trace and span ID of its request. Log records are not sampled, so every sign-in
is recorded even when only a share of the traces is kept.
The records of one sign-in share a polis.login.id across the authorize request, the IdP response, the token exchange and the
userinfo request. They carry the tenant, product, connection, IdP and the origin of the redirect URL, and from the IdP response
on, the user's ID and email address. A failure carries error.type, which says why it failed, and polis.sso_trace.id, the ID
the Admin Portal shows and the end user sees in the error. Records also carry the SAML request and response, the profile Polis
extracted and the OIDC ID token unless SSO_TELEMETRY_REDACT=true, which the Helm chart sets by default; a deployment that sets
its own environment exports them until it sets the variable. The user's ID and email address are logged either way. The OIDC
access and refresh tokens never are, not even in the profile of an identity federation app that includes them in its assertion.
Single logout is now instrumented as well, with the oauth.logout, oauth.logout.response and idfed.logout.response metrics
and spans, log records of the same names with a polis. prefix, and a polis.logout.id that ties a logout's request and response
together.
Logs are exported when an OTLP endpoint is configured for them, with OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or the general
OTEL_EXPORTER_OTLP_ENDPOINT, and batched as the OTEL_BLRP_* variables configure. A licensed deployment that already sets the
general endpoint therefore starts exporting logs; set OTEL_LOGS_EXPORTER=none to keep them off. The Helm chart has
monitoring.logs.otlpEndpoint and monitoring.logs.otlpProtocol.
Failed single sign-on and logout operations also carry error.type on their metrics, so an error series splits by cause. The
success series are unchanged.
See Logs and the environment variables.
Polis reports HTTP requests the way the Ory Go services do
Each request that matches a Polis route is now recorded as http_requests_total and http_requests_duration_seconds with the
code, method, endpoint and app attributes the Ory Go services carry on theirs, and the same latency buckets, so Polis
appears on the dashboards and alerts that read one request metric from every service. endpoint is the route template with path
parameters written {param}.
The metrics are measured on the request span, which Polis now opens whenever metrics are exported, even without a traces endpoint. Sampling traces does not sample them. See Observability for Enterprise SSO.
Polis traces every directory sync event to its webhook through OpenTelemetry
Each directory sync change now has an event ID, polis.dsync.event.id, on its log record and on every record of its webhook
delivery, so a change can be followed to the webhook and back. The ID is never sent to the webhook. An application that embeds
Polis with its own dsync.callback receives it as the callback's new, optional second argument.
A directory sync webhook delivery, webhook.delivery, now says which directory's events it carried, to which host and port, how
many, and which webhook event log entry it wrote: polis.dsync.webhook.log.id, the event ID the Admin Portal shows. Each event it
carried is also a polis.dsync.webhook.event log record, with its event ID, outcome, status code and attempt number, so one event
can be found in a batch and its retries followed. The dsync.webhook.event metric counts them by event type and outcome.
Batched events that the queue deletes unsent, because their directory was deleted or deactivated or no longer has a webhook, were
reported only in the application log. They are now counted by dsync.webhook.dropped, by event type and reason, and logged as
warnings with their event ID.
Like the other Polis logs, these records need a valid POLIS_LICENSE_KEY. The metrics do not.
See Webhook deliveries.
SAML logout completes against the connection that started it
When several SAML connections use the same identity provider, a logout response is now verified with the certificate of the connection that started the logout, and Polis redirects using that connection's allowed redirect URLs and default redirect URL.
A logout response whose issuer is not that connection's identity provider is rejected. A logout started before this release is rejected; start the logout again.
SCIM updates that collide with another request no longer drop its change
A SCIM PATCH of a user, or a change to a group's display name, that reached the database at the same time as another update of
the same record could write back over that other update. On CockroachDB, YugabyteDB and PostgreSQL at serializable isolation the
database reports such an overlap as a retryable conflict; Polis replayed the write but not the read it was built from, so the
record it wrote was the one it had read before the other request committed. These updates now read the record again before
replaying, the same way SSO connection updates already do, so the change the other request committed is carried into the replay
rather than overwritten. As with SSO connection updates, this narrows the window for a lost update without closing it: the
database only reports a conflict when the two writes overlap in time.
A replay that finds the user gone, because the request it lost the conflict to was the one that deleted it, now answers 404. A
PATCH whose write fails for any other reason now returns that error instead of an empty 200.
On PostgreSQL and YugabyteDB a deadlocked transaction is now replayed as well, where it previously surfaced as an internal server error. MySQL, MariaDB and PlanetScale already were, because they report a deadlock under the same code as a serialization conflict.
Verify the signature on Identity Federation logout requests
Identity Federation apps accept a new logoutRequestSigningCertificate field holding the service provider's X.509 certificate, in
PEM or bare base64 DER form. Set it and Polis verifies the signature on every SAML LogoutRequest that service provider sends to
/api/identity-federation/slo, for both the HTTP-POST and HTTP-Redirect bindings, and refuses the logout with 403 if the
signature does not verify. The field accepts a comma-separated list, so both certificates can be trusted while you rotate a key.
Set IDFED_REQUIRE_SIGNED_LOGOUT_REQUESTS=true to require a verified signature across every app. Apps with no certificate
configured can then no longer start a logout, so register the certificates first.
A verified request must also be addressed to this deployment's Single Logout endpoint, the one published in its IdP metadata, must
be no more than five minutes old by its own IssueInstant, and is accepted only once. So make sure the service provider sets
Destination and IssueInstant, gives each LogoutRequest its own ID, and keeps its clock within five minutes of yours.
Apps that leave the field unset behave as before. SHA-1 signatures are not accepted; configure a service provider that signs with SHA-256 or stronger.