From 3c78abca93e763094fffad76095b42d25b8c56ca Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 31 Jul 2025 00:47:40 +0200 Subject: [PATCH 1/9] chore: exclude npm packages from go linting (#10345) # Which Problems Are Solved Not all NPM packages are excluded from go linting. # How the Problems Are Solved Missing exclusions are added. # Additional Context Follows-up on #10331 Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- .golangci.yaml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 712df7d33d..cde08dcf41 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -100,9 +100,7 @@ linters: - .keys - .vscode - build - - console - deploy - - docs - guides - internal/api/ui/login/static - openapi @@ -111,6 +109,12 @@ linters: - third_party$ - builtin$ - examples$ + - apps + - packages + - console + - docs + - load-test + issues: max-issues-per-linter: 0 max-same-issues: 0 @@ -135,9 +139,7 @@ formatters: - .keys - .vscode - build - - console - deploy - - docs - guides - internal/api/ui/login/static - openapi @@ -146,3 +148,8 @@ formatters: - third_party$ - builtin$ - examples$ + - apps + - packages + - console + - docs + - load-test From dcdea5a4fe2c453450713aa8c91305049e3bc43c Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 31 Jul 2025 09:51:26 +0200 Subject: [PATCH 2/9] fix: fix login image (#10355) # Which Problems Are Solved The broken login image is fixed. # How the Problems Are Solved The most important learnings from https://github.com/zitadel/zitadel/pull/10318 are applied: - Path in entrypoint is fixed: `exec node /runtime/apps/login/server.js` - .dockerignore is updated so CSS styles are built into the image - `source: .` is passed to the docker-bake action. Without this, docker-bake builds from a remote context, which seems to be slow and not updated on new PR commits. Looks like the bake action uploads an artifact that [conflicts with the compile workflow](https://github.com/zitadel/zitadel/actions/runs/16620417216/job/47023478437). Therefore, a pattern is added to the compile workflow so only relevant artifacts are selected. --- .github/workflows/compile.yml | 1 + .github/workflows/login-container.yml | 1 + apps/login/scripts/entrypoint.sh | 4 +- build/login/Dockerfile | 8 ++-- ...{.dockerignore => Dockerfile.dockerignore} | 13 ++--- e2e/package.json | 2 +- pnpm-lock.yaml | 48 ++++++++++++++----- 7 files changed, 52 insertions(+), 25 deletions(-) rename build/login/{.dockerignore => Dockerfile.dockerignore} (80%) diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index e1493cfcff..0c36624a46 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -77,6 +77,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: executables + pattern: 'zitadel-*-*' - name: move files one folder up run: mv */*.tar.gz . && find . -type d -empty -delete working-directory: executables diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml index 4e78ba68cd..e9a0b97648 100644 --- a/.github/workflows/login-container.yml +++ b/.github/workflows/login-container.yml @@ -55,6 +55,7 @@ jobs: env: NODE_VERSION: ${{ inputs.node_version }} with: + source: . push: true provenance: true sbom: true diff --git a/apps/login/scripts/entrypoint.sh b/apps/login/scripts/entrypoint.sh index 123612d1cb..5bafc0012d 100755 --- a/apps/login/scripts/entrypoint.sh +++ b/apps/login/scripts/entrypoint.sh @@ -8,6 +8,4 @@ if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ] && [ -f "${ZITADEL_SERVICE_USER_T export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") fi - - -exec node /runtime/apps/login/apps/login/server.js +exec node /runtime/apps/login/server.js diff --git a/build/login/Dockerfile b/build/login/Dockerfile index ad9163285f..cd75728738 100644 --- a/build/login/Dockerfile +++ b/build/login/Dockerfile @@ -24,9 +24,9 @@ COPY . . RUN pnpm turbo build:login:standalone FROM scratch AS build-out -COPY --from=build /app/apps/login/.next/standalone / -COPY --from=build /app/apps/login/.next/static /.next/static -COPY --from=build /app/apps/login/public /public +COPY /apps/login/public ./apps/login/public +COPY --from=build /app/apps/login/.next/standalone ./ +COPY --from=build /app/apps/login/.next/static ./apps/login/.next/static FROM base AS login-standalone WORKDIR /runtime @@ -34,7 +34,7 @@ RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs # If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file -COPY apps/login/scripts ./ +COPY --chown=nextjs:nodejs apps/login/scripts ./ COPY --chown=nextjs:nodejs --from=build-out . . USER nextjs ENV HOSTNAME="0.0.0.0" diff --git a/build/login/.dockerignore b/build/login/Dockerfile.dockerignore similarity index 80% rename from build/login/.dockerignore rename to build/login/Dockerfile.dockerignore index 2070cf5982..c2a0ee4a18 100644 --- a/build/login/.dockerignore +++ b/build/login/Dockerfile.dockerignore @@ -8,7 +8,8 @@ !apps/login/next.config.mjs !apps/login/next-env-vars.d.ts !apps/login/next-env.d.ts -!apps/login/tailwind.config.js +!apps/login/tailwind.config.mjs +!apps/login/postcss.config.cjs !apps/login/tsconfig.json !apps/login/package.json !apps/login/turbo.json @@ -23,6 +24,7 @@ !packages/zitadel-proto/turbo.json !packages/zitadel-client/package.json +!packages/zitadel-client/**/package.json !packages/zitadel-client/src !packages/zitadel-client/tsconfig.json !packages/zitadel-client/tsup.config.ts @@ -30,8 +32,7 @@ !proto -*.md -*.png -node_modules -*.test.ts -*.test.tsx +**/*.md +**/node_modules +**/*.test.ts +**/*.test.tsx diff --git a/e2e/package.json b/e2e/package.json index b10f313c57..bf268a7de4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -30,6 +30,6 @@ }, "devDependencies": { "@types/node": "^22.3.0", - "cypress": "^13.13.3" + "cypress": "^14.5.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75cde1e27d..79a52ce530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,8 +576,8 @@ importers: specifier: ^22.3.0 version: 22.16.5 cypress: - specifier: ^13.13.3 - version: 13.17.0 + specifier: ^14.5.3 + version: 14.5.3 packages/zitadel-client: dependencies: @@ -2154,6 +2154,10 @@ packages: resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} engines: {node: '>= 6'} + '@cypress/request@3.0.9': + resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} + engines: {node: '>= 6'} + '@cypress/xvfb@1.2.4': resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} @@ -7033,16 +7037,16 @@ packages: cypress-wait-until@3.0.2: resolution: {integrity: sha512-iemies796dD5CgjG5kV0MnpEmKSH+s7O83ZoJLVzuVbZmm4lheMsZqAVT73hlMx4QlkwhxbyUzhOBUOZwoOe0w==} - cypress@13.17.0: - resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} - engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} - hasBin: true - cypress@14.5.2: resolution: {integrity: sha512-O4E4CEBqDHLDrJD/dfStHPcM+8qFgVVZ89Li7xDU0yL/JxO/V0PEcfF2I8aGa7uA2MGNLkNUAnghPM83UcHOJw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + cypress@14.5.3: + resolution: {integrity: sha512-syLwKjDeMg77FRRx68bytLdlqHXDT4yBVh0/PPkcgesChYDjUZbwxLqMXuryYKzAyJsPsQHUDW1YU74/IYEUIA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -16904,6 +16908,27 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 + '@cypress/request@3.0.9': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.4 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.0 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -23170,7 +23195,7 @@ snapshots: cypress-wait-until@3.0.2: {} - cypress@13.17.0: + cypress@14.5.2: dependencies: '@cypress/request': 3.0.8 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) @@ -23185,7 +23210,7 @@ snapshots: check-more-types: 2.24.0 ci-info: 4.3.0 cli-cursor: 3.1.0 - cli-table3: 0.6.5 + cli-table3: 0.6.1 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 @@ -23198,6 +23223,7 @@ snapshots: figures: 3.2.0 fs-extra: 9.1.0 getos: 3.2.1 + hasha: 5.2.2 is-installed-globally: 0.4.0 lazy-ass: 1.6.0 listr2: 3.14.0(enquirer@2.4.1) @@ -23216,9 +23242,9 @@ snapshots: untildify: 4.0.0 yauzl: 2.10.0 - cypress@14.5.2: + cypress@14.5.3: dependencies: - '@cypress/request': 3.0.8 + '@cypress/request': 3.0.9 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.9 From 6a93950b647ede1928fcbfd32860ea37a1d55c86 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:50:17 +0200 Subject: [PATCH 3/9] docs: fix the example docs related to set execution api (actions) (#10359) # Which Problems Are Solved Fix docs related to Actions V2 SetExecution API [endpoint](https://zitadel.com/docs/apis/resources/action_service_v2/action-service-set-execution) # How the Problems Are Solved The examples are updated # Additional Changes N/A # Additional Context - Closes #10320 --- .../guides/integrate/actions/testing-event.md | 4 +-- .../actions/testing-function-manipulation.md | 4 +-- .../integrate/actions/testing-function.md | 4 +-- .../actions/testing-request-manipulation.md | 4 +-- .../actions/testing-request-signature.md | 4 +-- .../integrate/actions/testing-request.md | 4 +-- .../actions/testing-response-manipulation.md | 4 +-- .../integrate/actions/testing-response.md | 4 +-- docs/docs/guides/integrate/actions/usage.md | 31 +++++-------------- .../action/v2beta/action_service.proto | 2 +- 10 files changed, 17 insertions(+), 48 deletions(-) diff --git a/docs/docs/guides/integrate/actions/testing-event.md b/docs/docs/guides/integrate/actions/testing-event.md index 8b4502703b..7c8bdd8c2b 100644 --- a/docs/docs/guides/integrate/actions/testing-event.md +++ b/docs/docs/guides/integrate/actions/testing-event.md @@ -103,9 +103,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-function-manipulation.md b/docs/docs/guides/integrate/actions/testing-function-manipulation.md index 8f5e2fc968..e82f0b8c18 100644 --- a/docs/docs/guides/integrate/actions/testing-function-manipulation.md +++ b/docs/docs/guides/integrate/actions/testing-function-manipulation.md @@ -129,9 +129,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-function.md b/docs/docs/guides/integrate/actions/testing-function.md index f14f20b69d..a2faa5e709 100644 --- a/docs/docs/guides/integrate/actions/testing-function.md +++ b/docs/docs/guides/integrate/actions/testing-function.md @@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-request-manipulation.md b/docs/docs/guides/integrate/actions/testing-request-manipulation.md index 1cb4f1776a..b10c32d372 100644 --- a/docs/docs/guides/integrate/actions/testing-request-manipulation.md +++ b/docs/docs/guides/integrate/actions/testing-request-manipulation.md @@ -154,9 +154,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-request-signature.md b/docs/docs/guides/integrate/actions/testing-request-signature.md index c1932a7d5b..b3a9f0fa5d 100644 --- a/docs/docs/guides/integrate/actions/testing-request-signature.md +++ b/docs/docs/guides/integrate/actions/testing-request-signature.md @@ -114,9 +114,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-request.md b/docs/docs/guides/integrate/actions/testing-request.md index b2413e606e..c99e16cd2f 100644 --- a/docs/docs/guides/integrate/actions/testing-request.md +++ b/docs/docs/guides/integrate/actions/testing-request.md @@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-response-manipulation.md b/docs/docs/guides/integrate/actions/testing-response-manipulation.md index 9d95479b05..2bec3e0acd 100644 --- a/docs/docs/guides/integrate/actions/testing-response-manipulation.md +++ b/docs/docs/guides/integrate/actions/testing-response-manipulation.md @@ -173,9 +173,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/testing-response.md b/docs/docs/guides/integrate/actions/testing-response.md index a2ab736505..aea6ac732f 100644 --- a/docs/docs/guides/integrate/actions/testing-response.md +++ b/docs/docs/guides/integrate/actions/testing-response.md @@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \ } }, "targets": [ - { - "target": "" - } + "" ] }' ``` diff --git a/docs/docs/guides/integrate/actions/usage.md b/docs/docs/guides/integrate/actions/usage.md index e21fb4935d..643c9a3995 100644 --- a/docs/docs/guides/integrate/actions/usage.md +++ b/docs/docs/guides/integrate/actions/usage.md @@ -406,17 +406,11 @@ If you then have a call on `/zitadel.user.v2.UserService/UpdateHumanUser` the fo And if you use a different service, for example `zitadel.session.v2.SessionService`, then the `all` Execution would still be used. -### Targets and Includes +### Targets -:::info -Includes are limited to 3 levels, which mean that include1->include2->include3 is the maximum for now. -If you have feedback to the include logic, or a reason why 3 levels are not enough, please open [an issue on github](https://github.com/zitadel/zitadel/issues) or [start a discussion on github](https://github.com/zitadel/zitadel/discussions)/[start a topic on discord](https://zitadel.com/chat) -::: +An execution can contain only a list of Targets, and Targets are comma separated string values. -An execution can not only contain a list of Targets, but also Includes. -The Includes can be defined in the Execution directly, which means you include all defined Targets by a before set Execution. - -If you define 2 Executions as follows: +Here's an example of a Target defined on a service (e.g. `zitadel.user.v2.UserService`) ```json { @@ -426,13 +420,12 @@ If you define 2 Executions as follows: } }, "targets": [ - { - "target": "" - } + "" ] } ``` +Here's an example of a Target defined on a method (e.g. `/zitadel.user.v2.UserService/AddHumanUser`) ```json { "condition": { @@ -441,21 +434,13 @@ If you define 2 Executions as follows: } }, "targets": [ - { - "target": "" - }, - { - "include": { - "request": { - "service": "zitadel.user.v2.UserService" - } - } - } + "", + "" ] } ``` -The called Targets on "/zitadel.user.v2.UserService/AddHumanUser" would be, in order: +The called Targets on `/zitadel.user.v2.UserService/AddHumanUser` would be, in order: 1. `` 2. `` diff --git a/proto/zitadel/action/v2beta/action_service.proto b/proto/zitadel/action/v2beta/action_service.proto index c040eb9a1d..1cf5c210ee 100644 --- a/proto/zitadel/action/v2beta/action_service.proto +++ b/proto/zitadel/action/v2beta/action_service.proto @@ -675,7 +675,7 @@ message SetExecutionRequest { // Ordered list of targets called during the execution. repeated string targets = 2; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { - example: "{\"condition\":{\"request\":{\"method\":\"zitadel.session.v2.SessionService/ListSessions\"}},\"targets\":[{\"target\":\"69629026806489455\"}]}"; + example: "{\"condition\":{\"request\":{\"method\":\"zitadel.session.v2.SessionService/ListSessions\"}},\"targets\":[\"69629026806489455\"]}"; }; } From b06e4e2f9cf69884b9b35e7fdf76746a201b0d4e Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 31 Jul 2025 11:51:38 +0200 Subject: [PATCH 4/9] docs(self-hosting): simplify lb example and replace compose example (#9844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The load balancing compose example uses a dedicated service `use-new-login` that gives the set up machine user the login role and requires the v2 login using an instance feature. This is cumbersome and unnecessary. # How the Problems Are Solved - A login client machine user is set up and the token is passed to the login by using the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE. - The unnecessary service is removed # Additional Changes - Uses the static `MasterkeyNeedsToHave32Characters` master key. - The load balancing example replaces the previous Docker Compose example. - The login uses `network_mode: service:zitadel` so it can access the zitadel service in the docker network via localhost. - Uses the docker provider for Traefik. # Additional Context - Complements https://github.com/zitadel/zitadel/pull/9496 - Partially closes https://github.com/zitadel/zitadel/issues/10016 - When we release, we should update the image tags to latest, for example with [this PR](https://github.com/zitadel/zitadel/pull/10249). --------- Co-authored-by: Tim Möhlmann --- docs/docs/self-hosting/deploy/.gitignore | 1 + docs/docs/self-hosting/deploy/compose.mdx | 86 +++++----- .../deploy/docker-compose-sa.yaml | 49 ------ .../self-hosting/deploy/docker-compose.yaml | 136 +++++++++++---- .../example-zitadel-config.yaml | 9 +- .../example-zitadel-init-steps.yaml | 11 +- .../example-zitadel-secrets.yaml | 0 .../deploy/loadbalancing-example/.gitignore | 1 - .../loadbalancing-example/docker-compose.yaml | 157 ------------------ .../example-traefik.yaml | 40 ----- .../loadbalancing-example.mdx | 74 --------- docs/sidebars.js | 1 - 12 files changed, 155 insertions(+), 410 deletions(-) create mode 100644 docs/docs/self-hosting/deploy/.gitignore delete mode 100644 docs/docs/self-hosting/deploy/docker-compose-sa.yaml rename docs/docs/self-hosting/deploy/{loadbalancing-example => }/example-zitadel-config.yaml (60%) rename docs/docs/self-hosting/deploy/{loadbalancing-example => }/example-zitadel-init-steps.yaml (58%) rename docs/docs/self-hosting/deploy/{loadbalancing-example => }/example-zitadel-secrets.yaml (100%) delete mode 100644 docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore delete mode 100644 docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml delete mode 100644 docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml delete mode 100644 docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx diff --git a/docs/docs/self-hosting/deploy/.gitignore b/docs/docs/self-hosting/deploy/.gitignore new file mode 100644 index 0000000000..83754bbee4 --- /dev/null +++ b/docs/docs/self-hosting/deploy/.gitignore @@ -0,0 +1 @@ +login-client-pat diff --git a/docs/docs/self-hosting/deploy/compose.mdx b/docs/docs/self-hosting/deploy/compose.mdx index 370c0e7f5d..f47a33da16 100644 --- a/docs/docs/self-hosting/deploy/compose.mdx +++ b/docs/docs/self-hosting/deploy/compose.mdx @@ -1,67 +1,65 @@ --- -title: Set up ZITADEL with Docker Compose +title: Set up Zitadel with Docker Compose sidebar_label: Docker Compose --- import CodeBlock from '@theme/CodeBlock'; import DockerComposeSource from '!!raw-loader!./docker-compose.yaml' -import DockerComposeSaSource from '!!raw-loader!./docker-compose-sa.yaml' -import Disclaimer from './_disclaimer.mdx' -import DefaultUser from './_defaultuser.mdx' -import Next from './_next.mdx' -import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx'; +import ExampleZitadelConfigSource from '!!raw-loader!./example-zitadel-config.yaml' +import ExampleZitadelSecretsSource from '!!raw-loader!./example-zitadel-secrets.yaml' +import ExampleZitadelInitStepsSource from '!!raw-loader!./example-zitadel-init-steps.yaml' +The stack consists of four long-running containers and a couple of short-lived containers: +- A [Traefik](https://doc.traefik.io/traefik/) reverse proxy container with upstream HTTP/2 enabled, issuing a self-signed TLS certificate. +- A Login container that is accessible via Traefik at `/ui/v2/login` +- A Zitadel container that is accessible via Traefik at all other paths than `/ui/v2/login`. +- An insecure [PostgreSQL](https://www.postgresql.org/docs/current/index.html). -The setup is tested against Docker version 20.10.17 and Docker Compose version v2.2.3 +The Traefik container and the login container call the Zitadel container via the internal Docker network at `h2c://zitadel:8080` -## Docker compose +The setup is tested against Docker version 28.3.2 and Docker Compose version v2.38.2 -By executing the commands below, you will download the following file: +By executing the commands below, you will download the following files:
docker-compose.yaml {DockerComposeSource}
+
+ example-zitadel-config.yaml + {ExampleZitadelConfigSource} +
+
+ example-zitadel-secrets.yaml + {ExampleZitadelSecretsSource} +
+
+ example-zitadel-init-steps.yaml + {ExampleZitadelInitStepsSource} +
```bash # Download the docker compose example configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/docker-compose.yaml -# Run the database and application containers. -docker compose up --detach +# Download and adjust the example configuration file containing standard configuration. +wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/example-zitadel-config.yaml + +# Download and adjust the example configuration file containing secret configuration. +wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/example-zitadel-secrets.yaml + +# Download and adjust the example configuration file containing database initialization configuration. +wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/example-zitadel-init-steps.yaml + +# Make sure you have the latest version of the images +docker compose pull + +# Run the containers +docker compose up ``` - +Open your favorite internet browser at https://localhost/ui/console?login_hint=zitadel-admin@zitadel.localhost. +Your browser warns you about the insecure self-signed TLS certificate. As localhost resolves to your local machine, you can safely proceed. +Use the password *Password1!* to log in. - - -## VideoGuide - - -## Docker compose with service account - -By executing the commands below, you will download the following file: - -
- docker-compose-sa.yaml - {DockerComposeSaSource} -
- -```bash -# Download the docker compose example configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/docker-compose-sa.yaml -O docker-compose.yaml - -# create the machine key directory -mkdir machinekey - -# Run the database and application containers. -docker compose up --detach - -# then you can move your machine key -mv ./machinekey/zitadel-admin-sa.json $HOME/zitadel-admin-sa.json -``` - -This key can be used to provision resources with for example [Terraform](/docs/guides/manage/terraform-provider). - - - +Read more about [the login process](/guides/integrate/login/oidc/login-users). \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/docker-compose-sa.yaml b/docs/docs/self-hosting/deploy/docker-compose-sa.yaml deleted file mode 100644 index 9edd95faa0..0000000000 --- a/docs/docs/self-hosting/deploy/docker-compose-sa.yaml +++ /dev/null @@ -1,49 +0,0 @@ -services: - zitadel: - # The user should have the permission to write to ./machinekey - user: "${UID:-1000}" - restart: 'always' - networks: - - 'zitadel' - image: 'ghcr.io/zitadel/zitadel:latest' - command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled' - environment: - ZITADEL_DATABASE_POSTGRES_HOST: db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - ZITADEL_EXTERNALSECURE: false - ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH: /machinekey/zitadel-admin-sa.json - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE: 1 - depends_on: - db: - condition: 'service_healthy' - ports: - - '8080:8080' - volumes: - - ./machinekey:/machinekey - - db: - restart: 'always' - image: postgres:17-alpine - environment: - PGUSER: postgres - POSTGRES_PASSWORD: postgres - networks: - - 'zitadel' - healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"] - interval: '10s' - timeout: '30s' - retries: 5 - start_period: '20s' - -networks: - zitadel: diff --git a/docs/docs/self-hosting/deploy/docker-compose.yaml b/docs/docs/self-hosting/deploy/docker-compose.yaml index f5164eb3b7..23d651efc9 100644 --- a/docs/docs/self-hosting/deploy/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/docker-compose.yaml @@ -1,41 +1,117 @@ services: - zitadel: - restart: 'always' - networks: - - 'zitadel' - image: 'ghcr.io/zitadel/zitadel:latest' - command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled' + + db: + image: postgres:17-alpine + restart: unless-stopped environment: - ZITADEL_DATABASE_POSTGRES_HOST: db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - ZITADEL_EXTERNALSECURE: false + - POSTGRES_USER=root + - POSTGRES_PASSWORD=postgres + networks: + - 'storage' + healthcheck: + test: [ "CMD-SHELL", "pg_isready", "-d", "db_prod" ] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + volumes: + - 'data:/var/lib/postgresql/data:rw' + + zitadel-init: + restart: 'no' + networks: + - 'storage' + image: 'ghcr.io/zitadel/zitadel:v4.0.0-rc.2' + command: [ init, --config, /example-zitadel-config.yaml, --config, /example-zitadel-secrets.yaml ] depends_on: db: condition: 'service_healthy' - ports: - - '8080:8080' + volumes: + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' - db: - restart: 'always' - image: postgres:17-alpine - environment: - PGUSER: postgres - POSTGRES_PASSWORD: postgres + zitadel-setup: + restart: 'no' networks: - - 'zitadel' + - 'storage' + image: 'ghcr.io/zitadel/zitadel:v4.0.0-rc.2' + command: [ setup, --config, /current-dir/example-zitadel-config.yaml, --config, /current-dir/example-zitadel-secrets.yaml, --steps, /current-dir/example-zitadel-init-steps.yaml, --masterkey, MasterkeyNeedsToHave32Characters ] + depends_on: + zitadel-init: + condition: 'service_completed_successfully' + restart: false + volumes: + - '.:/current-dir:rw' + + zitadel: + restart: 'unless-stopped' + networks: + - 'backend' + - 'storage' + labels: + - "traefik.http.routers.zitadel.rule=!PathPrefix(`/ui/v2/login`)" + - "traefik.http.routers.zitadel.tls=true" # Traefik uses a self-signed certificate + - "traefik.http.services.zitadel.loadbalancer.passhostheader=true" + - "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c" + - "traefik.http.services.zitadel.loadbalancer.server.port=8080" + image: 'ghcr.io/zitadel/zitadel:v4.0.0-rc.2' + command: [ start, --config, /example-zitadel-config.yaml, --config, /example-zitadel-secrets.yaml, --masterkey, MasterkeyNeedsToHave32Characters ] + depends_on: + zitadel-setup: + condition: 'service_completed_successfully' + restart: true + volumes: + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"] - interval: '10s' - timeout: '30s' + test: [ "CMD", "/app/zitadel", "ready", "--config", "/example-zitadel-config.yaml", "--config", "/example-zitadel-secrets.yaml" ] + interval: 10s + timeout: 60s retries: 5 - start_period: '20s' + start_period: 10s + + login: + restart: 'unless-stopped' + labels: + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + - "traefik.http.routers.login.tls=true" # Traefik uses a self-signed certificate + - "traefik.http.services.login.loadbalancer.passhostheader=true" + - "traefik.http.services.login.loadbalancer.server.port=3000" + image: 'ghcr.io/zitadel/zitadel-login:v4.0.0-rc.2' + # If you can't use the network_mode service:zitadel, you can pass the environment variable CUSTOM_REQUEST_HEADERS=Host:localhost instead. + network_mode: service:zitadel + environment: + - ZITADEL_API_URL=http://localhost:8080 + - NEXT_PUBLIC_BASE_PATH=/ui/v2/login + - ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client-pat + user: "${UID:-1000}" + volumes: + - '.:/current-dir:ro' + depends_on: + zitadel-setup: + condition: 'service_completed_successfully' + restart: false + + traefik: + image: traefik:latest + command: --providers.docker --api.insecure=true --entrypoints.websecure.address=:443 --log.level=DEBUG --accesslog + networks: + - 'backend' + ports: + - "443:443" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + zitadel: + condition: 'service_healthy' + login: + condition: 'service_started' networks: - zitadel: + storage: + backend: + + +volumes: + data: diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/example-zitadel-config.yaml similarity index 60% rename from docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml rename to docs/docs/self-hosting/deploy/example-zitadel-config.yaml index af5bb5145c..baacd6cefe 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/example-zitadel-config.yaml @@ -1,7 +1,6 @@ # All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml ExternalSecure: true -ExternalDomain: 127.0.0.1.sslip.io ExternalPort: 443 # Traefik terminates TLS. Inside the Docker network, we use plain text. @@ -16,14 +15,8 @@ Database: User.SSL.Mode: 'disable' Admin.SSL.Mode: 'disable' -# By default, ZITADEL should redirect to /ui/v2/login -OIDC: - DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 - DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 -SAML.DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 - # Access logs allow us to debug Network issues LogStore.Access.Stdout.Enabled: true # Skipping the MFA init step allows us to immediately authenticate at the console -DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" \ No newline at end of file +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml b/docs/docs/self-hosting/deploy/example-zitadel-init-steps.yaml similarity index 58% rename from docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml rename to docs/docs/self-hosting/deploy/example-zitadel-init-steps.yaml index 9bdf41269d..373c6ae744 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml +++ b/docs/docs/self-hosting/deploy/example-zitadel-init-steps.yaml @@ -1,12 +1,11 @@ # All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/setup/steps.yaml FirstInstance: - PatPath: '/pat' + LoginClientPatPath: '/current-dir/login-client-pat' Org: # We want to authenticate immediately at the console without changing the password - Human: - PasswordChangeRequired: false - Machine: + Human.PasswordChangeRequired: false + LoginClient: Machine: - Username: 'login-container' - Name: 'Login Container' + Username: 'login-client' + Name: 'Automatically Initialized IAM Login Client' Pat.ExpirationDate: '2029-01-01T00:00:00Z' \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-secrets.yaml b/docs/docs/self-hosting/deploy/example-zitadel-secrets.yaml similarity index 100% rename from docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-secrets.yaml rename to docs/docs/self-hosting/deploy/example-zitadel-secrets.yaml diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore deleted file mode 100644 index 8a28618b17..0000000000 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env-file \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml deleted file mode 100644 index 96a87fa8d7..0000000000 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ /dev/null @@ -1,157 +0,0 @@ -services: - - db: - image: postgres:17-alpine - restart: unless-stopped - environment: - - POSTGRES_USER=root - - POSTGRES_PASSWORD=postgres - networks: - - 'storage' - healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] - interval: 10s - timeout: 60s - retries: 5 - start_period: 10s - volumes: - - 'data:/var/lib/postgresql/data:rw' - - zitadel-init: - restart: 'no' - networks: - - 'storage' - image: 'ghcr.io/zitadel/zitadel:latest' - command: 'init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml' - depends_on: - db: - condition: 'service_healthy' - volumes: - - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' - - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' - - zitadel-setup: - restart: 'no' - networks: - - 'storage' - # We use the debug image so we have the environment to - # - create the .env file for the login to authenticate at Zitadel - # - set the correct permissions for the .env-file folder - image: 'ghcr.io/zitadel/zitadel:latest-debug' - user: root - entrypoint: '/bin/sh' - command: - - -c - - > - /app/zitadel setup - --config /example-zitadel-config.yaml - --config /example-zitadel-secrets.yaml - --steps /example-zitadel-init-steps.yaml - --masterkey ${ZITADEL_MASTERKEY} && - mv /pat /.env-file/pat || exit 0 && - echo ZITADEL_SERVICE_USER_TOKEN=$(cat /.env-file/pat) > /.env-file/.env && - chown -R 1001:${GID} /.env-file && - chmod -R 770 /.env-file - environment: - - GID - depends_on: - zitadel-init: - condition: 'service_completed_successfully' - restart: false - volumes: - - './.env-file:/.env-file:rw' - - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' - - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' - - './example-zitadel-init-steps.yaml:/example-zitadel-init-steps.yaml:ro' - - zitadel: - restart: 'unless-stopped' - networks: - - 'backend' - - 'storage' - image: 'ghcr.io/zitadel/zitadel:latest' - command: > - start --config /example-zitadel-config.yaml - --config /example-zitadel-secrets.yaml - --masterkey ${ZITADEL_MASTERKEY} - depends_on: - zitadel-setup: - condition: 'service_completed_successfully' - restart: true - volumes: - - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' - - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' - ports: - - "8080:8080" - healthcheck: - test: [ - "CMD", "/app/zitadel", "ready", - "--config", "/example-zitadel-config.yaml", - "--config", "/example-zitadel-secrets.yaml" - ] - interval: 10s - timeout: 60s - retries: 5 - start_period: 10s - - # The use-new-login service configures Zitadel to use the new login v2 for all applications. - # It also gives the setupped machine user the necessary IAM_LOGIN_CLIENT role. - use-new-login: - restart: 'on-failure' - user: "1001" - networks: - - 'backend' - image: 'badouralix/curl-jq:alpine' - entrypoint: '/bin/sh' - command: - - -c - - > - curl -X PUT -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/v2/features/instance -d '{"loginV2": {"required": true}}' && - LOGIN_USER=$(curl --fail-with-body -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/auth/v1/users/me | jq -r '.user.id') && - curl -X PUT -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/admin/v1/members/$${LOGIN_USER} -d '{"roles": ["IAM_OWNER", "IAM_LOGIN_CLIENT"]}' - volumes: - - './.env-file:/.env-file:ro' - depends_on: - zitadel: - condition: 'service_healthy' - restart: false - - login: - restart: 'unless-stopped' - networks: - - 'backend' - image: 'ghcr.io/zitadel/login:main' - environment: - - ZITADEL_API_URL=http://zitadel:8080 - - CUSTOM_REQUEST_HEADERS=Host:127.0.0.1.sslip.io - - NEXT_PUBLIC_BASE_PATH="/ui/v2/login" - user: "${UID:-1000}" - volumes: - - './.env-file:/.env-file:ro' - depends_on: - zitadel: - condition: 'service_healthy' - restart: false - - traefik: - restart: 'unless-stopped' - networks: - - 'backend' - image: "traefik:latest" - ports: - - "80:80" - - "443:443" - volumes: - - "./example-traefik.yaml:/etc/traefik/traefik.yaml" - depends_on: - zitadel: - condition: 'service_healthy' - login: - condition: 'service_started' - -networks: - storage: - backend: - -volumes: - data: \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml deleted file mode 100644 index a3af425172..0000000000 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml +++ /dev/null @@ -1,40 +0,0 @@ -log: - level: DEBUG - -accessLog: {} - -entrypoints: - websecure: - address: ":443" - -providers: - file: - filename: /etc/traefik/traefik.yaml - -http: - routers: - login: - entryPoints: - - websecure - service: login - rule: 'Host(`127.0.0.1.sslip.io`) && PathPrefix(`/ui/v2/login`)' - tls: {} - zitadel: - entryPoints: - - websecure - service: zitadel - rule: 'Host(`127.0.0.1.sslip.io`) && !PathPrefix(`/ui/v2/login`)' - tls: {} - - services: - login: - loadBalancer: - servers: - - url: http://login:3000 - passHostHeader: true - zitadel: - loadBalancer: - servers: - - url: h2c://zitadel:8080 - passHostHeader: true - diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx deleted file mode 100644 index 3fb4784ea0..0000000000 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: A Zitadel Load Balancing Example ---- - -import CodeBlock from '@theme/CodeBlock'; -import DockerComposeSource from '!!raw-loader!./docker-compose.yaml' -import ExampleTraefikSource from '!!raw-loader!./example-traefik.yaml' -import ExampleZITADELConfigSource from '!!raw-loader!./example-zitadel-config.yaml' -import ExampleZITADELSecretsSource from '!!raw-loader!./example-zitadel-secrets.yaml' -import ExampleZITADELInitStepsSource from '!!raw-loader!./example-zitadel-init-steps.yaml' - -The stack consists of four long-running containers and a couple of short-lived containers: -- A [Traefik](https://doc.traefik.io/traefik/) reverse proxy container with upstream HTTP/2 enabled, issuing a self-signed TLS certificate. -- A Login container that is accessible via Traefik at `/ui/v2/login` -- A Zitadel container that is accessible via Traefik at all other paths than `/ui/v2/login`. -- An insecure [PostgreSQL](https://www.postgresql.org/docs/current/index.html). - -The Traefik container and the login container call the Zitadel container via the internal Docker network at `h2c://zitadel:8080` - -The setup is tested against Docker version 28.0.4 and Docker Compose version v2.34.0 - -By executing the commands below, you will download the following files: - -
- docker-compose.yaml - {DockerComposeSource} -
-
- example-traefik.yaml - {ExampleTraefikSource} -
-
- example-zitadel-config.yaml - {ExampleZITADELConfigSource} -
-
- example-zitadel-secrets.yaml - {ExampleZITADELSecretsSource} -
-
- example-zitadel-init-steps.yaml - {ExampleZITADELInitStepsSource} -
- -```bash -# Download the docker compose example configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml - -# Download the Traefik example configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml - -# Download and adjust the example configuration file containing standard configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml - -# Download and adjust the example configuration file containing secret configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-secrets.yaml - -# Download and adjust the example configuration file containing database initialization configuration. -wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml - -# A single ZITADEL instance always needs the same 32 bytes long masterkey -# Generate one to a file if you haven't done so already and pass it as environment variable -LC_ALL=C tr -dc '[:graph:]' ./zitadel-masterkey -export ZITADEL_MASTERKEY="$(cat ./zitadel-masterkey)" - -# Run the database and application containers -docker compose up --detach --wait -``` - -Open your favorite internet browser at https://127.0.0.1.sslip.io/ui/console?login_hint=zitadel-admin@zitadel.127.0.0.1.sslip.io. -Your browser warns you about the insecure self-signed TLS certificate. As 127.0.0.1.sslip.io resolves to your localhost, you can safely proceed. -Use the password *Password1!* to log in. - -Read more about [the login process](/guides/integrate/login/oidc/login-users). \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index 578a337da9..105251f20c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -1084,7 +1084,6 @@ module.exports = { "self-hosting/deploy/devcontainer", "self-hosting/deploy/knative", "self-hosting/deploy/kubernetes", - "self-hosting/deploy/loadbalancing-example/loadbalancing-example", "self-hosting/deploy/troubleshooting/troubleshooting", ], }, From 28d70db6aed2b3c62aef99da367287f72642f367 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 31 Jul 2025 06:23:19 -0400 Subject: [PATCH 5/9] docs: document service ping configuration and data sent (#10167) # Which Problems Are Solved With the introduction of the service ping, we'll send data from all systems back to a central endpoint for analytics and getting insights about usage. To make it visible what data is sent and provide the users an easy way to opt-out, we need a small documentation to tell them what and how. # How the Problems Are Solved Document the service ping including what data is sent and how to opt-out or configure most important settings. # Additional Changes None # Additional Context relates to #9869 --- docs/docs/self-hosting/manage/service_ping.md | 83 +++++++++++++++++++ docs/sidebars.js | 1 + 2 files changed, 84 insertions(+) create mode 100644 docs/docs/self-hosting/manage/service_ping.md diff --git a/docs/docs/self-hosting/manage/service_ping.md b/docs/docs/self-hosting/manage/service_ping.md new file mode 100644 index 0000000000..65f16725ad --- /dev/null +++ b/docs/docs/self-hosting/manage/service_ping.md @@ -0,0 +1,83 @@ +--- +title: Service Ping +sidebar_label: Service Ping +--- + +Service Ping is a feature that periodically sends anonymized analytics and usage data from your ZITADEL system to a central endpoint. +This data helps improve ZITADEL by providing insights into its usage patterns. + +The feature is enabled by default, but can be disabled either completely or for specific reports. +Checkout the configuration options below. + +## Data Sent by Service Ping + +### Base Information + +If the feature is enabled, the base information will always be sent. To prevent that, you can opt out by disabling the entire Service Ping: + +```yaml +ServicePing: + Enabled: false # ZITADEL_SERVICEPING_ENABLED +``` + +The base information sent back includes the following: +- your systemID +- the currently run version of ZITADEL +- information on all instances + - id + - creation date + - domains + +### Resource Counts + +Resource counts is a report that provides us with information about the number of resources in your ZITADEL instances. + +The following resources are counted: +- Instances +- Organizations +- Projects per organization +- Users per organization +- Instance Administrators +- Identity Providers +- LDAP Identity Providers +- Actions (V1) +- Targets and set up executions +- Login Policies +- Password Complexity Policies +- Password Expiry Policies +- Lockout Policies + +The list might be extended in the future to include more resources. + +To disable this report, set the following in your configuration file: + +```yaml +ServicePing: + Telemetry: + ResourceCounts: + Enabled: false # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_ENABLED +``` + +## Configuration + +The Service Ping feature can be configured through the runtime configuration. Please check out the configuration file +for all available options. Below is a list of the most important options: + +### Interval + +This defines at which interval the Service Ping feature sends data to the central endpoint. It supports the extended cron syntax +and by default is set to `@daily`, which means it will send data every day. The time is randomized on startup to prevent +all systems from sending data at the same time. + +You can adjust it to your needs to make sure there is no performance impact on your system. +For example, if you already have some scheduled job syncing data in and out of ZITADEL around a specific time or have regularly a +lot of traffic during the day, you might want to change it to a different time, e.g. `15 4 * * *` to send it every day at 4:15 AM. + +The interval must be at least 30 minutes to prevent too frequent requests to the central endpoint. + +### MaxAttempts + +This defines how many attempts the Service Ping feature will make to send data to the central endpoint before giving up +for a specific interval and report. If one report fails, it will be retried up to this number of times. +Other reports will still be handled in parallel and have their own retry count. This means if the base information +only succeeded after three attempts, the resource count still has five attempts to be sent. diff --git a/docs/sidebars.js b/docs/sidebars.js index 105251f20c..acca7b1659 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -1117,6 +1117,7 @@ module.exports = { "self-hosting/manage/tls_modes", "self-hosting/manage/database/database", "self-hosting/manage/cache", + "self-hosting/manage/service_ping", "self-hosting/manage/updating_scaling", "self-hosting/manage/usage_control", { From 4046dd31b48476609b5c14067a5acec642b80b12 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:09:09 +0200 Subject: [PATCH 6/9] feat: actions v2 api GA (#10364) # Which Problems Are Solved The Actions v2beta API is not yet promoted to GA. # How the Problems Are Solved Promote Actions v2Beta API to Actions v2 API. # Additional Changes None # Additional Context None --- cmd/start/start.go | 4 + internal/api/grpc/action/v2/execution.go | 92 ++ .../integration_test/execution_target_test.go | 1310 +++++++++++++++++ .../v2/integration_test/execution_test.go | 565 +++++++ .../action/v2/integration_test/query_test.go | 784 ++++++++++ .../action/v2/integration_test/server_test.go | 23 + .../action/v2/integration_test/target_test.go | 549 +++++++ internal/api/grpc/action/v2/query.go | 404 +++++ internal/api/grpc/action/v2/server.go | 71 + internal/api/grpc/action/v2/target.go | 123 ++ internal/api/grpc/action/v2/target_test.go | 229 +++ .../integration_test/execution_target_test.go | 81 +- .../v2beta/integration_test/execution_test.go | 24 +- .../v2beta/integration_test/query_test.go | 26 +- .../v2beta/integration_test/target_test.go | 13 +- internal/api/grpc/action/v2beta/query.go | 18 +- internal/integration/client.go | 17 +- proto/zitadel/action/v2/action_service.proto | 728 +++++++++ proto/zitadel/action/v2/execution.proto | 135 ++ proto/zitadel/action/v2/query.proto | 108 ++ proto/zitadel/action/v2/target.proto | 75 + 21 files changed, 5314 insertions(+), 65 deletions(-) create mode 100644 internal/api/grpc/action/v2/execution.go create mode 100644 internal/api/grpc/action/v2/integration_test/execution_target_test.go create mode 100644 internal/api/grpc/action/v2/integration_test/execution_test.go create mode 100644 internal/api/grpc/action/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/action/v2/integration_test/server_test.go create mode 100644 internal/api/grpc/action/v2/integration_test/target_test.go create mode 100644 internal/api/grpc/action/v2/query.go create mode 100644 internal/api/grpc/action/v2/server.go create mode 100644 internal/api/grpc/action/v2/target.go create mode 100644 internal/api/grpc/action/v2/target_test.go create mode 100644 proto/zitadel/action/v2/action_service.proto create mode 100644 proto/zitadel/action/v2/execution.proto create mode 100644 proto/zitadel/action/v2/query.proto create mode 100644 proto/zitadel/action/v2/target.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index adbac7f822..045bc99d54 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -34,6 +34,7 @@ import ( "github.com/zitadel/zitadel/internal/api" "github.com/zitadel/zitadel/internal/api/assets" internal_authz "github.com/zitadel/zitadel/internal/api/authz" + action_v2 "github.com/zitadel/zitadel/internal/api/grpc/action/v2" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/admin" app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" @@ -509,6 +510,9 @@ func startAPIs( if err := apis.RegisterService(ctx, action_v2_beta.CreateServer(config.SystemDefaults, commands, queries, domain.AllActionFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, action_v2.CreateServer(config.SystemDefaults, commands, queries, domain.AllActionFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, project_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } diff --git a/internal/api/grpc/action/v2/execution.go b/internal/api/grpc/action/v2/execution.go new file mode 100644 index 0000000000..6941f62ace --- /dev/null +++ b/internal/api/grpc/action/v2/execution.go @@ -0,0 +1,92 @@ +package action + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/repository/execution" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +func (s *Server) SetExecution(ctx context.Context, req *connect.Request[action.SetExecutionRequest]) (*connect.Response[action.SetExecutionResponse], error) { + reqTargets := req.Msg.GetTargets() + targets := make([]*execution.Target, len(reqTargets)) + for i, target := range reqTargets { + targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeTarget, Target: target} + } + set := &command.SetExecution{ + Targets: targets, + } + var err error + var details *domain.ObjectDetails + instanceID := authz.GetInstance(ctx).InstanceID() + switch t := req.Msg.GetCondition().GetConditionType().(type) { + case *action.Condition_Request: + cond := executionConditionFromRequest(t.Request) + details, err = s.command.SetExecutionRequest(ctx, cond, set, instanceID) + case *action.Condition_Response: + cond := executionConditionFromResponse(t.Response) + details, err = s.command.SetExecutionResponse(ctx, cond, set, instanceID) + case *action.Condition_Event: + cond := executionConditionFromEvent(t.Event) + details, err = s.command.SetExecutionEvent(ctx, cond, set, instanceID) + case *action.Condition_Function: + details, err = s.command.SetExecutionFunction(ctx, command.ExecutionFunctionCondition(t.Function.GetName()), set, instanceID) + default: + err = zerrors.ThrowInvalidArgument(nil, "ACTION-5r5Ju", "Errors.Execution.ConditionInvalid") + } + if err != nil { + return nil, err + } + return connect.NewResponse(&action.SetExecutionResponse{ + SetDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) ListExecutionFunctions(ctx context.Context, _ *connect.Request[action.ListExecutionFunctionsRequest]) (*connect.Response[action.ListExecutionFunctionsResponse], error) { + return connect.NewResponse(&action.ListExecutionFunctionsResponse{ + Functions: s.ListActionFunctions(), + }), nil +} + +func (s *Server) ListExecutionMethods(ctx context.Context, _ *connect.Request[action.ListExecutionMethodsRequest]) (*connect.Response[action.ListExecutionMethodsResponse], error) { + return connect.NewResponse(&action.ListExecutionMethodsResponse{ + Methods: s.ListGRPCMethods(), + }), nil +} + +func (s *Server) ListExecutionServices(ctx context.Context, _ *connect.Request[action.ListExecutionServicesRequest]) (*connect.Response[action.ListExecutionServicesResponse], error) { + return connect.NewResponse(&action.ListExecutionServicesResponse{ + Services: s.ListGRPCServices(), + }), nil +} + +func executionConditionFromRequest(request *action.RequestExecution) *command.ExecutionAPICondition { + return &command.ExecutionAPICondition{ + Method: request.GetMethod(), + Service: request.GetService(), + All: request.GetAll(), + } +} + +func executionConditionFromResponse(response *action.ResponseExecution) *command.ExecutionAPICondition { + return &command.ExecutionAPICondition{ + Method: response.GetMethod(), + Service: response.GetService(), + All: response.GetAll(), + } +} + +func executionConditionFromEvent(event *action.EventExecution) *command.ExecutionEventCondition { + return &command.ExecutionEventCondition{ + Event: event.GetEvent(), + Group: event.GetGroup(), + All: event.GetAll(), + } +} diff --git a/internal/api/grpc/action/v2/integration_test/execution_target_test.go b/internal/api/grpc/action/v2/integration_test/execution_target_test.go new file mode 100644 index 0000000000..f5bd7c50ec --- /dev/null +++ b/internal/api/grpc/action/v2/integration_test/execution_target_test.go @@ -0,0 +1,1310 @@ +//go:build integration + +package action_test + +import ( + "context" + "encoding/base64" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" + oidc_api "github.com/zitadel/zitadel/internal/api/oidc" + saml_api "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" + "github.com/zitadel/zitadel/pkg/grpc/app" + "github.com/zitadel/zitadel/pkg/grpc/management" + "github.com/zitadel/zitadel/pkg/grpc/metadata" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +const ( + redirectURIImplicit = "http://localhost:9999/callback" +) + +var ( + loginV2 = &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}} +) + +func TestServer_ExecutionTarget(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + fullMethod := action.ActionService_GetTarget_FullMethodName + + tests := []struct { + name string + ctx context.Context + dep func(context.Context, *action.GetTargetRequest, *action.GetTargetResponse) (closeF func(), calledF func() bool) + clean func(context.Context) + req *action.GetTargetRequest + want *action.GetTargetResponse + wantErr bool + }{ + { + name: "GetTarget, request and response, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) (func(), func() bool) { + + orgID := instance.DefaultOrg.Id + projectID := "" + userID := instance.Users.Get(integration.UserTypeIAMOwner).ID + + // create target for target changes + targetCreatedName := gofakeit.Name() + targetCreatedURL := "https://nonexistent" + + targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, domain.TargetTypeCall, false) + + // request received by target + wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: middleware.Message{Message: request}} + changedRequest := &action.GetTargetRequest{Id: targetCreated.GetId()} + // replace original request with different targetID + urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusOK, changedRequest) + + targetRequest := waitForTarget(ctx, t, instance, urlRequest, domain.TargetTypeCall, false) + + waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) + + // expected response from the GetTarget + expectedResponse := &action.GetTargetResponse{ + Target: &action.Target{ + Id: targetCreated.GetId(), + CreationDate: targetCreated.GetCreationDate(), + ChangeDate: targetCreated.GetCreationDate(), + Name: targetCreatedName, + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + }, + } + + changedResponse := &action.GetTargetResponse{ + Target: &action.Target{ + Id: "changed", + CreationDate: targetCreated.GetCreationDate(), + ChangeDate: targetCreated.GetCreationDate(), + Name: targetCreatedName, + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + }, + } + // content for update + response.Target = &action.Target{ + Id: "changed", + CreationDate: targetCreated.GetCreationDate(), + ChangeDate: targetCreated.GetCreationDate(), + Name: targetCreatedName, + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + } + + // response received by target + wantResponse := &middleware.ContextInfoResponse{ + FullMethod: fullMethod, + InstanceID: instance.ID(), + OrgID: orgID, + ProjectID: projectID, + UserID: userID, + Request: middleware.Message{Message: changedRequest}, + Response: middleware.Message{Message: expectedResponse}, + } + // after request with different targetID, return changed response + targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusOK, changedResponse) + + targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, domain.TargetTypeCall, false) + waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) + return func() { + closeRequest() + closeResponse() + }, func() bool { + if calledRequest() != 1 { + return false + } + if calledResponse() != 1 { + return false + } + return true + } + }, + clean: func(ctx context.Context) { + instance.DeleteExecution(ctx, t, conditionRequestFullMethod(fullMethod)) + instance.DeleteExecution(ctx, t, conditionResponseFullMethod(fullMethod)) + }, + req: &action.GetTargetRequest{ + Id: "something", + }, + want: &action.GetTargetResponse{ + // defined in the dependency function + }, + }, + { + name: "GetTarget, request, interrupt", + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) (func(), func() bool) { + orgID := instance.DefaultOrg.Id + projectID := "" + userID := instance.Users.Get(integration.UserTypeIAMOwner).ID + + // request received by target + wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: middleware.Message{Message: request}} + urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusInternalServerError, nil) + + targetRequest := waitForTarget(ctx, t, instance, urlRequest, domain.TargetTypeCall, true) + waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) + // GetTarget with used target + request.Id = targetRequest.GetId() + return func() { + closeRequest() + }, func() bool { + return calledRequest() == 1 + } + }, + clean: func(ctx context.Context) { + instance.DeleteExecution(ctx, t, conditionRequestFullMethod(fullMethod)) + }, + req: &action.GetTargetRequest{}, + wantErr: true, + }, + { + name: "GetTarget, response, interrupt", + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) (func(), func() bool) { + orgID := instance.DefaultOrg.Id + projectID := "" + userID := instance.Users.Get(integration.UserTypeIAMOwner).ID + + // create target for target changes + targetCreatedName := gofakeit.Name() + targetCreatedURL := "https://nonexistent" + + targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, domain.TargetTypeCall, false) + + // GetTarget with used target + request.Id = targetCreated.GetId() + + // expected response from the GetTarget + expectedResponse := &action.GetTargetResponse{ + Target: &action.Target{ + Id: targetCreated.GetId(), + CreationDate: targetCreated.GetCreationDate(), + ChangeDate: targetCreated.GetCreationDate(), + Name: targetCreatedName, + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + }, + } + + // response received by target + wantResponse := &middleware.ContextInfoResponse{ + FullMethod: fullMethod, + InstanceID: instance.ID(), + OrgID: orgID, + ProjectID: projectID, + UserID: userID, + Request: middleware.Message{Message: request}, + Response: middleware.Message{Message: expectedResponse}, + } + // after request with different targetID, return changed response + targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusInternalServerError, nil) + + targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, domain.TargetTypeCall, true) + waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) + return func() { + closeResponse() + }, func() bool { + return calledResponse() == 1 + } + }, + clean: func(ctx context.Context) { + instance.DeleteExecution(ctx, t, conditionResponseFullMethod(fullMethod)) + }, + req: &action.GetTargetRequest{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeF, calledF := tt.dep(tt.ctx, tt.req, tt.want) + defer closeF() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV2.GetTarget(tt.ctx, tt.req) + if tt.wantErr { + require.Error(ttt, err) + return + } + require.NoError(ttt, err) + assert.EqualExportedValues(ttt, tt.want.GetTarget(), got.GetTarget()) + + }, retryDuration, tick, "timeout waiting for expected execution result") + + if tt.clean != nil { + tt.clean(tt.ctx) + } + require.True(t, calledF()) + }) + } +} + +func TestServer_ExecutionTarget_Event(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + event := "session.added" + urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 0, http.StatusOK, nil) + defer closeF() + + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) + + tests := []struct { + name string + ctx context.Context + eventCount int + expectedCalls int + clean func(context.Context) + wantErr bool + }{ + { + name: "event, 1 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 1, + expectedCalls: 1, + }, + { + name: "event, 5 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 5, + expectedCalls: 5, + }, + { + name: "event, 50 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 50, + expectedCalls: 50, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset the count of the target + resetF() + + for i := 0; i < tt.eventCount; i++ { + _, err := instance.Client.SessionV2.CreateSession(tt.ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + } + + // wait for called target + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + assert.True(ttt, calledF() == tt.expectedCalls) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + event := "session.added" + // call takes longer than timeout of target + urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 5*time.Second, http.StatusOK, nil) + defer closeF() + + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) + + tests := []struct { + name string + ctx context.Context + eventCount int + expectedCalls int + clean func(context.Context) + wantErr bool + }{ + { + name: "event, 1 session.added, error logs", + ctx: isolatedIAMOwnerCTX, + eventCount: 1, + expectedCalls: 1, + }, + { + name: "event, 5 session.added, error logs", + ctx: isolatedIAMOwnerCTX, + eventCount: 5, + expectedCalls: 5, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset the count of the target + resetF() + + for i := 0; i < tt.eventCount; i++ { + _, err := instance.Client.SessionV2.CreateSession(tt.ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + } + + // wait for called target + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + assert.True(ttt, calledF() == tt.expectedCalls) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + + event := "session.added" + urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 1*time.Second, http.StatusOK, nil) + defer closeF() + + targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) + + tests := []struct { + name string + ctx context.Context + eventCount int + expectedCalls int + clean func(context.Context) + wantErr bool + }{ + { + name: "event, 1 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 1, + expectedCalls: 1, + }, + { + name: "event, 5 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 5, + expectedCalls: 5, + }, + { + name: "event, 5 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 5, + expectedCalls: 5, + }, + { + name: "event, 20 session.added, ok", + ctx: isolatedIAMOwnerCTX, + eventCount: 20, + expectedCalls: 20, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset the count of the target + resetF() + + for i := 0; i < tt.eventCount; i++ { + _, err := instance.Client.SessionV2.CreateSession(tt.ctx, &session.CreateSessionRequest{}) + require.NoError(t, err) + } + + // wait for called target + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + assert.True(ttt, calledF() == tt.expectedCalls) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *integration.Instance, condition *action.Condition, targets []string) { + instance.SetExecution(ctx, t, condition, targets) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV2.ListExecutions(ctx, &action.ListExecutionsRequest{ + Filters: []*action.ExecutionSearchFilter{ + {Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{Conditions: []*action.Condition{condition}}, + }}, + }, + }) + if !assert.NoError(ttt, err) { + return + } + if !assert.Len(ttt, got.GetExecutions(), 1) { + return + } + gotTargets := got.GetExecutions()[0].GetTargets() + // always first check length, otherwise its failed anyway + if assert.Len(ttt, gotTargets, len(targets)) { + for i := range targets { + assert.EqualExportedValues(ttt, targets[i], gotTargets[i]) + } + } + }, retryDuration, tick, "timeout waiting for expected execution result") +} + +func waitForTarget(ctx context.Context, t *testing.T, instance *integration.Instance, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { + resp := instance.CreateTarget(ctx, t, "", endpoint, ty, interrupt) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV2.ListTargets(ctx, &action.ListTargetsRequest{ + Filters: []*action.TargetSearchFilter{ + {Filter: &action.TargetSearchFilter_InTargetIdsFilter{ + InTargetIdsFilter: &action.InTargetIDsFilter{TargetIds: []string{resp.GetId()}}, + }}, + }, + }) + if !assert.NoError(ttt, err) { + return + } + if !assert.Len(ttt, got.GetTargets(), 1) { + return + } + config := got.GetTargets()[0] + assert.Equal(ttt, config.GetEndpoint(), endpoint) + switch ty { + case domain.TargetTypeWebhook: + if !assert.NotNil(ttt, config.GetRestWebhook()) { + return + } + assert.Equal(ttt, interrupt, config.GetRestWebhook().GetInterruptOnError()) + case domain.TargetTypeAsync: + assert.NotNil(ttt, config.GetRestAsync()) + case domain.TargetTypeCall: + if !assert.NotNil(ttt, config.GetRestCall()) { + return + } + assert.Equal(ttt, interrupt, config.GetRestCall().GetInterruptOnError()) + } + }, retryDuration, tick, "timeout waiting for expected execution result") + return resp +} + +func conditionRequestFullMethod(fullMethod string) *action.Condition { + return &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: fullMethod, + }, + }, + }, + } +} + +func conditionResponseFullMethod(fullMethod string) *action.Condition { + return &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_Method{ + Method: fullMethod, + }, + }, + }, + } +} + +func conditionEvent(event string) *action.Condition { + return &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Event{ + Event: event, + }, + }, + }, + } +} + +func conditionFunction(function string) *action.Condition { + return &action.Condition{ + ConditionType: &action.Condition_Function{ + Function: &action.FunctionExecution{ + Name: function, + }, + }, + } +} + +func TestServer_ExecutionTargetPreUserinfo(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) + + client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) + require.NoError(t, err) + + type want struct { + addedClaims map[string]any + addedLogClaims map[string][]string + setUserMetadata []*metadata.Metadata + } + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) + req *oidc_pb.CreateCallbackRequest + want want + wantErr bool + }{ + { + name: "append claim", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + AppendClaims: []*oidc_api.AppendClaim{ + {Key: "added", Value: "value"}, + }, + } + return expectPreUserinfoExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedClaims: map[string]any{ + "added": "value", + }, + }, + wantErr: false, + }, + { + name: "append log claim", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + AppendLogClaims: []string{ + "addedLog", + }, + } + return expectPreUserinfoExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedLogClaims: map[string][]string{ + "urn:zitadel:iam:action:function/preuserinfo:log": {"addedLog"}, + }, + }, + wantErr: false, + }, + { + name: "set user metadata", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + SetUserMetadata: []*domain.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + } + return expectPreUserinfoExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + setUserMetadata: []*metadata.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + }, + wantErr: false, + }, + { + name: "full usage", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + SetUserMetadata: []*domain.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + AppendLogClaims: []string{ + "addedLog1", + "addedLog2", + "addedLog3", + }, + AppendClaims: []*oidc_api.AppendClaim{ + {Key: "added1", Value: "value1"}, + {Key: "added2", Value: "value2"}, + {Key: "added3", Value: "value3"}, + }, + } + return expectPreUserinfoExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedClaims: map[string]any{ + "added1": "value1", + "added2": "value2", + "added3": "value3", + }, + setUserMetadata: []*metadata.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + addedLogClaims: map[string][]string{ + "urn:zitadel:iam:action:function/preuserinfo:log": {"addedLog1", "addedLog2", "addedLog3"}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID, closeF := tt.dep(isolatedIAMCtx, t, tt.req) + defer closeF() + + got, err := instance.Client.OIDCv2.CreateCallback(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + callbackUrl, err := url.Parse(strings.Replace(got.GetCallbackUrl(), "#", "?", 1)) + require.NoError(t, err) + claims := getIDTokenClaimsFromCallbackURL(tt.ctx, t, instance, client.GetClientId(), callbackUrl) + + for k, v := range tt.want.addedClaims { + value, ok := claims[k] + if !assert.True(t, ok) { + return + } + assert.Equal(t, v, value) + } + for k, v := range tt.want.addedLogClaims { + value, ok := claims[k] + if !assert.True(t, ok) { + return + } + assert.ElementsMatch(t, v, value) + } + if len(tt.want.setUserMetadata) > 0 { + checkForSetMetadata(isolatedIAMCtx, t, instance, userID, tt.want.setUserMetadata) + } + }) + } +} + +func expectPreUserinfoExecution(ctx context.Context, t *testing.T, instance *integration.Instance, clientID string, req *oidc_pb.CreateCallbackRequest, response *oidc_api.ContextInfoResponse) (string, func()) { + userEmail := gofakeit.Email() + userPhone := "+41" + gofakeit.Phone() + userResp := instance.CreateHumanUserVerified(ctx, instance.DefaultOrg.Id, userEmail, userPhone) + + sessionResp := createSession(ctx, t, instance, userResp.GetUserId()) + req.CallbackKind = &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + } + expectedContextInfo := contextInfoForUserOIDC(instance, "function/preuserinfo", clientID, userResp, userEmail, userPhone) + + targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) + + targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preuserinfo"), []string{targetResp.GetId()}) + return userResp.GetUserId(), closeF +} + +func createSession(ctx context.Context, t *testing.T, instance *integration.Instance, userID string) *session.CreateSessionResponse { + sessionResp, err := instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + }, + }) + require.NoError(t, err) + return sessionResp +} + +func checkForSetMetadata(ctx context.Context, t *testing.T, instance *integration.Instance, userID string, metadataExpected []*metadata.Metadata) { + integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + metadataResp, err := instance.Client.Mgmt.ListUserMetadata(ctx, &management.ListUserMetadataRequest{Id: userID}) + if !assert.NoError(ct, err) { + return + } + for _, dataExpected := range metadataExpected { + found := false + for _, dataCheck := range metadataResp.GetResult() { + if dataExpected.Key == dataCheck.Key { + found = true + if !assert.Equal(ct, dataExpected.Value, dataCheck.Value) { + return + } + } + } + if !assert.True(ct, found) { + return + } + } + }, retryDuration, tick) +} + +func getIDTokenClaimsFromCallbackURL(ctx context.Context, t *testing.T, instance *integration.Instance, clientID string, callbackURL *url.URL) map[string]any { + accessToken := callbackURL.Query().Get("access_token") + idToken := callbackURL.Query().Get("id_token") + + provider, err := instance.CreateRelyingParty(ctx, clientID, redirectURIImplicit, oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone) + require.NoError(t, err) + claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier()) + require.NoError(t, err) + return claims.Claims +} + +type CustomAccessTokenClaims struct { + oidc.TokenClaims + Added1 string `json:"added1,omitempty"` + Added2 string `json:"added2,omitempty"` + Added3 string `json:"added3,omitempty"` + Log []string `json:"urn:zitadel:iam:action:function/preaccesstoken:log,omitempty"` +} + +func getAccessTokenClaims(ctx context.Context, t *testing.T, instance *integration.Instance, callbackURL *url.URL) *CustomAccessTokenClaims { + accessToken := callbackURL.Query().Get("access_token") + + verifier := op.NewAccessTokenVerifier(instance.OIDCIssuer(), rp.NewRemoteKeySet(http.DefaultClient, instance.OIDCIssuer()+"/oauth/v2/keys")) + + claims, err := op.VerifyAccessToken[*CustomAccessTokenClaims](ctx, accessToken, verifier) + require.NoError(t, err) + return claims +} + +func contextInfoForUserOIDC(instance *integration.Instance, function string, clientID string, userResp *user.AddHumanUserResponse, email, phone string) *oidc_api.ContextInfo { + return &oidc_api.ContextInfo{ + Function: function, + UserInfo: &oidc.UserInfo{ + Subject: userResp.GetUserId(), + }, + User: &query.User{ + ID: userResp.GetUserId(), + CreationDate: userResp.Details.ChangeDate.AsTime(), + ChangeDate: userResp.Details.ChangeDate.AsTime(), + ResourceOwner: instance.DefaultOrg.GetId(), + Sequence: userResp.Details.Sequence, + State: 1, + Username: email, + PreferredLoginName: email, + Human: &query.Human{ + FirstName: "Mickey", + LastName: "Mouse", + NickName: "Mickey", + DisplayName: "Mickey Mouse", + AvatarKey: "", + PreferredLanguage: language.Dutch, + Gender: 2, + Email: domain.EmailAddress(email), + IsEmailVerified: true, + Phone: domain.PhoneNumber(phone), + IsPhoneVerified: true, + PasswordChangeRequired: false, + PasswordChanged: time.Time{}, + MFAInitSkipped: time.Time{}, + }, + }, + UserMetadata: nil, + Application: &oidc_api.ContextInfoApplication{ + ClientID: clientID, + }, + Org: &query.UserInfoOrg{ + ID: instance.DefaultOrg.GetId(), + Name: instance.DefaultOrg.GetName(), + PrimaryDomain: instance.DefaultOrg.GetPrimaryDomain(), + }, + UserGrants: nil, + Response: nil, + } +} + +func TestServer_ExecutionTargetPreAccessToken(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) + + client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) + require.NoError(t, err) + + type want struct { + addedClaims *CustomAccessTokenClaims + addedLogClaims map[string][]string + setUserMetadata []*metadata.Metadata + } + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) + req *oidc_pb.CreateCallbackRequest + want want + wantErr bool + }{ + { + name: "append claim", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + AppendClaims: []*oidc_api.AppendClaim{ + {Key: "added1", Value: "value"}, + }, + } + return expectPreAccessTokenExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedClaims: &CustomAccessTokenClaims{ + Added1: "value", + }, + }, + wantErr: false, + }, + { + name: "append log claim", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + AppendLogClaims: []string{ + "addedLog", + }, + } + return expectPreAccessTokenExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedClaims: &CustomAccessTokenClaims{ + Log: []string{"addedLog"}, + }, + }, + wantErr: false, + }, + { + name: "set user metadata", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + SetUserMetadata: []*domain.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + } + return expectPreAccessTokenExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + setUserMetadata: []*metadata.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + }, + wantErr: false, + }, + { + name: "full usage", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *oidc_pb.CreateCallbackRequest) (string, func()) { + response := &oidc_api.ContextInfoResponse{ + SetUserMetadata: []*domain.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + AppendLogClaims: []string{ + "addedLog1", + "addedLog2", + "addedLog3", + }, + AppendClaims: []*oidc_api.AppendClaim{ + {Key: "added1", Value: "value1"}, + {Key: "added2", Value: "value2"}, + {Key: "added3", Value: "value3"}, + }, + } + return expectPreAccessTokenExecution(ctx, t, instance, client.GetClientId(), req, response) + }, + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(isolatedIAMCtx, client.GetClientId(), redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + }, + want: want{ + addedClaims: &CustomAccessTokenClaims{ + Added1: "value1", + Added2: "value2", + Added3: "value3", + Log: []string{"addedLog1", "addedLog2", "addedLog3"}, + }, + setUserMetadata: []*metadata.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID, closeF := tt.dep(isolatedIAMCtx, t, tt.req) + defer closeF() + + got, err := instance.Client.OIDCv2.CreateCallback(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + callbackUrl, err := url.Parse(strings.Replace(got.GetCallbackUrl(), "#", "?", 1)) + require.NoError(t, err) + claims := getAccessTokenClaims(tt.ctx, t, instance, callbackUrl) + + if tt.want.addedClaims != nil { + assert.Equal(t, tt.want.addedClaims.Added1, claims.Added1) + assert.Equal(t, tt.want.addedClaims.Added2, claims.Added2) + assert.Equal(t, tt.want.addedClaims.Added3, claims.Added3) + assert.Equal(t, tt.want.addedClaims.Log, claims.Log) + } + if len(tt.want.setUserMetadata) > 0 { + checkForSetMetadata(isolatedIAMCtx, t, instance, userID, tt.want.setUserMetadata) + } + + }) + } +} + +func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance *integration.Instance, clientID string, req *oidc_pb.CreateCallbackRequest, response *oidc_api.ContextInfoResponse) (string, func()) { + userEmail := gofakeit.Email() + userPhone := "+41" + gofakeit.Phone() + userResp := instance.CreateHumanUserVerified(ctx, instance.DefaultOrg.Id, userEmail, userPhone) + + sessionResp := createSession(ctx, t, instance, userResp.GetUserId()) + req.CallbackKind = &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + } + expectedContextInfo := contextInfoForUserOIDC(instance, "function/preaccesstoken", clientID, userResp, userEmail, userPhone) + + targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) + + targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preaccesstoken"), []string{targetResp.GetId()}) + return userResp.GetUserId(), closeF +} + +func TestServer_ExecutionTargetPreSAMLResponse(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) + + idpMetadata, err := instance.GetSAMLIDPMetadata() + require.NoError(t, err) + + acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] + _, _, spMiddlewarePost := createSAMLApplication(isolatedIAMCtx, t, instance, idpMetadata, saml.HTTPPostBinding, false, false) + + type want struct { + addedAttributes map[string][]saml.AttributeValue + setUserMetadata []*metadata.Metadata + } + tests := []struct { + name string + ctx context.Context + dep func(ctx context.Context, t *testing.T, req *saml_pb.CreateResponseRequest) (string, func()) + req *saml_pb.CreateResponseRequest + want want + wantErr bool + }{ + { + name: "append attribute", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *saml_pb.CreateResponseRequest) (string, func()) { + response := &saml_api.ContextInfoResponse{ + AppendAttribute: []*saml_api.AppendAttribute{ + {Name: "added", NameFormat: "format", Value: []string{"value"}}, + }, + } + return expectPreSAMLResponseExecution(ctx, t, instance, req, response) + }, + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + _, samlRequestID, err := instance.CreateSAMLAuthRequest(spMiddlewarePost, instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return samlRequestID + }(), + }, + want: want{ + addedAttributes: map[string][]saml.AttributeValue{ + "added": {saml.AttributeValue{Value: "value"}}, + }, + }, + wantErr: false, + }, + { + name: "set user metadata", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *saml_pb.CreateResponseRequest) (string, func()) { + response := &saml_api.ContextInfoResponse{ + SetUserMetadata: []*domain.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + } + return expectPreSAMLResponseExecution(ctx, t, instance, req, response) + }, + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + _, samlRequestID, err := instance.CreateSAMLAuthRequest(spMiddlewarePost, instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return samlRequestID + }(), + }, + want: want{ + setUserMetadata: []*metadata.Metadata{ + {Key: "key", Value: []byte("value")}, + }, + }, + wantErr: false, + }, + { + name: "set user metadata", + ctx: ctxLoginClient, + dep: func(ctx context.Context, t *testing.T, req *saml_pb.CreateResponseRequest) (string, func()) { + response := &saml_api.ContextInfoResponse{ + AppendAttribute: []*saml_api.AppendAttribute{ + {Name: "added1", NameFormat: "format", Value: []string{"value1"}}, + {Name: "added2", NameFormat: "format", Value: []string{"value2"}}, + {Name: "added3", NameFormat: "format", Value: []string{"value3"}}, + }, + SetUserMetadata: []*domain.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + } + return expectPreSAMLResponseExecution(ctx, t, instance, req, response) + }, + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + _, samlRequestID, err := instance.CreateSAMLAuthRequest(spMiddlewarePost, instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return samlRequestID + }(), + }, + want: want{ + addedAttributes: map[string][]saml.AttributeValue{ + "added1": {saml.AttributeValue{Value: "value1"}}, + "added2": {saml.AttributeValue{Value: "value2"}}, + "added3": {saml.AttributeValue{Value: "value3"}}, + }, + setUserMetadata: []*metadata.Metadata{ + {Key: "key1", Value: []byte("value1")}, + {Key: "key2", Value: []byte("value2")}, + {Key: "key3", Value: []byte("value3")}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID, closeF := tt.dep(isolatedIAMCtx, t, tt.req) + defer closeF() + + got, err := instance.Client.SAMLv2.CreateResponse(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + attributes := getSAMLResponseAttributes(t, got.GetPost().GetSamlResponse(), spMiddlewarePost) + for k, v := range tt.want.addedAttributes { + found := false + for _, attribute := range attributes { + if attribute.Name == k { + found = true + assert.Equal(t, v, attribute.Values) + } + } + if !assert.True(t, found) { + return + } + } + if len(tt.want.setUserMetadata) > 0 { + checkForSetMetadata(isolatedIAMCtx, t, instance, userID, tt.want.setUserMetadata) + } + }) + } +} + +func expectPreSAMLResponseExecution(ctx context.Context, t *testing.T, instance *integration.Instance, req *saml_pb.CreateResponseRequest, response *saml_api.ContextInfoResponse) (string, func()) { + userEmail := gofakeit.Email() + userPhone := "+41" + gofakeit.Phone() + userResp := instance.CreateHumanUserVerified(ctx, instance.DefaultOrg.Id, userEmail, userPhone) + + sessionResp := createSession(ctx, t, instance, userResp.GetUserId()) + req.ResponseKind = &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + } + expectedContextInfo := contextInfoForUserSAML(instance, "function/presamlresponse", userResp, userEmail, userPhone) + + targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) + + targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("presamlresponse"), []string{targetResp.GetId()}) + + return userResp.GetUserId(), closeF +} + +func createSAMLSP(t *testing.T, idpMetadata *saml.EntityDescriptor, binding string) (string, *samlsp.Middleware) { + rootURL := "example." + gofakeit.DomainName() + spMiddleware, err := integration.CreateSAMLSP("https://"+rootURL, idpMetadata, binding) + require.NoError(t, err) + return rootURL, spMiddleware +} + +func createSAMLApplication(ctx context.Context, t *testing.T, instance *integration.Instance, idpMetadata *saml.EntityDescriptor, binding string, projectRoleCheck, hasProjectCheck bool) (string, string, *samlsp.Middleware) { + project := instance.CreateProject(ctx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), projectRoleCheck, hasProjectCheck) + rootURL, sp := createSAMLSP(t, idpMetadata, binding) + _, err := instance.CreateSAMLClient(ctx, project.GetId(), sp) + require.NoError(t, err) + return project.GetId(), rootURL, sp +} + +func getSAMLResponseAttributes(t *testing.T, samlResponse string, sp *samlsp.Middleware) []saml.Attribute { + data, err := base64.StdEncoding.DecodeString(samlResponse) + require.NoError(t, err) + sp.ServiceProvider.AllowIDPInitiated = true + assertion, err := sp.ServiceProvider.ParseXMLResponse(data, []string{}) + require.NoError(t, err) + return assertion.AttributeStatements[0].Attributes +} + +func contextInfoForUserSAML(instance *integration.Instance, function string, userResp *user.AddHumanUserResponse, email, phone string) *saml_api.ContextInfo { + return &saml_api.ContextInfo{ + Function: function, + User: &query.User{ + ID: userResp.GetUserId(), + CreationDate: userResp.Details.ChangeDate.AsTime(), + ChangeDate: userResp.Details.ChangeDate.AsTime(), + ResourceOwner: instance.DefaultOrg.GetId(), + Sequence: userResp.Details.Sequence, + State: 1, + Type: domain.UserTypeHuman, + Username: email, + PreferredLoginName: email, + LoginNames: []string{email}, + Human: &query.Human{ + FirstName: "Mickey", + LastName: "Mouse", + NickName: "Mickey", + DisplayName: "Mickey Mouse", + AvatarKey: "", + PreferredLanguage: language.Dutch, + Gender: 2, + Email: domain.EmailAddress(email), + IsEmailVerified: true, + Phone: domain.PhoneNumber(phone), + IsPhoneVerified: true, + PasswordChangeRequired: false, + PasswordChanged: time.Time{}, + MFAInitSkipped: time.Time{}, + }, + }, + UserGrants: nil, + Response: nil, + } +} diff --git a/internal/api/grpc/action/v2/integration_test/execution_test.go b/internal/api/grpc/action/v2/integration_test/execution_test.go new file mode 100644 index 0000000000..da31fe5a0b --- /dev/null +++ b/internal/api/grpc/action/v2/integration_test/execution_test.go @@ -0,0 +1,565 @@ +//go:build integration + +package action_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +func TestServer_SetExecution_Request(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) + + tests := []struct { + name string + ctx context.Context + req *action.SetExecutionRequest + wantSetDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_All{All: true}, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no condition, error", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "method, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.NotExistingService/List", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "method, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/ListSessions", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "service, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Service{ + Service: "NotExistingService", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "service, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Service{ + Service: "zitadel.session.v2.SessionService", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "all, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_All{ + All: true, + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We want to have the same response no matter how often we call the function + creationDate := time.Now().UTC() + got, err := instance.Client.ActionV2.SetExecution(tt.ctx, tt.req) + setDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) + + // cleanup to not impact other requests + instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + }) + } +} + +func assertSetExecutionResponse(t *testing.T, creationDate, setDate time.Time, expectedSetDate bool, actualResp *action.SetExecutionResponse) { + if expectedSetDate { + if !setDate.IsZero() { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, setDate) + } else { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.SetDate) + } +} + +func TestServer_SetExecution_Response(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) + + tests := []struct { + name string + ctx context.Context + req *action.SetExecutionRequest + wantSetDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_All{All: true}, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no condition, error", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "method, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_Method{ + Method: "/zitadel.session.v2.NotExistingService/List", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "method, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_Method{ + Method: "/zitadel.session.v2.SessionService/ListSessions", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "service, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_Service{ + Service: "NotExistingService", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "service, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_Service{ + Service: "zitadel.session.v2.SessionService", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "all, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_All{ + All: true, + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.ActionV2.SetExecution(tt.ctx, tt.req) + setDate := time.Now().UTC() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) + + // cleanup to not impact other requests + instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + }) + } +} + +func TestServer_SetExecution_Event(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) + + tests := []struct { + name string + ctx context.Context + req *action.SetExecutionRequest + wantSetDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_All{ + All: true, + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no condition, error", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "event, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Event{ + Event: "user.human.notexisting", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "event, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Event{ + Event: "user.human.added", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "group, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Group{ + Group: "user.notexisting", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "group, level 1, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Group{ + Group: "user", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "group, level 2, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_Group{ + Group: "user.human", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + { + name: "all, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Event{ + Event: &action.EventExecution{ + Condition: &action.EventExecution_All{ + All: true, + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.ActionV2.SetExecution(tt.ctx, tt.req) + setDate := time.Now().UTC() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) + + // cleanup to not impact other requests + instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + }) + } +} + +func TestServer_SetExecution_Function(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) + + tests := []struct { + name string + ctx context.Context + req *action.SetExecutionRequest + wantSetDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{ + Condition: &action.ResponseExecution_All{All: true}, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no condition, error", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Response{ + Response: &action.ResponseExecution{}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "function, not existing", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Function{ + Function: &action.FunctionExecution{Name: "xxx"}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantErr: true, + }, + { + name: "function, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.SetExecutionRequest{ + Condition: &action.Condition{ + ConditionType: &action.Condition_Function{ + Function: &action.FunctionExecution{Name: "presamlresponse"}, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + wantSetDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.ActionV2.SetExecution(tt.ctx, tt.req) + setDate := time.Now().UTC() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) + + // cleanup to not impact other requests + instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + }) + } +} diff --git a/internal/api/grpc/action/v2/integration_test/query_test.go b/internal/api/grpc/action/v2/integration_test/query_test.go new file mode 100644 index 0000000000..2a93a4ad4b --- /dev/null +++ b/internal/api/grpc/action/v2/integration_test/query_test.go @@ -0,0 +1,784 @@ +//go:build integration + +package action_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestServer_GetTarget(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + dep func(context.Context, *action.GetTargetRequest, *action.GetTargetResponse) error + req *action.GetTargetRequest + } + tests := []struct { + name string + args args + want *action.GetTargetResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.GetTargetRequest{}, + }, + wantErr: true, + }, + { + name: "not found", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.GetTargetRequest{Id: "notexisting"}, + }, + wantErr: true, + }, + { + name: "get, ok", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeWebhook, false) + request.Id = resp.GetId() + response.Target.Id = resp.GetId() + response.Target.Name = name + response.Target.CreationDate = resp.GetCreationDate() + response.Target.ChangeDate = resp.GetCreationDate() + response.Target.SigningKey = resp.GetSigningKey() + return nil + }, + req: &action.GetTargetRequest{}, + }, + want: &action.GetTargetResponse{ + Target: &action.Target{ + Endpoint: "https://example.com", + TargetType: &action.Target_RestWebhook{ + RestWebhook: &action.RESTWebhook{}, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + { + name: "get, async, ok", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeAsync, false) + request.Id = resp.GetId() + response.Target.Id = resp.GetId() + response.Target.Name = name + response.Target.CreationDate = resp.GetCreationDate() + response.Target.ChangeDate = resp.GetCreationDate() + response.Target.SigningKey = resp.GetSigningKey() + return nil + }, + req: &action.GetTargetRequest{}, + }, + want: &action.GetTargetResponse{ + Target: &action.Target{ + Endpoint: "https://example.com", + TargetType: &action.Target_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + { + name: "get, webhook interruptOnError, ok", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeWebhook, true) + request.Id = resp.GetId() + response.Target.Id = resp.GetId() + response.Target.Name = name + response.Target.CreationDate = resp.GetCreationDate() + response.Target.ChangeDate = resp.GetCreationDate() + response.Target.SigningKey = resp.GetSigningKey() + return nil + }, + req: &action.GetTargetRequest{}, + }, + want: &action.GetTargetResponse{ + Target: &action.Target{ + Endpoint: "https://example.com", + TargetType: &action.Target_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + { + name: "get, call, ok", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeCall, false) + request.Id = resp.GetId() + response.Target.Id = resp.GetId() + response.Target.Name = name + response.Target.CreationDate = resp.GetCreationDate() + response.Target.ChangeDate = resp.GetCreationDate() + response.Target.SigningKey = resp.GetSigningKey() + return nil + }, + req: &action.GetTargetRequest{}, + }, + want: &action.GetTargetResponse{ + Target: &action.Target{ + Endpoint: "https://example.com", + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + { + name: "get, call interruptOnError, ok", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.GetTargetRequest, response *action.GetTargetResponse) error { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeCall, true) + request.Id = resp.GetId() + response.Target.Id = resp.GetId() + response.Target.Name = name + response.Target.CreationDate = resp.GetCreationDate() + response.Target.ChangeDate = resp.GetCreationDate() + response.Target.SigningKey = resp.GetSigningKey() + return nil + }, + req: &action.GetTargetRequest{}, + }, + want: &action.GetTargetResponse{ + Target: &action.Target{ + Endpoint: "https://example.com", + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + err := tt.args.dep(tt.args.ctx, tt.args.req, tt.want) + require.NoError(t, err) + } + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(isolatedIAMOwnerCTX, 2*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV2.GetTarget(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err, "Error: "+err.Error()) + return + } + assert.NoError(ttt, err) + assert.EqualExportedValues(ttt, tt.want, got) + }, retryDuration, tick, "timeout waiting for expected target Executions") + }) + } +} + +func TestServer_ListTargets(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + dep func(context.Context, *action.ListTargetsRequest, *action.ListTargetsResponse) + req *action.ListTargetsRequest + } + tests := []struct { + name string + args args + want *action.ListTargetsResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.ListTargetsRequest{}, + }, + wantErr: true, + }, + { + name: "list, not found", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.ListTargetsRequest{ + Filters: []*action.TargetSearchFilter{ + {Filter: &action.TargetSearchFilter_InTargetIdsFilter{ + InTargetIdsFilter: &action.InTargetIDsFilter{ + TargetIds: []string{"notfound"}, + }, + }, + }, + }, + }, + }, + want: &action.ListTargetsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Targets: []*action.Target{}, + }, + }, + { + name: "list single id", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeWebhook, false) + request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ + InTargetIdsFilter: &action.InTargetIDsFilter{ + TargetIds: []string{resp.GetId()}, + }, + } + + response.Targets[0].Id = resp.GetId() + response.Targets[0].Name = name + response.Targets[0].CreationDate = resp.GetCreationDate() + response.Targets[0].ChangeDate = resp.GetCreationDate() + response.Targets[0].SigningKey = resp.GetSigningKey() + }, + req: &action.ListTargetsRequest{ + Filters: []*action.TargetSearchFilter{{}}, + }, + }, + want: &action.ListTargetsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Targets: []*action.Target{ + { + Endpoint: "https://example.com", + TargetType: &action.Target_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + }, { + name: "list single name", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { + name := gofakeit.Name() + resp := instance.CreateTarget(ctx, t, name, "https://example.com", domain.TargetTypeWebhook, false) + request.Filters[0].Filter = &action.TargetSearchFilter_TargetNameFilter{ + TargetNameFilter: &action.TargetNameFilter{ + TargetName: name, + }, + } + + response.Targets[0].Id = resp.GetId() + response.Targets[0].Name = name + response.Targets[0].CreationDate = resp.GetCreationDate() + response.Targets[0].ChangeDate = resp.GetCreationDate() + response.Targets[0].SigningKey = resp.GetSigningKey() + }, + req: &action.ListTargetsRequest{ + Filters: []*action.TargetSearchFilter{{}}, + }, + }, + want: &action.ListTargetsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Targets: []*action.Target{ + { + Endpoint: "https://example.com", + TargetType: &action.Target_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListTargetsRequest, response *action.ListTargetsResponse) { + name1 := gofakeit.Name() + name2 := gofakeit.Name() + name3 := gofakeit.Name() + resp1 := instance.CreateTarget(ctx, t, name1, "https://example.com", domain.TargetTypeWebhook, false) + resp2 := instance.CreateTarget(ctx, t, name2, "https://example.com", domain.TargetTypeCall, true) + resp3 := instance.CreateTarget(ctx, t, name3, "https://example.com", domain.TargetTypeAsync, false) + request.Filters[0].Filter = &action.TargetSearchFilter_InTargetIdsFilter{ + InTargetIdsFilter: &action.InTargetIDsFilter{ + TargetIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + }, + } + + response.Targets[2].Id = resp1.GetId() + response.Targets[2].Name = name1 + response.Targets[2].CreationDate = resp1.GetCreationDate() + response.Targets[2].ChangeDate = resp1.GetCreationDate() + response.Targets[2].SigningKey = resp1.GetSigningKey() + + response.Targets[1].Id = resp2.GetId() + response.Targets[1].Name = name2 + response.Targets[1].CreationDate = resp2.GetCreationDate() + response.Targets[1].ChangeDate = resp2.GetCreationDate() + response.Targets[1].SigningKey = resp2.GetSigningKey() + + response.Targets[0].Id = resp3.GetId() + response.Targets[0].Name = name3 + response.Targets[0].CreationDate = resp3.GetCreationDate() + response.Targets[0].ChangeDate = resp3.GetCreationDate() + response.Targets[0].SigningKey = resp3.GetSigningKey() + }, + req: &action.ListTargetsRequest{ + Filters: []*action.TargetSearchFilter{{}}, + }, + }, + want: &action.ListTargetsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Targets: []*action.Target{ + { + Endpoint: "https://example.com", + TargetType: &action.Target_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(5 * time.Second), + }, + { + Endpoint: "https://example.com", + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + { + Endpoint: "https://example.com", + TargetType: &action.Target_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.ctx, tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(isolatedIAMOwnerCTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.ActionV2.ListTargets(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Targets, len(tt.want.Targets)) { + for i := range tt.want.Targets { + assert.EqualExportedValues(ttt, tt.want.Targets[i], got.Targets[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution Executions") + }) + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} + +func TestServer_ListExecutions(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false) + + type args struct { + ctx context.Context + dep func(context.Context, *action.ListExecutionsRequest, *action.ListExecutionsResponse) + req *action.ListExecutionsRequest + } + tests := []struct { + name string + args args + want *action.ListExecutionsResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.ListExecutionsRequest{}, + }, + wantErr: true, + }, + { + name: "list request single condition", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + cond := request.Filters[0].GetInConditionsFilter().GetConditions()[0] + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + + // Set expected response with used values for SetExecution + response.Executions[0].CreationDate = resp.GetSetDate() + response.Executions[0].ChangeDate = resp.GetSetDate() + response.Executions[0].Condition = cond + }, + req: &action.ListExecutionsRequest{ + Filters: []*action.ExecutionSearchFilter{{ + Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{ + Conditions: []*action.Condition{{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/GetSession", + }, + }, + }, + }}, + }, + }, + }}, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Executions: []*action.Execution{ + { + Condition: &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/GetSession", + }, + }, + }, + }, + Targets: []string{targetResp.GetId()}, + }, + }, + }, + }, + { + name: "list request single target", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + target := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false) + // add target as Filter to the request + request.Filters[0] = &action.ExecutionSearchFilter{ + Filter: &action.ExecutionSearchFilter_TargetFilter{ + TargetFilter: &action.TargetFilter{ + TargetId: target.GetId(), + }, + }, + } + cond := &action.Condition{ + ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.management.v1.ManagementService/UpdateAction", + }, + }, + }, + } + resp := instance.SetExecution(ctx, t, cond, []string{target.GetId()}) + + response.Executions[0].CreationDate = resp.GetSetDate() + response.Executions[0].ChangeDate = resp.GetSetDate() + response.Executions[0].Condition = cond + response.Executions[0].Targets = []string{target.GetId()} + }, + req: &action.ListExecutionsRequest{ + Filters: []*action.ExecutionSearchFilter{{}}, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Executions: []*action.Execution{ + { + Condition: &action.Condition{}, + Targets: []string{""}, + }, + }, + }, + }, + { + name: "list multiple conditions", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + + request.Filters[0] = &action.ExecutionSearchFilter{ + Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{ + Conditions: []*action.Condition{ + {ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/GetSession", + }, + }, + }}, + {ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/CreateSession", + }, + }, + }}, + {ConditionType: &action.Condition_Request{ + Request: &action.RequestExecution{ + Condition: &action.RequestExecution_Method{ + Method: "/zitadel.session.v2.SessionService/SetSession", + }, + }, + }}, + }, + }, + }, + } + + cond1 := request.Filters[0].GetInConditionsFilter().GetConditions()[0] + resp1 := instance.SetExecution(ctx, t, cond1, []string{targetResp.GetId()}) + response.Executions[2] = &action.Execution{ + CreationDate: resp1.GetSetDate(), + ChangeDate: resp1.GetSetDate(), + Condition: cond1, + Targets: []string{targetResp.GetId()}, + } + + cond2 := request.Filters[0].GetInConditionsFilter().GetConditions()[1] + resp2 := instance.SetExecution(ctx, t, cond2, []string{targetResp.GetId()}) + response.Executions[1] = &action.Execution{ + CreationDate: resp2.GetSetDate(), + ChangeDate: resp2.GetSetDate(), + Condition: cond2, + Targets: []string{targetResp.GetId()}, + } + + cond3 := request.Filters[0].GetInConditionsFilter().GetConditions()[2] + resp3 := instance.SetExecution(ctx, t, cond3, []string{targetResp.GetId()}) + response.Executions[0] = &action.Execution{ + CreationDate: resp3.GetSetDate(), + ChangeDate: resp3.GetSetDate(), + Condition: cond3, + Targets: []string{targetResp.GetId()}, + } + }, + req: &action.ListExecutionsRequest{ + Filters: []*action.ExecutionSearchFilter{ + {}, + }, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Executions: []*action.Execution{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple conditions all types", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + conditions := request.Filters[0].GetInConditionsFilter().GetConditions() + for i, cond := range conditions { + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + response.Executions[(len(conditions)-1)-i] = &action.Execution{ + CreationDate: resp.GetSetDate(), + ChangeDate: resp.GetSetDate(), + Condition: cond, + Targets: []string{targetResp.GetId()}, + } + } + }, + req: &action.ListExecutionsRequest{ + Filters: []*action.ExecutionSearchFilter{{ + Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{ + Conditions: []*action.Condition{ + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: "user.added"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: "user"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Function{Function: &action.FunctionExecution{Name: "presamlresponse"}}}, + }, + }, + }, + }}, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 10, + AppliedLimit: 100, + }, + Executions: []*action.Execution{ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + }, + }, + }, + { + name: "list multiple conditions all types, sort id", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + conditions := request.Filters[0].GetInConditionsFilter().GetConditions() + for i, cond := range conditions { + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + response.Executions[i] = &action.Execution{ + CreationDate: resp.GetSetDate(), + ChangeDate: resp.GetSetDate(), + Condition: cond, + Targets: []string{targetResp.GetId()}, + } + } + }, + req: &action.ListExecutionsRequest{ + SortingColumn: gu.Ptr(action.ExecutionFieldName_EXECUTION_FIELD_NAME_ID), + Filters: []*action.ExecutionSearchFilter{{ + Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{ + Conditions: []*action.Condition{ + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Function{Function: &action.FunctionExecution{Name: "presamlresponse"}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: "user.added"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: "user"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_All{All: true}}}}, + }, + }, + }, + }}, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 10, + AppliedLimit: 100, + }, + Executions: []*action.Execution{ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.ctx, tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(isolatedIAMOwnerCTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.ActionV2.ListExecutions(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Executions, len(tt.want.Executions)) { + assert.EqualExportedValues(ttt, got.Executions, tt.want.Executions) + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution Executions") + }) + } +} diff --git a/internal/api/grpc/action/v2/integration_test/server_test.go b/internal/api/grpc/action/v2/integration_test/server_test.go new file mode 100644 index 0000000000..07ee051c63 --- /dev/null +++ b/internal/api/grpc/action/v2/integration_test/server_test.go @@ -0,0 +1,23 @@ +//go:build integration + +package action_test + +import ( + "context" + "os" + "testing" + "time" +) + +var ( + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + return m.Run() + }()) +} diff --git a/internal/api/grpc/action/v2/integration_test/target_test.go b/internal/api/grpc/action/v2/integration_test/target_test.go new file mode 100644 index 0000000000..5908a9d56e --- /dev/null +++ b/internal/api/grpc/action/v2/integration_test/target_test.go @@ -0,0 +1,549 @@ +//go:build integration + +package action_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +func TestServer_CreateTarget(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type want struct { + id bool + creationDate bool + signingKey bool + } + alreadyExistingTargetName := gofakeit.AppName() + instance.CreateTarget(isolatedIAMOwnerCTX, t, alreadyExistingTargetName, "https://example.com", domain.TargetTypeAsync, false) + tests := []struct { + name string + ctx context.Context + req *action.CreateTargetRequest + want + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + }, + wantErr: true, + }, + { + name: "empty name", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: "", + }, + wantErr: true, + }, + { + name: "empty type", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + TargetType: nil, + }, + wantErr: true, + }, + { + name: "empty webhook url", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + TargetType: &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{}, + }, + }, + wantErr: true, + }, + { + name: "empty request response url", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + TargetType: &action.CreateTargetRequest_RestCall{ + RestCall: &action.RESTCall{}, + }, + }, + wantErr: true, + }, + { + name: "empty timeout", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{}, + }, + Timeout: nil, + }, + wantErr: true, + }, + { + name: "async, already existing, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: alreadyExistingTargetName, + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(10 * time.Second), + }, + wantErr: true, + }, + { + name: "async, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(10 * time.Second), + }, + want: want{ + id: true, + creationDate: true, + signingKey: true, + }, + }, + { + name: "webhook, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }, + want: want{ + id: true, + creationDate: true, + signingKey: true, + }, + }, + { + name: "webhook, interrupt on error, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }, + want: want{ + id: true, + creationDate: true, + signingKey: true, + }, + }, + { + name: "call, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }, + want: want{ + id: true, + creationDate: true, + signingKey: true, + }, + }, + + { + name: "call, interruptOnError, ok", + ctx: isolatedIAMOwnerCTX, + req: &action.CreateTargetRequest{ + Name: gofakeit.Name(), + Endpoint: "https://example.com", + TargetType: &action.CreateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }, + want: want{ + id: true, + creationDate: true, + signingKey: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.ActionV2.CreateTarget(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateTargetResponse(t, creationDate, changeDate, tt.want.creationDate, tt.want.id, tt.want.signingKey, got) + }) + } +} + +func assertCreateTargetResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate, expectedID, expectedSigningKey bool, actualResp *action.CreateTargetResponse) { + if expectedCreationDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.CreationDate) + } + + if expectedID { + assert.NotEmpty(t, actualResp.GetId()) + } else { + assert.Nil(t, actualResp.Id) + } + + if expectedSigningKey { + assert.NotEmpty(t, actualResp.GetSigningKey()) + } else { + assert.Nil(t, actualResp.SigningKey) + } +} + +func TestServer_UpdateTarget(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + req *action.UpdateTargetRequest + } + type want struct { + change bool + changeDate bool + signingKey bool + } + tests := []struct { + name string + prepare func(request *action.UpdateTargetRequest) + args args + want want + wantErr bool + }{ + { + name: "missing permission", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.UpdateTargetRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "not existing", + prepare: func(request *action.UpdateTargetRequest) { + request.Id = "notexisting" + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + Endpoint: gu.Ptr("https://example.com"), + }, + }, + want: want{ + change: false, + changeDate: true, + signingKey: false, + }, + }, + { + name: "change name, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, + { + name: "regenerate signingkey, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + ExpirationSigningKey: durationpb.New(0 * time.Second), + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: true, + }, + }, + { + name: "change type, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + TargetType: &action.UpdateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, + { + name: "change url, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + Endpoint: gu.Ptr("https://example.com/hooks/new"), + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, + { + name: "change timeout, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + Timeout: durationpb.New(20 * time.Second), + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, + { + name: "change type async, ok", + prepare: func(request *action.UpdateTargetRequest) { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeAsync, false).GetId() + request.Id = targetID + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.UpdateTargetRequest{ + TargetType: &action.UpdateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + }, + }, + want: want{ + change: true, + changeDate: true, + signingKey: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.ActionV2.UpdateTarget(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateTargetResponse(t, creationDate, changeDate, tt.want.changeDate, tt.want.signingKey, got) + }) + } +} + +func assertUpdateTargetResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate, expectedSigningKey bool, actualResp *action.UpdateTargetResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } + + if expectedSigningKey { + assert.NotEmpty(t, actualResp.GetSigningKey()) + } else { + assert.Nil(t, actualResp.SigningKey) + } +} + +func TestServer_DeleteTarget(t *testing.T) { + instance := integration.NewInstance(CTX) + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + tests := []struct { + name string + ctx context.Context + prepare func(request *action.DeleteTargetRequest) (time.Time, time.Time) + req *action.DeleteTargetRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), + req: &action.DeleteTargetRequest{ + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "empty id", + ctx: iamOwnerCtx, + req: &action.DeleteTargetRequest{ + Id: "", + }, + wantErr: true, + }, + { + name: "delete target, not existing", + ctx: iamOwnerCtx, + req: &action.DeleteTargetRequest{ + Id: "notexisting", + }, + wantDeletionDate: false, + }, + { + name: "delete target", + ctx: iamOwnerCtx, + prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + return creationDate, time.Time{} + }, + req: &action.DeleteTargetRequest{}, + wantDeletionDate: true, + }, + { + name: "delete target, already removed", + ctx: iamOwnerCtx, + prepare: func(request *action.DeleteTargetRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + targetID := instance.CreateTarget(iamOwnerCtx, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetId() + request.Id = targetID + instance.DeleteTarget(iamOwnerCtx, t, targetID) + return creationDate, time.Now().UTC() + }, + req: &action.DeleteTargetRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.ActionV2.DeleteTarget(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteTargetResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertDeleteTargetResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *action.DeleteTargetResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/action/v2/query.go b/internal/api/grpc/action/v2/query.go new file mode 100644 index 0000000000..c900d29d75 --- /dev/null +++ b/internal/api/grpc/action/v2/query.go @@ -0,0 +1,404 @@ +package action + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +const ( + conditionIDAllSegmentCount = 0 + conditionIDRequestResponseServiceSegmentCount = 1 + conditionIDRequestResponseMethodSegmentCount = 2 + conditionIDEventGroupSegmentCount = 1 +) + +func (s *Server) GetTarget(ctx context.Context, req *connect.Request[action.GetTargetRequest]) (*connect.Response[action.GetTargetResponse], error) { + resp, err := s.query.GetTargetByID(ctx, req.Msg.GetId()) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.GetTargetResponse{ + Target: targetToPb(resp), + }), nil +} + +type InstanceContext interface { + GetInstanceId() string + GetInstanceDomain() string +} + +type Context interface { + GetOwner() InstanceContext +} + +func (s *Server) ListTargets(ctx context.Context, req *connect.Request[action.ListTargetsRequest]) (*connect.Response[action.ListTargetsResponse], error) { + queries, err := s.ListTargetsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.SearchTargets(ctx, queries) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.ListTargetsResponse{ + Targets: targetsToPb(resp.Targets), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) ListExecutions(ctx context.Context, req *connect.Request[action.ListExecutionsRequest]) (*connect.Response[action.ListExecutionsResponse], error) { + queries, err := s.ListExecutionsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.SearchExecutions(ctx, queries) + if err != nil { + return nil, err + } + return connect.NewResponse(&action.ListExecutionsResponse{ + Executions: executionsToPb(resp.Executions), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func targetsToPb(targets []*query.Target) []*action.Target { + t := make([]*action.Target, len(targets)) + for i, target := range targets { + t[i] = targetToPb(target) + } + return t +} + +func targetToPb(t *query.Target) *action.Target { + target := &action.Target{ + Id: t.ID, + Name: t.Name, + Timeout: durationpb.New(t.Timeout), + Endpoint: t.Endpoint, + SigningKey: t.SigningKey, + } + switch t.TargetType { + case domain.TargetTypeWebhook: + target.TargetType = &action.Target_RestWebhook{RestWebhook: &action.RESTWebhook{InterruptOnError: t.InterruptOnError}} + case domain.TargetTypeCall: + target.TargetType = &action.Target_RestCall{RestCall: &action.RESTCall{InterruptOnError: t.InterruptOnError}} + case domain.TargetTypeAsync: + target.TargetType = &action.Target_RestAsync{RestAsync: &action.RESTAsync{}} + default: + target.TargetType = nil + } + + if !t.EventDate.IsZero() { + target.ChangeDate = timestamppb.New(t.EventDate) + } + if !t.CreationDate.IsZero() { + target.CreationDate = timestamppb.New(t.CreationDate) + } + return target +} + +func (s *Server) ListTargetsRequestToModel(req *action.ListTargetsRequest) (*query.TargetSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := targetQueriesToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.TargetSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: targetFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func targetQueriesToQuery(queries []*action.TargetSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = targetQueryToQuery(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func targetQueryToQuery(filter *action.TargetSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *action.TargetSearchFilter_TargetNameFilter: + return targetNameQueryToQuery(q.TargetNameFilter) + case *action.TargetSearchFilter_InTargetIdsFilter: + return targetInTargetIdsQueryToQuery(q.InTargetIdsFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func targetNameQueryToQuery(q *action.TargetNameFilter) (query.SearchQuery, error) { + return query.NewTargetNameSearchQuery(filter.TextMethodPbToQuery(q.Method), q.GetTargetName()) +} + +func targetInTargetIdsQueryToQuery(q *action.InTargetIDsFilter) (query.SearchQuery, error) { + return query.NewTargetInIDsSearchQuery(q.GetTargetIds()) +} + +// targetFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func targetFieldNameToSortingColumn(field *action.TargetFieldName) query.Column { + if field == nil { + return query.TargetColumnCreationDate + } + switch *field { + case action.TargetFieldName_TARGET_FIELD_NAME_UNSPECIFIED: + return query.TargetColumnCreationDate + case action.TargetFieldName_TARGET_FIELD_NAME_ID: + return query.TargetColumnID + case action.TargetFieldName_TARGET_FIELD_NAME_CREATED_DATE: + return query.TargetColumnCreationDate + case action.TargetFieldName_TARGET_FIELD_NAME_CHANGED_DATE: + return query.TargetColumnChangeDate + case action.TargetFieldName_TARGET_FIELD_NAME_NAME: + return query.TargetColumnName + case action.TargetFieldName_TARGET_FIELD_NAME_TARGET_TYPE: + return query.TargetColumnTargetType + case action.TargetFieldName_TARGET_FIELD_NAME_URL: + return query.TargetColumnURL + case action.TargetFieldName_TARGET_FIELD_NAME_TIMEOUT: + return query.TargetColumnTimeout + case action.TargetFieldName_TARGET_FIELD_NAME_INTERRUPT_ON_ERROR: + return query.TargetColumnInterruptOnError + default: + return query.TargetColumnCreationDate + } +} + +// executionFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func executionFieldNameToSortingColumn(field *action.ExecutionFieldName) query.Column { + if field == nil { + return query.ExecutionColumnCreationDate + } + switch *field { + case action.ExecutionFieldName_EXECUTION_FIELD_NAME_UNSPECIFIED: + return query.ExecutionColumnCreationDate + case action.ExecutionFieldName_EXECUTION_FIELD_NAME_ID: + return query.ExecutionColumnID + case action.ExecutionFieldName_EXECUTION_FIELD_NAME_CREATED_DATE: + return query.ExecutionColumnCreationDate + case action.ExecutionFieldName_EXECUTION_FIELD_NAME_CHANGED_DATE: + return query.ExecutionColumnChangeDate + default: + return query.ExecutionColumnCreationDate + } +} + +func (s *Server) ListExecutionsRequestToModel(req *action.ListExecutionsRequest) (*query.ExecutionSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := executionQueriesToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.ExecutionSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: executionFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func executionQueriesToQuery(queries []*action.ExecutionSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = executionQueryToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func executionQueryToQuery(searchQuery *action.ExecutionSearchFilter) (query.SearchQuery, error) { + switch q := searchQuery.Filter.(type) { + case *action.ExecutionSearchFilter_InConditionsFilter: + return inConditionsQueryToQuery(q.InConditionsFilter) + case *action.ExecutionSearchFilter_ExecutionTypeFilter: + return executionTypeToQuery(q.ExecutionTypeFilter) + case *action.ExecutionSearchFilter_TargetFilter: + return query.NewTargetSearchQuery(q.TargetFilter.GetTargetId()) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func executionTypeToQuery(q *action.ExecutionTypeFilter) (query.SearchQuery, error) { + switch q.ExecutionType { + case action.ExecutionType_EXECUTION_TYPE_UNSPECIFIED: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeUnspecified) + case action.ExecutionType_EXECUTION_TYPE_REQUEST: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeRequest) + case action.ExecutionType_EXECUTION_TYPE_RESPONSE: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeResponse) + case action.ExecutionType_EXECUTION_TYPE_EVENT: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeEvent) + case action.ExecutionType_EXECUTION_TYPE_FUNCTION: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeFunction) + default: + return query.NewExecutionTypeSearchQuery(domain.ExecutionTypeUnspecified) + } +} + +func inConditionsQueryToQuery(q *action.InConditionsFilter) (query.SearchQuery, error) { + values := make([]string, len(q.GetConditions())) + for i, condition := range q.GetConditions() { + id, err := conditionToID(condition) + if err != nil { + return nil, err + } + values[i] = id + } + return query.NewExecutionInIDsSearchQuery(values) +} + +func conditionToID(q *action.Condition) (string, error) { + switch t := q.GetConditionType().(type) { + case *action.Condition_Request: + cond := &command.ExecutionAPICondition{ + Method: t.Request.GetMethod(), + Service: t.Request.GetService(), + All: t.Request.GetAll(), + } + return cond.ID(domain.ExecutionTypeRequest), nil + case *action.Condition_Response: + cond := &command.ExecutionAPICondition{ + Method: t.Response.GetMethod(), + Service: t.Response.GetService(), + All: t.Response.GetAll(), + } + return cond.ID(domain.ExecutionTypeResponse), nil + case *action.Condition_Event: + cond := &command.ExecutionEventCondition{ + Event: t.Event.GetEvent(), + Group: t.Event.GetGroup(), + All: t.Event.GetAll(), + } + return cond.ID(), nil + case *action.Condition_Function: + return command.ExecutionFunctionCondition(t.Function.GetName()).ID(), nil + default: + return "", zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func executionsToPb(executions []*query.Execution) []*action.Execution { + e := make([]*action.Execution, len(executions)) + for i, execution := range executions { + e[i] = executionToPb(execution) + } + return e +} + +func executionToPb(e *query.Execution) *action.Execution { + targets := make([]string, len(e.Targets)) + for i := range e.Targets { + switch e.Targets[i].Type { + case domain.ExecutionTargetTypeTarget: + targets[i] = e.Targets[i].Target + case domain.ExecutionTargetTypeInclude, domain.ExecutionTargetTypeUnspecified: + continue + default: + continue + } + } + + exec := &action.Execution{ + Condition: executionIDToCondition(e.ID), + Targets: targets, + } + if !e.EventDate.IsZero() { + exec.ChangeDate = timestamppb.New(e.EventDate) + } + if !e.CreationDate.IsZero() { + exec.CreationDate = timestamppb.New(e.CreationDate) + } + return exec +} + +func executionIDToCondition(include string) *action.Condition { + if strings.HasPrefix(include, domain.ExecutionTypeRequest.String()) { + return includeRequestToCondition(strings.TrimPrefix(include, domain.ExecutionTypeRequest.String())) + } + if strings.HasPrefix(include, domain.ExecutionTypeResponse.String()) { + return includeResponseToCondition(strings.TrimPrefix(include, domain.ExecutionTypeResponse.String())) + } + if strings.HasPrefix(include, domain.ExecutionTypeEvent.String()) { + return includeEventToCondition(strings.TrimPrefix(include, domain.ExecutionTypeEvent.String())) + } + if strings.HasPrefix(include, domain.ExecutionTypeFunction.String()) { + return includeFunctionToCondition(strings.TrimPrefix(include, domain.ExecutionTypeFunction.String())) + } + return nil +} + +func includeRequestToCondition(id string) *action.Condition { + switch strings.Count(id, "/") { + case conditionIDRequestResponseMethodSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: id}}}} + case conditionIDRequestResponseServiceSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: strings.TrimPrefix(id, "/")}}}} + case conditionIDAllSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}} + default: + return nil + } +} +func includeResponseToCondition(id string) *action.Condition { + switch strings.Count(id, "/") { + case conditionIDRequestResponseMethodSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: id}}}} + case conditionIDRequestResponseServiceSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: strings.TrimPrefix(id, "/")}}}} + case conditionIDAllSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}} + default: + return nil + } +} + +func includeEventToCondition(id string) *action.Condition { + switch strings.Count(id, "/") { + case conditionIDEventGroupSegmentCount: + if strings.HasSuffix(id, command.EventGroupSuffix) { + return &action.Condition{ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: strings.TrimSuffix(strings.TrimPrefix(id, "/"), command.EventGroupSuffix)}}}} + } else { + return &action.Condition{ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: strings.TrimPrefix(id, "/")}}}} + } + case conditionIDAllSegmentCount: + return &action.Condition{ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_All{All: true}}}} + default: + return nil + } +} + +func includeFunctionToCondition(id string) *action.Condition { + return &action.Condition{ConditionType: &action.Condition_Function{Function: &action.FunctionExecution{Name: strings.TrimPrefix(id, "/")}}} +} diff --git a/internal/api/grpc/action/v2/server.go b/internal/api/grpc/action/v2/server.go new file mode 100644 index 0000000000..b94e43ef6b --- /dev/null +++ b/internal/api/grpc/action/v2/server.go @@ -0,0 +1,71 @@ +package action + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" + "github.com/zitadel/zitadel/pkg/grpc/action/v2/actionconnect" +) + +var _ actionconnect.ActionServiceHandler = (*Server)(nil) + +type Server struct { + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + ListActionFunctions func() []string + ListGRPCMethods func() []string + ListGRPCServices func() []string +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + listActionFunctions func() []string, + listGRPCMethods func() []string, + listGRPCServices func() []string, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + ListActionFunctions: listActionFunctions, + ListGRPCMethods: listGRPCMethods, + ListGRPCServices: listGRPCServices, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return actionconnect.NewActionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return action.File_zitadel_action_v2_action_service_proto +} + +func (s *Server) AppName() string { + return action.ActionService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return action.ActionService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return action.ActionService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return action.RegisterActionServiceHandler +} diff --git a/internal/api/grpc/action/v2/target.go b/internal/api/grpc/action/v2/target.go new file mode 100644 index 0000000000..971a6b871e --- /dev/null +++ b/internal/api/grpc/action/v2/target.go @@ -0,0 +1,123 @@ +package action + +import ( + "context" + + "connectrpc.com/connect" + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +func (s *Server) CreateTarget(ctx context.Context, req *connect.Request[action.CreateTargetRequest]) (*connect.Response[action.CreateTargetResponse], error) { + add := createTargetToCommand(req.Msg) + instanceID := authz.GetInstance(ctx).InstanceID() + createdAt, err := s.command.AddTarget(ctx, add, instanceID) + if err != nil { + return nil, err + } + var creationDate *timestamppb.Timestamp + if !createdAt.IsZero() { + creationDate = timestamppb.New(createdAt) + } + return connect.NewResponse(&action.CreateTargetResponse{ + Id: add.AggregateID, + CreationDate: creationDate, + SigningKey: add.SigningKey, + }), nil +} + +func (s *Server) UpdateTarget(ctx context.Context, req *connect.Request[action.UpdateTargetRequest]) (*connect.Response[action.UpdateTargetResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + update := updateTargetToCommand(req.Msg) + changedAt, err := s.command.ChangeTarget(ctx, update, instanceID) + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !changedAt.IsZero() { + changeDate = timestamppb.New(changedAt) + } + return connect.NewResponse(&action.UpdateTargetResponse{ + ChangeDate: changeDate, + SigningKey: update.SigningKey, + }), nil +} + +func (s *Server) DeleteTarget(ctx context.Context, req *connect.Request[action.DeleteTargetRequest]) (*connect.Response[action.DeleteTargetResponse], error) { + instanceID := authz.GetInstance(ctx).InstanceID() + deletedAt, err := s.command.DeleteTarget(ctx, req.Msg.GetId(), instanceID) + if err != nil { + return nil, err + } + var deletionDate *timestamppb.Timestamp + if !deletedAt.IsZero() { + deletionDate = timestamppb.New(deletedAt) + } + return connect.NewResponse(&action.DeleteTargetResponse{ + DeletionDate: deletionDate, + }), nil +} + +func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { + var ( + targetType domain.TargetType + interruptOnError bool + ) + switch t := req.GetTargetType().(type) { + case *action.CreateTargetRequest_RestWebhook: + targetType = domain.TargetTypeWebhook + interruptOnError = t.RestWebhook.InterruptOnError + case *action.CreateTargetRequest_RestCall: + targetType = domain.TargetTypeCall + interruptOnError = t.RestCall.InterruptOnError + case *action.CreateTargetRequest_RestAsync: + targetType = domain.TargetTypeAsync + } + return &command.AddTarget{ + Name: req.GetName(), + TargetType: targetType, + Endpoint: req.GetEndpoint(), + Timeout: req.GetTimeout().AsDuration(), + InterruptOnError: interruptOnError, + } +} + +func updateTargetToCommand(req *action.UpdateTargetRequest) *command.ChangeTarget { + // TODO handle expiration, currently only immediate expiration is supported + expirationSigningKey := req.GetExpirationSigningKey() != nil + + if req == nil { + return nil + } + target := &command.ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.GetId(), + }, + Name: req.Name, + Endpoint: req.Endpoint, + ExpirationSigningKey: expirationSigningKey, + } + if req.TargetType != nil { + switch t := req.GetTargetType().(type) { + case *action.UpdateTargetRequest_RestWebhook: + target.TargetType = gu.Ptr(domain.TargetTypeWebhook) + target.InterruptOnError = gu.Ptr(t.RestWebhook.InterruptOnError) + case *action.UpdateTargetRequest_RestCall: + target.TargetType = gu.Ptr(domain.TargetTypeCall) + target.InterruptOnError = gu.Ptr(t.RestCall.InterruptOnError) + case *action.UpdateTargetRequest_RestAsync: + target.TargetType = gu.Ptr(domain.TargetTypeAsync) + target.InterruptOnError = gu.Ptr(false) + } + } + if req.Timeout != nil { + target.Timeout = gu.Ptr(req.GetTimeout().AsDuration()) + } + return target +} diff --git a/internal/api/grpc/action/v2/target_test.go b/internal/api/grpc/action/v2/target_test.go new file mode 100644 index 0000000000..f41932933a --- /dev/null +++ b/internal/api/grpc/action/v2/target_test.go @@ -0,0 +1,229 @@ +package action + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" +) + +func Test_createTargetToCommand(t *testing.T) { + type args struct { + req *action.CreateTargetRequest + } + tests := []struct { + name string + args args + want *command.AddTarget + }{ + { + name: "nil", + args: args{nil}, + want: &command.AddTarget{ + Name: "", + Endpoint: "", + Timeout: 0, + InterruptOnError: false, + }, + }, + { + name: "all fields (webhook)", + args: args{&action.CreateTargetRequest{ + Name: "target 1", + Endpoint: "https://example.com/hooks/1", + TargetType: &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{}, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.AddTarget{ + Name: "target 1", + TargetType: domain.TargetTypeWebhook, + Endpoint: "https://example.com/hooks/1", + Timeout: 10 * time.Second, + InterruptOnError: false, + }, + }, + { + name: "all fields (async)", + args: args{&action.CreateTargetRequest{ + Name: "target 1", + Endpoint: "https://example.com/hooks/1", + TargetType: &action.CreateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.AddTarget{ + Name: "target 1", + TargetType: domain.TargetTypeAsync, + Endpoint: "https://example.com/hooks/1", + Timeout: 10 * time.Second, + InterruptOnError: false, + }, + }, + { + name: "all fields (interrupting response)", + args: args{&action.CreateTargetRequest{ + Name: "target 1", + Endpoint: "https://example.com/hooks/1", + TargetType: &action.CreateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.AddTarget{ + Name: "target 1", + TargetType: domain.TargetTypeCall, + Endpoint: "https://example.com/hooks/1", + Timeout: 10 * time.Second, + InterruptOnError: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createTargetToCommand(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_updateTargetToCommand(t *testing.T) { + type args struct { + req *action.UpdateTargetRequest + } + tests := []struct { + name string + args args + want *command.ChangeTarget + }{ + { + name: "nil", + args: args{nil}, + want: nil, + }, + { + name: "all fields nil", + args: args{&action.UpdateTargetRequest{ + Name: nil, + TargetType: nil, + Timeout: nil, + }}, + want: &command.ChangeTarget{ + Name: nil, + TargetType: nil, + Endpoint: nil, + Timeout: nil, + InterruptOnError: nil, + }, + }, + { + name: "all fields empty", + args: args{&action.UpdateTargetRequest{ + Name: gu.Ptr(""), + TargetType: nil, + Timeout: durationpb.New(0), + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr(""), + TargetType: nil, + Endpoint: nil, + Timeout: gu.Ptr(0 * time.Second), + InterruptOnError: nil, + }, + }, + { + name: "all fields (webhook)", + args: args{&action.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + TargetType: &action.UpdateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeWebhook), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(false), + }, + }, + { + name: "all fields (webhook interrupt)", + args: args{&action.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + TargetType: &action.UpdateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeWebhook), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(true), + }, + }, + { + name: "all fields (async)", + args: args{&action.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + TargetType: &action.UpdateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeAsync), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(false), + }, + }, + { + name: "all fields (interrupting response)", + args: args{&action.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + TargetType: &action.UpdateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: true, + }, + }, + Timeout: durationpb.New(10 * time.Second), + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeCall), + Endpoint: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(true), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateTargetToCommand(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go index a2e6131e11..4db254fe30 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go @@ -48,7 +48,7 @@ var ( func TestServer_ExecutionTarget(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) fullMethod := action.ActionService_GetTarget_FullMethodName tests := []struct { @@ -164,8 +164,8 @@ func TestServer_ExecutionTarget(t *testing.T) { } }, clean: func(ctx context.Context) { - instance.DeleteExecution(ctx, t, conditionRequestFullMethod(fullMethod)) - instance.DeleteExecution(ctx, t, conditionResponseFullMethod(fullMethod)) + deleteExecution(ctx, t, instance, conditionRequestFullMethod(fullMethod)) + deleteExecution(ctx, t, instance, conditionResponseFullMethod(fullMethod)) }, req: &action.GetTargetRequest{ Id: "something", @@ -197,7 +197,7 @@ func TestServer_ExecutionTarget(t *testing.T) { } }, clean: func(ctx context.Context) { - instance.DeleteExecution(ctx, t, conditionRequestFullMethod(fullMethod)) + deleteExecution(ctx, t, instance, conditionRequestFullMethod(fullMethod)) }, req: &action.GetTargetRequest{}, wantErr: true, @@ -259,7 +259,7 @@ func TestServer_ExecutionTarget(t *testing.T) { } }, clean: func(ctx context.Context) { - instance.DeleteExecution(ctx, t, conditionResponseFullMethod(fullMethod)) + deleteExecution(ctx, t, instance, conditionResponseFullMethod(fullMethod)) }, req: &action.GetTargetRequest{}, wantErr: true, @@ -290,9 +290,16 @@ func TestServer_ExecutionTarget(t *testing.T) { } } +func deleteExecution(ctx context.Context, t *testing.T, instance *integration.Instance, cond *action.Condition) { + _, err := instance.Client.ActionV2beta.SetExecution(ctx, &action.SetExecutionRequest{ + Condition: cond, + }) + require.NoError(t, err) +} + func TestServer_ExecutionTarget_Event(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) event := "session.added" urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 0, http.StatusOK, nil) @@ -349,7 +356,7 @@ func TestServer_ExecutionTarget_Event(t *testing.T) { func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) event := "session.added" // call takes longer than timeout of target @@ -401,7 +408,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) event := "session.added" urlRequest, closeF, calledF, resetF := integration.TestServerCall(nil, 1*time.Second, http.StatusOK, nil) @@ -463,7 +470,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) } func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *integration.Instance, condition *action.Condition, targets []string) { - instance.SetExecution(ctx, t, condition, targets) + setExecution(ctx, t, instance, condition, targets) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -488,11 +495,19 @@ func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *in } } }, retryDuration, tick, "timeout waiting for expected execution result") - return +} + +func setExecution(ctx context.Context, t *testing.T, instance *integration.Instance, cond *action.Condition, targets []string) *action.SetExecutionResponse { + target, err := instance.Client.ActionV2beta.SetExecution(ctx, &action.SetExecutionRequest{ + Condition: cond, + Targets: targets, + }) + require.NoError(t, err) + return target } func waitForTarget(ctx context.Context, t *testing.T, instance *integration.Instance, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { - resp := instance.CreateTarget(ctx, t, "", endpoint, ty, interrupt) + resp := createTarget(ctx, t, instance, "", endpoint, ty, interrupt) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -529,6 +544,38 @@ func waitForTarget(ctx context.Context, t *testing.T, instance *integration.Inst return resp } +func createTarget(ctx context.Context, t *testing.T, instance *integration.Instance, name, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { + if name == "" { + name = gofakeit.Name() + } + req := &action.CreateTargetRequest{ + Name: name, + Endpoint: endpoint, + Timeout: durationpb.New(5 * time.Second), + } + switch ty { + case domain.TargetTypeWebhook: + req.TargetType = &action.CreateTargetRequest_RestWebhook{ + RestWebhook: &action.RESTWebhook{ + InterruptOnError: interrupt, + }, + } + case domain.TargetTypeCall: + req.TargetType = &action.CreateTargetRequest_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: interrupt, + }, + } + case domain.TargetTypeAsync: + req.TargetType = &action.CreateTargetRequest_RestAsync{ + RestAsync: &action.RESTAsync{}, + } + } + target, err := instance.Client.ActionV2beta.CreateTarget(ctx, req) + require.NoError(t, err) + return target +} + func conditionRequestFullMethod(fullMethod string) *action.Condition { return &action.Condition{ ConditionType: &action.Condition_Request{ @@ -577,8 +624,8 @@ func conditionFunction(function string) *action.Condition { func TestServer_ExecutionTargetPreUserinfo(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) require.NoError(t, err) @@ -893,8 +940,8 @@ func contextInfoForUserOIDC(instance *integration.Instance, function string, cli func TestServer_ExecutionTargetPreAccessToken(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) require.NoError(t, err) @@ -1086,8 +1133,8 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance * func TestServer_ExecutionTargetPreSAMLResponse(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) + isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + ctxLoginClient := instance.WithAuthorizationToken(CTX, integration.UserTypeLogin) idpMetadata, err := instance.GetSAMLIDPMetadata() require.NoError(t, err) diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_test.go index 2199b9f454..dee736991b 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_test.go @@ -17,7 +17,7 @@ import ( func TestServer_SetExecution_Request(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) tests := []struct { @@ -29,7 +29,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.SetExecutionRequest{ Condition: &action.Condition{ ConditionType: &action.Condition_Request{ @@ -155,7 +155,7 @@ func TestServer_SetExecution_Request(t *testing.T) { assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) // cleanup to not impact other requests - instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + deleteExecution(tt.ctx, t, instance, tt.req.GetCondition()) }) } } @@ -174,7 +174,7 @@ func assertSetExecutionResponse(t *testing.T, creationDate, setDate time.Time, e func TestServer_SetExecution_Response(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) tests := []struct { @@ -186,7 +186,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.SetExecutionRequest{ Condition: &action.Condition{ ConditionType: &action.Condition_Response{ @@ -311,14 +311,14 @@ func TestServer_SetExecution_Response(t *testing.T) { assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) // cleanup to not impact other requests - instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + deleteExecution(tt.ctx, t, instance, tt.req.GetCondition()) }) } } func TestServer_SetExecution_Event(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) tests := []struct { @@ -330,7 +330,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.SetExecutionRequest{ Condition: &action.Condition{ ConditionType: &action.Condition_Event{ @@ -474,14 +474,14 @@ func TestServer_SetExecution_Event(t *testing.T) { assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) // cleanup to not impact other requests - instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + deleteExecution(tt.ctx, t, instance, tt.req.GetCondition()) }) } } func TestServer_SetExecution_Function(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) tests := []struct { @@ -493,7 +493,7 @@ func TestServer_SetExecution_Function(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.SetExecutionRequest{ Condition: &action.Condition{ ConditionType: &action.Condition_Response{ @@ -559,7 +559,7 @@ func TestServer_SetExecution_Function(t *testing.T) { assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) // cleanup to not impact other requests - instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) + deleteExecution(tt.ctx, t, instance, tt.req.GetCondition()) }) } } diff --git a/internal/api/grpc/action/v2beta/integration_test/query_test.go b/internal/api/grpc/action/v2beta/integration_test/query_test.go index 65cc541123..1118311bd2 100644 --- a/internal/api/grpc/action/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/query_test.go @@ -21,7 +21,7 @@ import ( func TestServer_GetTarget(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context dep func(context.Context, *action.GetTargetRequest, *action.GetTargetResponse) error @@ -36,7 +36,7 @@ func TestServer_GetTarget(t *testing.T) { { name: "missing permission", args: args{ - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.GetTargetRequest{}, }, wantErr: true, @@ -213,7 +213,7 @@ func TestServer_GetTarget(t *testing.T) { func TestServer_ListTargets(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context dep func(context.Context, *action.ListTargetsRequest, *action.ListTargetsResponse) @@ -228,7 +228,7 @@ func TestServer_ListTargets(t *testing.T) { { name: "missing permission", args: args{ - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.ListTargetsRequest{}, }, wantErr: true, @@ -445,7 +445,7 @@ func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationRes func TestServer_ListExecutions(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false) type args struct { @@ -462,7 +462,7 @@ func TestServer_ListExecutions(t *testing.T) { { name: "missing permission", args: args{ - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.ListExecutionsRequest{}, }, wantErr: true, @@ -473,7 +473,7 @@ func TestServer_ListExecutions(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { cond := request.Filters[0].GetInConditionsFilter().GetConditions()[0] - resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + resp := setExecution(ctx, t, instance, cond, []string{targetResp.GetId()}) // Set expected response with used values for SetExecution response.Executions[0].CreationDate = resp.GetSetDate() @@ -542,7 +542,7 @@ func TestServer_ListExecutions(t *testing.T) { }, }, } - resp := instance.SetExecution(ctx, t, cond, []string{target.GetId()}) + resp := setExecution(ctx, t, instance, cond, []string{target.GetId()}) response.Executions[0].CreationDate = resp.GetSetDate() response.Executions[0].ChangeDate = resp.GetSetDate() @@ -603,7 +603,7 @@ func TestServer_ListExecutions(t *testing.T) { } cond1 := request.Filters[0].GetInConditionsFilter().GetConditions()[0] - resp1 := instance.SetExecution(ctx, t, cond1, []string{targetResp.GetId()}) + resp1 := setExecution(ctx, t, instance, cond1, []string{targetResp.GetId()}) response.Executions[2] = &action.Execution{ CreationDate: resp1.GetSetDate(), ChangeDate: resp1.GetSetDate(), @@ -612,7 +612,7 @@ func TestServer_ListExecutions(t *testing.T) { } cond2 := request.Filters[0].GetInConditionsFilter().GetConditions()[1] - resp2 := instance.SetExecution(ctx, t, cond2, []string{targetResp.GetId()}) + resp2 := setExecution(ctx, t, instance, cond2, []string{targetResp.GetId()}) response.Executions[1] = &action.Execution{ CreationDate: resp2.GetSetDate(), ChangeDate: resp2.GetSetDate(), @@ -621,7 +621,7 @@ func TestServer_ListExecutions(t *testing.T) { } cond3 := request.Filters[0].GetInConditionsFilter().GetConditions()[2] - resp3 := instance.SetExecution(ctx, t, cond3, []string{targetResp.GetId()}) + resp3 := setExecution(ctx, t, instance, cond3, []string{targetResp.GetId()}) response.Executions[0] = &action.Execution{ CreationDate: resp3.GetSetDate(), ChangeDate: resp3.GetSetDate(), @@ -652,7 +652,7 @@ func TestServer_ListExecutions(t *testing.T) { dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { conditions := request.Filters[0].GetInConditionsFilter().GetConditions() for i, cond := range conditions { - resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + resp := setExecution(ctx, t, instance, cond, []string{targetResp.GetId()}) response.Executions[(len(conditions)-1)-i] = &action.Execution{ CreationDate: resp.GetSetDate(), ChangeDate: resp.GetSetDate(), @@ -708,7 +708,7 @@ func TestServer_ListExecutions(t *testing.T) { dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { conditions := request.Filters[0].GetInConditionsFilter().GetConditions() for i, cond := range conditions { - resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + resp := setExecution(ctx, t, instance, cond, []string{targetResp.GetId()}) response.Executions[i] = &action.Execution{ CreationDate: resp.GetSetDate(), ChangeDate: resp.GetSetDate(), diff --git a/internal/api/grpc/action/v2beta/integration_test/target_test.go b/internal/api/grpc/action/v2beta/integration_test/target_test.go index 8238d3146d..25a4e5f550 100644 --- a/internal/api/grpc/action/v2beta/integration_test/target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/target_test.go @@ -19,7 +19,7 @@ import ( func TestServer_CreateTarget(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) type want struct { id bool creationDate bool @@ -36,7 +36,7 @@ func TestServer_CreateTarget(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.CreateTargetRequest{ Name: gofakeit.Name(), }, @@ -243,7 +243,7 @@ func assertCreateTargetResponse(t *testing.T, creationDate, changeDate time.Time func TestServer_UpdateTarget(t *testing.T) { instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + isolatedIAMOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context req *action.UpdateTargetRequest @@ -267,7 +267,7 @@ func TestServer_UpdateTarget(t *testing.T) { request.Id = targetID }, args: args{ - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.UpdateTargetRequest{ Name: gu.Ptr(gofakeit.Name()), }, @@ -278,7 +278,6 @@ func TestServer_UpdateTarget(t *testing.T) { name: "not existing", prepare: func(request *action.UpdateTargetRequest) { request.Id = "notexisting" - return }, args: args{ ctx: isolatedIAMOwnerCTX, @@ -461,7 +460,7 @@ func assertUpdateTargetResponse(t *testing.T, creationDate, changeDate time.Time func TestServer_DeleteTarget(t *testing.T) { instance := integration.NewInstance(CTX) - iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) tests := []struct { name string ctx context.Context @@ -472,7 +471,7 @@ func TestServer_DeleteTarget(t *testing.T) { }{ { name: "missing permission", - ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), req: &action.DeleteTargetRequest{ Id: "notexisting", }, diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index 64cf3b7618..164283b890 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -82,7 +82,7 @@ func targetsToPb(targets []*query.Target) []*action.Target { func targetToPb(t *query.Target) *action.Target { target := &action.Target{ - Id: t.ObjectDetails.ID, + Id: t.ID, Name: t.Name, Timeout: durationpb.New(t.Timeout), Endpoint: t.Endpoint, @@ -99,11 +99,11 @@ func targetToPb(t *query.Target) *action.Target { target.TargetType = nil } - if !t.ObjectDetails.EventDate.IsZero() { - target.ChangeDate = timestamppb.New(t.ObjectDetails.EventDate) + if !t.EventDate.IsZero() { + target.ChangeDate = timestamppb.New(t.EventDate) } - if !t.ObjectDetails.CreationDate.IsZero() { - target.CreationDate = timestamppb.New(t.ObjectDetails.CreationDate) + if !t.CreationDate.IsZero() { + target.CreationDate = timestamppb.New(t.CreationDate) } return target } @@ -334,11 +334,11 @@ func executionToPb(e *query.Execution) *action.Execution { Condition: executionIDToCondition(e.ID), Targets: targets, } - if !e.ObjectDetails.EventDate.IsZero() { - exec.ChangeDate = timestamppb.New(e.ObjectDetails.EventDate) + if !e.EventDate.IsZero() { + exec.ChangeDate = timestamppb.New(e.EventDate) } - if !e.ObjectDetails.CreationDate.IsZero() { - exec.CreationDate = timestamppb.New(e.ObjectDetails.CreationDate) + if !e.CreationDate.IsZero() { + exec.CreationDate = timestamppb.New(e.CreationDate) } return exec } diff --git a/internal/integration/client.go b/internal/integration/client.go index d4e57d06d0..5365b1fca8 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -21,7 +21,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration/scim" - action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/action/v2" + action_v2beta "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" "github.com/zitadel/zitadel/pkg/grpc/admin" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" @@ -69,7 +70,8 @@ type Client struct { OIDCv2 oidc_pb.OIDCServiceClient OrgV2beta org_v2beta.OrganizationServiceClient OrgV2 org.OrganizationServiceClient - ActionV2beta action.ActionServiceClient + ActionV2beta action_v2beta.ActionServiceClient + ActionV2 action.ActionServiceClient FeatureV2beta feature_v2beta.FeatureServiceClient FeatureV2 feature.FeatureServiceClient UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient @@ -112,7 +114,8 @@ func newClient(ctx context.Context, target string) (*Client, error) { OIDCv2: oidc_pb.NewOIDCServiceClient(cc), OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), OrgV2: org.NewOrganizationServiceClient(cc), - ActionV2beta: action.NewActionServiceClient(cc), + ActionV2beta: action_v2beta.NewActionServiceClient(cc), + ActionV2: action.NewActionServiceClient(cc), FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), FeatureV2: feature.NewFeatureServiceClient(cc), UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), @@ -1057,27 +1060,27 @@ func (i *Instance) CreateTarget(ctx context.Context, t *testing.T, name, endpoin RestAsync: &action.RESTAsync{}, } } - target, err := i.Client.ActionV2beta.CreateTarget(ctx, req) + target, err := i.Client.ActionV2.CreateTarget(ctx, req) require.NoError(t, err) return target } func (i *Instance) DeleteTarget(ctx context.Context, t *testing.T, id string) { - _, err := i.Client.ActionV2beta.DeleteTarget(ctx, &action.DeleteTargetRequest{ + _, err := i.Client.ActionV2.DeleteTarget(ctx, &action.DeleteTargetRequest{ Id: id, }) require.NoError(t, err) } func (i *Instance) DeleteExecution(ctx context.Context, t *testing.T, cond *action.Condition) { - _, err := i.Client.ActionV2beta.SetExecution(ctx, &action.SetExecutionRequest{ + _, err := i.Client.ActionV2.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, }) require.NoError(t, err) } func (i *Instance) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []string) *action.SetExecutionResponse { - target, err := i.Client.ActionV2beta.SetExecution(ctx, &action.SetExecutionRequest{ + target, err := i.Client.ActionV2.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, Targets: targets, }) diff --git a/proto/zitadel/action/v2/action_service.proto b/proto/zitadel/action/v2/action_service.proto new file mode 100644 index 0000000000..d9e57a83a5 --- /dev/null +++ b/proto/zitadel/action/v2/action_service.proto @@ -0,0 +1,728 @@ +syntax = "proto3"; + +package zitadel.action.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "zitadel/action/v2/target.proto"; +import "zitadel/action/v2/execution.proto"; +import "zitadel/action/v2/query.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v2;action"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Action Service"; + version: "2.0"; + description: "This API is intended to manage custom executions (previously known as actions) in a ZITADEL instance."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage custom executions. +// The service provides methods to create, update, delete and list targets and executions. +service ActionService { + + // Create Target + // + // Create a new target to your endpoint, which can be used in executions. + // + // Required permission: + // - `action.target.write` + // + // Required feature flag: + // - `actions` + rpc CreateTarget (CreateTargetRequest) returns (CreateTargetResponse) { + option (google.api.http) = { + post: "/v2/actions/targets" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Target created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The target to create already exists."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `actions` is not enabled."; + } + }; + }; + } + + // Update Target + // + // Update an existing target. + // To generate a new signing key set the optional expirationSigningKey. + // + // Required permission: + // - `action.target.write` + // + // Required feature flag: + // - `actions` + rpc UpdateTarget (UpdateTargetRequest) returns (UpdateTargetResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Target successfully updated or left unchanged"; + }; + }; + responses: { + key: "404" + value: { + description: "The target to update does not exist."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `actions` is not enabled."; + } + }; + }; + } + + // Delete Target + // + // Delete an existing target. This will remove it from any configured execution as well. + // In case the target is not found, the request will return a successful response as + // the desired state is already achieved. + // + // Required permission: + // - `action.target.delete` + // + // Required feature flag: + // - `actions` + rpc DeleteTarget (DeleteTargetRequest) returns (DeleteTargetResponse) { + option (google.api.http) = { + delete: "/v2/actions/targets/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Target deleted successfully"; + }; + }; + responses: { + key: "400" + value: { + description: "The feature flag `actions` is not enabled."; + } + }; + }; + } + + // Get Target + // + // Returns the target identified by the requested ID. + // + // Required permission: + // - `action.target.read` + // + // Required feature flag: + // - `actions` + rpc GetTarget (GetTargetRequest) returns (GetTargetResponse) { + option (google.api.http) = { + get: "/v2/actions/targets/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Target retrieved successfully"; + } + }; + responses: { + key: "404" + value: { + description: "The target to update does not exist."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `actions` is not enabled."; + } + }; + }; + } + + // List targets + // + // List all matching targets. By default all targets of the instance are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - `action.target.read` + // + // Required feature flag: + // - `actions` + rpc ListTargets (ListTargetsRequest) returns (ListTargetsResponse) { + option (google.api.http) = { + post: "/v2/actions/targets/search", + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.target.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all targets matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + responses: { + key: "400" + value: { + description: "The feature flag `actions` is not enabled."; + } + }; + }; + } + + // Set Execution + // + // Sets an execution to call a target or include the targets of another execution. + // Setting an empty list of targets will remove all targets from the execution, making it a noop. + // + // Required permission: + // - `action.execution.write` + // + // Required feature flag: + // - `actions` + rpc SetExecution (SetExecutionRequest) returns (SetExecutionResponse) { + option (google.api.http) = { + put: "/v2/actions/executions" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.execution.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Execution successfully updated or left unchanged"; + }; + }; + responses: { + key: "400" + value: { + description: "Condition to set execution does not exist or the feature flag `actions` is not enabled."; + } + }; + }; + } + + // List Executions + // + // List all matching executions. By default all executions of the instance are returned that have at least one execution target. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - `action.execution.read` + // + // Required feature flag: + // - `actions` + rpc ListExecutions (ListExecutionsRequest) returns (ListExecutionsResponse) { + option (google.api.http) = { + post: "/v2/actions/executions/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "action.execution.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all non noop executions matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "Invalid list query or the feature flag `actions` is not enabled."; + }; + }; + }; + } + + // List Execution Functions + // + // List all available functions which can be used as condition for executions. + rpc ListExecutionFunctions (ListExecutionFunctionsRequest) returns (ListExecutionFunctionsResponse) { + option (google.api.http) = { + get: "/v2/actions/executions/functions" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "List all functions successfully"; + }; + }; + }; + } + + // List Execution Methods + // + // List all available methods which can be used as condition for executions. + rpc ListExecutionMethods (ListExecutionMethodsRequest) returns (ListExecutionMethodsResponse) { + option (google.api.http) = { + get: "/v2/actions/executions/methods" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "List all methods successfully"; + }; + }; + }; + } + + // List Execution Services + // + // List all available services which can be used as condition for executions. + rpc ListExecutionServices (ListExecutionServicesRequest) returns (ListExecutionServicesResponse) { + option (google.api.http) = { + get: "/v2/actions/executions/services" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "List all services successfully"; + }; + }; + }; + } +} + +message CreateTargetRequest { + string name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ip_allow_list\""; + min_length: 1 + max_length: 1000 + } + ]; + // Defines the target type and how the response of the target is treated. + oneof target_type { + option (validate.required) = true; + // Wait for response but response body is ignored, status is checked, call is sent as post. + RESTWebhook rest_webhook = 2; + // Wait for response and response body is used, status is checked, call is sent as post. + RESTCall rest_call = 3; + // Call is executed in parallel to others, ZITADEL does not wait until the call is finished. The state is ignored, call is sent as post. + RESTAsync rest_async = 4; + } + // Timeout defines the duration until ZITADEL cancels the execution. + // If the target doesn't respond before this timeout expires, then the connection is closed and the action fails. Depending on the target type and possible setting on `interrupt_on_error` following targets will not be called. In case of a `rest_async` target only this specific target will fail, without any influence on other targets of the same execution. + google.protobuf.Duration timeout = 5 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 270}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"10s\""; + } + ]; + string endpoint = 6 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://example.com/hooks/ip_check\"" + min_length: 1 + max_length: 1000 + } + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"name\": \"ip_allow_list\",\"restWebhook\":{\"interruptOnError\":true},\"timeout\":\"10s\",\"endpoint\":\"https://example.com/hooks/ip_check\"}"; + }; +} + +message CreateTargetResponse { + // The unique identifier of the newly created target. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the target creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Key used to sign and check payload sent to the target. + string signing_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; +} + +message UpdateTargetRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + optional string name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ip_allow_list\"" + min_length: 1 + max_length: 1000 + } + ]; + // Defines the target type and how the response of the target is treated. + oneof target_type { + // Wait for response but response body is ignored, status is checked, call is sent as post. + RESTWebhook rest_webhook = 3; + // Wait for response and response body is used, status is checked, call is sent as post. + RESTCall rest_call = 4; + // Call is executed in parallel to others, ZITADEL does not wait until the call is finished. The state is ignored, call is sent as post. + RESTAsync rest_async = 5; + } + // Timeout defines the duration until ZITADEL cancels the execution. + // If the target doesn't respond before this timeout expires, then the connection is closed and the action fails. Depending on the target type and possible setting on `interrupt_on_error` following targets will not be called. In case of a `rest_async` target only this specific target will fail, without any influence on other targets of the same execution. + optional google.protobuf.Duration timeout = 6 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 270}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"10s\""; + } + ]; + optional string endpoint = 7 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://example.com/hooks/ip_check\"" + min_length: 1 + max_length: 1000 + } + ]; + // Regenerate the key used for signing and checking the payload sent to the target. + // Set the graceful period for the existing key. During that time, the previous + // signing key and the new one will be used to sign the request to allow you a smooth + // transition onf your API. + // + // Note that we currently only allow an immediate rotation ("0s") and will support + // longer expirations in the future. + optional google.protobuf.Duration expiration_signing_key = 8 [ + (validate.rules).duration = {const: {seconds: 0, nanos: 0}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"0s\"" + minimum: 0 + maximum: 0 + } + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"name\": \"ip_allow_list\",\"restCall\":{\"interruptOnError\":true},\"timeout\":\"10s\",\"endpoint\":\"https://example.com/hooks/ip_check\",\"expirationSigningKey\":\"0s\"}"; + }; +} + +message UpdateTargetResponse { + // The timestamp of the change of the target. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // Key used to sign and check payload sent to the target. + optional string signing_key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; +} + +message DeleteTargetRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeleteTargetResponse { + // The timestamp of the deletion of the target. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetTargetRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message GetTargetResponse { + Target target = 1; +} + +message ListTargetsRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional TargetFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"TARGET_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated TargetSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"TARGET_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"targetNameFilter\":{\"targetName\":\"ip_allow_list\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"inTargetIdsFilter\":{\"targetIds\":[\"69629023906488334\",\"69622366012355662\"]}}]}"; + }; +} + +message ListTargetsResponse { + reserved 'result'; + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated Target targets = 2; +} + +message SetExecutionRequest { + // Condition defining when the execution should be used. + Condition condition = 1; + // Ordered list of targets called during the execution. + repeated string targets = 2; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"condition\":{\"request\":{\"method\":\"zitadel.session.v2.SessionService/ListSessions\"}},\"targets\":[{\"target\":\"69629026806489455\"}]}"; + }; +} + +message SetExecutionResponse { + // The timestamp of the execution set. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ListExecutionsRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional ExecutionFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"EXECUTION_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated ExecutionSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"EXECUTION_FIELD_NAME_ID\",\"filters\":[{\"targetFilter\":{\"targetId\":\"69629023906488334\"}}]}"; + }; +} + +message ListExecutionsResponse { + reserved 'result'; + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated Execution executions = 2; +} + +message ListExecutionFunctionsRequest{} +message ListExecutionFunctionsResponse{ + // All available methods + repeated string functions = 1; +} +message ListExecutionMethodsRequest{} +message ListExecutionMethodsResponse{ + // All available methods + repeated string methods = 1; +} + +message ListExecutionServicesRequest{} +message ListExecutionServicesResponse{ + // All available methods + repeated string services = 1; +} diff --git a/proto/zitadel/action/v2/execution.proto b/proto/zitadel/action/v2/execution.proto new file mode 100644 index 0000000000..9f166a4c2c --- /dev/null +++ b/proto/zitadel/action/v2/execution.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +package zitadel.action.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v2;action"; + +message Execution { + Condition condition = 1; + // The timestamp of the execution creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the execution. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // Ordered list of targets called during the execution. + repeated string targets = 4; +} + +message Condition { + // Condition-types under which conditions the execution should happen, only one possible. + oneof condition_type { + option (validate.required) = true; + + // Condition-type to execute if a request on the defined API point happens. + RequestExecution request = 1; + // Condition-type to execute on response if a request on the defined API point happens. + ResponseExecution response = 2; + // Condition-type to execute if function is used, replaces actions v1. + FunctionExecution function = 3; + // Condition-type to execute if an event is created in the system. + EventExecution event = 4; + } +} + +message RequestExecution { + // Condition for the request execution. Only one is possible. + oneof condition{ + option (validate.required) = true; + // GRPC-method as condition. + string method = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"/zitadel.session.v2.SessionService/ListSessions\""; + } + ]; + // GRPC-service as condition. + string service = 2 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"zitadel.session.v2.SessionService\""; + } + ]; + // All calls to any available services and methods as condition. + bool all = 3 [(validate.rules).bool = {const: true}]; + } +} + +message ResponseExecution { + // Condition for the response execution. Only one is possible. + oneof condition{ + option (validate.required) = true; + // GRPC-method as condition. + string method = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"/zitadel.session.v2.SessionService/ListSessions\""; + } + ]; + // GRPC-service as condition. + string service = 2 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"zitadel.session.v2.SessionService\""; + } + ]; + // All calls to any available services and methods as condition. + bool all = 3 [(validate.rules).bool = {const: true}]; + } +} + +// Executed on the specified function +message FunctionExecution { + string name = 1 [(validate.rules).string = {min_len: 1, max_len: 1000}]; +} + +message EventExecution { + // Condition for the event execution. Only one is possible. + oneof condition{ + option (validate.required) = true; + // Event name as condition. + string event = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"user.human.added\""; + } + ]; + // Event group as condition, all events under this group. + string group = 2 [ + (validate.rules).string = {min_len: 1, max_len: 1000}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"user.human\""; + } + ]; + // all events as condition. + bool all = 3 [(validate.rules).bool = {const: true}]; + } +} diff --git a/proto/zitadel/action/v2/query.proto b/proto/zitadel/action/v2/query.proto new file mode 100644 index 0000000000..39e94bd826 --- /dev/null +++ b/proto/zitadel/action/v2/query.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +package zitadel.action.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v2;action"; + +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +import "zitadel/action/v2/execution.proto"; +import "zitadel/filter/v2/filter.proto"; + +message ExecutionSearchFilter { + oneof filter { + option (validate.required) = true; + + InConditionsFilter in_conditions_filter = 1; + ExecutionTypeFilter execution_type_filter = 2; + TargetFilter target_filter = 3; + } +} + +message InConditionsFilter { + // Defines the conditions to query for. + repeated Condition conditions = 1; +} + +message ExecutionTypeFilter { + // Defines the type to query for. + ExecutionType execution_type = 1; +} + +message TargetFilter { + // Defines the id to query for. + string target_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the id of the targets to include" + example: "\"69629023906488334\""; + } + ]; +} + +enum TargetFieldName { + TARGET_FIELD_NAME_UNSPECIFIED = 0; + TARGET_FIELD_NAME_ID = 1; + TARGET_FIELD_NAME_CREATED_DATE = 2; + TARGET_FIELD_NAME_CHANGED_DATE = 3; + TARGET_FIELD_NAME_NAME = 4; + TARGET_FIELD_NAME_TARGET_TYPE = 5; + TARGET_FIELD_NAME_URL = 6; + TARGET_FIELD_NAME_TIMEOUT = 7; + TARGET_FIELD_NAME_INTERRUPT_ON_ERROR = 8; +} + +message TargetSearchFilter { + oneof filter { + option (validate.required) = true; + + TargetNameFilter target_name_filter = 1; + InTargetIDsFilter in_target_ids_filter = 2; + } +} + +message TargetNameFilter { + // Defines the name of the target to query for. + string target_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"ip_allow_list\""; + } + ]; + // Defines which text comparison method used for the name query. + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message InTargetIDsFilter { + // Defines the ids to query for. + repeated string target_ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the ids of the targets to include" + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} + +enum ExecutionType { + EXECUTION_TYPE_UNSPECIFIED = 0; + EXECUTION_TYPE_REQUEST = 1; + EXECUTION_TYPE_RESPONSE = 2; + EXECUTION_TYPE_EVENT = 3; + EXECUTION_TYPE_FUNCTION = 4; +} + + +enum ExecutionFieldName { + EXECUTION_FIELD_NAME_UNSPECIFIED = 0; + EXECUTION_FIELD_NAME_ID = 1; + EXECUTION_FIELD_NAME_CREATED_DATE = 2; + EXECUTION_FIELD_NAME_CHANGED_DATE = 3; +} \ No newline at end of file diff --git a/proto/zitadel/action/v2/target.proto b/proto/zitadel/action/v2/target.proto new file mode 100644 index 0000000000..ee98b35149 --- /dev/null +++ b/proto/zitadel/action/v2/target.proto @@ -0,0 +1,75 @@ +syntax = "proto3"; + +package zitadel.action.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v2;action"; + +message Target { + // The unique identifier of the target. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the target creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the target (e.g. creation, activation, deactivation). + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + string name = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ip_allow_list\""; + } + ]; + // Defines the target type and how the response of the target is treated. + oneof target_type { + RESTWebhook rest_webhook = 5; + RESTCall rest_call = 6; + RESTAsync rest_async = 7; + } + // Timeout defines the duration until ZITADEL cancels the execution. + // If the target doesn't respond before this timeout expires, the the connection is closed and the action fails. Depending on the target type and possible setting on `interrupt_on_error` following targets will not be called. In case of a `rest_async` target only this specific target will fail, without any influence on other targets of the same execution. + google.protobuf.Duration timeout = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"10s\""; + } + ]; + string endpoint = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://example.com/hooks/ip_check\"" + } + ]; + string signing_key = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; +} + +message RESTWebhook { + // Define if any error stops the whole execution. By default the process continues as normal. + bool interrupt_on_error = 1; +} + +message RESTCall { + // Define if any error stops the whole execution. By default the process continues as normal. + bool interrupt_on_error = 1; +} + +message RESTAsync {} From 00b0af43680d2c5a58cfb29ff0e71d5f72a63c84 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:12:26 +0200 Subject: [PATCH 7/9] fix(saml): use transient mapping attribute when nameID is missing in saml response (#10353) # Which Problems Are Solved In the SAML responses from some IDPs (e.g. ADFS and Shibboleth), the `` part could be missing in ``, and in some cases, the `` part might be missing as well. This causes Zitadel to fail the SAML login with the following error message: ``` ID=SAML-EFG32 Message=Errors.Intent.ResponseInvalid ``` # How the Problems Are Solved This is solved by adding a workaround to accept a transient mapping attribute when the `NameID` or the `Subject` is missing in the SAML response. This requires setting the custom transient mapping attribute in the SAML IDP config in Zitadel, and it should be present in the SAML response as well. image # Additional Changes N/A # Additional Context - Closes #10251 --- internal/idp/providers/saml/session.go | 25 +++++-- internal/idp/providers/saml/session_test.go | 80 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index e1f32209b0..f49f50f4e6 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/url" + "strings" "time" "github.com/beevik/etree" @@ -75,21 +76,31 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return nil, zerrors.ThrowInvalidArgument(err, "SAML-nuo0vphhh9", "Errors.Intent.ResponseInvalid") } + userMapper := NewUser() // nameID is required, but at least in ADFS it will not be sent unless explicitly configured if s.Assertion.Subject == nil || s.Assertion.Subject.NameID == nil { - return nil, zerrors.ThrowInvalidArgument(err, "SAML-EFG32", "Errors.Intent.ResponseInvalid") - } - nameID := s.Assertion.Subject.NameID - userMapper := NewUser() - // use the nameID as default mapping id - userMapper.SetID(nameID.Value) - if nameID.Format == string(saml.TransientNameIDFormat) { + if strings.TrimSpace(s.TransientMappingAttributeName) == "" { + return nil, zerrors.ThrowInvalidArgument(err, "SAML-EFG32", "Errors.Intent.MissingTransientMappingAttributeName") + } + // workaround to use the transient mapping attribute when the subject / nameID are missing (e.g. in ADFS, Shibboleth) mappingID, err := s.transientMappingID() if err != nil { return nil, err } userMapper.SetID(mappingID) + } else { + nameID := s.Assertion.Subject.NameID + // use the nameID as default mapping id + userMapper.SetID(nameID.Value) + if nameID.Format == string(saml.TransientNameIDFormat) { + mappingID, err := s.transientMappingID() + if err != nil { + return nil, err + } + userMapper.SetID(mappingID) + } } + for _, statement := range s.Assertion.AttributeStatements { for _, attribute := range statement.Attributes { values := make([]string, len(attribute.Values)) diff --git a/internal/idp/providers/saml/session_test.go b/internal/idp/providers/saml/session_test.go index ea3e510d60..836462c5d8 100644 --- a/internal/idp/providers/saml/session_test.go +++ b/internal/idp/providers/saml/session_test.go @@ -138,7 +138,49 @@ func TestSession_FetchUser(t *testing.T) { }, }, { - name: "response invalid (missing nameID)", + name: "missing nameID, custom transient mapping attribute config is set and also present in the response", + fields: fields{ + name: "saml", + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + metadata: []byte("\n \n \n \n \n MIIFFTCCAv2gAwIBAgIUGdd3KdAmoGLcSBBpGD91vfiwtNAwDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMB4XDTI0MTAwMjE2MTQ0MVoXDTM0MDkzMDE2MTQ0MVowGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlVkeF2COiZAuvuA68ZaanoExvG+xynhEbNB9RgJUltkp6AiMlyhju+fLBzqH635FjNZHgkKoCTfxPW5Rq+iRSm9qyP86QogZsUYnLpyrnmDVJc8l75Flf+3USdIKnVA9mUAKyxUnYBMR/QCsNFcNTkGcFzx/GUGdRq0iWY6cF73o8DJR0c/liJjNL5kpxlKa28DVEgZceFb9w+/16PoNJ51XO4C7eOyEggKOGK9JBC845H8dUpFAs7Vl1Pal+dCUiNm+cwPQQz9ypIBqt1J6uICUiVXJtAhk5QN8yuEpp47T8FV3hcAmj4vERTNCV3JCB0Ft186X2WVe3RDUTKZ4pVkRes8ihP2Waxkphzd1qRBHMTgMDkBP3siraTDjkdtbyfpp25cfq2T8GcZVw4q2ObaiKheOAxRdO1rrOBrMffujMO8SZxRGh12ZqtPqQIDl4IfB65Ktri1po/Mw6s/s+r592BUm7drRq7wSXRcyk9uy1KWKho8n1fwx00M7FvPXPZpEq3kQyQgCI+ZazBCwtZlcSl4EJ5DDkRtrzjx+642kApr+XcKW1V3mp9beQwvXNmtt+krHvshft6JBVea9osJs3r9kKFQg+A1L7mSSg87xqvkCkfttHUFzHqkWTyvhjxZCbw45dzM+6U5hecgy3Xv6sL93ChB5VINipkQ85jECAwEAAaNTMFEwHQYDVR0OBBYEFOJ5SUCf3Kw787313G5AaRk2LnUyMB8GA1UdIwQYMBaAFOJ5SUCf3Kw787313G5AaRk2LnUyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBADhZUpklgAgNCSPqrKSqfz60R0CNYQI2t6kyKm+QqwCf68HshEiPZefNv+YAmQdE5qZCdWP2PSYXfbk6sfVfQBlfOQiI2C6Du08Y652A7kbYQe4/itJLibxUAuV1T1Rg8dKAjt3GSqVhEuUbbxbTlu8xlM+gmyPM3JLFo1AC+SSZ85PS9S1PsiWoV2rDa+3qOGek0+1ct0fesZo7VwnF/mlWSqvFa0W7lzozDOPj48DPhr+2VRGPX7ZLuuYwxhxihSljMiRBLdlhAS4kK4tgIpacP/iBr3l0GgVaTKE1saL5lPn5vulgzoM8Ar1dGcs6M/fKOAtdWIuc9iizvU0m25kW8WUT+31ouxpXEDqVQjbKsk1aifnqf8OjCKZlFpTSNNV+M6wrDYwvTxF/L//JlfaGozjAmGUMJpOI4kLSt7VrhCx+lCL+4Foz4wZ1/XQOJtpn/nD4VsRtdgVvVG7+P19yGwKAGvVSDZHbd2hGDiRFtevrO+R+Ysq/OijbFy2rCjUvkIwZd0fNWfRjd9kyMlVzlpe9SyOu9nVVcZHceRXBiTq891eTChz/+8sw6Z3yIUjfovafLNisZ6f+Dohb6TwwwBApkCe+iCab4kIXWym54dUBZ4Mjgz7ruoPwAi2lMt5ej7Un8rGNYuklr5CFozQOfh+TNTJDow6hHq3Eo18m\n \n \n \n \n \n \n MIIFFTCCAv2gAwIBAgIUGdd3KdAmoGLcSBBpGD91vfiwtNAwDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMB4XDTI0MTAwMjE2MTQ0MVoXDTM0MDkzMDE2MTQ0MVowGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlVkeF2COiZAuvuA68ZaanoExvG+xynhEbNB9RgJUltkp6AiMlyhju+fLBzqH635FjNZHgkKoCTfxPW5Rq+iRSm9qyP86QogZsUYnLpyrnmDVJc8l75Flf+3USdIKnVA9mUAKyxUnYBMR/QCsNFcNTkGcFzx/GUGdRq0iWY6cF73o8DJR0c/liJjNL5kpxlKa28DVEgZceFb9w+/16PoNJ51XO4C7eOyEggKOGK9JBC845H8dUpFAs7Vl1Pal+dCUiNm+cwPQQz9ypIBqt1J6uICUiVXJtAhk5QN8yuEpp47T8FV3hcAmj4vERTNCV3JCB0Ft186X2WVe3RDUTKZ4pVkRes8ihP2Waxkphzd1qRBHMTgMDkBP3siraTDjkdtbyfpp25cfq2T8GcZVw4q2ObaiKheOAxRdO1rrOBrMffujMO8SZxRGh12ZqtPqQIDl4IfB65Ktri1po/Mw6s/s+r592BUm7drRq7wSXRcyk9uy1KWKho8n1fwx00M7FvPXPZpEq3kQyQgCI+ZazBCwtZlcSl4EJ5DDkRtrzjx+642kApr+XcKW1V3mp9beQwvXNmtt+krHvshft6JBVea9osJs3r9kKFQg+A1L7mSSg87xqvkCkfttHUFzHqkWTyvhjxZCbw45dzM+6U5hecgy3Xv6sL93ChB5VINipkQ85jECAwEAAaNTMFEwHQYDVR0OBBYEFOJ5SUCf3Kw787313G5AaRk2LnUyMB8GA1UdIwQYMBaAFOJ5SUCf3Kw787313G5AaRk2LnUyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBADhZUpklgAgNCSPqrKSqfz60R0CNYQI2t6kyKm+QqwCf68HshEiPZefNv+YAmQdE5qZCdWP2PSYXfbk6sfVfQBlfOQiI2C6Du08Y652A7kbYQe4/itJLibxUAuV1T1Rg8dKAjt3GSqVhEuUbbxbTlu8xlM+gmyPM3JLFo1AC+SSZ85PS9S1PsiWoV2rDa+3qOGek0+1ct0fesZo7VwnF/mlWSqvFa0W7lzozDOPj48DPhr+2VRGPX7ZLuuYwxhxihSljMiRBLdlhAS4kK4tgIpacP/iBr3l0GgVaTKE1saL5lPn5vulgzoM8Ar1dGcs6M/fKOAtdWIuc9iizvU0m25kW8WUT+31ouxpXEDqVQjbKsk1aifnqf8OjCKZlFpTSNNV+M6wrDYwvTxF/L//JlfaGozjAmGUMJpOI4kLSt7VrhCx+lCL+4Foz4wZ1/XQOJtpn/nD4VsRtdgVvVG7+P19yGwKAGvVSDZHbd2hGDiRFtevrO+R+Ysq/OijbFy2rCjUvkIwZd0fNWfRjd9kyMlVzlpe9SyOu9nVVcZHceRXBiTq891eTChz/+8sw6Z3yIUjfovafLNisZ6f+Dohb6TwwwBApkCe+iCab4kIXWym54dUBZ4Mjgz7ruoPwAi2lMt5ej7Un8rGNYuklr5CFozQOfh+TNTJDow6hHq3Eo18m\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + options: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + WithBinding(saml.HTTPRedirectBinding), + WithSignedRequest(), + WithCustomRequestTracker(&requesttracker.RequestTracker{}), + WithTransientMappingAttributeName("urn:oid:1.3.6.1.4.1.5923.1.1.1.6"), + }, + rootURL: "http://localhost:8080/idps/228968792372281708/", + timeNow: func() time.Time { + return time.Date(2025, 9, 21, 13, 47, 40, 0, time.UTC) + }, + }, + args: args{ + request: httpPostFormRequest(t, + "http://localhost:8080/idps/228968792372281708/saml/acs", + "232881438356144492", + "PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzYW1scDpSZXNwb25zZSB4bWxuczpzYW1sPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiB4bWxuczpzYW1scD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIElEPSJwZng0M2UzZjA2YS1lMWU3LWQ5NjctOTZhNi1mY2EyODg5NmM5YjkiIEluUmVzcG9uc2VUbz0iaWQtYjIyYzkwZGI4OGJmMDFkODJmZmIwYTdiNmZlMjVhYzlmY2IyYzY3OSIgVmVyc2lvbj0iMi4wIiBJc3N1ZUluc3RhbnQ9IjIwMjUtMDktMjFUMTM6NDk6MjMuOTM4WiIgRGVzdGluYXRpb249Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL2FjcyI+PHNhbWw6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwOi8vbG9jYWxob3N0OjgwMDAvbWV0YWRhdGE8L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI3JzYS1zaGExIi8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNwZng0M2UzZjA2YS1lMWU3LWQ5NjctOTZhNi1mY2EyODg5NmM5YjkiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48L2RzOlRyYW5zZm9ybXM+PGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+PGRzOkRpZ2VzdFZhbHVlPmRTb2M2VmUvbGFrdmM5cUVrYTlLZlo3ekJxWT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+VWdrdklwVSsreFZPRjlFNG1rZ1hkNUIvRUdmVkc4eEFFd0hPS3BjZDQzQ0dhV3FoTTBGVTZEQ1FyOE5wcC84dENpaHVvWENMMnNGRjZydy9DUmRGUEpFWUhqdldSVm9ESTVwdGRnRDZlZXVqRnN4bzRzTWU5aW83cHhvYVd6STFRWUFSM3oxUER6bDNvaTRnWG9oVXhsSEovTldaRzV1VkdSMnd6dXdLdjA4Uit6U2liOHhiZ3dsUUZiTEdNTzFNZVdJL1pxV0x6UURvY0hxZWxjbG9SL1V4cWsxMnRJU3B2Y25tTEpROVNid1JnYmtrZWZsQng5NzZIUVdURFEyS2M4b1lqZjVZSzlYbDdJVFFFQzE1UFYvZ3hMQXNTU2x0VDlJKzh1YXI1L2lKZlV1eWhVbG5KR0oxd3dSRW9XVXh3aEM0ckhjSFF1K05qUjM0akMvdlRWU1JZSkZNVVpieHp0MXdJVGpsdkxveGlMTktzeG9MbDFhcHArMHk1ZjVlcWJvRGtxTnFCcTdEQ3NjTXk0WTQ2aWdWdWt1Vk1YM21HLzdZeFJCS3lPcTZKQjc0TFVvb0Y3U3oxQTBuU3U4ekNKQ0JJTWVUNXdGdURXYlNaOUw2NC9qbFJYRFpOWEtlMGx6bkRaU2h6UllZdC9EUjFMWTNyQ3BwNTdzT0tVWDJSVzdJYld0Mmp2bDdHc1VELzBUVEZncHZ1M29UMENrRTRSSnhFVDluQnpYRkNtckY4VzVjWE9zWThrSEpZOGJtYk1IbUlNNVRuWkM0QVZaaUd3aGdEKzZBUWpnc3piVnh4amlvRWt6Q1Q1SndKR2NZUEtsQ2I2QWtjY2pRcnpUeUJLZzdaSzBEUEdVbFcrSDYxMGREN3hpM1A0Qit5MlBqRitmMkhqRi9kaUE9PC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRkZUQ0NBdjJnQXdJQkFnSVVHZGQzS2RBbW9HTGNTQkJwR0Q5MXZmaXd0TkF3RFFZSktvWklodmNOQVFFTEJRQXdHakVZTUJZR0ExVUVBd3dQZDNkM0xtVjRZVzF3YkdVdVkyOXRNQjRYRFRJME1UQXdNakUyTVRRME1Wb1hEVE0wTURrek1ERTJNVFEwTVZvd0dqRVlNQllHQTFVRUF3d1BkM2QzTG1WNFlXMXdiR1V1WTI5dE1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0NBZ0VBbFZrZUYyQ09pWkF1dnVBNjhaYWFub0V4dkcreHluaEViTkI5UmdKVWx0a3A2QWlNbHloanUrZkxCenFINjM1RmpOWkhna0tvQ1RmeFBXNVJxK2lSU205cXlQODZRb2dac1VZbkxweXJubURWSmM4bDc1RmxmKzNVU2RJS25WQTltVUFLeXhVbllCTVIvUUNzTkZjTlRrR2NGengvR1VHZFJxMGlXWTZjRjczbzhESlIwYy9saUpqTkw1a3B4bEthMjhEVkVnWmNlRmI5dysvMTZQb05KNTFYTzRDN2VPeUVnZ0tPR0s5SkJDODQ1SDhkVXBGQXM3VmwxUGFsK2RDVWlObStjd1BRUXo5eXBJQnF0MUo2dUlDVWlWWEp0QWhrNVFOOHl1RXBwNDdUOEZWM2hjQW1qNHZFUlROQ1YzSkNCMEZ0MTg2WDJXVmUzUkRVVEtaNHBWa1JlczhpaFAyV2F4a3BoemQxcVJCSE1UZ01Ea0JQM3NpcmFURGprZHRieWZwcDI1Y2ZxMlQ4R2NaVnc0cTJPYmFpS2hlT0F4UmRPMXJyT0JyTWZmdWpNTzhTWnhSR2gxMlpxdFBxUUlEbDRJZkI2NUt0cmkxcG8vTXc2cy9zK3I1OTJCVW03ZHJScTd3U1hSY3lrOXV5MUtXS2hvOG4xZnd4MDBNN0Z2UFhQWnBFcTNrUXlRZ0NJK1phekJDd3RabGNTbDRFSjVERGtSdHJ6angrNjQya0FwcitYY0tXMVYzbXA5YmVRd3ZYTm10dCtrckh2c2hmdDZKQlZlYTlvc0pzM3I5a0tGUWcrQTFMN21TU2c4N3hxdmtDa2Z0dEhVRnpIcWtXVHl2aGp4WkNidzQ1ZHpNKzZVNWhlY2d5M1h2NnNMOTNDaEI1VklOaXBrUTg1akVDQXdFQUFhTlRNRkV3SFFZRFZSME9CQllFRk9KNVNVQ2YzS3c3ODczMTNHNUFhUmsyTG5VeU1COEdBMVVkSXdRWU1CYUFGT0o1U1VDZjNLdzc4NzMxM0c1QWFSazJMblV5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0lCQURoWlVwa2xnQWdOQ1NQcXJLU3FmejYwUjBDTllRSTJ0Nmt5S20rUXF3Q2Y2OEhzaEVpUFplZk52K1lBbVFkRTVxWkNkV1AyUFNZWGZiazZzZlZmUUJsZk9RaUkyQzZEdTA4WTY1MkE3a2JZUWU0L2l0SkxpYnhVQXVWMVQxUmc4ZEtBanQzR1NxVmhFdVViYnhiVGx1OHhsTStnbXlQTTNKTEZvMUFDK1NTWjg1UFM5UzFQc2lXb1YyckRhKzNxT0dlazArMWN0MGZlc1pvN1Z3bkYvbWxXU3F2RmEwVzdsem96RE9QajQ4RFBocisyVlJHUFg3Wkx1dVl3eGh4aWhTbGpNaVJCTGRsaEFTNGtLNHRnSXBhY1AvaUJyM2wwR2dWYVRLRTFzYUw1bFBuNXZ1bGd6b004QXIxZEdjczZNL2ZLT0F0ZFdJdWM5aWl6dlUwbTI1a1c4V1VUKzMxb3V4cFhFRHFWUWpiS3NrMWFpZm5xZjhPakNLWmxGcFRTTk5WK002d3JEWXd2VHhGL0wvL0psZmFHb3pqQW1HVU1KcE9JNGtMU3Q3VnJoQ3grbENMKzRGb3o0d1oxL1hRT0p0cG4vbkQ0VnNSdGRnVnZWRzcrUDE5eUd3S0FHdlZTRFpIYmQyaEdEaVJGdGV2ck8rUitZc3EvT2lqYkZ5MnJDalV2a0l3WmQwZk5XZlJqZDlreU1sVnpscGU5U3lPdTluVlZjWkhjZVJYQmlUcTg5MWVUQ2h6Lys4c3c2WjN5SVVqZm92YWZMTmlzWjZmK0RvaGI2VHd3d0JBcGtDZStpQ2FiNGtJWFd5bTU0ZFVCWjRNamd6N3J1b1B3QWkybE10NWVqN1VuOHJHTll1a2xyNUNGb3pRT2ZoK1ROVEpEb3c2aEhxM0VvMThtPC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIElEPSJwZng5NGJjNjJjOS1kZDQ1LWYxNGEtOGFlNS1mNWIwZGM2ZTQ4OTciIElzc3VlSW5zdGFudD0iMjAyNS0wOS0yMVQxMzo0OToyMy45NDFaIiBWZXJzaW9uPSIyLjAiPjxzYW1sOklzc3VlciBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OmVudGl0eSI+aHR0cDovL2xvY2FsaG9zdDo4MDAwL21ldGFkYXRhPC9zYW1sOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4KICA8ZHM6U2lnbmVkSW5mbz48ZHM6Q2Fub25pY2FsaXphdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPgogICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgogIDxkczpSZWZlcmVuY2UgVVJJPSIjcGZ4OTRiYzYyYzktZGQ0NS1mMTRhLThhZTUtZjViMGRjNmU0ODk3Ij48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIvPjxkczpEaWdlc3RWYWx1ZT55eW94RlJ4OUt5SEZoTkM0cDN4SmEvVDI0Q2M9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPkVVOUJKOVdHSnVjKzVINko2a2I2STRFU0ZidlEyQ3kxbnFtanFyTWgxL0NTdU1xODBDek1QSDlaN1l2TUlMeFpKUlZFaVZqRGxQSXFwYnpHUFhKQlJYMkQ5TnI2SXduM3RBa0VtcW9WVXRmdmJ0dUhwV05DdENhcE5BM3NCalB6WEFFZnEzZElIWnBVSE5Nd1R4WGZkTEtTOXZXd1pNNUtLL1htQWlYNnpnTmgydW9FMitZZTgvUHViMFg1NFVIaUcyeUdhYlhpbEV5VUlqWE1FV0pZS1BySU9tMlR5TXZDeENvSndJOUYwYWIxOGdJVkVBL0szVG8xc2Z4Q3pWZ0FJZFgxbzM3enQzdnYvcVJwQkxka3JZdTg3OVpXZVBxenVHT2RTbWFLdkRUd01mWFJYYWhicFhScTloQ3pSaXBzaHg3blc2dExwYUFFNlhBYkJjS1Rndkg3SlBET240ajhpclJOVmtpTEhRM2cvaGlDVWhBUHh2S1NyaURNWEJ5UUszeHBlbTRZUGZVaWRuSU04cmtxUU1UVjYySGpDMjZFV0pDZm5taHBoNUNBNDBPQ0lIbEZzMVZMOXBoQTdaeUZqa0s5Q3FqRkpjR21qQ3JUMDFvK0I2UDYzdmFQeDBrOTBtVjd5MFB2MDdxQmo3UVluNVZMMzRvOUptbU5XWDk0azFFNDdZWGtyUG1IOS9idy9BWGpKTXI0SDAzSXVvRmU3S3ROOTdRL1NwTlhMOVAxaFlEcEk0QjEvRUxISGhUSXR6REpXZk0wU3Z3MndBM1U4VVV6UHBqeWlJajJKT3pCeDhWUFdHSWMvWHZhNkU4V3BKUmMyaFNkbGtVS3grTTNHeGxJeExPNE5jQWZzZGs4aXR0NDNOd2I4bWc2WFRrTVZLdVBhY0xxeVJ3PTwvZHM6U2lnbmF0dXJlVmFsdWU+CjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSUZGVENDQXYyZ0F3SUJBZ0lVR2RkM0tkQW1vR0xjU0JCcEdEOTF2Zml3dE5Bd0RRWUpLb1pJaHZjTkFRRUxCUUF3R2pFWU1CWUdBMVVFQXd3UGQzZDNMbVY0WVcxd2JHVXVZMjl0TUI0WERUSTBNVEF3TWpFMk1UUTBNVm9YRFRNME1Ea3pNREUyTVRRME1Wb3dHakVZTUJZR0ExVUVBd3dQZDNkM0xtVjRZVzF3YkdVdVkyOXRNSUlDSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQWc4QU1JSUNDZ0tDQWdFQWxWa2VGMkNPaVpBdXZ1QTY4WmFhbm9FeHZHK3h5bmhFYk5COVJnSlVsdGtwNkFpTWx5aGp1K2ZMQnpxSDYzNUZqTlpIZ2tLb0NUZnhQVzVScStpUlNtOXF5UDg2UW9nWnNVWW5McHlybm1EVkpjOGw3NUZsZiszVVNkSUtuVkE5bVVBS3l4VW5ZQk1SL1FDc05GY05Ua0djRnp4L0dVR2RScTBpV1k2Y0Y3M284REpSMGMvbGlKak5MNWtweGxLYTI4RFZFZ1pjZUZiOXcrLzE2UG9OSjUxWE80QzdlT3lFZ2dLT0dLOUpCQzg0NUg4ZFVwRkFzN1ZsMVBhbCtkQ1VpTm0rY3dQUVF6OXlwSUJxdDFKNnVJQ1VpVlhKdEFoazVRTjh5dUVwcDQ3VDhGVjNoY0FtajR2RVJUTkNWM0pDQjBGdDE4NlgyV1ZlM1JEVVRLWjRwVmtSZXM4aWhQMldheGtwaHpkMXFSQkhNVGdNRGtCUDNzaXJhVERqa2R0YnlmcHAyNWNmcTJUOEdjWlZ3NHEyT2JhaUtoZU9BeFJkTzFyck9Cck1mZnVqTU84U1p4UkdoMTJacXRQcVFJRGw0SWZCNjVLdHJpMXBvL013NnMvcytyNTkyQlVtN2RyUnE3d1NYUmN5azl1eTFLV0tobzhuMWZ3eDAwTTdGdlBYUFpwRXEza1F5UWdDSStaYXpCQ3d0WmxjU2w0RUo1RERrUnRyemp4KzY0MmtBcHIrWGNLVzFWM21wOWJlUXd2WE5tdHQra3JIdnNoZnQ2SkJWZWE5b3NKczNyOWtLRlFnK0ExTDdtU1NnODd4cXZrQ2tmdHRIVUZ6SHFrV1R5dmhqeFpDYnc0NWR6TSs2VTVoZWNneTNYdjZzTDkzQ2hCNVZJTmlwa1E4NWpFQ0F3RUFBYU5UTUZFd0hRWURWUjBPQkJZRUZPSjVTVUNmM0t3Nzg3MzEzRzVBYVJrMkxuVXlNQjhHQTFVZEl3UVlNQmFBRk9KNVNVQ2YzS3c3ODczMTNHNUFhUmsyTG5VeU1BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dJQkFEaFpVcGtsZ0FnTkNTUHFyS1NxZno2MFIwQ05ZUUkydDZreUttK1Fxd0NmNjhIc2hFaVBaZWZOditZQW1RZEU1cVpDZFdQMlBTWVhmYms2c2ZWZlFCbGZPUWlJMkM2RHUwOFk2NTJBN2tiWVFlNC9pdEpMaWJ4VUF1VjFUMVJnOGRLQWp0M0dTcVZoRXVVYmJ4YlRsdTh4bE0rZ215UE0zSkxGbzFBQytTU1o4NVBTOVMxUHNpV29WMnJEYSszcU9HZWswKzFjdDBmZXNabzdWd25GL21sV1NxdkZhMFc3bHpvekRPUGo0OERQaHIrMlZSR1BYN1pMdXVZd3hoeGloU2xqTWlSQkxkbGhBUzRrSzR0Z0lwYWNQL2lCcjNsMEdnVmFUS0Uxc2FMNWxQbjV2dWxnem9NOEFyMWRHY3M2TS9mS09BdGRXSXVjOWlpenZVMG0yNWtXOFdVVCszMW91eHBYRURxVlFqYktzazFhaWZucWY4T2pDS1psRnBUU05OVitNNndyRFl3dlR4Ri9MLy9KbGZhR296akFtR1VNSnBPSTRrTFN0N1ZyaEN4K2xDTCs0Rm96NHdaMS9YUU9KdHBuL25ENFZzUnRkZ1Z2Vkc3K1AxOXlHd0tBR3ZWU0RaSGJkMmhHRGlSRnRldnJPK1IrWXNxL09pamJGeTJyQ2pVdmtJd1pkMGZOV2ZSamQ5a3lNbFZ6bHBlOVN5T3U5blZWY1pIY2VSWEJpVHE4OTFlVENoei8rOHN3NlozeUlVamZvdmFmTE5pc1o2ZitEb2hiNlR3d3dCQXBrQ2UraUNhYjRrSVhXeW01NGRVQlo0TWpnejdydW9Qd0FpMmxNdDVlajdVbjhyR05ZdWtscjVDRm96UU9maCtUTlRKRG93NmhIcTNFbzE4bTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb25EYXRhIEFkZHJlc3M9Ils6OjFdOjU5MzM0IiBJblJlc3BvbnNlVG89ImlkLWIyMmM5MGRiODhiZjAxZDgyZmZiMGE3YjZmZTI1YWM5ZmNiMmM2NzkiIE5vdE9uT3JBZnRlcj0iMjAyNS0wOS0yMVQxMzo1MDo1My45MzhaIiBSZWNpcGllbnQ9Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL2FjcyIvPjwvc2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uPjwvc2FtbDpTdWJqZWN0PjxzYW1sOkNvbmRpdGlvbnMgTm90QmVmb3JlPSIyMDI1LTA5LTIxVDEzOjQ5OjE0LjI5OFoiIE5vdE9uT3JBZnRlcj0iMjAyNS0wOS0yMVQxMzo1MDo0NC4yOThaIj48c2FtbDpBdWRpZW5jZVJlc3RyaWN0aW9uPjxzYW1sOkF1ZGllbmNlPmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL21ldGFkYXRhPC9zYW1sOkF1ZGllbmNlPjwvc2FtbDpBdWRpZW5jZVJlc3RyaWN0aW9uPjwvc2FtbDpDb25kaXRpb25zPjxzYW1sOkF1dGhuU3RhdGVtZW50IEF1dGhuSW5zdGFudD0iMjAyNS0wOS0yMVQxMzo0NzozNS4xMDNaIiBTZXNzaW9uSW5kZXg9IjRjMzliMTk1NDJjN2NlMWMzOWU5YzA1YmUxN2E3MmE2ZDg4ZTU1YTdkYWJhZGFlZDc4NjEwMGI5ZTM4MGZhMDgiPjxzYW1sOlN1YmplY3RMb2NhbGl0eSBBZGRyZXNzPSJbOjoxXTo1OTMzNCIvPjxzYW1sOkF1dGhuQ29udGV4dD48c2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YWM6Y2xhc3NlczpQYXNzd29yZFByb3RlY3RlZFRyYW5zcG9ydDwvc2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWw6QXV0aG5Db250ZXh0Pjwvc2FtbDpBdXRoblN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0idWlkIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5hbGljZTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvblByaW5jaXBhbE5hbWUiIE5hbWU9InVybjpvaWQ6MS4zLjYuMS40LjEuNTkyMy4xLjEuMS42IiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVyaSI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+YWxpY2VAZXhhbXBsZS5jb208L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJzbiIgTmFtZT0idXJuOm9pZDoyLjUuNC40IiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVyaSI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+U21pdGg8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJnaXZlbk5hbWUiIE5hbWU9InVybjpvaWQ6Mi41LjQuNDIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5BbGljZTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImNuIiBOYW1lPSJ1cm46b2lkOjIuNS40LjMiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5BbGljZSBTbWl0aDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvbkFmZmlsaWF0aW9uIiBOYW1lPSJ1cm46b2lkOjEuMy42LjEuNC4xLjU5MjMuMS4xLjEuMSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPkFkbWluaXN0cmF0b3JzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPlVzZXJzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+", + ), + requestID: "id-b22c90db88bf01d82ffb0a7b6fe25ac9fcb2c679", + }, + want: want{ + id: "alice@example.com", + attributes: map[string][]string{ + "urn:oid:0.9.2342.19200300.100.1.1": {"alice"}, + "urn:oid:1.3.6.1.4.1.5923.1.1.1.6": {"alice@example.com"}, + "urn:oid:2.5.4.4": {"Smith"}, + "urn:oid:2.5.4.42": {"Alice"}, + "urn:oid:2.5.4.3": {"Alice Smith"}, + "urn:oid:1.3.6.1.4.1.5923.1.1.1.1": {"Administrators", "Users"}, + }, + }, + }, + { + name: "missing nameID and custom transient mapping attribute config is not set", fields: fields{ name: "saml", key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), @@ -167,7 +209,41 @@ func TestSession_FetchUser(t *testing.T) { requestID: "id-b22c90db88bf01d82ffb0a7b6fe25ac9fcb2c679", }, want: want{ - err: zerrors.ThrowInvalidArgument(nil, "SAML-EFG32", "Errors.Intent.ResponseInvalid"), + err: zerrors.ThrowInvalidArgument(nil, "SAML-EFG32", "Errors.Intent.MissingTransientMappingAttributeName"), + }, + }, + { + name: "missing nameID and missing custom transient mapping attribute in the response", + fields: fields{ + name: "saml", + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + metadata: []byte("\n \n \n \n \n MIIFFTCCAv2gAwIBAgIUGdd3KdAmoGLcSBBpGD91vfiwtNAwDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMB4XDTI0MTAwMjE2MTQ0MVoXDTM0MDkzMDE2MTQ0MVowGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlVkeF2COiZAuvuA68ZaanoExvG+xynhEbNB9RgJUltkp6AiMlyhju+fLBzqH635FjNZHgkKoCTfxPW5Rq+iRSm9qyP86QogZsUYnLpyrnmDVJc8l75Flf+3USdIKnVA9mUAKyxUnYBMR/QCsNFcNTkGcFzx/GUGdRq0iWY6cF73o8DJR0c/liJjNL5kpxlKa28DVEgZceFb9w+/16PoNJ51XO4C7eOyEggKOGK9JBC845H8dUpFAs7Vl1Pal+dCUiNm+cwPQQz9ypIBqt1J6uICUiVXJtAhk5QN8yuEpp47T8FV3hcAmj4vERTNCV3JCB0Ft186X2WVe3RDUTKZ4pVkRes8ihP2Waxkphzd1qRBHMTgMDkBP3siraTDjkdtbyfpp25cfq2T8GcZVw4q2ObaiKheOAxRdO1rrOBrMffujMO8SZxRGh12ZqtPqQIDl4IfB65Ktri1po/Mw6s/s+r592BUm7drRq7wSXRcyk9uy1KWKho8n1fwx00M7FvPXPZpEq3kQyQgCI+ZazBCwtZlcSl4EJ5DDkRtrzjx+642kApr+XcKW1V3mp9beQwvXNmtt+krHvshft6JBVea9osJs3r9kKFQg+A1L7mSSg87xqvkCkfttHUFzHqkWTyvhjxZCbw45dzM+6U5hecgy3Xv6sL93ChB5VINipkQ85jECAwEAAaNTMFEwHQYDVR0OBBYEFOJ5SUCf3Kw787313G5AaRk2LnUyMB8GA1UdIwQYMBaAFOJ5SUCf3Kw787313G5AaRk2LnUyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBADhZUpklgAgNCSPqrKSqfz60R0CNYQI2t6kyKm+QqwCf68HshEiPZefNv+YAmQdE5qZCdWP2PSYXfbk6sfVfQBlfOQiI2C6Du08Y652A7kbYQe4/itJLibxUAuV1T1Rg8dKAjt3GSqVhEuUbbxbTlu8xlM+gmyPM3JLFo1AC+SSZ85PS9S1PsiWoV2rDa+3qOGek0+1ct0fesZo7VwnF/mlWSqvFa0W7lzozDOPj48DPhr+2VRGPX7ZLuuYwxhxihSljMiRBLdlhAS4kK4tgIpacP/iBr3l0GgVaTKE1saL5lPn5vulgzoM8Ar1dGcs6M/fKOAtdWIuc9iizvU0m25kW8WUT+31ouxpXEDqVQjbKsk1aifnqf8OjCKZlFpTSNNV+M6wrDYwvTxF/L//JlfaGozjAmGUMJpOI4kLSt7VrhCx+lCL+4Foz4wZ1/XQOJtpn/nD4VsRtdgVvVG7+P19yGwKAGvVSDZHbd2hGDiRFtevrO+R+Ysq/OijbFy2rCjUvkIwZd0fNWfRjd9kyMlVzlpe9SyOu9nVVcZHceRXBiTq891eTChz/+8sw6Z3yIUjfovafLNisZ6f+Dohb6TwwwBApkCe+iCab4kIXWym54dUBZ4Mjgz7ruoPwAi2lMt5ej7Un8rGNYuklr5CFozQOfh+TNTJDow6hHq3Eo18m\n \n \n \n \n \n \n MIIFFTCCAv2gAwIBAgIUGdd3KdAmoGLcSBBpGD91vfiwtNAwDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMB4XDTI0MTAwMjE2MTQ0MVoXDTM0MDkzMDE2MTQ0MVowGjEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlVkeF2COiZAuvuA68ZaanoExvG+xynhEbNB9RgJUltkp6AiMlyhju+fLBzqH635FjNZHgkKoCTfxPW5Rq+iRSm9qyP86QogZsUYnLpyrnmDVJc8l75Flf+3USdIKnVA9mUAKyxUnYBMR/QCsNFcNTkGcFzx/GUGdRq0iWY6cF73o8DJR0c/liJjNL5kpxlKa28DVEgZceFb9w+/16PoNJ51XO4C7eOyEggKOGK9JBC845H8dUpFAs7Vl1Pal+dCUiNm+cwPQQz9ypIBqt1J6uICUiVXJtAhk5QN8yuEpp47T8FV3hcAmj4vERTNCV3JCB0Ft186X2WVe3RDUTKZ4pVkRes8ihP2Waxkphzd1qRBHMTgMDkBP3siraTDjkdtbyfpp25cfq2T8GcZVw4q2ObaiKheOAxRdO1rrOBrMffujMO8SZxRGh12ZqtPqQIDl4IfB65Ktri1po/Mw6s/s+r592BUm7drRq7wSXRcyk9uy1KWKho8n1fwx00M7FvPXPZpEq3kQyQgCI+ZazBCwtZlcSl4EJ5DDkRtrzjx+642kApr+XcKW1V3mp9beQwvXNmtt+krHvshft6JBVea9osJs3r9kKFQg+A1L7mSSg87xqvkCkfttHUFzHqkWTyvhjxZCbw45dzM+6U5hecgy3Xv6sL93ChB5VINipkQ85jECAwEAAaNTMFEwHQYDVR0OBBYEFOJ5SUCf3Kw787313G5AaRk2LnUyMB8GA1UdIwQYMBaAFOJ5SUCf3Kw787313G5AaRk2LnUyMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBADhZUpklgAgNCSPqrKSqfz60R0CNYQI2t6kyKm+QqwCf68HshEiPZefNv+YAmQdE5qZCdWP2PSYXfbk6sfVfQBlfOQiI2C6Du08Y652A7kbYQe4/itJLibxUAuV1T1Rg8dKAjt3GSqVhEuUbbxbTlu8xlM+gmyPM3JLFo1AC+SSZ85PS9S1PsiWoV2rDa+3qOGek0+1ct0fesZo7VwnF/mlWSqvFa0W7lzozDOPj48DPhr+2VRGPX7ZLuuYwxhxihSljMiRBLdlhAS4kK4tgIpacP/iBr3l0GgVaTKE1saL5lPn5vulgzoM8Ar1dGcs6M/fKOAtdWIuc9iizvU0m25kW8WUT+31ouxpXEDqVQjbKsk1aifnqf8OjCKZlFpTSNNV+M6wrDYwvTxF/L//JlfaGozjAmGUMJpOI4kLSt7VrhCx+lCL+4Foz4wZ1/XQOJtpn/nD4VsRtdgVvVG7+P19yGwKAGvVSDZHbd2hGDiRFtevrO+R+Ysq/OijbFy2rCjUvkIwZd0fNWfRjd9kyMlVzlpe9SyOu9nVVcZHceRXBiTq891eTChz/+8sw6Z3yIUjfovafLNisZ6f+Dohb6TwwwBApkCe+iCab4kIXWym54dUBZ4Mjgz7ruoPwAi2lMt5ej7Un8rGNYuklr5CFozQOfh+TNTJDow6hHq3Eo18m\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + options: []ProviderOpts{ + WithLinkingAllowed(), + WithCreationAllowed(), + WithAutoCreation(), + WithAutoUpdate(), + WithBinding(saml.HTTPRedirectBinding), + WithSignedRequest(), + WithCustomRequestTracker(&requesttracker.RequestTracker{}), + WithTransientMappingAttributeName("customTransientAttribute"), + }, + rootURL: "http://localhost:8080/idps/228968792372281708/", + timeNow: func() time.Time { + return time.Date(2025, 9, 21, 13, 47, 40, 0, time.UTC) + }, + }, + args: args{ + request: httpPostFormRequest(t, + "http://localhost:8080/idps/228968792372281708/saml/acs", + "232881438356144492", + "PD94bWwgdmVyc2lvbj0iMS4wIj8+CjxzYW1scDpSZXNwb25zZSB4bWxuczpzYW1sPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiB4bWxuczpzYW1scD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIElEPSJwZng0M2UzZjA2YS1lMWU3LWQ5NjctOTZhNi1mY2EyODg5NmM5YjkiIEluUmVzcG9uc2VUbz0iaWQtYjIyYzkwZGI4OGJmMDFkODJmZmIwYTdiNmZlMjVhYzlmY2IyYzY3OSIgVmVyc2lvbj0iMi4wIiBJc3N1ZUluc3RhbnQ9IjIwMjUtMDktMjFUMTM6NDk6MjMuOTM4WiIgRGVzdGluYXRpb249Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL2FjcyI+PHNhbWw6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwOi8vbG9jYWxob3N0OjgwMDAvbWV0YWRhdGE8L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI3JzYS1zaGExIi8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNwZng0M2UzZjA2YS1lMWU3LWQ5NjctOTZhNi1mY2EyODg5NmM5YjkiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48L2RzOlRyYW5zZm9ybXM+PGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+PGRzOkRpZ2VzdFZhbHVlPmRTb2M2VmUvbGFrdmM5cUVrYTlLZlo3ekJxWT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+VWdrdklwVSsreFZPRjlFNG1rZ1hkNUIvRUdmVkc4eEFFd0hPS3BjZDQzQ0dhV3FoTTBGVTZEQ1FyOE5wcC84dENpaHVvWENMMnNGRjZydy9DUmRGUEpFWUhqdldSVm9ESTVwdGRnRDZlZXVqRnN4bzRzTWU5aW83cHhvYVd6STFRWUFSM3oxUER6bDNvaTRnWG9oVXhsSEovTldaRzV1VkdSMnd6dXdLdjA4Uit6U2liOHhiZ3dsUUZiTEdNTzFNZVdJL1pxV0x6UURvY0hxZWxjbG9SL1V4cWsxMnRJU3B2Y25tTEpROVNid1JnYmtrZWZsQng5NzZIUVdURFEyS2M4b1lqZjVZSzlYbDdJVFFFQzE1UFYvZ3hMQXNTU2x0VDlJKzh1YXI1L2lKZlV1eWhVbG5KR0oxd3dSRW9XVXh3aEM0ckhjSFF1K05qUjM0akMvdlRWU1JZSkZNVVpieHp0MXdJVGpsdkxveGlMTktzeG9MbDFhcHArMHk1ZjVlcWJvRGtxTnFCcTdEQ3NjTXk0WTQ2aWdWdWt1Vk1YM21HLzdZeFJCS3lPcTZKQjc0TFVvb0Y3U3oxQTBuU3U4ekNKQ0JJTWVUNXdGdURXYlNaOUw2NC9qbFJYRFpOWEtlMGx6bkRaU2h6UllZdC9EUjFMWTNyQ3BwNTdzT0tVWDJSVzdJYld0Mmp2bDdHc1VELzBUVEZncHZ1M29UMENrRTRSSnhFVDluQnpYRkNtckY4VzVjWE9zWThrSEpZOGJtYk1IbUlNNVRuWkM0QVZaaUd3aGdEKzZBUWpnc3piVnh4amlvRWt6Q1Q1SndKR2NZUEtsQ2I2QWtjY2pRcnpUeUJLZzdaSzBEUEdVbFcrSDYxMGREN3hpM1A0Qit5MlBqRitmMkhqRi9kaUE9PC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRkZUQ0NBdjJnQXdJQkFnSVVHZGQzS2RBbW9HTGNTQkJwR0Q5MXZmaXd0TkF3RFFZSktvWklodmNOQVFFTEJRQXdHakVZTUJZR0ExVUVBd3dQZDNkM0xtVjRZVzF3YkdVdVkyOXRNQjRYRFRJME1UQXdNakUyTVRRME1Wb1hEVE0wTURrek1ERTJNVFEwTVZvd0dqRVlNQllHQTFVRUF3d1BkM2QzTG1WNFlXMXdiR1V1WTI5dE1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0NBZ0VBbFZrZUYyQ09pWkF1dnVBNjhaYWFub0V4dkcreHluaEViTkI5UmdKVWx0a3A2QWlNbHloanUrZkxCenFINjM1RmpOWkhna0tvQ1RmeFBXNVJxK2lSU205cXlQODZRb2dac1VZbkxweXJubURWSmM4bDc1RmxmKzNVU2RJS25WQTltVUFLeXhVbllCTVIvUUNzTkZjTlRrR2NGengvR1VHZFJxMGlXWTZjRjczbzhESlIwYy9saUpqTkw1a3B4bEthMjhEVkVnWmNlRmI5dysvMTZQb05KNTFYTzRDN2VPeUVnZ0tPR0s5SkJDODQ1SDhkVXBGQXM3VmwxUGFsK2RDVWlObStjd1BRUXo5eXBJQnF0MUo2dUlDVWlWWEp0QWhrNVFOOHl1RXBwNDdUOEZWM2hjQW1qNHZFUlROQ1YzSkNCMEZ0MTg2WDJXVmUzUkRVVEtaNHBWa1JlczhpaFAyV2F4a3BoemQxcVJCSE1UZ01Ea0JQM3NpcmFURGprZHRieWZwcDI1Y2ZxMlQ4R2NaVnc0cTJPYmFpS2hlT0F4UmRPMXJyT0JyTWZmdWpNTzhTWnhSR2gxMlpxdFBxUUlEbDRJZkI2NUt0cmkxcG8vTXc2cy9zK3I1OTJCVW03ZHJScTd3U1hSY3lrOXV5MUtXS2hvOG4xZnd4MDBNN0Z2UFhQWnBFcTNrUXlRZ0NJK1phekJDd3RabGNTbDRFSjVERGtSdHJ6angrNjQya0FwcitYY0tXMVYzbXA5YmVRd3ZYTm10dCtrckh2c2hmdDZKQlZlYTlvc0pzM3I5a0tGUWcrQTFMN21TU2c4N3hxdmtDa2Z0dEhVRnpIcWtXVHl2aGp4WkNidzQ1ZHpNKzZVNWhlY2d5M1h2NnNMOTNDaEI1VklOaXBrUTg1akVDQXdFQUFhTlRNRkV3SFFZRFZSME9CQllFRk9KNVNVQ2YzS3c3ODczMTNHNUFhUmsyTG5VeU1COEdBMVVkSXdRWU1CYUFGT0o1U1VDZjNLdzc4NzMxM0c1QWFSazJMblV5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0lCQURoWlVwa2xnQWdOQ1NQcXJLU3FmejYwUjBDTllRSTJ0Nmt5S20rUXF3Q2Y2OEhzaEVpUFplZk52K1lBbVFkRTVxWkNkV1AyUFNZWGZiazZzZlZmUUJsZk9RaUkyQzZEdTA4WTY1MkE3a2JZUWU0L2l0SkxpYnhVQXVWMVQxUmc4ZEtBanQzR1NxVmhFdVViYnhiVGx1OHhsTStnbXlQTTNKTEZvMUFDK1NTWjg1UFM5UzFQc2lXb1YyckRhKzNxT0dlazArMWN0MGZlc1pvN1Z3bkYvbWxXU3F2RmEwVzdsem96RE9QajQ4RFBocisyVlJHUFg3Wkx1dVl3eGh4aWhTbGpNaVJCTGRsaEFTNGtLNHRnSXBhY1AvaUJyM2wwR2dWYVRLRTFzYUw1bFBuNXZ1bGd6b004QXIxZEdjczZNL2ZLT0F0ZFdJdWM5aWl6dlUwbTI1a1c4V1VUKzMxb3V4cFhFRHFWUWpiS3NrMWFpZm5xZjhPakNLWmxGcFRTTk5WK002d3JEWXd2VHhGL0wvL0psZmFHb3pqQW1HVU1KcE9JNGtMU3Q3VnJoQ3grbENMKzRGb3o0d1oxL1hRT0p0cG4vbkQ0VnNSdGRnVnZWRzcrUDE5eUd3S0FHdlZTRFpIYmQyaEdEaVJGdGV2ck8rUitZc3EvT2lqYkZ5MnJDalV2a0l3WmQwZk5XZlJqZDlreU1sVnpscGU5U3lPdTluVlZjWkhjZVJYQmlUcTg5MWVUQ2h6Lys4c3c2WjN5SVVqZm92YWZMTmlzWjZmK0RvaGI2VHd3d0JBcGtDZStpQ2FiNGtJWFd5bTU0ZFVCWjRNamd6N3J1b1B3QWkybE10NWVqN1VuOHJHTll1a2xyNUNGb3pRT2ZoK1ROVEpEb3c2aEhxM0VvMThtPC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIElEPSJwZng5NGJjNjJjOS1kZDQ1LWYxNGEtOGFlNS1mNWIwZGM2ZTQ4OTciIElzc3VlSW5zdGFudD0iMjAyNS0wOS0yMVQxMzo0OToyMy45NDFaIiBWZXJzaW9uPSIyLjAiPjxzYW1sOklzc3VlciBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OmVudGl0eSI+aHR0cDovL2xvY2FsaG9zdDo4MDAwL21ldGFkYXRhPC9zYW1sOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4KICA8ZHM6U2lnbmVkSW5mbz48ZHM6Q2Fub25pY2FsaXphdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPgogICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgogIDxkczpSZWZlcmVuY2UgVVJJPSIjcGZ4OTRiYzYyYzktZGQ0NS1mMTRhLThhZTUtZjViMGRjNmU0ODk3Ij48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIvPjxkczpEaWdlc3RWYWx1ZT55eW94RlJ4OUt5SEZoTkM0cDN4SmEvVDI0Q2M9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPkVVOUJKOVdHSnVjKzVINko2a2I2STRFU0ZidlEyQ3kxbnFtanFyTWgxL0NTdU1xODBDek1QSDlaN1l2TUlMeFpKUlZFaVZqRGxQSXFwYnpHUFhKQlJYMkQ5TnI2SXduM3RBa0VtcW9WVXRmdmJ0dUhwV05DdENhcE5BM3NCalB6WEFFZnEzZElIWnBVSE5Nd1R4WGZkTEtTOXZXd1pNNUtLL1htQWlYNnpnTmgydW9FMitZZTgvUHViMFg1NFVIaUcyeUdhYlhpbEV5VUlqWE1FV0pZS1BySU9tMlR5TXZDeENvSndJOUYwYWIxOGdJVkVBL0szVG8xc2Z4Q3pWZ0FJZFgxbzM3enQzdnYvcVJwQkxka3JZdTg3OVpXZVBxenVHT2RTbWFLdkRUd01mWFJYYWhicFhScTloQ3pSaXBzaHg3blc2dExwYUFFNlhBYkJjS1Rndkg3SlBET240ajhpclJOVmtpTEhRM2cvaGlDVWhBUHh2S1NyaURNWEJ5UUszeHBlbTRZUGZVaWRuSU04cmtxUU1UVjYySGpDMjZFV0pDZm5taHBoNUNBNDBPQ0lIbEZzMVZMOXBoQTdaeUZqa0s5Q3FqRkpjR21qQ3JUMDFvK0I2UDYzdmFQeDBrOTBtVjd5MFB2MDdxQmo3UVluNVZMMzRvOUptbU5XWDk0azFFNDdZWGtyUG1IOS9idy9BWGpKTXI0SDAzSXVvRmU3S3ROOTdRL1NwTlhMOVAxaFlEcEk0QjEvRUxISGhUSXR6REpXZk0wU3Z3MndBM1U4VVV6UHBqeWlJajJKT3pCeDhWUFdHSWMvWHZhNkU4V3BKUmMyaFNkbGtVS3grTTNHeGxJeExPNE5jQWZzZGs4aXR0NDNOd2I4bWc2WFRrTVZLdVBhY0xxeVJ3PTwvZHM6U2lnbmF0dXJlVmFsdWU+CjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSUZGVENDQXYyZ0F3SUJBZ0lVR2RkM0tkQW1vR0xjU0JCcEdEOTF2Zml3dE5Bd0RRWUpLb1pJaHZjTkFRRUxCUUF3R2pFWU1CWUdBMVVFQXd3UGQzZDNMbVY0WVcxd2JHVXVZMjl0TUI0WERUSTBNVEF3TWpFMk1UUTBNVm9YRFRNME1Ea3pNREUyTVRRME1Wb3dHakVZTUJZR0ExVUVBd3dQZDNkM0xtVjRZVzF3YkdVdVkyOXRNSUlDSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQWc4QU1JSUNDZ0tDQWdFQWxWa2VGMkNPaVpBdXZ1QTY4WmFhbm9FeHZHK3h5bmhFYk5COVJnSlVsdGtwNkFpTWx5aGp1K2ZMQnpxSDYzNUZqTlpIZ2tLb0NUZnhQVzVScStpUlNtOXF5UDg2UW9nWnNVWW5McHlybm1EVkpjOGw3NUZsZiszVVNkSUtuVkE5bVVBS3l4VW5ZQk1SL1FDc05GY05Ua0djRnp4L0dVR2RScTBpV1k2Y0Y3M284REpSMGMvbGlKak5MNWtweGxLYTI4RFZFZ1pjZUZiOXcrLzE2UG9OSjUxWE80QzdlT3lFZ2dLT0dLOUpCQzg0NUg4ZFVwRkFzN1ZsMVBhbCtkQ1VpTm0rY3dQUVF6OXlwSUJxdDFKNnVJQ1VpVlhKdEFoazVRTjh5dUVwcDQ3VDhGVjNoY0FtajR2RVJUTkNWM0pDQjBGdDE4NlgyV1ZlM1JEVVRLWjRwVmtSZXM4aWhQMldheGtwaHpkMXFSQkhNVGdNRGtCUDNzaXJhVERqa2R0YnlmcHAyNWNmcTJUOEdjWlZ3NHEyT2JhaUtoZU9BeFJkTzFyck9Cck1mZnVqTU84U1p4UkdoMTJacXRQcVFJRGw0SWZCNjVLdHJpMXBvL013NnMvcytyNTkyQlVtN2RyUnE3d1NYUmN5azl1eTFLV0tobzhuMWZ3eDAwTTdGdlBYUFpwRXEza1F5UWdDSStaYXpCQ3d0WmxjU2w0RUo1RERrUnRyemp4KzY0MmtBcHIrWGNLVzFWM21wOWJlUXd2WE5tdHQra3JIdnNoZnQ2SkJWZWE5b3NKczNyOWtLRlFnK0ExTDdtU1NnODd4cXZrQ2tmdHRIVUZ6SHFrV1R5dmhqeFpDYnc0NWR6TSs2VTVoZWNneTNYdjZzTDkzQ2hCNVZJTmlwa1E4NWpFQ0F3RUFBYU5UTUZFd0hRWURWUjBPQkJZRUZPSjVTVUNmM0t3Nzg3MzEzRzVBYVJrMkxuVXlNQjhHQTFVZEl3UVlNQmFBRk9KNVNVQ2YzS3c3ODczMTNHNUFhUmsyTG5VeU1BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dJQkFEaFpVcGtsZ0FnTkNTUHFyS1NxZno2MFIwQ05ZUUkydDZreUttK1Fxd0NmNjhIc2hFaVBaZWZOditZQW1RZEU1cVpDZFdQMlBTWVhmYms2c2ZWZlFCbGZPUWlJMkM2RHUwOFk2NTJBN2tiWVFlNC9pdEpMaWJ4VUF1VjFUMVJnOGRLQWp0M0dTcVZoRXVVYmJ4YlRsdTh4bE0rZ215UE0zSkxGbzFBQytTU1o4NVBTOVMxUHNpV29WMnJEYSszcU9HZWswKzFjdDBmZXNabzdWd25GL21sV1NxdkZhMFc3bHpvekRPUGo0OERQaHIrMlZSR1BYN1pMdXVZd3hoeGloU2xqTWlSQkxkbGhBUzRrSzR0Z0lwYWNQL2lCcjNsMEdnVmFUS0Uxc2FMNWxQbjV2dWxnem9NOEFyMWRHY3M2TS9mS09BdGRXSXVjOWlpenZVMG0yNWtXOFdVVCszMW91eHBYRURxVlFqYktzazFhaWZucWY4T2pDS1psRnBUU05OVitNNndyRFl3dlR4Ri9MLy9KbGZhR296akFtR1VNSnBPSTRrTFN0N1ZyaEN4K2xDTCs0Rm96NHdaMS9YUU9KdHBuL25ENFZzUnRkZ1Z2Vkc3K1AxOXlHd0tBR3ZWU0RaSGJkMmhHRGlSRnRldnJPK1IrWXNxL09pamJGeTJyQ2pVdmtJd1pkMGZOV2ZSamQ5a3lNbFZ6bHBlOVN5T3U5blZWY1pIY2VSWEJpVHE4OTFlVENoei8rOHN3NlozeUlVamZvdmFmTE5pc1o2ZitEb2hiNlR3d3dCQXBrQ2UraUNhYjRrSVhXeW01NGRVQlo0TWpnejdydW9Qd0FpMmxNdDVlajdVbjhyR05ZdWtscjVDRm96UU9maCtUTlRKRG93NmhIcTNFbzE4bTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb25EYXRhIEFkZHJlc3M9Ils6OjFdOjU5MzM0IiBJblJlc3BvbnNlVG89ImlkLWIyMmM5MGRiODhiZjAxZDgyZmZiMGE3YjZmZTI1YWM5ZmNiMmM2NzkiIE5vdE9uT3JBZnRlcj0iMjAyNS0wOS0yMVQxMzo1MDo1My45MzhaIiBSZWNpcGllbnQ9Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL2FjcyIvPjwvc2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uPjwvc2FtbDpTdWJqZWN0PjxzYW1sOkNvbmRpdGlvbnMgTm90QmVmb3JlPSIyMDI1LTA5LTIxVDEzOjQ5OjE0LjI5OFoiIE5vdE9uT3JBZnRlcj0iMjAyNS0wOS0yMVQxMzo1MDo0NC4yOThaIj48c2FtbDpBdWRpZW5jZVJlc3RyaWN0aW9uPjxzYW1sOkF1ZGllbmNlPmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9pZHBzLzIyODk2ODc5MjM3MjI4MTcwOC9zYW1sL21ldGFkYXRhPC9zYW1sOkF1ZGllbmNlPjwvc2FtbDpBdWRpZW5jZVJlc3RyaWN0aW9uPjwvc2FtbDpDb25kaXRpb25zPjxzYW1sOkF1dGhuU3RhdGVtZW50IEF1dGhuSW5zdGFudD0iMjAyNS0wOS0yMVQxMzo0NzozNS4xMDNaIiBTZXNzaW9uSW5kZXg9IjRjMzliMTk1NDJjN2NlMWMzOWU5YzA1YmUxN2E3MmE2ZDg4ZTU1YTdkYWJhZGFlZDc4NjEwMGI5ZTM4MGZhMDgiPjxzYW1sOlN1YmplY3RMb2NhbGl0eSBBZGRyZXNzPSJbOjoxXTo1OTMzNCIvPjxzYW1sOkF1dGhuQ29udGV4dD48c2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YWM6Y2xhc3NlczpQYXNzd29yZFByb3RlY3RlZFRyYW5zcG9ydDwvc2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWw6QXV0aG5Db250ZXh0Pjwvc2FtbDpBdXRoblN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0idWlkIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5hbGljZTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvblByaW5jaXBhbE5hbWUiIE5hbWU9InVybjpvaWQ6MS4zLjYuMS40LjEuNTkyMy4xLjEuMS42IiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVyaSI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+YWxpY2VAZXhhbXBsZS5jb208L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJzbiIgTmFtZT0idXJuOm9pZDoyLjUuNC40IiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVyaSI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+U21pdGg8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJnaXZlbk5hbWUiIE5hbWU9InVybjpvaWQ6Mi41LjQuNDIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5BbGljZTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImNuIiBOYW1lPSJ1cm46b2lkOjIuNS40LjMiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5BbGljZSBTbWl0aDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvbkFmZmlsaWF0aW9uIiBOYW1lPSJ1cm46b2lkOjEuMy42LjEuNC4xLjU5MjMuMS4xLjEuMSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPkFkbWluaXN0cmF0b3JzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPlVzZXJzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+", + ), + requestID: "id-b22c90db88bf01d82ffb0a7b6fe25ac9fcb2c679", + }, + want: want{ + err: zerrors.ThrowInvalidArgument(nil, "SAML-swwg2", "Errors.Intent.MissingSingleMappingAttribute"), }, }, { From c6392e175fbcbd3f78b5cd8a060a1974c6c486a2 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Fri, 1 Aug 2025 12:27:26 +0200 Subject: [PATCH 8/9] docs: update roadmap (#10369) # Which Problems Are Solved The roadmap page is not up to date with the latest changes that have been released with Zitadel v4. # How the Problems Are Solved Update the doc according to #10309 # Dependencies - https://github.com/zitadel/zitadel/pull/10249 -> Update the API docs with deprecated endpoints - https://github.com/zitadel/zitadel/pull/10364 -> Actions v2 beta to v2 # Additional Context - Closes #10309 --- docs/docs/product/roadmap.mdx | 131 ++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 22 deletions(-) diff --git a/docs/docs/product/roadmap.mdx b/docs/docs/product/roadmap.mdx index b61323fa90..a7409cead2 100644 --- a/docs/docs/product/roadmap.mdx +++ b/docs/docs/product/roadmap.mdx @@ -293,7 +293,7 @@ Excitingly, v3 introduces the foundational elements for Actions V2, opening up a ### v4.x -**Current State**: Implementation +**Current State**: General Availability / Stable
@@ -311,9 +311,13 @@ Excitingly, v3 introduces the foundational elements for Actions V2, opening up a This change, along with standardized naming and improved documentation, will simplify integration, accelerate development, and create a more intuitive experience for our customers and community. Resources integrated in this release: - - Instances + - Applications (in beta) + - Authorizations (in beta) + - Instances (in beta) - Organizations - - Projects + - Permissions (in beta) + - Projects (in beta) + - Settings (beta) now includes 3 new endpoints: `ListOrganizationSettings()`, `SetOrganizationSettings()` and `DeleteOrganizationSettings()` - Users For more details read the [Github Issue](https://github.com/zitadel/zitadel/issues/6305) @@ -369,40 +373,123 @@ Excitingly, v3 introduces the foundational elements for Actions V2, opening up a We're officially moving our new Login UI v2 from beta to General Availability. Starting now, it will be the default login experience for all new customers. - With this release, 8.0we are also focused on implementing previously missing features, such as device authorization and LDAP IDP support, to make the new UI fully feature-complete. + With this release, 8.0 we are also focused on implementing previously missing features, such as device authorization and LDAP IDP support, to make the new UI fully feature-complete. - - [Hosted Login V2](http://localhost:3000/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) + - [Hosted Login V2](../guides/integrate/login/hosted-login#hosted-login-version-2-beta)
- Web Keys + Actions v2 - Web Keys in ZITADEL are used to sign and verify JSON Web Tokens (JWT). - ID tokens are created, signed and returned by ZITADEL when a OpenID connect (OIDC) or OAuth2 authorization flow completes and a user is authenticated. - Based on customer and community feedback, we've updated our key management system. You now have full manual control over key generation and rotation, instead of the previous automatic process. + This API enables you to manage custom executions and targets—formerly known as actions—across your entire ZITADEL instance. + With Actions V2, you gain significantly more flexibility to tailor ZITADEL’s behavior compared to previous versions. + Actions are now available instance-wide, eliminating the need to configure them for each organization individually. + ZITADEL no longer restricts the implementation language, tooling, or runtime for action executions. + Instead, you define external endpoints that are called by ZITADEL and maintained by you. - Read the full description about Web Keys in our [Documentation](https://zitadel.com/docs/guides/integrate/login/oidc/webkeys). + - [Actions V2](../apis/resources/action_service_v2) +
+ + +
+ Deprecated endpoints + + + +
+ Organization Objects V1 > Users V1 + + - `AddMachineKey()` + - `AddMachineUser()` + - `AddPersonalAccessToken()` + - `BulkRemoveUserMetadata()` + - `BulkSetUserMetadata()` + - `GenerateMachineSecret()` + - `GetMachineKeyByIDs()` + - `GetOrgByDomainGlobal()` + - `GetPersonalAccessTokenByIDs()` + - `GetUserMetadata()` + - `ListAppKeys()` + - `ListMachineKeys()` + - `ListPersonalAccessTokens()` + - `ListUserMetadata()` + - `RemoveMachineKey()` + - `RemoveMachineSecret()` + - `RemovePersonalAccessToken()` + - `RemoveUserMetadata()` + - `SetUserMetadata()` + - `UpdateHumanPhone()` + - `UpdateMachine()` + - `UpdateUserName()`
- SCIM 2.0 Server - User Resource + Projects V1 - The Zitadel SCIM v2 service provider interface enables seamless integration of identity and access management (IAM) systems with Zitadel, following the System for Cross-domain Identity Management (SCIM) v2.0 specification. - This interface allows standardized management of IAM resources, making it easier to automate user provisioning and deprovisioning. - - - [SCIM 2.0 API](https://zitadel.com/docs/apis/scim2) - - [Manage Users Guide](https://zitadel.com/docs/guides/manage/user/scim2) + - `AddProject()` + - `AddProjectGrant()` + - `AddProjectRole()` + - `BulkAddProjectRoles()` + - `DeactivateProject()` + - `DeactivateProjectGrant()` + - `GetGrantedProjectByID()` + - `GetProjectByID()` + - `GetProjectGrantByID()` + - `ListAllProjectGrants()` + - `ListGrantedProjectRoles()` + - `ListGrantedProjects()` + - `ListProjectGrants()` + - `ListProjectRoles()` + - `ListProjects()` + - `ReactivateProject()` + - `ReactivateProjectGrant()` + - `RemoveProject()` + - `RemoveProjectGrant()` + - `RemoveProjectRole()` + - `UpdateProject()` + - `UpdateProjectGrant()` + - `UpdateProjectRole()`
- Caches + Members V1 - ZITADEL supports the use of a caches to speed up the lookup of frequently needed objects. - As opposed to HTTP caches which might reside between ZITADEL and end-user applications, the cache build into ZITADEL uses active invalidation when an object gets updated. - Another difference is that HTTP caches only cache the result of a complete request and the built-in cache stores objects needed for the internal business logic. - For example, each request made to ZITADEL needs to retrieve and set instance information in middleware. + - `AddIAMMember()` + - `AddOrgMember()` + - `AddProjectGrantMember()` + - `AddProjectMember()` + - `ListIAMMembers()` + - `ListOrgMembers()` + - `ListProjectGrantMembers()` + - `ListProjectMembers()` + - `ListUserMemberships()` + - `RemoveIAMMember()` + - `RemoveOrgMember()` + - `RemoveProjectGrantMember()` + - `RemoveProjectMember()` + - `UpdateIAMMember()` + - `UpdateOrgMember()` + - `UpdateProjectGrantMember()` + - `UpdateProjectMember()` +
- Read more about Zitadel Caches [here](https://zitadel.com/docs/self-hosting/manage/cache) +
+ Instance Lifecycle V1 > System Service V1 + + - `AddInstanceTrustedDomain()` + - `GetMyInstance()` + - `ListInstanceDomains()` + - `ListInstanceTrustedDomains()` + - `RemoveInstanceTrustedDomain()` + +
+ +
+ Instance Objects V1 > Organizations V1 + + - `GetDefaultOrg()` + - `GetOrgByID()` + - `IsOrgUnique()`
From f73b2fefd6a6cbc82b757a73d4eebd4dfd98f2cf Mon Sep 17 00:00:00 2001 From: Maximilian Date: Fri, 1 Aug 2025 13:09:47 +0200 Subject: [PATCH 9/9] docs(inconsistent naming): Organization ID (#10370) # Which Problems Are Solved partially #9342 # How the Problems Are Solved Suggested changes. "Resource Owner" will remain in a couple of places, since these are terms that are used in console / APIs. # Additional Changes # Additional Context --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- docs/docs/apis/actions/objects.md | 2 +- docs/docs/apis/openidoauth/claims.md | 6 +++--- docs/docs/apis/openidoauth/scopes.md | 6 +++--- docs/docs/guides/manage/console/projects.mdx | 4 ++-- docs/docs/guides/migrate/sources/zitadel.md | 4 ++-- docs/docs/guides/solution-scenarios/b2b.mdx | 2 +- docs/src/components/authrequest.jsx | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/docs/apis/actions/objects.md b/docs/docs/apis/actions/objects.md index 41307ee580..bbde0f22d3 100644 --- a/docs/docs/apis/actions/objects.md +++ b/docs/docs/apis/actions/objects.md @@ -145,7 +145,7 @@ This object contains context information about the request to the [authorization - `requestedOrgDomain` *bool* - `applicationResourceOwner` *string* - `privateLabelingSetting` *Number* -
  • 0: Unspecified
  • 1: Enforce project resource owner policy
  • 2: Allow login user resource owner policy
+
  • 0: Unspecified
  • 1: Enforce project's policy
  • 2: Allow user's organization login policy
- `selectedIdpConfigId` *string* - `linkingUsers` Array of [*ExternalUser*](#external-user) - `passwordVerified` *bool* diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index b7424aaf1d..c82e3a3883 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -111,6 +111,6 @@ ZITADEL reserves some claims to assert certain data. Please check out the [reser | urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on the current project (where your client belongs to). | | urn:zitadel:iam:org:project:\{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. | | urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. | -| urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. | -| urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the name of the resource owner organisation of the user. | -| urn:zitadel:iam:user:resourceowner:primary_domain | `{"urn:zitadel:iam:user:resourceowner:primary_domain": "acme.ch"}` | This claim represents the primary domain of the resource owner organisation of the user. | +| urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the user's organization ID. | +| urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the user's organization's name. | +| urn:zitadel:iam:user:resourceowner:primary_domain | `{"urn:zitadel:iam:user:resourceowner:primary_domain": "acme.ch"}` | This claim represents the user's organization's primary domain. | diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index d1fe9c7c5b..c7efa914c6 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -31,8 +31,8 @@ In addition to the standard compliant scopes we utilize the following scopes. | `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | | `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | | `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. | -| `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | -| `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | +| `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested project id will be added to the audience of the access token | +| `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project id will be added to the audience of the access token | | `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. | -| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the resource owner (the users organization) will be included in the token. | +| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the user's organization will be included in the token. | | `urn:zitadel:iam:org:idp:id:{idp_id}` | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. | diff --git a/docs/docs/guides/manage/console/projects.mdx b/docs/docs/guides/manage/console/projects.mdx index 53abea2dac..9fa41d850e 100644 --- a/docs/docs/guides/manage/console/projects.mdx +++ b/docs/docs/guides/manage/console/projects.mdx @@ -77,8 +77,8 @@ You can choose from | Setting | Description | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Unspecified | If nothing is specified the default will trigger. (System settings) | -| Enforce project resource owner policy | This setting will enforce the private labeling of the organization (resource owner) of the project through the whole login process. | -| Allow Login User resource owner policy | With this setting first the private labeling of the organization (resource owner) of the project will trigger. As soon as the user and its organization (resource owner) is identified by ZITADEL, the settings will change to the organization of the user. | +| Enforce project's policy | This setting will enforce the private labeling of the organization of the project through the whole login process. | +| Allow login user policy | With this setting first the private labeling of the organization of the project will trigger. As soon as the user and its organization is identified by ZITADEL, the settings will change to the organization of the user. | In a B2B use case, you would typically use the organization setting. If you want to omit organization detection, you can preselect an organization with the [primary domain scope](/apis/openidoauth/scopes#reserved-scopes) (ex. `urn:zitadel:iam:org:domain:primary:{domainname}`). diff --git a/docs/docs/guides/migrate/sources/zitadel.md b/docs/docs/guides/migrate/sources/zitadel.md index 99b6e1d64e..3f97629951 100644 --- a/docs/docs/guides/migrate/sources/zitadel.md +++ b/docs/docs/guides/migrate/sources/zitadel.md @@ -80,7 +80,7 @@ curl --request POST \ | Field | Type | Description | | ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| org_ids | list of strings | provide a list of organizationIDs to select which organizations should be exported (eg, `[ "70669144072186707", "70671105999825752" ]`); leave empty to export all | +| org_ids | list of strings | provide a list of Organization IDs to select which organizations should be exported (eg, `[ "70669144072186707", "70671105999825752" ]`); leave empty to export all | | excluded_org_ids | list of strings | to exclude several organization, if for example no organizations are selected | | with_passwords | bool | to include the hashed_passwords of the users in the export | | with_otp | bool | to include the OTP-code of the users in the export | @@ -143,7 +143,7 @@ curl --request POST \ | Field | Type | Description | | ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| org_ids | list of strings | provide a list of organizationIDs to select which organizations should be exported (eg, `[ "70669144072186707", "70671105999825752" ]`); leave empty to export all | +| org_ids | list of strings | provide a list of Organization IDs to select which organizations should be exported (eg, `[ "70669144072186707", "70671105999825752" ]`); leave empty to export all | | excluded_org_ids | list of strings | to exclude several organization, if for example no organizations are selected | | with_passwords | bool | to include the hashed_passwords of the users in the export | | with_otp | bool | to include the OTP-code of the users in the export | diff --git a/docs/docs/guides/solution-scenarios/b2b.mdx b/docs/docs/guides/solution-scenarios/b2b.mdx index 144853b3f8..7ec1129b52 100644 --- a/docs/docs/guides/solution-scenarios/b2b.mdx +++ b/docs/docs/guides/solution-scenarios/b2b.mdx @@ -43,7 +43,7 @@ In order to define the need of the **Portal Application** some planning consider You can decide whether a organization is preselected for the login or if the user is redirected to the default login screen. Using OpenID Connect, you can send the user to a specific organization by defining the organization in a [reserved scope](/docs/apis/openidoauth/scopes#reserved-scopes) (primary domain). Settings to the branding or the login options of the organization can be made from the organization section in [Console](/docs/concepts/features/console). -The behavior of the login branding can be set in your projects detail page. You can choose the branding of the selected organization, the user resource owner, or the projects resource owner. +The behavior of the login branding can be set in your projects detail page. You can choose the branding of the selected organization, the user's organization, or the project's organization. ### Organizations diff --git a/docs/src/components/authrequest.jsx b/docs/src/components/authrequest.jsx index 82ecc91337..f864b1cbbb 100644 --- a/docs/src/components/authrequest.jsx +++ b/docs/src/components/authrequest.jsx @@ -111,7 +111,7 @@ export function SetAuthRequest() { "urn:zitadel:iam:org:project:id:zitadel:aud", "urn:zitadel:iam:user:metadata", `urn:zitadel:iam:org:id:${ - organizationId ? organizationId : "[organizationId]" + organizationId ? organizationId : "[Organization ID]" }`, ]; @@ -525,7 +525,7 @@ export function SetAuthRequest() { const value = event.target.value; setOrganizationId(value); allScopes[7] = `urn:zitadel:iam:org:id:${ - value ? value : "[organizationId]" + value ? value : "[Organization ID]" }`; toggleScope(8, true); setScope(