Commit Graph
4824 Commits
Author SHA1 Message Date
Fabienne Bühler eb79d94178 chore: Add information for contributors about next gen (#12741)
<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

Currently the state for contributions is not clear to community
contributors.

# How the Problems Are Solved

Added a note to the contribution.md and the pr template

(cherry picked from commit 20d79ca05e)
2026-09-25 05:59:11 +02:00
Livio Spring f1891b884c perf(eventstore): order by sort key as column list instead of row constructor (#12792)
# Which Problems Are Solved

- #12703 changed the descending default order of events to a row
constructor `ORDER BY ("position", in_tx_order, instance_id,
aggregate_type, aggregate_id, "sequence") DESC`. Postgres treats a row
constructor as one composite expression, so no btree index can presort
it and no incremental sort is possible. Every descending query through
the sort key branch reads all matching events and sorts them before
applying the `LIMIT`, even where an index matches the leading sort
columns.
- The default `ORDER BY`, the creation date `ORDER BY` from #12789 and
the tuple of the resume cursor were three separate string literals that
had to be kept in sync by hand.

# How the Problems Are Solved

- The `ORDER BY` is written as a column list with the direction on every
column. The result order is identical to the row constructor, but the
planner can presort from an index again and stop after `LIMIT` rows.
- The sort key columns are defined once and drive the default `ORDER
BY`, the creation date `ORDER BY` and the cursor tuple, so they can no
longer drift apart.
- The row-wise `>` comparison of the resume cursor is unchanged. That is
the one place where the tuple form is needed.

# Additional Changes

- Table test for all order-by variants and a test pinning the cursor
tuple.
- Updated the two SQL fixtures that expected the row constructor.

# Additional Context

Same query, only the `ORDER BY` form differs (instance filter,
`aggregate_type = 'user'`, `LIMIT 1000`, ordered by `created_at` plus
the sort key):

| ORDER BY form | Plan | Estimated cost |
| --- | --- | --- |
| row constructor | parallel index scan + full sort of 3.5 M rows | 11.2
M |
| column list | incremental sort on index, stops at limit | 20 k, 15 ms
|

Projection catch-up queries filter several aggregate and event types
with `= ANY`, so on Postgres before 17 they still need a sort. The gain
there is a top-N sort instead of a full sort where an index prefix
matches, and it grows on Postgres 17 which can return ordered output
across array conditions.

- Follow-up for PR #12703
- Stacked on #12790 (which is stacked on #12789)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1874e58503)
2026-09-25 05:59:00 +02:00
Livio Spring 76cd246089 perf(eventstore): order events API by creation date (#12789)
# Which Problems Are Solved

- The admin `ListEvents` endpoint (Console "Events" page) reads far more
events than it returns. An unfiltered request had to read every event of
the instance and sort it before applying the `LIMIT`, because the events
were ordered by the position sort key and no index can serve that order
since the `(instance_id, position)` index was dropped in #10626.
- Since #12703 the shared `ORDER BY` is a row constructor `(position,
in_tx_order, ...)`, which Postgres can never presort from an index, so
even a matching index would not help this endpoint.
- The API's documented paging cursor for this endpoint is the creation
date (`from` / `range` with `asc`), but the results were ordered by
position. Since #12703 stamps `created_at` and `position` with different
clocks, an event can land on the wrong page for API consumers paging
with `from`.

# How the Problems Are Solved

- Adds an opt-in `SearchQueryBuilder.OrderByCreationDate()` that orders
by `created_at` first and uses the event sort key `(position,
in_tx_order, instance_id, aggregate_type, aggregate_id, sequence)` as
tie breaker, in the direction given by `OrderAsc` / `OrderDesc`.
- The order is emitted as a plain column list, not as a row constructor,
so Postgres presorts from the existing `es_active_instances (created_at
DESC, instance_id)` index with an incremental sort and stops after
`LIMIT` rows. No new index is needed.
- Only the admin `ListEvents` filter sets the flag. Projections keep the
position sort key and their cursor semantics. Queries filtering a single
aggregate id keep the sequence ordering, and the v1 events table is
unaffected.

# Additional Changes

- SQL fixtures for the new ordering (desc instance only, asc with
filters) and for the cases where the flag must be ignored (single
aggregate id, v1).
- Test asserting the admin `ListEvents` filter sets creation date
ordering for both directions.

# Additional Context

Measured on a production-sized database with `EXPLAIN ANALYZE` (instance
filter, `aggregate_type = 'user'`, `LIMIT 1000`):

| Query | Plan | Time |
| --- | --- | --- |
| current (row constructor) | parallel index scan + full sort, est. cost
11.2 M | did not finish |
| `created_at` column list, large instance | incremental sort on
`es_active_instances`, est. cost 20 k | 15 ms |
| `created_at` column list, small instance | pkey range scan + top-N
sort | 83 ms cold |

The planner picks the index walk for large instances and a bounded range
scan plus sort for small ones, so no dedicated `(instance_id,
created_at)` index is required.

**Known, pre-existing:** the Console's "load more" pages with the
`sequence` of the last row. Since the `events2` migration the sequence
is per aggregate, so `sequence <`/`>` is not a cursor for the
instance-wide stream and pages can omit events. This is independent of
the ordering and was equally broken with the position order. It will be
addressed separately in the Console by paging with `from`; the proto
documentation of `sequence` ("it's always counting") is outdated as
well.

Follow-up (separate PR): the shared `ORDER BY` introduced in #12703 can
be written as a column list with `DESC` on every key with identical
result order. That would restore index-ordered scans and incremental
sorts for projection queries, while the row-wise `>` cursor comparison
stays as is.

- Follow-up for PR #12703
- Related #10626

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 92dcb84743)
2026-09-25 05:58:55 +02:00
Tim Möhlmann 3a1b76e244 perf(eventstore): read projection events per event type (#12753)
# Which Problems Are Solved

- A projection that subscribes to several event types gets slower the
further it falls behind.
- The handler reads its events with one query: `aggregate_type =
ANY(...) AND event_type = ANY(...)`, ordered by position and limited to
the bulk limit.
- The `es_projection` index returns each (aggregate type, event type)
range in position order, but Postgres cannot merge those ranges.
- So each batch reads every matching event after the projection's
position, sorts them all, and keeps only the bulk limit.
- A projection that is N events behind therefore reads about N rows per
batch. Catching up costs about N² in total, and once events arrive
faster than such batches can process them, the projection does not
recover.
- Every query that triggers the projection waits for that catch-up.
Examples are `GetSession` and the notification handlers.

# How the Problems Are Solved

- **New opt-in, `SearchQueryBuilder.ScanEventTypesSeparately()`.** It
applies to event queries for a single instance, ascending and limited,
whose sub-queries filter only by aggregate and event types.
- For such a query, the SQL layer reads each distinct (aggregate type,
event type) combination in its own `ORDER BY … LIMIT` subquery, using
`UNNEST … CROSS JOIN LATERAL`.
  - It then merges the results with the same sort key and limit.
- Each subquery is one `es_projection` range read in position order, so
it stops at the limit. A batch reads at most `limit × combinations`
rows, however far behind the projection is.
  - Every other query renders exactly as before.
- **The projection handler** now passes its real combinations, one
sub-query per aggregate type instead of the cross product of all types,
and sets the opt-in.
- **Results are identical to the single query.**
- The sort key (`position, in_tx_order, instance_id, aggregate_type,
aggregate_id, sequence`) is a total order, and the global top n is
contained in the union of each combination's top n.
- The cursor from #12703 and the open-transaction cap apply inside every
subquery.
- A test against a real Postgres compares both shapes from every cursor
and every position, for several limits. The test events include
positions shared by several transactions, whose `in_tx_order` restarts.

# Additional Changes

This is a separate commit and can be split into its own PR:
- `cmd/defaults.yaml` sets `BulkLimit: 1000` for the `sessions`
projection.
- `projection_state_latency` gets histogram buckets up to one week.
Until now, every projection more than 30 minutes behind was reported in
the same top bucket.

# Additional Context

These numbers come from a local, CPU-pinned sandbox, for comparing
builds only.
- **Database:** Postgres 17 in a VM with 8 vCPUs and 16 GiB. The
`Eventstore.Autovacuum` and `Projections` settings mirror a
production-like setup.
- **Starting state:** `sessions8` 4.8M events behind, restored
byte-identically for every run.

Batch query for `sessions8`, `EXPLAIN ANALYZE`, median of 5 runs,
parallel workers at the default:

| | before | after |
| --- | ---: | ---: |
| at the backlog, limit 1000 | 832 ms | 4.0 ms |
| at the backlog, limit 200 | 832 ms | 1.0 ms |
| at the head (idle poll) | 0.14 ms | 0.19 ms |
| generic plan (`plan_cache_mode=force_generic_plan`), at the backlog |
1,042 ms | 4.0 ms |

Draining `sessions8` from that backlog for 600 s, 3 runs per build
(median, with the range in brackets):

| | before | after |
| --- | ---: | ---: |
| events/s | 975 (932-1,032) | 7,811 (7,523-7,955) |
| mean batch time, including writes | 1.03 s | 0.13 s |
| still behind after 600 s | 4.2M | 17k-277k |

A new index `(instance_id, position, in_tx_order, aggregate_type,
event_type)` with the old query was evaluated as an alternative and
rejected. Under a generic plan, Postgres chose it for projections whose
events are rare and walked the whole event stream: 430 ms instead of
0.15 ms. This is the behaviour that led to dropping
`es_instance_position` in #10626.

- Follow-up for #12703

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 4bc484dac9)
2026-09-25 05:58:30 +02:00
Federico Coppede 57a425f0a1 docs: fix transparency issue on docs search modal (#12782)
fix transparency issue in light mode for the docs search modal

(cherry picked from commit 98272ab5d1)
2026-09-25 05:56:30 +02:00
Federico Coppede e8cdc7d61e docs: migrate to native Fumadocs search & UI fixes (#12732)
Changes:

- **Search**: Replaced Inkeep with Fumadocs' native built-in search
(createFromSource). Removed @inkeep/cxkit-react and custom search
dialogs.

- **Hydration Fix**: Removed markdown links from ### headings in
egress.mdx (across all versions) to fix the <a inside a> DOM mismatch
error.

- **Console Fix**: Added the missing key prop to <VersionSelector/> in
the layout to resolve the React list warning.

---------

Co-authored-by: Florian Forster <florian@zitadel.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 8ccd5b206f)
2026-09-25 05:55:59 +02:00
Livio Spring 2903009a15 docs: fix broken links to renamed repository paths (#12618)
# Which Problems Are Solved

Several links in the docs point at repository paths that no longer
exist, so they 404 on GitHub:

- The `load-test` directory was renamed to `benchmark`. The benchmarks
page still links to `load-test`, `load-test/Makefile` and
`load-test/README.md` (5 occurrences).
- `apps/docs/content/index.mdx` links to `CONTRIBUTING.mdx`, but the
file is `CONTRIBUTING.md`.
- The login-app guide links to `apps/login/src/middleware.ts`, which was
renamed to `apps/login/src/proxy.ts` in the Next.js 16 upgrade (#11629).

# How the Problems Are Solved

- Point the benchmarks page at `benchmark/`, `benchmark/Makefile` and
`benchmark/README.md`. The `#test` anchor still resolves —
`benchmark/README.md` has a `### Test` heading.
- Fix the `CONTRIBUTING.md` extension.
- Point the middleware example at `apps/login/src/proxy.ts`.

Verified by extracting every
`github.com/zitadel/zitadel/{blob,tree}/main/...` link from
`apps/docs/content` and resolving each one against the working tree —
all 14 distinct paths now exist.

# Additional Changes

- Prose in the benchmarks page that names the directory ("the load-test
Makefile", "the load-test readme") now says "benchmark". Generic uses of
"load-test"/"load-testing" as an activity are left as they are.

# Additional Context

- The versioned docs under `apps/docs/content/v4.13`–`v4.16` contain the
same stale links, but those directories are generated at build time from
release tags by `apps/docs/scripts/fetch-remote-content.mjs` and are
gitignored, so they cannot be fixed from `main`. The published pages for
those versions will keep the old links.
- `apps/docs/scripts/check-links.ts` skips anything starting with
`http`, which is why these went unnoticed. Teaching it to resolve
`github.com/zitadel/zitadel/blob/main/<path>` links against the working
tree would catch this class of breakage in CI — not done here to keep
the PR focused.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7e34f04361)
2026-09-25 05:55:56 +02:00
Rajat Singh bd8b7988ea docs: error reference page fails to load, missing basePath in fetch (#12704)
# Which Problems Are Solved

The API error reference page (`/docs/apis/errors`) shows "Couldn't load
the error reference. Try reloading the page." instead of the table. This
started after #12502 switched the page from a static import of
`data.json` to fetching it from a route handler at runtime.

# How the Problems Are Solved

`apps/docs` is served under `basePath: '/docs'` (`next.config.mjs`). The
fetch call in `ErrorReference` used `/api/error-reference`, but a plain
`fetch()` does not get the basePath prefix added the way `<Link>` and
`router.push()` do, so the request always 404'd. Fetches
`/docs/api/error-reference` instead, the actual prefixed route.

# Additional Changes

None.

# Additional Context

- Follow-up for #12502
- Confirmed locally: `curl localhost:3000/api/error-reference` returns
404, `curl localhost:3000/docs/api/error-reference` returns 200 with the
full catalog

Co-authored-by: Rajat Singh <rajat@zitadel.com>
(cherry picked from commit 95cbe6a796)
2026-09-25 05:55:29 +02:00
556026a38c docs: add searchable API error reference (#12502)
## Summary
- Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs
can return (`content/apis/errors.mdx`), wired into the sidebar next to
"gRPC Status Codes" (with a cross-link added there too). There was
previously no such reference — error IDs are assigned ad hoc at ~3,800
call sites across the backend.
- `scripts/generate-error-reference.ts` scans `internal/**/*.go` and
`backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n
messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes
by (subsystem, gRPC code, message) — so identical causes are merged into
one entry instead of repeated ~3,200 times.
- Detects real ID collisions (same ID thrown for genuinely different
reasons at different call sites) and surfaces them as warnings, rather
than silently merging them.
- Generates a worked example API response for every cause.
- 116 of the highest-impact causes (covering the majority of call sites)
carry an explanation written against the actual source rather than the
raw message text; the rest carry a mechanically derived explanation —
both are visibly labeled by source so the depth of research is
transparent.
- New `ErrorReference` component renders the catalog as a searchable,
collapsible subsystem → gRPC code → cause tree.

The generator is a standalone script (`pnpm generate:error-reference` in
`apps/docs`), not wired into the docs build's `generate` chain, since
scanning the whole Go backend on every docs build would be wasteful. The
generated `data.json` and the curated overrides are committed, the same
way `content/apis/benchmarks/**/output.json` is already committed
despite being externally generated.

## Test plan
- [x] `pnpm nx run @zitadel/docs:check-types` passes
- [x] `pnpm nx run @zitadel/docs:lint` passes
- [x] `pnpm nx run @zitadel/docs:check-links` passes
- [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders,
tree expands/collapses, search filters by ID/message, collision warnings
render correctly (e.g. `AUTHZ-Kl3p0`)

---------

Co-authored-by: Rajat Singh <rajat@zitadel.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Marco A. <marco@zitadel.com>

(cherry picked from commit e8d95f306b)
2026-09-25 05:55:24 +02:00
zitadel-knowledge-bot[bot] 8319f12da8 docs: update knowledge gap ID 66 (#12691)
Automatically generated PR resolving Knowledge Gap ID 66.

**Thread ID:** manual-1788445047146
**Action:** UPDATE
**Target Files:** 1

### AI Summary
> We need to add to the support page a section for a new feature that
allows support staff to access a customer's instance to review logs,
configurations and troubleshoot issues.

### Human Reviewer Instructions
_No extra context provided._

---
🤖 **Need adjustments?**
Leave a comment below and tag **@zitadel-knowledge-bot** with your
requested changes, and I will automatically update the files and push a
new commit!

---------

Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com>
Co-authored-by: fcoppede <fcoppede@gmail.com>
(cherry picked from commit 13948f2bcd)
2026-09-25 05:55:11 +02:00
adlerhurst 76959769b9 test: fix backfill test v4.19.1 2026-09-23 16:47:36 +02:00
42cdd5d1e9 fix: setup step 79 batch unique constraint owner backfill (#12803)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->

- Setup 79 rewrites unique-constraint owners in one statement per file.
On a large table that statement holds row locks for the whole update,
and adding `LIMIT` does not help: the username statement bitmap-scans
every empty `owners` value and then index-scans every user in the
instance for each constraint.
- The backfill joined projection tables before those tables were created
and before event replay filled them.
- The page cursor is a `text[]`. `database.TextArray.Value` joined
elements with commas and never quoted them, so a project role key such
as `a,b` became two array elements. The next page repeated the same row
and the migration stopped.

- Each setup 79 statement pages its projection by primary key and
updates `eventstore.unique_constraints` with an equality lookup on
`(instance_id, unique_type, unique_field)`.
- The original field and `lower(...)` are separate `UNION` arms, so the
lookup stays a primary-key probe.
- Setup repeats a file until the projection page is empty. A page that
matches no constraint still advances the cursor.
- `BatchSize` is a `uint16`, configured like `ForceFinalize`
(`cmd/setup/steps.yaml`,
`ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_BATCHSIZE`) and is not stored on
lastRun. The default in `steps.yaml` is 5000.
- Each page logs the file, the page index, and the number of rows
updated.
- The backfill runs after projection prefill. When `InitProjections` is
disabled or setup is for mirror, it still runs against the projection
rows already stored.
- `TextArray.Value` quotes every element and backslash-escapes `"` and
`\`. An empty array is still SQL `NULL`, so existing `ANY($1)` callers
stay unchanged. The setup statements already cast `$1` to `text[]`.

- Integration tests for the username and org-domain statements pass an
empty cursor and a page size.
- `TestTextArray_Value` expects quoted elements, and a round trip
through `Scan` covers a comma, a quote, and a backslash.
- initial fix https://github.com/zitadel/zitadel/pull/12740
- fix of the setup job, broken by the first fix
https://github.com/zitadel/zitadel/pull/12785
- needs to be a `fix` tag because `perf` wouldn't bump the version

- Based on `owner-app-fix`.
- A setup process already running the old statement keeps that plan
until it is restarted.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
2026-09-23 16:25:56 +02:00
765027e0d7 feat(eventstore): remove application constraints by app owner (#12798)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Deleting an application loads the SAML config only to learn the entity
id, then deletes the `entity_ids` unique constraint by that field. The
app name constraint is deleted by `name:projectID`. Neither row is
tagged with the app id, so the delete cannot use the owner index.

# How the Problems Are Solved

`appname` and `entity_ids` rows are tagged `app:<appID>`, in addition to
the existing org and project tags.

- Live writes stamp the tag on application add/rename and on SAML
entity-id add/change.
- Setup 79 appends `app:<appID>` on the next version run, including rows
that already have `org` and `project` owners.
- When the owners backfill is finalized, `RemoveApplication` emits
`RemoveByOwner(app, appID)` and does not read the SAML config.
- Before that backfill, removal still deletes by the app name and, for
SAML apps, by the entity id.

Renames stay field deletes. Project names and roles stay tagged with org
and project only.

# Additional Changes

- `UniqueConstraintOwnerApp` is added next to the other owner kinds.

# Additional Context

- Base branch: `v4.x`
- Follow-up for the unique-constraint owners work (setup 78/79)

Verified locally:

- `go test ./cmd/setup -run TestBackfillUniqueConstraintOwnersStmts`
- `go test ./internal/command -run
'TestCommandSide_RemoveApplication|TestCommandSide_RemoveDynamicOIDCClient'`
- `go test ./internal/eventstore ./internal/v2/eventstore`

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
v4.19.0
2026-09-23 07:17:09 +02:00
63085396e1 fix(eventstore): omit owners on empty unique constraint inserts (#12785)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->

After #12740, setup on existing deployments (v4.17.x → v4.18.0) fails
before migration 78 can add `eventstore.unique_constraints.owners`.
Starting any setup step pushes `system.migration.started`, which inserts
a unique constraint that names the `owners` column. On upgraded
databases that column does not exist yet, so setup dies on
`76_users14_login_equality_indexes` with `SQLSTATE 42703`.

Empty-owner unique-constraint inserts no longer name the `owners`
column:

- `len(Owners) == 0` → `INSERT (instance_id, unique_type, unique_field)`
- tagged `WithOwners(...)` → unchanged `INSERT (..., owners)`

Setup started/done events never stamp owners, so 76–78 can start.
Migration 78 still adds the column; after that, omitted `owners` uses
`DEFAULT '{}'`. Both v2 and v3 pushers apply the same rule. Mixed pushes
split into two INSERT batches so tagged and empty rows do not share a
column list.

- v2 SQL mocks now expect the three-column INSERT for untagged adds,
plus tagged and mixed cases
- Pusher tests cover mixed tagged+empty adds and adds against a table
that does not have `owners` yet
- The missing-column pusher test drops `owners` and pushes in one
transaction (`PushWithClient`), then rolls back so the shared test
schema is not mutated. Other tests may stall on `AccessExclusiveLock`
until rollback.

- Closes #12780
- Follow-up for PR #12740
- Operators do not need to re-run init or apply DDL outside setup

Verified locally:

- `go test ./internal/eventstore ./internal/v2/eventstore/postgres
./internal/eventstore/v3` (including `-race` on those packages)
- Empty-owner insert succeeds when the `owners` column is missing;
tagged insert still requires the column

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
2026-09-23 05:55:30 +02:00
gayathriandClaude Opus 4.8 6d7878a2e4 Merge commit from fork
* fix(login): gate username change on the change-username step

handleChangeUsername renamed the account identified by authReq.UserID
without checking that the login flow had reached the change-username
step or that any factor had succeeded. Because authReq.UserID is bound
as soon as a login name is submitted (checkLoginName -> SetUserInfo),
an unauthenticated caller could start a flow, type a victim's login
name and then POST a new username to rename that account, including
instance admins.

Gate the handler on the current step being *domain.ChangeUsernameStep,
mirroring the other login handlers. That step is only produced in
nextSteps after the first factor and any required MFA have succeeded,
so the gate also proves the caller owns the account. The legitimate
forced-username-change flow is unaffected: the UI only ever renders
that form when the step is at index 0, and PossibleSteps is recomputed
on every request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aup17GXd4tQnXG36xr5UiZ

* test(login): add integration reproduction for GHSA-4hgj-wm6c-q7p2

Drive the unauthenticated-rename attack over raw HTTP against a running
instance, mirroring the sibling GHSA-738m external-login integration
test in the same package: authorize (V1) -> load login page (scrape
CSRF) -> POST the victim's login name (binds authReq.UserID, no factor)
-> POST a new username.

The test first proves the exploit precondition held - step 3 advanced
the flow to the victim's password step, so authReq.UserID was bound -
then asserts over gRPC that the victim's username is unchanged. The
precondition check guards against a false green: without it, a broken
upstream hop would make the unchanged-username assertion pass vacuously
even against unpatched code.

The unchanged-username poll and the sibling test's requireNeverUserByEmail
now share a small pollFor helper in the package.

Red -> green: fails against the unpatched handler (the rename lands),
passes against the fixed handler (the ChangeUsernameStep gate rejects
the POST before the command runs). Runs in the existing
nx test-integration job via the integration build tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aup17GXd4tQnXG36xr5UiZ

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
v4.18.0
2026-09-21 13:38:53 +02:00
Silvan c397a60cb5 fix(eventstore): add owners to unique constraints (#12740)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Bulk unique-constraint deletes (org, IDP with many links, user, project)
reconstruct every uniqueness key in Go and emit N `DELETE`s. That can
OOM on large tenants.

A one-shot owner backfill is also not enough: during a rolling deploy,
older binaries still insert `owners = '{}'`, so a later owner-only
delete would miss those rows.

Owner-only org/user/grant delete only matches rows whose `owners` bag
contains that tag. Several grant and member types stamped the aggregate
resource owner, which is not always the user’s home org or the granted
org. Username-scope rewrites also dropped `user:` tags, so owner-only
user delete could miss rewritten username rows.

Command must not switch to owner-only delete on the first 79 of a
version, because leftover `'{}'` rows from rolling-update overlap are
only backfilled on a later 79. A failed 79 must not turn owner-delete
on, and backfill joins must not stamp the wrong org (unverified domains,
org-scoped usernames colliding with instance-scoped ones).

# How the Problems Are Solved

Uniqueness stays the existing primary key `(instance_id, unique_type,
unique_field)`. `owners` is only a lifecycle bag of tags (`org:<id>`,
`user:<id>`, `idp:<id>`, `project:<id>`, `grant:<id>`).

- Add `owners TEXT[] NOT NULL DEFAULT '{}'` on
`eventstore.unique_constraints` (init SQL for greenfield, setup 78 for
existing clusters).
- Create `unique_constraints_owners_gin` with `CREATE INDEX
CONCURRENTLY` **outside a transaction** (same pattern as step 76).
Built-in `GIN (owners)` only; no `btree_gin`.
- Stamp `WithOwners` on **add** events. Field-level removes ignore
owners. `RemoveByOwner` stores the tag in `Owners` and both v1/v3 and v2
pushers run `DELETE … WHERE instance_id = $1 AND owners @>
ARRAY[$tag]::text[]`.
- Setup 79 backfills `owners` from projections via numbered
`cmd/setup/79/*.sql` files. Table names are interpolated from
`internal/query/projection` constants. Matches use `unique_field IN (x,
lower(x))` so the primary key can be used. Missing projection tables
(`42P01`) are skipped.
- Org domains are tagged only when `is_verified`. Instance-scoped
usernames join only users that are not org-scoped (organization settings
or domain policy `user_login_must_be_domain`). Every username row still
gets `org:` + `user:` of that user.
- 79 is a **repeatable** migration. `Check` re-runs when
`lastRun["version"]` changed or when setup-only `ForceFinalize` is set
and `lastRun["finalized"]` is still false. `Finalized` is written only
after Execute succeeds.
- Failed setup events keep the last successful `lastRun`. Command reads
`finalized` only from `system.migration.repeatable.done`.
- Org / user / project remove skip N-key event replay **when that
`finalized` bit is true**. Otherwise they keep today’s N-key lists
**and** still emit `RemoveByOwner`. The “ready” result is cached in
memory once true.

Setup writes `finalized` on the 79 repeatable.done payload:

- First 79 of a version: backfill only, `finalized=false` (unless
`ForceFinalize` is already set).
- Later 79: version change or
`BackfillUniqueConstraintOwners.ForceFinalize` /
`ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_FORCEFINALIZE` after the rolling
update is done. That run backfills leftovers and writes
`finalized=true`.

Member and grant owner bags now include every org that should delete the
row:

- Instance, org, and project members also stamp
`org:<user_resource_owner>`.
- User grants stamp grant, user, project, and granted orgs.
- Project grant members stamp the project org, user org, and granted
org.
- Project grants stamp both orgs plus `grant:<id>`. Grant remove emits
`RemoveByOwner(grant)`.
- Username-scope rewrites keep `user:<id>` as well as `org:`.
- User/grant orgs are carried as unexported add-event fields (not
persisted JSON). `WithOwners` skips empty and duplicate tags.

# Additional Changes

- Table-driven owner-tag tests, including project grant (two `org:` tags
plus `grant:`), cross-org members, user grants, and username-scope
rewrite `user:` tags.
- Table-driven 79 `Check` + `finalizeAfterSuccess` tests for version
change, sequential version bumps, ForceFinalize restart, and
already-finalized skip.
- Command tests: a failed 79 event with `finalized=true` does not enable
owner-delete.
- Integration fixtures (verified vs unverified org domain;
instance-scoped `bob` vs org-scoped `bob`).
- Instance-only and global unique types stay untagged (`'{}'`). Instance
wipe still uses `UniqueConstraintInstanceRemove`.
- Pusher owner cases live in `eventstore_pusher_owners_test.go` so
`eventstore_pusher_test.go` stays under 1k lines.

# Additional Context

A later cleanup can delete the N-key replay helpers once a release that
ran 79 a second time (or an operator who set `ForceFinalize`) is in
production. That is not required for correctness.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>

Adapted for v4.x, which has neither user groups nor organization-scoped
username settings; usernames are organization scoped only through the
domain policy's userLoginMustBeDomain here:

- Organization settings and group code is not ported, including the
  16_group_name.sql backfill and the organization settings join in
  02_usernames.sql.
- Username owner tags use the existing userLoginMustBeDomain fields.
- DomainPolicyRemovedEvent does not rewrite usernames on this branch,
  so NewUsernameUniqueConstraints is not ported. Policy-driven rewrites
  go through UsernameChangedEventWithPolicyChange, which is tagged and
  covered by TestUsernameScopeRewriteKeepsUserTag instead.
- The org-scoped username integration fixture uses an organization
  domain policy instead of organization settings.

(cherry picked from commit 3ff4fbc7e4)
2026-09-18 17:47:47 +02:00
Max Peintner 482ddc79a8 fix(login): forward login_hint to the external IdP (#12760)
# Which Problems Are Solved

Login V2 never forwards the OIDC `login_hint` to an external identity
provider. The user enters their identifier once at ZITADEL, is
redirected to the IdP, and has to type it again because the IdP's
authorize request contains no `login_hint`. Login V1 has done this since
#6899 (`sessionParamsFromAuthRequest`), and the backend half for V2
exists since #11552 (`RedirectURLs.login_hint` on
`StartIdentityProviderIntent`), but none of the three places in the
login app that start an IdP intent set it.

# How the Problems Are Solved

Every call to `startIdentityProviderFlow` now sets `urls.loginHint`,
mirroring the priority Login V1 uses:

- `sendLoginname`: when the user is already known and linked to exactly
one IdP, the hint is the username the IdP knows the user by
(`IDPLink.userName`), falling back to the typed login name (with the org
domain suffix re-attached). When an org's single active IdP triggers the
redirect, the hint is the login name.
- `handleOIDCFlowInitiation`: when the auth request selects an IdP via
`urn:zitadel:iam:org:idp🆔<id>`, the hint is `authRequest.loginHint`.
- `redirectToIdp` (the user clicks an IdP button): `SignInWithIdp` gets
an optional `loginHint` prop rendered as a hidden form field. The
loginname page passes the login name it was prefilled with from
`login_hint`; the authenticator setup page passes the session's login
name when linking an IdP to an existing user.

Unit tests cover all three call sites, including the linked-username
priority and the empty-hint case.

Closes #12080
Based on #12683 by @aarontraynor

(cherry picked from commit 9e1b85fa91)
2026-09-18 15:11:47 +02:00
Max Peintner 473b0fcaec fix(login): verify API credentials at startup instead of in readiness probe (#12742)
# Which Problems Are Solved

The Login V2 readiness endpoint `/ui/v2/login/ready` performs an
authenticated `SettingsService.GetGeneralSettings` call on every
invocation.

This only affects self-hosted Login V2 deployments that probe the
endpoint periodically, for example via the Helm chart's readiness and
startup probes, the Docker `HEALTHCHECK`, or a custom HTTP probe. In
those setups every login replica continuously issues authenticated API
calls that pass through the access-log interceptor and are treated as
regular authenticated requests of the instance, although they are only
probe traffic. The login hosted by ZITADEL Cloud is not affected, and
neither are self-hosted deployments that do not probe `/ready`.

Independent of the counting, validating credentials on every probe is
unusual: readiness probes are meant to report whether a replica can
serve traffic, while configuration errors such as invalid credentials
are better caught once at startup.

# How the Problems Are Solved

The readiness endpoint now only checks that the ZITADEL API is reachable
and ready, using the API's unauthenticated `/debug/ready` endpoint. That
endpoint is served outside the access-log and quota interceptors, so
probe traffic is no longer recorded. `CUSTOM_REQUEST_HEADERS` are
applied to the readiness request as they are to gRPC requests.

The credential check moves to process startup: `register()` in
`instrumentation.ts` calls the new `verifyApiCredentials()` once, using
the same auth and transport path as real requests. If the API rejects
the credentials (`Unauthenticated`/`PermissionDenied`) or no credentials
are configured, the process exits with a clear error so misconfiguration
is visible immediately. If the API is not reachable at startup, a
warning is logged and the process continues; reachability remains the
job of the readiness probe and `ZITADEL_API_AWAITINITIALCONN`. Unit
tests cover the readiness route, the credential check result mapping,
and the startup behavior of `register()`.

No changes to the Helm chart or Dockerfile are required. Self-hosted
deployments pick up the fix with the next login image; until then, the
probe interval can be increased to reduce the number of calls.

(cherry picked from commit ceba26f434)
2026-09-18 15:11:47 +02:00
Tim Möhlmann ff7b399acb feat(database): publish pgxpool pool stats as metrics (#12737)
ZITADEL opens its Postgres connection pool via `pgxpool.NewWithConfig`
(`internal/database/postgres/pg.go`) but never reads
`pgxpool.Pool.Stat()`,
so operators have no way to observe pool saturation (acquired/idle/total
conns vs. `MaxConns`) or connection churn (acquire waits, canceled/empty
acquires, idle/lifetime destroys, new connections) through ZITADEL's
existing metrics pipeline.

- Adds `RegisterCounterObserver` (backed by an OTel
`Int64ObservableCounter`)
  to the `Metrics` interface, its implementation, and the test mock
  (`backend/v3/instrumentation/metric.go`,
  `backend/v3/instrumentation/metrics/metric.go`,
  `backend/v3/instrumentation/metrics/mock.go`), mirroring the existing
`RegisterValueObserver`/`RegisterUpDownSumObserver` — there was
previously
  no observable instrument for cumulative counters.
- Adds `database.RegisterPoolMetrics(ctx, pool)`
  (`internal/database/metrics.go`), which registers 5 observable gauges
  (`acquired_conns`, `constructing_conns`, `idle_conns`, `max_conns`,
  `total_conns`) and 7 observable counters (`acquire_count`,
  `acquire_duration_milliseconds`, `canceled_acquire_count`,
  `empty_acquire_count`, `max_idle_destroy_count`,
  `max_lifetime_destroy_count`, `new_conns_count`) sourced directly from
`pgxpool.Pool.Stat()`, published under `zitadel.database.pool.*` metric
  names.
- Wires the registration call into `cmd/start/start.go` right after the
  pool is created, so the running server process exposes these series on
  `/metrics`.

- Adds unit tests covering the new `RegisterCounterObserver` method
  (`backend/v3/instrumentation/metric_test.go`) and the pool metrics
  registration (`internal/database/metrics_test.go`).

- Scope is intentionally limited to `cmd/start` (the long-running
process
  that serves `/metrics`); one-shot commands (`setup`, `mirror`,
`initialise`, `key`) are left untouched since an exporter running for a
  single command invocation wouldn't produce useful series.
- https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Stat

(cherry picked from commit 73de633c71)
2026-09-18 15:11:47 +02:00
Silvan d49a665b63 fix(eventstore): prevent projections from skipping events (#12703)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->

Handler/v2 projections (`users14` and every other event projection) can
overlook committed events under load:

- `eventstore.commands_to_events` stamped `position` with `NOW()`
(transaction start). A slow create TX could commit after a faster TX
that started later, so the projector stored a later position and never
saw the earlier one.
- Resume used `OFFSET` on a **filtered** row count. That could skip
`in_tx_order = 1` at the next position (for example `user.human.added`).
- Two transactions in the same microsecond both start at `in_tx_order =
1`. A cursor of `in_tx_order > last` alone would drop the other TX’s
first event.

This does not repair rows that are already missing from projections.

- Setup step 77 replaces `eventstore.commands_to_events` so `created_at
= statement_timestamp()` and `position = EXTRACT(EPOCH FROM
clock_timestamp())` at INSERT time. Initialise `08_events_table.sql`
matches that for new databases. The function is `VOLATILE` to match
`clock_timestamp()`.
- The projector still drains in-flight writers with `pg_advisory_lock` /
`pg_advisory_unlock` in one statement, then caps with `position <=
EXTRACT(EPOCH FROM now())` (projector TX start). The exclusive lock is
**not** held across the event `SELECT`. The cap must stay `now()`, not
`clock_timestamp()`, or later inserts become a high-water mark.
- Resume is a lexicographic `EventSortKey` on `(position, in_tx_order,
instance_id, aggregate_type, aggregate_id, sequence)` via
`AfterEventSortKey`. SQL `OFFSET` is no longer used for projection
catch-up.
- Handler and FieldHandler persist the last event’s `in_tx_order` in
`current_states.filter_offset` (existing column, new meaning). Step 77
backfills matching rows from `events2`; unmatched rows keep their
previous value.

- `eventstore.Event` exposes `InTxOrder() uint32` (BaseEvent, repository
Event, v3 event, v1 models).
- v3 push SELECT/RETURNING now includes `in_tx_order` so newly written
events carry a real ordinal.
- `WithMinPosition` stays an inclusive `PositionAtLeast` floor and does
not smash the sort-key cursor.
- `SearchQuery.PositionAfter(decimal)` is unchanged (used by
access-token lookup). Do not confuse it with
`SearchQueryBuilder.AfterEventSortKey`.
- `commands_to_events` in setup 77 and initialise 08 is `VOLATILE
PARALLEL SAFE`. Shipped 40/64/70 stay `STABLE` because they still stamp
with `NOW()`.
- Drop unused `last_owner TEXT` DECLARE from setup 77 and initialise
`08_events_table.sql` (`i INTEGER` remains for the enforce-owner loop).
Shipped 40/64/70 keep the leftover.
- This PR’s migration is numbered 77 because `main` already has setup 76
(`76_users14_login_equality_indexes`). First-loop order is s70, then
main’s 76, then this 77.

**Reviewer map**

| Start here | Why |
| --- | --- |
| `cmd/setup/77.sql` + `cmd/setup/77.go` | Position stamp + atomic
function replace/backfill after shipped `s70` and main’s s76 |
| `internal/eventstore/event_sort_key.go` + `search_query.go` |
Canonical cursor and `AfterEventSortKey` |
| `internal/eventstore/handler/v2/{state.go,handler.go}` |
Persist/resume cursor; `eventQuery` |
| `internal/eventstore/repository/sql/{query.go,postgres.go}` | Tuple
`>` filter, 6-column `ORDER BY`, await cap |
| `internal/eventstore/v3/push.go` / `push.sql` | Scan `in_tx_order` on
write |

Mechanical / low-risk: handler and query test fixtures, Event interface
stubs (`event_base.go`, repository Event, mocks, v1 models).

**Do not look for** a `70.sql` change — shipped migrations stay as on
`main`. Setup 40/64 still use `NOW()`; step 77 overwrites the function
afterward. Fresh `FirstInstance` (runs before `s70`) can still stamp
`NOW()` until 77; runtime pushes after 77 use `clock_timestamp()`.
Main’s `cmd/setup/76.go` is the users14 login-equality indexes, not this
stamp.

**Rollout**

1. Run setup so 77 replaces `commands_to_events` and backfills
`filter_offset`.
2. Restart ZITADEL so projectors load the new resume path.
3. Existing missing `users14` rows are not repaired by this PR.

**Tests**

- `go test ./internal/eventstore/... ./cmd/setup/`
- Sort-key SQL fixtures in `query_sort_key_test.go` (no `OFFSET`;
aggregate-id queries keep 6-column `ORDER BY`)
- `cmd/setup/77_test.go` asserts the embed has no unused `last_owner`
and still uses `clock_timestamp()` / `VOLATILE PARALLEL SAFE`

Related: #8509, #10480 (remove OFFSET), #10560 (advisory locks), #10710
(stable ORDER BY).
Out of scope: repairing already-missing projection rows, holding the
exclusive lock during SELECT, rewriting historical setup 40/64 SQL.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
(cherry picked from commit fbe7b96138)
2026-09-18 15:11:47 +02:00
Silvan c0b17e0134 perf(query): use UNION of index seeks for ListUsers login equality (#12701)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->

Login V2 `ListUsers` lookups of the form username OR email (and email OR
phone) hash-join the full `users14` / `users14_humans` tables, then
filter. That cost grows with instance size (~40ms at 10k users, ~650ms
at 200k).

Top-level login-name equals already used an indexed join, but the same
login-name filter inside an `OrQuery` fell back to the slow view-based
exists query.

**TL;DR:** equality lookups on username, email, phone, or login name
become `INNER JOIN (UNION of indexed ID seeks) AS matches`. Mixed /
contains / name searches keep the old WHERE shape.

| Read first | Why |
| --- | --- |
| `internal/query/user_login_equality.go` | Typed filters + extractor +
UNION join |
| `internal/query/user.go` (`prepareUsersQuery`, constructors) | Wiring;
username/email/phone equals return `lowerEqualsFilter` |
| `cmd/setup/76.go` + `cmd/setup/76/*.sql` | Concurrent indexes for
existing deployments; drop invalid leftovers |
| `internal/query/projection/user.go` | Same indexes in `Init()` for new
databases |
| `internal/query/user_login_name_exists_test.go` | SQL-shape coverage |
| `internal/api/grpc/user/v2/integration_test/query_test.go` | Nested
username AND/OR user results |

- Extract one equals leaf, a flat `OrQuery` of those leaves, or the same
nested under `AndQuery`. Remaining AND conjuncts stay as WHERE. Equality
nested under `OrQuery` / `NotQuery` is left as WHERE.
- Login-name equals is a UNION arm that reuses `login_names3_users` SQL,
so it works inside OR as well as at the top level.
- EQUALS seeks use `LOWER(col)` for the index, then recheck `col =
value` so case-sensitive searches do not match extra rows.
EQUALS_IGNORE_CASE is unchanged.
- Empty OR arms are not skipped (that would change OR meaning); mixed
empty-OR falls back to WHERE. Login UI already omits empty arms.

- Setup 76: `CREATE INDEX CONCURRENTLY IF NOT EXISTS` on
`users14_username_lower_idx` `(instance_id, LOWER(username))` and
`users14_humans_phone_lower_idx` `(instance_id, LOWER(phone))`. Not
wrapped in a transaction.
- Before create, drop any leftover `pg_index.indisvalid = false` indexes
of those names (failed/cancelled `CREATE INDEX CONCURRENTLY`). `IF NOT
EXISTS` would otherwise skip rebuild.
- `Init()` creates the same indexes without `CONCURRENTLY` (empty
tables). Existing `users14_username` and email lower indexes are
unchanged.

- Unit tests assert UNION SQL for username OR email, email OR phone,
login name OR email, username OR username, nested `AndQuery(loginName,
org)`, and nested `AndQuery(Or(...), org)`; that contains / display-name
/ mixed OR / nested `OrQuery(AndQuery(...), ...)` do not UNION; that
email EQUALS rechecks the raw column; and that empty-OR is not
extracted.
- Setup 76 sqlmock coverage: missing `users14` skips work; valid/absent
indexes only create; invalid leftover username index is dropped then
both creates run.
- `TestServer_ListUsers_UsernameAndOr` checks live results: org AND
(alice OR bob) returns both users, sibling username AND is empty, nested
`AndQuery(Or(...), org)` returns both.

- Base branch: `main`
- Extends the login-name equals rewrite from #12460 to
username/email/phone OR trees used by Login V2 search.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-1f043b1c-6168-45ca-9e9e-c18451994207?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-1f043b1c-6168-45ca-9e9e-c18451994207&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
(cherry picked from commit 684de2e691)
2026-09-18 15:11:47 +02:00
Tim Möhlmann 8195a580e8 feat(telemetry): auto detect google attributes (#12694)
# Which Problems Are Solved

We run multiple deployments (regions) under a single GCP project. Within
these deployments we scale containers. Currently all metrics target the
same time series in GCP, regardless or region or container. All metics
ended up in `generic_node` and Cloud Monitoring rejects the duplicate
and out-of-order points.

This can be fixed by setting vendor specific attributes such as
`faas.name` (K_SERVICE), `faas.version` (K_REVISION), `faas.instance`
(instance id from the metadata server), and `cloud.region` (metadata
instance/region).

# How the Problems Are Solved

Use `go.opentelemetry.io/contrib/detectors/gcp` to auto-detect values as
mentioned above. A new `Instrumentation.Detectors` config entry is
added, which accepts and array of enum for detecotrs. At the moment only
Google is accepted, other entries from
[contrib](https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/detectors)
can be added if deemed usefull.

I've considered using `autodetect` contrib package, identifiers and
constructor, but this pulls in the full AWS SDK, increasing zitadel's
binary size by 50MiB.

By default detection is switched off, as it relies on unauthenticated
link-local calls that are insecure anywhere outside of the
infrastructure it targets. It also prevents breaking existing
deployments that rely on detection being off already. (May break
existing trace and logging pipelines).

# Additional Changes

- none

# Additional Context

- (internal only): https://github.com/caos/infra/pull/1813

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 8b87ca50d4)
2026-09-18 15:11:47 +02:00
Livio SpringandCursor c16a56cb99 docs: add instance administrator hardening guide (#12678)
# Which Problems Are Solved

- Operators lack a single guide for separating instance-level principals
(break-glass, login client) from tenant organizations.
- Login policy and IdP behavior for platform accounts is easy to
conflate with customer org settings when admins share one organization.
- There is no documented procedure to inventory instance administrators
or detect unexpected grants over time.

# How the Problems Are Solved

- Add Hardening instance administrators covering:
- Dedicated platform organization layout and why login, password, and
lockout policies matter for operator accounts.
- Login client (`IAM_LOGIN_CLIENT`) and break-glass (`IAM_OWNER`)
hygiene.
- Periodic review with Internal Permission Service v2
`ListAdministrators` (instance and platform org filters).
- Actions v2 webhooks for `instance.member.*` and `org.member.*` events
(with payload interpretation and alert hints).
- Event API polling alternative for SIEM/batch pipelines
(`instance.member.*` and platform-org `org.member.*`).
- Register the page in the docs sidebar under Roles & Permissions.
- Cross-link from administrators, production checklists, login-client
setup, and service account authentication guides.

# Additional Changes

- Clarify instance vs org IdP availability depends on effective login
policy and linked providers (not automatic for all orgs).
- Note deprecated Admin API `POST /admin/v1/members/_search` vs v2
`ListAdministrators`.

# Additional Context

- Operational hardening guide only (no product authz changes).

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 16:07:58 +02:00
41b11149c6 fix(crypto): use authenticated encryption for IDP intent tokens
---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
v4.17.3
2026-09-04 06:28:10 +02:00
Marco A.andClaude Opus 5 20f0984a34 fix(security): check auth method permission on the user's organization
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 06:27:52 +02:00
gayathriandClaude Opus 4.8 a53fbc4b71 fix(login): authorize IDP linking via enrollment guard, not isSessionValid
---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 06:26:21 +02:00
Matías Racedo 739e91e805 fix(login): translate authentication and OTP strings (#12668)
Closes #12481
Closes #12226

# Which Problems Are Solved

Login V2 still contained several hardcoded authentication-method and OTP
strings, preventing them from following the selected locale or being
overridden through custom Login translations.

Additionally, the password-reset flow already referenced
`password.errors.passwordResetNotAllowed`, but the key was missing from
the Login V2 translations, causing the raw translation key to be
displayed when password reset is disabled.

# How the Problems Are Solved

* Replaced the hardcoded authentication-method labels with Login V2
translation keys.
* Replaced the OTP challenge and verification fallback messages with
translated values.
* Reused the existing `otp.verify.resendCode` translation for the resend
button's accessible label.
* Added `password.errors.passwordResetNotAllowed`.
* Added the new keys to all supported Login V2 locales and mirrored them
in `internal/query/v2-default.json` so frontend and API-provided default
translations remain consistent.
* Added unit coverage for the translated authentication labels and OTP
failure paths.

# How the Changes Are Tested

* `pnpm nx run @zitadel/login:test-unit`
* `pnpm nx run @zitadel/login:lint`
* `pnpm nx run @zitadel/login:build`
* `pnpm nx run-many --projects @zitadel/login @zitadel/client
@zitadel/proto --targets lint build test`
* Manually verified Login V2 with multiple locales, including
authentication-method labels, OTP failure fallbacks, and the disabled
password-reset flow.
2026-09-04 06:22:27 +02:00
Marco A. 20f006d5a4 chore: gRPC-Go deps update to 1.83.2 (#12680)
Dependency update of google.golang.org/grpc to 1.83.2

`go get -u google.golang.org/grpc`
2026-09-04 06:22:26 +02:00
Tim Möhlmann a7b3f69209 docs(perf): v4.17.1 results and harness fixes (#12651)
Publishes the v4.17.1 benchmark sweep and the harness fixes the sweep
uncovered.

## Results

All 11 published targets ran for 30 minutes each against a fixed
7-container deployment (vCPU 6 / 6 Gi, psql v17.4, US1 client and
server), at 600 VUs except where noted. Latencies are
`http_req_duration` over the test phase.

| target | VUs | iter/s | p50 | p95 | p99 | failed requests |
|:---|---:|---:|---:|---:|---:|:---|
| machine_pat_login | 600 | 3452 | 24.53ms | 872ms | 1.00s | 29 /
6,335,633 (0.00%) |
| user_info | 600 | 3217 | 29.32ms | 943ms | 1.13s | 66 / 5,800,788
(0.00%) |
| introspect | 600 | 2297 | 46.49ms | 1.16s | 1.36s | 1,400 / 4,159,863
(0.03%) |
| machine_jwt_profile_grant | 600 | 1076 | 85.34ms | 2.57s | 2.93s | 97
/ 1,986,156 (0.00%) |
| machine_client_credentials_login | 600 | 834 | 56.78ms | 1.74s | 2.42s
| 1,789 / 3,073,497 (0.05%) |
| add_session | 600 | 767 | 95.25ms | 3.53s | 4.15s | 1 / 1,416,546
(0.00%) |
| oidc_session | 600 | 326 | 77.47ms | 3.41s | 4.43s | 3 / 1,767,162
(0.00%) |
| otp_session | 600 | 84 | 47.80ms | 13.83s | 20.22s | 182 / 466,756
(0.03%) |
| password_session | 600 | 68 | 408.63ms | 20.00s | 24.05s | 1 / 253,280
(0.00%) |
| human_password_login | 600 | 49 | 146.67ms | 4.08s | 1m0s | 9,227 /
683,256 (1.35%) |
| manipulate_user | **200** | 19 | 304.58ms | 10.23s | 38.43s | 225 /
171,987 (0.13%) |

Against the goals:

- **#8352 is met.** `machine_jwt_profile_grant` reached **1076/s**
against the 1000/s goal, with p50 at 85ms.
- **#4424 is not met.** `human_password_login` runs at **49/s** against
a 1200/s goal.

The sweep ran with `improvedPerformance` enabled (`PROJECT_GRANT`,
`PROJECT`, `USER_GRANT`, `ORG_DOMAIN_VERIFIED`), captured from the
instance before the first target rather than assumed; the full flag set
is on every page.

## What the observability rows show

The containers were never the constraint. ZITADEL CPU sat at 9–50%
(p50), memory at 6–7% (p50, peaking at 16%) of 6 GiB, and instance count
was flat at 7 with no container startups in any window. Database CPU was
at **86–98% median on nine of eleven targets**. Any "ZITADEL achieves
N/s" figure that omits which resource ran out is true and misleading, so
the pages say.

The query breakdown then shows what the CPU numbers cannot: **advisory
lock wait is the single largest category of database time across the
sweep (91,857 s accumulated)**, ahead of event reads (64,805 s), event
push (26,411 s) and projection reads (14,625 s). The three locks are the
projection handler lock (`handler/v2/handler.go`), the eventstore read
barrier (`repository/sql/query.go`) and the shared lock every push takes
(`eventstore/v3/push.go`).

That is what sets the `flowchart outcome` on each page, and it is not
uniform:

- **Scale** on the seven targets where a resource genuinely ran out —
database CPU at 92–98% with 0% lock share.
- **Potential issues** on `human_password_login`, `password_session`,
`otp_session` and `manipulate_user`, where 45–74% of the top query time
is lock wait. `password_session` runs at 51% database CPU and 50%
container CPU and still only manages 68 iter/s; adding vCPU there buys
nothing.

Autovacuum is cleared as a contributor: 93.9% median DB CPU in buckets
containing a vacuum against 93.1% without, within noise. The new v4.17
eventstore autovacuum is barely triggered — `events2` is not in the top
twenty vacuumed tables.

## Harness fixes

Defects found during the sweep, all of which corrupted published numbers
or aborted runs:

1. **Colliding check names.** `updateHuman`, `lockUser` and `deleteUser`
all registered checks as `update user is status ok`, two of them
asserting `status === 201` against endpoints returning 200. k6
aggregates checks by name, so this produced a permanent, fictional 28%
failure rate. Each check now has its own name and accepts 2xx.
2. **User-creation read-after-write race**, in three variants, each
found after the previous fix. The read-back resolved `undefined` when
the projection was behind, surfacing much later as `Cannot read property
'0' of undefined`; then an unguarded `res.json()` turned every request
timeout into a killed iteration; then a *partial* read — a valid user
object with `loginNames` not yet projected — passed an `if (user)` guard
and failed identically. `readUserAfterCreate()` now guards status and
body before parsing, requires the field rather than the object, and
retries with backoff, rejecting with the user id and username.
3. **Metric cardinality.** Id-bearing URLs produced 800k+ time series
against k6's suggested 100k, driving k6 to 6.8 GB and an OOM mid-run.
All 16 such call sites now set a grouped `name` tag.
4. **`password_session` wrote its CSV to `otp_session`'s filename** in
the Makefile, silently overwriting the previous target's output.

The read-back race is not only a harness defect: at 600 VUs it measures
real load-induced projection lag, exceeding 25 s for login-name
projection and 100 s for the user projection, against instant reads on
an unloaded instance.

## Stable v2 user API

Four calls in `benchmark/src/user.ts` still went to `/v2beta/users` —
update, lock, delete and the machine read-back. They date from the
original load-test bootstrap in April 2024 (#7635), when the user
service was still v2beta, and were never moved when it was promoted;
they surfaced in this diff only because the read-after-write fix rewrote
the surrounding functions. All four are now on stable v2.

`updateHuman` is the one that changed shape: `PUT /v2beta/users/{id}` is
`UpdateHumanUser`, whose stable-v2 counterpart `PUT
/v2/users/human/{id}` is deprecated in the proto, so it now calls `PATCH
/v2/users/{id}` (`UpdateUser`) and `manipulate_user` nests the profile
under `human`. Both RPCs land on `command.ChangeUserHuman`, so the
measured work is unchanged. `lockUser`, `deleteUser` and the
`createMachine` read-back move over unchanged in method and payload.

No published result is affected: per-endpoint latencies come from custom
Trends, and `run-bench.sh` drops the `name` system tag, so no artefact
ever recorded a v2beta URL. The remaining `/management/v1` and
`/admin/v1` calls are left alone — migrating those changes which API
surface is benchmarked and would need a re-run.

## Timestamps

Pages reported `Test start` as a bare `HH:MM`, so a run could not be
correlated against monitoring the next day. Pages now carry the full UTC
date and time and a `Test end` row, both derived from the first and last
per-second row of `output.json`. `_template.mdx` is updated to match.

## Post-mortem

`benchmark/postmortems/2026-08-27-503-burst.md` records two bursts of
HTTP 503 `Service error -27` — an HTML page from the Google frontend,
not a ZITADEL error — on `manipulate_user` and `human_password_login`,
six hours apart. **The investigation is open**, and the note is explicit
about what is established (no container startups; the two bursts have
opposite server-side signatures), what is provisionally ruled out
(restart, panic), and what is not yet known. It is included because both
affected pages report the 503s as a separate population from the ZITADEL
failures beside them, and that split needs its evidence somewhere.

It also records two defects found by accident in the collected logs,
unrelated to the 503s: `sql: transaction has already been committed or
rolled back` (232 occurrences under load) and a missing i18n key,
`"unable to get user by id" not found in language "en"`.

## Notes for review

- **`manipulate_user` ran at 200 VUs, not 600, and the page says so.**
Throughput saturates near 100 VUs; at 600 the same target produced 4,283
failed requests for identical throughput against 225 at 200 VUs. Its
failure mode is degradation over sustained single-org churn —
per-operation p50 climbs 1.7x–6x between the first and last five minutes
of a run — not a concurrency threshold.
- **Two `Observed errors` rows are hand-corrected** and deliberately
differ from the generated text, on `manipulate_user` and
`human_password_login`. Both cases are one generated number covering two
unrelated failure populations; each page says it was corrected and why.
- Org deletion failing with a 500 (`V3-C8l3V`) after heavy user churn is
already tracked as #8510 and was hit repeatedly during cleanup.
- **The run/publish tooling is no longer part of this PR.** It moved to
a private repository, along with the cloud collection scripts, because
deployment identity does not belong here. What remains is the published
pages, the post-mortem findings and the harness fixes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3be21ccbf0)
2026-09-04 06:14:40 +02:00
Silvan 35aa213863 docs(oidc): remove stale back-channel logout unimplemented notice (#12660)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

- The OIDC logout guide still claimed Back-Channel Logout was not
implemented in ZITADEL, even though the feature is supported and
documented elsewhere.

# How the Problems Are Solved

- Removed the outdated "This is not yet implemented in ZITADEL" callout
from the Back-Channel Logout section of the logout guide.
- Added a short note linking to the dedicated [OIDC Back-Channel
Logout](/guides/integrate/back-channel-logout) guide.
- Left the Front-Channel Logout "not yet implemented" callout unchanged,
since that mechanism is still unsupported.

# Additional Changes

- None.

# Additional Context

- Highlighted in
https://github.com/zitadel/zitadel/issues/8287#issuecomment-5425724614
- Related epic: #8287
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-3be6fda6-ce62-409c-96f0-f7ae9c58914d?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-3be6fda6-ce62-409c-96f0-f7ae9c58914d&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Silvan <adlerhurst@users.noreply.github.com>
(cherry picked from commit 8de625dc48)
v4.17.2
2026-08-31 10:36:46 +02:00
Livio Spring 60a2195f45 fix(oidc): allow OrgRoleIDScope downscoping in token exchange (#12563)
# Which Problems Are Solved

- Token exchange rejects `urn:zitadel:iam:org:roles🆔{orgID}` when
that scope is not already present on the subject or actor token
(`invalid_scope`).
- A documented least-privilege pattern — exchanging a broad access token
for one whose roles claims are limited to a single organization — has
been broken since the v4.15.3 token-exchange hardening and was not
restored by #12312.
- A naive allow-any-`roles:id` exception would let a previously narrowed
token widen again (switch org filter, or drop the filter and get all
role orgs), including via an actor token's filter on actor-path
exchanges.
- A client-requested bare `urn:zitadel:iam:org:roles🆔` (empty org id)
is not a meaningful filter and should be rejected on exchange.

# How the Problems Are Solved

- Treats `OrgRoleIDScope` as a restriction filter with downscope-only
semantics via `applyOrgRoleIDScopeDownscoping(requested,
ceilingScopes)`:
- union / standard exchange passes the **subject** token scopes as the
ceiling (actor filters ignored)
- impersonation (scopeless `user_id` / `id_token` subject) passes the
**actor** token scopes as the ceiling
- ceiling unfiltered → any requested `roles:id` is allowed (broad →
narrow)
  - ceiling filtered → requested filters must be ⊆ that set
- ceiling filtered but request omits them → inherit the ceiling (omit
must not re-widen)
- Rejects a **client-requested** bare `urn:zitadel:iam:org:roles🆔` in
`validateTokenExchangeScopes` after normalize (before empty-request
fallback). Input tokens may still carry the bare prefix as a degenerate
ceiling; empty-request / omit inherit does not re-trigger the rejection.
- Keeps non-restriction scopes on the existing subject ∪ actor presence
checks.
- Adds unit coverage for downscope, further downscope, widen rejection,
omit-inherit, mixed subject/actor filters, empty-scope access/JWT
subjects, and bare-prefix request/ceiling cases.
- Adds an integration test for broad → `roles:id` filtered roles, plus a
second exchange that rejects widening to another org.
- Documents downscope-only and omit-inherit behavior in the reserved
scopes table and the token exchange guide.

# Additional Changes

- Updates the token-exchange guide Scope section to reflect that
authorization-related reserved scopes must already be present on input
tokens (aligned with the v4.15.3 hardening), rather than suggesting
scope can be freely extended.

# Additional Context

- Closes #12413
- Follow-up for PR #12312
- Related discussion in #11869 (recommended `roles:id` workaround; this
restores that path for token exchange without allowing filter widening)
- Behavior note for release notes: during token exchange, an existing
`urn:zitadel:iam:org:roles🆔*` filter on the subject is inherited when
omitted from the request, so role claims cannot re-widen.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Livio Spring <livio-a@users.noreply.github.com>
(cherry picked from commit 8e442ed4bc)
2026-08-31 10:36:41 +02:00
Livio Spring 266787cbd2 chore(ci): let nx stop continuous run-db tasks instead of compose down from within the test targets (#12663)
# Which Problems Are Solved

CI runs have been flaky since ~Aug 27 with the nx step failing on

```
Task "@zitadel/api:test-integration-run-db" is continuous but exited with code 0
Task "@zitadel/functional-ui:run-db" is continuous but exited with code 0
```

even when every test passed, and with the sibling test suite killed
mid-run (`Process completed with exit code 130`).

Root cause: the test targets shoot their own database.
`@zitadel/api:test-integration` and `@zitadel/functional-ui:test` end
their command list with an explicit `nx run ...:stop` (`compose down`)
of the very service their **continuous** `run-db` dependency (`compose
up`) is supervising. The `run-db` task then exits on its own, which nx
(22.x) counts as a **failed task**:

1. the nx run is marked failed even with all tests green, and
2. with `--nxBail`, the other suite that is still running is aborted
mid-flight.

Whichever suite finishes first triggers it, so the failure is
timing-dependent and flaky. Recent examples: [attempt 1 of this
run](https://github.com/zitadel/zitadel/actions/runs/33161619698/attempts/1)
(all 42 integration packages + all Cypress specs green, job still
failed) and [this
run](https://github.com/zitadel/zitadel/actions/runs/33166856821)
(Cypress finished first, integration tests killed at 4m13s).

# How the Problems Are Solved

Drop the trailing `nx run ...:stop` commands from the two test targets.
nx stops continuous dependencies itself once all their dependents
finish, so the self-inflicted exit never happens. The `stop` targets
remain unchanged for manual use as documented in CONTRIBUTING.md.

Since this PR touches both projects' configuration, both test suites run
on this PR — a green pipeline here exercises exactly the raced path.

# Additional Context

- The race existed since the nx 22 upgrade (#12592) but only started
losing consistently around Aug 27, presumably due to changed teardown
timing on the updated Depot runner images (same window as the
`*.integration.localhost` DNS breakage worked around in #12621).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 94f53145da)
2026-08-28 16:07:18 +02:00
Livio Spring 8fb897d8c8 fix(login): resolve the registration org consistently with the login policy (#12621)
# Which Problems Are Solved

When resolving the organization for a self-registration, the resource
owner was determined only from an explicitly requested org, falling back
to the default org. The login policy applied to the same request,
however, is resolved from the requested org, the user org, and only then
the default org. As a result the two could diverge — e.g. after the
login context had already resolved a user's organization — and a user
could be registered in a different organization than the one whose
settings were evaluated for the request.

# How the Problems Are Solved

`determineResourceOwner` now uses the same resolution order as the
policy lookup (requested org, then user org, then default org), so the
organization a user is registered in matches the one the request was
evaluated against.

# Additional Changes

Added a unit test covering the resolution order.

# Additional Context

Aligns login v1 registration org resolution with the behavior in login
v2.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 711d688294)
2026-08-28 13:11:59 +02:00
zitadel-knowledge-bot[bot] 01f756e748 docs: create knowledge gap ID 65 (#12646)
Automatically generated PR resolving Knowledge Gap ID 65.

**Thread ID:** manual-1787771291264
**Action:** CREATE
**New File:**
`apps/docs/content/guides/integrate/login-ui/fork-and-deploy-login-app.mdx`

### AI Summary
> My recommendation is to publish a a new Deploy & Operate guide for the
Login App, then link to it from the existing Login App page. The
existing pages address architecture/development, login-client
authorization, and Login V2 adoption separately, but none currently
explains long-term fork ownership and upgrades. (Contribution guide,
login-client setup, adoption guide)

The article should cover:

Full-repository fork as the simplest current recommendation.
The customer’s history-preserving slim-fork approach as an advanced
alternative.
Why copying only apps/login is unsafe while the current npm packages
remain unavailable, linked to #12250.
Release-tag pinning and versioning such as v4.17.0-custom.1.
Development, generation, linting, tests, and Docker builds.
An EKS/Kubernetes Deployment and Service example.
PAT handling through ZITADEL_SERVICE_USER_TOKEN_FILE.
Same-origin versus separate-domain proxy requirements.
Gradual rollout, break-glass access, rollback, and troubleshooting.
A repeatable upstream synchronization and release process.

### Human Reviewer Instructions
> Sample content:
> Maintain and deploy a customized ZITADEL Login UI
>
> The ZITADEL Login UI is an MIT-licensed Next.js application maintained
in the ZITADEL monorepo. You can customize and deploy it independently,
but your fork becomes an application that your team must build, test,
secure, monitor, and update.
>
> This guide describes the current workflow for maintaining a customized
Login UI. It is intended for teams that need source-code changes beyond
the branding and text customization available through ZITADEL settings.
>
> If you only need to change colors, logos, fonts, or translated login
text, first use ZITADEL's supported branding and customization settings.
A source fork creates an ongoing maintenance responsibility.
>
> Understand the source dependencies
>
> The Login UI is not currently an isolated project in the repository.
Its relevant dependency chain is:
>
> apps/login
> └── packages/zitadel-client
>     └── packages/zitadel-proto
>         └── proto
>
> The root pnpm workspace and Nx configuration also participate in
installing, generating, testing, and building these projects. Copying
only apps/login can therefore fail or silently separate the UI from the
API definitions against which it was developed.
>
> For the current monorepo workflow, keep the Login UI and these
workspace dependencies on the same upstream ZITADEL revision.
>
> Choose a fork strategy
>
> Full repository fork — recommended
>
> Fork the complete zitadel/zitadel repository and limit your own
changes to apps/login wherever possible. This is the simplest model to
understand and the least likely to miss a new build-time dependency when
upstream changes.
>
> Use two remotes:
>
> git clone git@github.com:YOUR_ORGANIZATION/zitadel.git
> cd zitadel
> git remote add upstream https://github.com/zitadel/zitadel.git
> git fetch upstream --tags
>
> Start customization from a released tag rather than an arbitrary
commit on main:
>
> git checkout -b custom-login/vX.Y.Z vX.Y.Z
>
> Replace vX.Y.Z with the ZITADEL release you have selected. A release
tag gives you a reproducible compatibility baseline and a corresponding
changelog.
>
> History-preserving slim fork — advanced
>
> If repository size or internal policy makes a full fork impractical,
you can maintain a filtered fork containing only:
>
> apps/login
>
> packages/zitadel-client
>
> packages/zitadel-proto
>
> proto
>
> the root pnpm, lockfile, Node, and Nx configuration required by those
projects
>
> Use git filter-repo identically both when bootstrapping the fork and
during every later synchronization. The list of retained paths and all
filter arguments must remain stable so that freshly filtered upstream
history shares commit ancestry with your fork.
>
> Important constraints for this model:
>
> Bootstrap and update with one shared filtering implementation; do not
duplicate the path list in separate scripts.
>
> Merge the initial import and subsequent update branches with a merge
commit or fast-forward. Do not squash them, because squashing removes
the filtered upstream ancestry needed for future three-way merges.
>
> Treat any change to the retained path list as a new bootstrap. Adding
or removing paths rewrites the filtered commit IDs.
>
> Re-run pnpm install after filtering or merging so the lockfile is
reconciled with the retained workspace.
>
> Review the retained path list whenever upstream changes the Login
build graph.
>
> This is a valid Git maintenance technique, but it adds machinery that
ZITADEL's normal build does not require. Own and test the filtering
automation as part of your fork.
>
> Why a standalone apps/login copy is not recommended today
>
> At the time of writing, current @zitadel/client and @zitadel/proto
releases are not available as version-matched npm dependencies for the
Login UI. The application consumes their workspace sources from the
monorepo. Until version-matched packages are published, retain those
sources rather than replacing the workspace dependencies with old npm
versions. Progress is tracked in zitadel/zitadel#12250.
>
> Configure a development environment
>
> The current contribution guide requires Node.js 22.x, Corepack/pnpm,
and Nx. From the repository root:
>
> corepack enable
> pnpm install
> pnpm nx run-many --target generate
>
> To develop the Login UI against an existing ZITADEL instance, create
apps/login/.env.dev.local:
>
> ZITADEL_API_URL=https://your-zitadel-instance.example.com
> ZITADEL_SERVICE_USER_TOKEN=your-login-client-pat
>
> Do not commit this file or its token. ZITADEL_API_URL must not have a
trailing slash.
>
> Start the development server:
>
> pnpm nx run @zitadel/login:dev
>
> The token must belong to a machine user with the Instance Login Client
role (IAM_LOGIN_CLIENT). See Connect your self-hosted Login UI to
ZITADEL for the supported setup.
>
> Run the required quality checks
>
> Run the checks for all three affected projects, not only the Next.js
application:
>
> pnpm nx run-many \
>   --projects @zitadel/login @zitadel/client @zitadel/proto \
>   --targets lint build test
>
> If your update includes .proto changes, regenerate the TypeScript
clients before testing:
>
> pnpm nx run @zitadel/proto:generate
>
> For meaningful Login UI changes, also perform end-to-end tests against
a staging ZITADEL instance. At minimum, cover the authentication methods
and policies you enable in production:
>
> Authorization Code Flow with PKCE and callback to the application
>
> password, passkey, external identity provider, and applicable MFA/OTP
flows
>
> registration, account selection, password reset, verification, and
logout
>
> organization-specific policies, branding, and translated text
>
> expected negative cases such as invalid or expired codes
>
> desktop and mobile browsers supported by your application
>
> Build an immutable image
>
> The repository's documented Docker build flow is:
>
> pnpm nx run @zitadel/login:build
> docker build -t
registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1 apps/login
> docker push
registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1
>
> Use an immutable tag that records both the upstream ZITADEL baseline
and your customization revision. For example:
>
> v4.17.0-custom.1
>
> Also retain the source commit and image digest in your release record.
Do not deploy a mutable latest tag to production.
>
> Deploy outside Vercel
>
> Vercel is not required. The Login UI can run as the Node.js
application produced by the Next.js build or as a container. In
Kubernetes or EKS, run it as a separate Deployment and Service behind
your ingress or load balancer.
>
> The following fragment shows the important application settings. Adapt
registry authentication, resources, topology, and ingress to your
platform:
>
> apiVersion: apps/v1
> kind: Deployment
> metadata:
>   name: zitadel-login
> spec:
>   replicas: 2
>   selector:
>     matchLabels:
>       app: zitadel-login
>   template:
>     metadata:
>       labels:
>         app: zitadel-login
>     spec:
>       containers:
>         - name: login
> image: registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1
>           ports:
>             - name: http
>               containerPort: 3000
>           env:
>             - name: ZITADEL_API_URL
>               value: https://zitadel.example.com
>             - name: NEXT_PUBLIC_BASE_PATH
>               value: /ui/v2/login
>             - name: ZITADEL_SERVICE_USER_TOKEN_FILE
>               value: /var/run/secrets/zitadel/login-client.pat
>           volumeMounts:
>             - name: login-client
>               mountPath: /var/run/secrets/zitadel
>               readOnly: true
>           readinessProbe:
>             tcpSocket:
>               port: http
>           livenessProbe:
>             tcpSocket:
>               port: http
>       volumes:
>         - name: login-client
>           secret:
>             secretName: zitadel-login-client
> ---
> apiVersion: v1
> kind: Service
> metadata:
>   name: zitadel-login
> spec:
>   selector:
>     app: zitadel-login
>   ports:
>     - name: http
>       port: 3000
>       targetPort: http
>
> Store the PAT in a Kubernetes Secret or external secrets manager and
expose it as a read-only file. Restrict access to the Secret and plan
token rotation. Do not bake the PAT into the image.
>
> Add CPU and memory requests and limits based on load tests from your
own authentication traffic. Use multiple replicas, disruption controls,
and multi-zone scheduling according to your availability requirements.
>
> Configure routing and domains
>
> The Login UI contains a server-side component and Next.js middleware
that proxies OIDC-related requests to ZITADEL. Correct proxy routing is
part of the authentication flow, not only static frontend delivery.
>
> Same public origin
>
> When the Login UI and ZITADEL share a public origin, route:
>
> /ui/v2/login to the Login UI service
>
> all other ZITADEL paths to the ZITADEL API service
>
> Set NEXT_PUBLIC_BASE_PATH=/ui/v2/login at build and runtime as
required by your packaging. Keep the value consistent with the Login V2
base URI configured in ZITADEL.
>
> Separate Login domain
>
> If the Login UI uses a separate public domain such as
login.example.com:
>
> Serve it over HTTPS.
>
> Add the Login domain to the instance's Trusted Domains.
>
> Configure the proxy in front of the Login UI as documented by ZITADEL:
>
> x-zitadel-public-host: the public Login UI host, for example
login.example.com
>
> x-zitadel-instance-host: the ZITADEL instance host, for example
auth.example.com
>
> Test OIDC and SAML callbacks and every configured external identity
provider through the public route.
>
> Do not assume that setting only ZITADEL_API_URL replaces the required
host-routing behavior for every topology. The Login App architectural
and proxy guidance is the source of truth for these headers.
>
> Enable Login V2 safely
>
> For an existing self-hosted ZITADEL installation, follow Adopt Login
V2 on an existing installation.
>
> Prefer a gradual rollout:
>
> Deploy the custom Login UI without changing the instance-wide setting.
>
> Enable the new Login UI for a non-production test application and set
its custom base URL.
>
> Run the complete authentication test matrix.
>
> Expand to selected applications.
>
> Enable it instance-wide only after validation, if that is your
intended topology.
>
> Keep a break-glass PAT for a machine user with the Instance Owner role
(IAM_OWNER) so that you can revert the Login V2 setting if interactive
login becomes unavailable. Store and audit this credential according to
your incident-access policy.
>
> Keep the fork synchronized
>
> Treat upstream synchronization as a normal application dependency
update, not an automatic deployment.
>
> For a full repository fork:
>
> git fetch upstream --tags
> git checkout -b update-login-vX.Y.Z
> git merge vX.Y.Z
>
> Resolve conflicts while preserving the intent of your customizations.
Then install, generate, test, and build again. For a slim fork, fetch a
freshly filtered copy of the same release and merge its history using
the identical filter configuration used during bootstrap.
>
> Recommended update workflow:
>
> Select a released ZITADEL version and read its release notes and
migration guidance.
>
> Merge that release into a dedicated update branch.
>
> Review changes to apps/login, packages/zitadel-client,
packages/zitadel-proto, proto, and the root build files.
>
> Reconcile dependencies and generated code.
>
> Run lint, build, unit, and staging end-to-end tests.
>
> Build an immutable custom image and scan it using your normal
supply-chain controls.
>
> Deploy to staging, then canary or gradually roll out to production.
>
> Retain the previous image digest and configuration for rollback.
>
> Do not merge upstream continuously into production without
qualification. Login changes can affect cookies, redirects, external IdP
callbacks, API contracts, and authentication-policy behavior.
>
> Recommended release policy for your fork
>
> ZITADEL releases are the upstream input; your custom Login UI image is
your own release artifact. A useful version format is:
>
> <upstream-zitadel-version>-custom.<revision>
>
> Maintain a short release record containing:
>
> upstream tag and commit
>
> custom fork commit
>
> container image digest
>
> applicable ZITADEL backend version or tested range
>
> configuration changes and migrations
>
> test evidence and known limitations
>
> Subscribe to ZITADEL releases and security notices. Decide
deliberately which releases to adopt; prioritize security fixes and
changes that affect Login V2 or the APIs it consumes.
>
> Troubleshooting checklist
>
> If the Login UI returns an internal error or an authentication flow
redirects incorrectly, verify:
>
> the PAT is readable and belongs to a user with IAM_LOGIN_CLIENT
>
> ZITADEL_API_URL is reachable from the Login pod and has no trailing
slash
>
> the public Login domain is trusted by the ZITADEL instance
>
> the configured Login V2 base URI exactly matches the public route and
base path
>
> the ingress sends Login UI traffic to the correct service
>
> separate-domain deployments set the public-host and instance-host
headers correctly
>
> the selected Login source, client package, proto package, and ZITADEL
API version were tested together
>
> external IdP callback URLs reach the expected public endpoint
>
> the prior known-good image and configuration can still be restored
>
> Related documentation
>
> Login App architecture and setup
>
> Contribute to the Login UI
>
> Connect your self-hosted Login UI to ZITADEL
>
> Adopt Login V2 on an existing installation
>
> ZITADEL releases

### ⚠️ Manual Action Required
> **Sidebar Update Needed:** Because this PR creates a brand new
document, it will not automatically appear in the site's navigation.
Please review the file location and manually update
`apps/docs/lib/sidebar-data.ts` to include a reference to this new page
before merging.

---
🤖 **Need adjustments?**
Leave a comment below and tag **@zitadel-knowledge-bot** with your
requested changes, and I will automatically update the files and push a
new commit!

---------

Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com>
Co-authored-by: Matías Racedo <matiasracedo@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 27ce10276b)
2026-08-28 13:11:53 +02:00
Max Peintner e5f526fc8e fix(login): show account selection after RP-initiated logout (#12638)
**Supersedes #12471** by @conblem — same fix, rebased onto current
`main` with conflicts resolved and review feedback applied. Original
commits are preserved, all credit to @conblem.

Closes #12252

### Problem

After an RP-initiated logout (`end_session` with `id_token_hint`), Login
V2 redirected straight to `/loginname` instead of showing the account
picker like Login V1. The `sessions` cookie still referenced the
previous accounts, but their server-side sessions had been terminated,
so `listSessions` returned nothing and the flow treated the browser as
having no accounts at all.

### Solution

- `handleOIDCFlowInitiation` now checks the `sessions` cookie
(`hasCookieAccount`) when no live session is eligible for the request's
organization. If the cookie still knows an account and the RP passed no
`login_hint`, the user is sent to `/accounts` instead of `/loginname`.
`prompt=none` is excluded since it must never render UI.
- `/accounts` synthesizes an (invalid) `Session` for each cookie entry
that has no live counterpart, so the account is still listed and a click
re-authenticates via `/loginname` → password in one step.
- `clearSession` prunes the cookie entry when the server reports the
session as already gone (`NotFound` / `PermissionDenied`), so stale
cards can be dismissed. Any other error is rethrown so a still-valid
server session is never silently orphaned.
- `getAllSessionCookieIds` is now a thin wrapper over `getAllSessions`;
`FlowInitiationParams.sessionCookies` is typed as `Cookie[]`.
- Tests: new `describe` block covering the cookie fallback, `login_hint`
precedence, `prompt=none`, org-scope matching/mismatch, and
`SELECT_ACCOUNT` / default prompt with live sessions filtered out by
org.
- Also adds `.claude/`, `.cursor/`, `.codex/`, `.windsurf/`,
`.copilot/`, `.aider*` to `.gitignore`.

---------

Co-authored-by: conblem <mail@conblem.me>
(cherry picked from commit 3c746281f1)
2026-08-27 14:49:16 +02:00
20cbfcf552 fix(security): prevent TOTP reuse (#12616)
# Which Problems Are Solved

TOTP codes could be reused, violating [RFC 6238, section
5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2).

# How the Problems Are Solved

Transmitted codes are now recorded in each success event. As the codes
amount to secrets, they are not stored verbatim but as a randomly salted
HMAC-SHA256 (`crypto.HMACValue`). Each value gets a fresh random salt,
so an equal code always produces a different stored value and two
entries can never be recognized as being derived from the same code.

This is deliberately not a replacement for proper secret storage: with
the salt stored alongside the hash and a 6 digit payload, anyone with
event access can brute-force the code in milliseconds. That is
acceptable here because a code is worthless once its window has passed.
The `HMACValue` doc comment states the same.

TOTP codes are low-entropy (6 digits), pseudo random codes. Therefore it
is possible for the same code to be generated more than once over the
lifetime of a user. In order to prevent the (unlikely) case where users
login with large time intervalls and a generated code may be equal to
the one last used a long time ago, we only check for codes used within
the recent period window.

A simpler implementation would be simply storing the latest consumed
step, but the TOTP library does not return this value. Because of the
allowed skew there are always 3 steps within the validation window, so
we can't calculate the current step based on time alone. The retained
history spans one period more than the validation window: validity is
decided by the app server's clock, while the stored events carry the
database clock, and the extra period absorbs the drift between them.

**Note:** a reused code pushes a distinct
`user.human.mfa.otp.check.reused` event, not a failed check event. A
replay therefore stays observable through the Events API without
counting towards the lockout policy. Otherwise anyone who captured a
code could replay it to lock the user out.

# Additional Changes

- Read only the latest events in recheck query

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/6452

---------

Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: muhlemmer <5411563+muhlemmer@users.noreply.github.com>

(cherry picked from commit 6128aeaa89)
2026-08-27 14:49:12 +02:00
Livio Spring f0329f0298 fix(api): prevent cross-instance caching of well-known app link files (#12644)
# Which Problems Are Solved

The `/.well-known/apple-app-site-association` and
`/.well-known/assetlinks.json` responses are instance specific: the body
depends on the requested host. The responses did not declare this, so a
shared cache whose cache key does not include the host could store one
instance's file and serve it on another instance's domain. This could
result in a wrong or empty file being returned intermittently, breaking
native passkey verification.

Dropping the `public` directive (#12634) was not sufficient: depending
on its cache mode, a shared cache may store any response carrying a
`max-age`.

The docs page for native app links also still documented the old
`Cache-Control: public` header and only the single `get_login_creds`
relation (missing `handle_all_urls`, added in #12634).

# How the Problems Are Solved

- Send **`Vary: Host`** on both well-known responses, declaring that the
body depends on the host: a spec-compliant shared cache must store
per-host variants or skip caching. Clients addressing a single host (the
Apple and Google verifiers, browsers) are unaffected and keep honoring
the configured `max-age`.
- Correct the outdated caching reasoning in the code comments.
- Guard the header with unit and integration tests.
- Update the native app links docs page: both Digital Asset Links
relations and the current response headers.

# Additional Changes

None. `WellKnown.AppLinksCacheControlMaxAge` behavior is unchanged (`0`
still results in `no-store`).

# Additional Context

- Follow-up to #12634; relates to #12497.
- Verified with interleaved requests against two instances: responses
are no longer reused across domains, and each domain consistently serves
its own configuration.
- To be cherry-picked to `v4.x` from `main` once merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c9d747d407)
2026-08-27 14:48:59 +02:00
Marco A. 9cce012892 docs: remove outdated Actions v2 feature flag references (#12641)
# Which Problems Are Solved

The docs ([Actions v2 concepts
page](https://zitadel.com/docs/concepts/features/actions_v2) and the
Actions testing guides) state that Actions v2 must be enabled via the
"Actions" feature flag. That flag no longer exists — it is `reserved` in
`proto/zitadel/feature/v2/instance.proto` and `system.proto` — and
Actions v2 is available by default. The callout even linked to a
non-existent API field.

# How the Problems Are Solved

- Removed the stale feature-flag callout (and the roadmap teaser
callout) from `concepts/features/actions_v2.mdx`.
- Removed the "Your ZITADEL instance needs to have the actions feature
enabled." prerequisite bullet from the seven
`guides/integrate/actions/testing-*.mdx` guides.

Pure deletions, 17 lines across 8 files.

# Additional Context

The `SetInstanceFeatures` mentions in `hosted-login.mdx` and
`webkeys.mdx` refer to flags that still exist and are untouched.

(cherry picked from commit 6fdb74593c)
2026-08-27 14:48:56 +02:00
Federico Coppede 9cfb0a34df docs: add ZITADEL Cloud refund policy (#12577)
Adds a new public-facing Refund Policy to the documentation for ZITADEL
Cloud subscriptions.

This document clarifies billing expectations for customers and
standardizes support operations by outlining:

- **Scope:** Eligible situations for refunds (e.g., accidental overages,
billing errors, accidental auto-renewals).
- **Out of Scope:** Non-refundable items (e.g., Startup plan, standard
usage-based fees, partial periods).
- **Process:** Clear instructions on how customers can submit a refund
request via the customer portal.

---------

Co-authored-by: Maximilian <mpa@zitadel.com>
(cherry picked from commit 7e8a3fb21d)
2026-08-27 14:48:53 +02:00
Livio Spring 21dbb2e8ca fix(api): correct caching and relations of well-known app link files (#12634)
# Which Problems Are Solved

Two issues with the native app link well-known files reported after the
v4.17.0 rollout:

**1. `assetlinks.json` intermittently returned an empty `[]`** on
ZITADEL Cloud without any configuration change, across all of a
customer's environments, breaking native passkey verification. The
configuration itself was stored correctly.

The responses were served with `Cache-Control: public, max-age=<n>`. The
`public` directive opts the response into shared caches: the CDN in
front of ZITADEL Cloud is configured to cache only responses that
explicitly set `public` or `s-maxage`. That cache is a fleet of
independent edge nodes, each holding its own copy. A single momentarily
inconsistent origin response (e.g. an empty file) gets pinned on the
edge that captured it for the whole `max-age` and served to every client
routed there, while other edges serve the correct file — producing the
observed flip-flop. This is confirmed by the JWKS endpoint, which serves
`max-age=<n>, must-revalidate` **without** `public`, has never been
cached by the CDN, and has never shown this inconsistency.

**2. Passkey creation failed on Google Pixel devices** while working on
iOS and other Android devices. Google's [Credential Manager
prerequisites](https://developer.android.com/identity/credential-manager/prerequisites)
require the Digital Asset Links statement for passkeys to declare
**both** `delegate_permission/common.handle_all_urls` and
`delegate_permission/common.get_login_creds`; ZITADEL only served
`get_login_creds`, and Pixel devices enforce the documented contract
strictly.

# How the Problems Are Solved

- Drop `public` and mirror the JWKS endpoint: `Cache-Control:
max-age=<n>, must-revalidate`. Clients (the Apple and Google verifiers,
browsers) still cache the files for `max-age`; the origin stays the
single source of truth. The load profile matches JWKS, which already
serves every request from origin at far higher volume, so this does not
introduce a thundering-herd risk. A test guard asserts `public` is never
emitted.
- Emit both required relations in `/.well-known/assetlinks.json`,
matching Google's documented example. The additional relation only
permits the app to handle links for the domain; it has no effect unless
the app itself declares matching intent filters, and the entry is
configured by the app owner.

# Additional Changes

None. `AppLinksCacheControlMaxAge` and its `0 → no-store` behaviour are
unchanged.

# Additional Context

- Introduced with the native app links feature (#12580 / #12497).
- Immediate relief for the caching issue was applied by invalidating the
CDN cache for both paths; this change prevents recurrence.
- To be cherry-picked to `v4.x` from `main` once merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 3c3bd2bc57)
2026-08-27 14:48:46 +02:00
Max Peintner c543449e0f fix(console): support Zitadel Support IDP (#12619)
# Which Problems Are Solved

The backend and the new login already support the dedicated ZITADEL IdP
template
(`AddZitadelProvider`/`UpdateZitadelProvider`, `PROVIDER_TYPE_ZITADEL`,
`InstanceRolesInfo`),
but the Console does not expose it:

- A ZITADEL provider cannot be created or edited from the Console,
neither at instance nor at
organization level — it is missing from the "add provider" list in the
IdP settings.
- Providers created through the API are listed in the IdP table as
`coming soon` (the type
`@switch` has no `PROVIDER_TYPE_ZITADEL` case) and the row has no detail
route, so an existing
  provider cannot be opened.
- `InstanceRolesInfo` — the organization ID/domain pairs that drive
instance-role synchronization
  for instance-level providers — is not configurable outside the API.

# How the Problems Are Solved

- Added `ProviderZitadelComponent`
(`console/src/app/modules/providers/provider-zitadel/`),
  modeled on the generic OIDC provider component:
- Form for `name`, `issuer`, `clientId`, `clientSecret` and `scopesList`
(chip input, defaulting
to `openid`, `profile`, `email`), plus the shared
`cnsl-provider-options` block.
- On edit the client secret is optional and is only sent when the
"update client secret"
    checkbox is set.
- Reuses `cnsl-provider-next` for the callback URL, activation and "what
now" flow.
- Added the instance role mapping section, rendered only for
instance-level providers
(`serviceType === ADMIN`), since the API rejects `instance_roles_info`
on organization-scoped
ZITADEL providers. Entries are added as organization ID + organization
domain pairs and are
submitted as `InstanceRolesInfo`; both values are required and are
matched together on login.
- Added `addZitadelProvider`/`updateZitadelProvider` to `AdminService`
and `ManagementService`
  wrapping the existing gRPC calls.
- Declared the component in `providers.module.ts` and mapped
`PROVIDER_TYPE_ZITADEL` to the
`zitadel` path in `providers-routing.module.ts`, so
`/instance/provider/zitadel/...` and
  `/org/provider/zitadel/...` resolve for both create and edit.
- Added a ZITADEL tile to the "add provider" list in the IdP settings
and a
`PROVIDER_TYPE_ZITADEL` case to the IdP table;
`IdpTableComponent.routerLinkForRow` now returns
  the detail route for ZITADEL rows.

# Additional Changes

- Added the new i18n keys `IDP.CREATE.ZITADEL.TITLE`/`DESCRIPTION`,
`IDP.INSTANCEROLESINFO`,
`IDP.INSTANCEROLESINFO_DESC`, `IDP.ORGANIZATIONID` and
`IDP.ORGANIZATIONDOMAIN` to all 22
Console locale files with actual translations instead of English
fallbacks.
- The provider tile, table entry and detail header use the colored
ZITADEL logo assets
(`zitadel-logo-solo-light.svg` in the dark theme,
`zitadel-logo-solo-dark.svg` in the light
theme), consistent with the header, rather than the monochrome
`*-solo-*-icon.svg` variants.
- Additional sync roles trigger for the support IDP to behave correctly
on first login after a disable/enable cycle — where auto-linking jumps
in — but not required for the link to be established.

# Additional Context

- Closes #11825
- Part of the "Zitadel as an Identity Provider" epic #5127
- Console counterpart to the API work in #12018, #12056 (add), #12371,
#12378, #12384 (update),
#12394 (get/list), #12396 (delete), #12422 (active IdPs) and to the
login support in #12469,
  #12530 and #12568

---------

Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
(cherry picked from commit 324cc54d9a)
2026-08-27 14:48:42 +02:00
Max Peintner b81cd5ea3e chore(deps): update npm dependencies and clean up pnpm overrides (#12592)
# Which Problems Are Solved

Several dependencies across the workspace had fallen behind their latest
patch releases, and the override list in pnpm-workspace.yaml had
drifted: many of the 28 entries were no longer needed because the
dependency tree had already moved past them, while a few enforced
outdated version floors.

# How the Problems Are Solved

Direct dependencies were updated where newer patch releases were
available: `@angular/*` → ^21.2.19, `next` → 16.2.11 (docs and login,
incl. `@next/eslint-plugin-next`), `nx` → 22.7.7, and `js-yaml` → ^4.3.1
/ `postcss` → ^8.5.23 in docs. The lockfile was regenerated from scratch
so all transitive dependencies resolve to the newest versions within
their allowed ranges. With that in place, the override list was rebuilt
down to the 8 entries that are still required because a parent package
pins an older version (axios and brace-expansion via nx, postcss and
sharp via next, esbuild via tsx, uuid via testcontainers, and the two
OpenTelemetry packages); the other 20 overrides were removed. A
verification pass confirmed the resolved versions stay at or above the
floors the removed overrides used to enforce, and `pnpm audit` comes
back clean.

---------

Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
(cherry picked from commit a1a4572303)
2026-08-27 14:48:38 +02:00
Max Peintner c660049de4 fix(login): don't prepend base path to absolute IdP URL on login_hint redirect (#12610)
# Which Problems Are Solved

When an OIDC authorize request carries a `login_hint` that resolves via
domain discovery to an organization configured to auto-redirect to a
single external IdP, Login V2's server-side flow initiation returns a
malformed `Location` header: the login base path is concatenated with
the absolute IdP authorize URL without a separator (e.g.
`https://<instance>/ui/v2/loginhttps://login.microsoftonline.com/...`).
The flow ends in `{"code":5,"message":"Not Found"}` and the IdP is never
contacted. The same IdP works when the user submits the login name
interactively, because the client-side handler distinguishes external
URLs; only the server-side `login_hint` fast-path introduced in #12431
is affected.

Additionally, the same fast-path only handled the `redirect` response
shape from `sendLoginname`. When the discovered organization's IdP is
SAML with POST binding, `sendLoginname` returns `samlData` (form fields
instead of a URL), which was silently ignored — the user fell back to
the prefilled `/loginname` screen instead of being signed in silently,
unlike the client-side flow, which auto-submits the SAML AuthnRequest
form.

# How the Problems Are Solved

`resolveLoginHint` passed every redirect returned by `sendLoginname`
through `constructUrl`, which unconditionally prepends
`NEXT_PUBLIC_BASE_PATH` and assumes a relative path. It now checks
`isExternalUrl` first: absolute URLs are validated with
`isSafeRedirectUri` (blocking `javascript:`/`data:`/etc., falling back
to `/loginname` if unsafe) and redirected as-is, while relative paths
keep being resolved against the base path — matching the handling
already used in the idp-scope branch of `handleOIDCFlowInitiation` and
in the client-side `handleServerActionResponse`.

`resolveLoginHint` also handles the `samlData` response shape now: after
validating the target URL with `isSafeRedirectUri`, it responds with the
same auto-submit HTML form used by the existing SAML flows, so a
`login_hint` resolving to a POST-binding SAML IdP completes silently as
well. Since this form was previously duplicated inline three times in
`flow-initiation.ts`, it is extracted into a shared
`buildAutoSubmitFormResponse` helper used by all call sites.

Regression tests cover the absolute-URL redirect (fails on the previous
code with the exact malformed URL), the SAML POST auto-submit response,
and the unsafe-scheme fallbacks for both shapes.

(cherry picked from commit d488ecb07f)
2026-08-27 14:48:33 +02:00
Livio Spring 6a4bffb026 docs: fix redirect to WIP page (#12594)
Fixes the path for the temporary redirect for all /reference/api/group/*
routes to a new "Under
Development" page while the API is being built.

(cherry picked from commit 632a519680)
2026-08-27 14:48:30 +02:00
Federico Coppede 77e15d518d docs: redirect /api/group/* to WIP page (#12593)
Adds a temporary redirect for all /api/group/* routes to a new "Under
Development" page while the API is being built.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit a0e53933a2)
2026-08-27 14:48:23 +02:00
zitadel-knowledge-bot[bot] b24e328ce7 docs: update knowledge gap ID 43 (#12590)
Automatically generated PR resolving Knowledge Gap ID 43.

**Thread ID:** mixpanel-cmvvwsc2uwvlkujw
**Action:** UPDATE
**Target Files:** 1

### AI Summary
> *Negative Docs Feedback*
*Path:* `/sdk-examples/react`
*Comment:* "ai generator went crazy it seems, with so many repeated &
unfinished sentences"

---
🤖 **Need adjustments?**
Leave a comment below and tag **@zitadel-knowledge-bot** with your
requested changes, and I will automatically update the files and push a
new commit!

Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com>
(cherry picked from commit 5f3a0de33a)
2026-08-27 14:48:19 +02:00
Max Peintner af3a9b2bae fix(login): redirect unknown users to external IdP after domain discovery when enumeration protection is active (#12581)
# Which Problems Are Solved

A `login_hint` that resolves an organization via domain discovery but
matches no
existing user is no longer automatically redirected to the
organization's external
IdP when enumeration protection (`ignoreUnknownUsernames`) is active on
the request
context.

#12369 combined the unknown-user IdP redirect into a single condition,
but gated it
with `&& !ignoreUnknownUsernames`:

```ts
if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !ignoreUnknownUsernames) {
```

Since the flag is resolved from the pre-discovery context (the instance
default
settings — during domain discovery there is no organization context
yet), enabling
enumeration protection instance-wide disables the redirect even for
IdP-only
organizations (`allowLocalAuthentication: false`). The flow falls
through to
`preventUserEnumeration()` and strands the user on a decoy Password
screen for an
account that does not exist; the Back button re-enters the auto-submit
loop and
dead-ends identically. This breaks silent-SSO first logins where the
external IdP
is supposed to create the account on first authentication (Login V2).

The guard is also counterproductive on its own terms: existing users of
the same
org still auto-redirect to the IdP, so the divergent treatment acts as
an
enumeration oracle — redirect means the user exists, password screen
means it
doesn't. And it protects nothing in practice, since the IdP remains
reachable
manually via the account switcher → login-name screen → IdP button.

# How the Problems Are Solved

Split the condition by what known users of the resolved organization
actually
experience, so unknown usernames always mimic the known-user flow:

```ts
if (
  !effectiveLoginSettings?.allowLocalAuthentication ||
  (discoveredOrganization && !ignoreUnknownUsernames)
) {
```

- **Local authentication disabled (IdP-only org):** redirect unknown
usernames to
the single allowed IdP regardless of `ignoreUnknownUsernames`. Known
users are
auto-redirected there, so the identical redirect is precisely what keeps
unknown
usernames indistinguishable — and it restores first-login account
creation via
  the external IdP.
- **Local authentication enabled:** known (password) users see the
password screen,
so the decoy password screen remains the indistinguishable behavior and
enumeration protection keeps gating the discovery-based IdP redirect, as
  introduced by #12369.

Adds regression tests for both cases: domain discovery into an IdP-only
org with
`ignoreUnknownUsernames: true` expects the IdP redirect; discovery into
a
local-auth org expects the decoy `/password` redirect with no IdP flow
started.

(cherry picked from commit a331e5b7ba)
2026-08-27 14:48:12 +02:00
Livio Spring a9311b8c70 fix: block Login V2 auth for users in deactivated organizations
# Which Problems Are Solved

- Deactivating an organization did not prevent its users from authenticating via Login V2 (session creation, OIDC token issuance/refresh, SAML session creation).
- Only user-level state was checked on the Login V2 path; organization state was ignored.
- That contradicted documented and in-product behavior.
- Already-issued sessions, refresh tokens, V2 access tokens, claim-serving paths, and ZITADEL API calls from the deactivated org also continued to work.

# How the Problems Are Solved

- Shared auth guard: user and org state before Login V2 session bind, OIDC issue/refresh, and SAML session create.
- Claim serving: active resource-owner org required in OIDC userinfo and SAML attributes.
- Cleanup on `OrgDeactivated`: drop V2 sessions and V1 user sessions / tokens / refresh tokens (same pattern as `OrgRemoved`). Also drop V2 sessions on `OrgRemoved`.
- Authz: `CheckOrgActive` on the caller’s `resourceOwner` (cached `OrgByID`). Inactive caller org returns 401 Unauthenticated. Target org (`x-zitadel-orgid`) is not gated so instance admins can still reactivate.
- Durable refresh: reject exchange if `OrgDeactivated` occurred after `RefreshTokenIssuedAt` (stays invalid after reactivate).
- Durable V2 AT: `OrgDeactivated` after token position is treated as session termination in `checkSessionNotTerminatedAfter` / `ActiveAccessTokenByToken`.

# Additional Changes

- `OrgState.IsActive()` helper.
- Distinct error IDs for inactive user vs inactive org.
- `Errors.Org.NotActive` i18n.
- Unit and integration coverage for mint, claims, authz, refresh, and AT paths.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 260446f91f)
v4.17.1
2026-08-14 07:00:05 +02:00
Marco A. 5f75afb98c fix(login): prevent the browser from requesting OTP codes via returnCode
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a4266fa7fc)
2026-08-14 06:23:05 +02:00
gayathri db4237161c fix(login): require MFA prompt step before 2FA enrollment
## Summary

Login V1 (legacy hosted UI, `internal/api/ui/login`) MFA **enrollment/init** handlers acted on `authReq.UserID` via `setUserContext` **without verifying the auth request had completed its first factor**. A request that submitted only a loginname (no password) could:

- enroll attacker-controlled TOTP / SMS / Email / U2F factors on a victim's account,
- **overwrite the victim's verified phone number** (`ChangeUserPhone`), and
- enumerate users (discrepant `COMMAND-SqyJz` errors bypassed `IgnoreUnknownUsernames`).

No password is ever supplied. Instance-wide / cross-org; not cross-instance. Does **not** yield account takeover (password never learned; Login V1 reset is email-only). Related to GHSA-45f2-5q3r-xgg6 (`6fa5c0e6`), which patched the passkey init path but left these three 2FA handlers ungated.

## Root cause

`nextSteps` runs `firstFactorChecked` before `mfaChecked`, so a `*domain.MFAPromptStep` is only produced **after** the first factor is verified. The verify handler asserts its step (`mfa_verify_handler.go`), but the init handlers had no equivalent gate, so they were reachable with a loginname alone.

## Fix

Add a shared guard that rejects any request whose current step is not `*domain.MFAPromptStep`, and wire it into all four enrollment POST handlers before any `setUserContext`/command:

- `handleMFAPrompt` (TOTP/SMS/Email/U2F dispatch)
- `handleMFAInitVerify` (TOTP finalize)
- `handleRegisterSMSCheck` (phone overwrite + SMS enroll)
- `handleRegisterU2F`

This also closes the enumeration oracle — the command that produced the discrepancy is never reached.

## Testing

- Unit: `Test_isMFAPromptStep` (loginname-only/verification/nil/wrong-position → blocked; genuine prompt step → allowed). Full login package green.
- Manual (local instance, rebuilt image): all three reported sinks return `Errors.User.NotAllowed`; enumeration oracle closed (identical error for real vs. non-existent users, incl. `IgnoreUnknownUsernames=true`); legitimate post-password enrollment still completes.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 522b0ed7ed)
2026-08-14 06:23:05 +02:00