Merge branch 'clean-transactional-propsal' into rt-domains

This commit is contained in:
adlerhurst
2025-08-07 10:23:23 +02:00
124 changed files with 16908 additions and 2403 deletions

View File

@@ -8,9 +8,13 @@ ENV SHELL=/bin/bash \
PNPM_HOME=/home/node/.local/share/pnpm \ PNPM_HOME=/home/node/.local/share/pnpm \
PATH=/home/node/.local/share/pnpm:$PATH PATH=/home/node/.local/share/pnpm:$PATH
RUN apt-get update && \ RUN apt-get update && \
apt-get --no-install-recommends install -y \ apt-get --no-install-recommends install -y \
# Cypress dependencies
libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb && \ libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb && \
apt-get clean && \ apt-get clean && \
corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@10.13.1 --activate
COPY --chown=node:node commands /commands
USER node

View File

@@ -0,0 +1,2 @@
*
!commands

View File

@@ -0,0 +1,39 @@
#!/bin/bash
if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then
set -e
fi
echo
echo
echo
echo -e "THANKS FOR CONTRIBUTING TO ZITADEL 🚀"
echo
echo "Your dev container is configured for fixing login integration tests."
echo "The login is running in a separate container with the same configuration."
echo "It calls the mock-zitadel container which provides a mocked Zitadel gRPC API."
echo
echo "Also the test suite is configured correctly."
echo "For example, run a single test file:"
echo "pnpm cypress run --spec integration/integration/login.cy.ts"
echo
echo "You can also run the test interactively."
echo "However, this is only possible from outside the dev container."
echo "On your host machine, run:"
echo "cd apps/login"
echo "pnpm cypress open"
echo
echo "If you want to change the login code, you can replace the login container by a hot reloading dev server."
echo "docker stop login-integration"
echo "pnpm turbo dev"
echo "Navigate to the page you want to fix, for example:"
echo "http://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc"
echo "Change some code and reload the page for instant feedback."
echo
echo "When you are done, make sure all integration tests pass:"
echo "pnpm cypress run"
echo
if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then
exit 0
fi

View File

@@ -0,0 +1,18 @@
#!/bin/bash
if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then
echo "Running in fail-on-errors mode"
set -e
fi
pnpm install --frozen-lockfile \
--filter @zitadel/login \
--filter @zitadel/client \
--filter @zitadel/proto \
--filter zitadel-monorepo
pnpm cypress install
pnpm test:integration:login
if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then
exit 0
fi

View File

@@ -0,0 +1,30 @@
#!/bin/bash
if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then
set -e
fi
echo
echo
echo
echo -e "THANKS FOR CONTRIBUTING TO ZITADEL 🚀"
echo
echo "Your dev container is configured for fixing linting and unit tests."
echo "No other services are running alongside this container."
echo
echo "To fix all auto-fixable linting errors, run:"
echo "pnpm turbo lint:fix"
echo
echo "To watch console linting errors, run:"
echo "pnpm turbo watch lint --filter console"
echo
echo "To watch @zitadel/client unit test failures, run:"
echo "pnpm turbo watch test:unit --filter @zitadel/client"
echo
echo "To watch @zitadel/login relevant unit tests and linting failures, run:"
echo "pnpm turbo watch lint test:unit --filter @zitadel/login..."
echo
if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then
exit 0
fi

View File

@@ -0,0 +1,12 @@
#!/bin/bash
if [ "$FAIL_COMMANDS_ON_ERRORS" == "true" ]; then
set -e
fi
pnpm install --frozen-lockfile --recursive
pnpm turbo lint test:unit
if [ "$FAIL_COMMANDS_ON_ERRORS" != "true" ]; then
exit 0
fi

View File

@@ -1,15 +1,15 @@
{ {
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json", "$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
"name": "devcontainer", "name": "Base: Build and Run the Components you need",
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yaml",
"service": "devcontainer", "service": "devcontainer",
"runServices": [
"devContainer",
"db"
],
"workspaceFolder": "/workspaces", "workspaceFolder": "/workspaces",
"features": { "remoteEnv": {
"ghcr.io/devcontainers/features/go:1": { "DISPLAY": ""
"version": "1.24"
},
"ghcr.io/guiyomh/features/golangci-lint:0": {},
"ghcr.io/jungaretti/features/make:1": {}
}, },
"forwardPorts": [ "forwardPorts": [
3000, 3000,
@@ -17,12 +17,13 @@
4200, 4200,
8080 8080
], ],
"onCreateCommand": "pnpm install -g sass@1.64.1", "onCreateCommand": "pnpm install --frozen-lockfile --recursive --prefer-offline",
"customizations": { "features": {
"jetbrains": { "ghcr.io/devcontainers/features/go:1": {
"settings": { "version": "1.24"
"com.intellij:app:HttpConfigurable.use_proxy_pac": true },
} "ghcr.io/guiyomh/features/golangci-lint:0": {},
} "ghcr.io/jungaretti/features/make:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker": {}
} }
} }

View File

@@ -1,20 +1,11 @@
x-build-cache: &build-cache
cache_from:
- type=gha
cache_to:
- type=gha,mode=max
services: services:
devcontainer: devcontainer:
container_name: devcontainer container_name: devcontainer
build: build:
context: . context: ../base
<<: *build-cache
volumes: volumes:
- ../../:/workspaces:cached - ../../:/workspaces:cached
- /tmp/.X11-unix:/tmp/.X11-unix:cached
- home-dir:/home/node:delegated
command: sleep infinity command: sleep infinity
working_dir: /workspaces working_dir: /workspaces
environment: environment:
@@ -39,34 +30,9 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
mock-zitadel:
container_name: mock-zitadel
build:
context: ../../apps/login/integration/core-mock
<<: *build-cache
ports:
- 22220:22220
- 22222:22222
login-integration:
container_name: login-integration
build:
context: ../..
dockerfile: build/login/Dockerfile
<<: *build-cache
image: "${LOGIN_TAG:-zitadel-login:local}"
env_file: ../../apps/login/.env.test
network_mode: service:devcontainer
environment:
NODE_ENV: test
PORT: 3001
depends_on:
mock-zitadel:
condition: service_started
zitadel: zitadel:
image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:v4.0.0-rc.2}"
container_name: zitadel container_name: zitadel
image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:latest}"
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml'
volumes: volumes:
- ../../apps/login/acceptance/pat:/pat:delegated - ../../apps/login/acceptance/pat:/pat:delegated
@@ -89,7 +55,6 @@ services:
build: build:
context: ../../apps/login/acceptance/setup context: ../../apps/login/acceptance/setup
dockerfile: ../go-command.Dockerfile dockerfile: ../go-command.Dockerfile
<<: *build-cache
entrypoint: "./setup.sh" entrypoint: "./setup.sh"
network_mode: service:devcontainer network_mode: service:devcontainer
environment: environment:
@@ -111,7 +76,7 @@ services:
login-acceptance: login-acceptance:
container_name: login container_name: login
image: "${LOGIN_TAG:-ghcr.io/zitadel/zitadel-login:v4.0.0-rc.2}" image: "${LOGIN_TAG:-ghcr.io/zitadel/zitadel-login:latest}"
network_mode: service:devcontainer network_mode: service:devcontainer
volumes: volumes:
- ../../apps/login/.env.test.local:/env-files/.env:cached - ../../apps/login/.env.test.local:/env-files/.env:cached
@@ -126,7 +91,6 @@ services:
dockerfile: ../go-command.Dockerfile dockerfile: ../go-command.Dockerfile
args: args:
- LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine}
<<: *build-cache
environment: environment:
PORT: '3333' PORT: '3333'
command: command:
@@ -151,7 +115,6 @@ services:
dockerfile: ../go-command.Dockerfile dockerfile: ../go-command.Dockerfile
args: args:
- LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine}
<<: *build-cache
network_mode: service:devcontainer network_mode: service:devcontainer
environment: environment:
API_URL: 'http://localhost:8080' API_URL: 'http://localhost:8080'
@@ -175,7 +138,6 @@ services:
# dockerfile: ../../go-command.Dockerfile # dockerfile: ../../go-command.Dockerfile
# args: # args:
# - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine}
# <<: *build-cache
# network_mode: service:devcontainer # network_mode: service:devcontainer
# environment: # environment:
# API_URL: 'http://localhost:8080' # API_URL: 'http://localhost:8080'
@@ -197,7 +159,6 @@ services:
dockerfile: ../go-command.Dockerfile dockerfile: ../go-command.Dockerfile
args: args:
- LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine}
<<: *build-cache
network_mode: service:devcontainer network_mode: service:devcontainer
environment: environment:
API_URL: 'http://localhost:8080' API_URL: 'http://localhost:8080'
@@ -219,7 +180,6 @@ services:
# dockerfile: ../../go-command.Dockerfile # dockerfile: ../../go-command.Dockerfile
# args: # args:
# - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} # - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine}
# <<: *build-cache
# network_mode: service:devcontainer # network_mode: service:devcontainer
# environment: # environment:
# API_URL: 'http://localhost:8080' # API_URL: 'http://localhost:8080'
@@ -236,4 +196,3 @@ services:
volumes: volumes:
postgres-data: postgres-data:
home-dir:

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
"name": "login-integration-debug",
"dockerComposeFile": [
"../base/docker-compose.yml",
"docker-compose.yml"
],
"service": "login-integration-debug",
"runServices": ["login-integration-debug"],
"workspaceFolder": "/workspaces",
"forwardPorts": [3001],
"onCreateCommand": "pnpm install --recursive",
"postAttachCommand": "pnpm turbo daemon clean; pnpm turbo @zitadel/login#dev test:integration:login:debug",
"customizations": {
"jetbrains": {
"settings": {
"com.intellij:app:HttpConfigurable.use_proxy_pac": true
}
}
}
}

View File

@@ -1,9 +0,0 @@
services:
login-integration-debug:
extends:
file: ../base/docker-compose.yml
service: devcontainer
container_name: login-integration-debug
depends_on:
mock-zitadel:
condition: service_started

View File

@@ -1,19 +1,26 @@
{ {
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json", "$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
"name": "login-integration", "name": "Login Integration",
"dockerComposeFile": [ "dockerComposeFile": [
"../base/docker-compose.yml" "./docker-compose.yaml"
], ],
"service": "devcontainer", "service": "login-integration-dev",
"runServices": ["login-integration"], "runServices": [
"workspaceFolder": "/workspaces", "login-integration"
"forwardPorts": [3001], ],
"onCreateCommand": "pnpm install --frozen-lockfile --recursive && cd apps/login/packages/integration && pnpm cypress install && pnpm test:integration:login", "workspaceFolder": "/workspaces/apps/login",
"customizations": { "forwardPorts": [
"jetbrains": { 22220,
"settings": { 22222,
"com.intellij:app:HttpConfigurable.use_proxy_pac": true 3001
} ],
} "remoteEnv": {
"FAIL_COMMANDS_ON_ERRORS": "${localEnv:FAIL_COMMANDS_ON_ERRORS}",
"DISPLAY": ""
},
"updateContentCommand": "/commands/login-integration.update-content.sh",
"postAttachCommand": "/commands/login-integration.post-attach.sh",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker": {}
} }
} }

View File

@@ -0,0 +1,35 @@
services:
login-integration-dev:
extends:
file: ../base/docker-compose.yaml
service: devcontainer
container_name: login-integration-dev
env_file: ../../apps/login/.env.test
environment:
CORE_MOCK_STUBS_URL: http://localhost:22220/v1/stubs
LOGIN_BASE_URL: http://localhost:3001/ui/v2/login
CYPRESS_CACHE_FOLDER: /workspaces/.artifacts/cypress
network_mode: service:mock-zitadel
depends_on:
login-integration:
condition: service_healthy
login-integration:
container_name: login-integration
image: "${LOGIN_TAG:-ghcr.io/zitadel/zitadel-login:latest}"
build:
context: ../..
dockerfile: build/login/Dockerfile
env_file: ../../apps/login/.env.test
network_mode: service:mock-zitadel
mock-zitadel:
container_name: mock-zitadel
build:
context: ../../apps/login/integration/core-mock
additional_contexts:
- zitadel-protos=../../proto
ports:
- 22220:22220
- 22222:22222
- 3001:3001

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.base.schema.json",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20-bookworm",
"name": "Login Subtree Container - Use the Login As If You Would Have Forked the Mirror Repo",
"workspaceFolder": "/login",
"workspaceMount": "source=${localWorkspaceFolder}/apps/login,target=/login,type=bind,consistency=cached",
"mounts": [],
"forwardPorts": [
22220,
22222,
3000,
3001
],
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.24"
},
"ghcr.io/guiyomh/features/golangci-lint:0": {},
"ghcr.io/jungaretti/features/make:1": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker": {}
}
}

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
"name": "turbo-lint-unit-debug",
"dockerComposeFile": [
"../base/docker-compose.yml",
"docker-compose.yml"
],
"service": "turbo-lint-unit-debug",
"runServices": ["turbo-lint-unit-debug"],
"workspaceFolder": "/workspaces",
"forwardPorts": [3001],
"onCreateCommand": "pnpm install --recursive",
"postAttachCommand": "pnpm turbo daemon clean; pnpm turbo watch lint test:unit",
"customizations": {
"jetbrains": {
"settings": {
"com.intellij:app:HttpConfigurable.use_proxy_pac": true
}
}
}
}

View File

@@ -1,6 +0,0 @@
services:
turbo-lint-unit-debug:
extends:
file: ../base/docker-compose.yml
service: devcontainer
container_name: turbo-lint-unit-debug

View File

@@ -1,18 +1,20 @@
{ {
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json", "$schema": "https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.schema.json",
"name": "turbo-lint-unit", "name": "Turbo Lint and Unit Tests",
"dockerComposeFile": [ "dockerComposeFile": [
"../base/docker-compose.yml" "../base/docker-compose.yaml"
], ],
"service": "devcontainer", "service": "devcontainer",
"runServices": ["devcontainer"], "runServices": [
"devcontainer"
],
"workspaceFolder": "/workspaces", "workspaceFolder": "/workspaces",
"postStartCommand": "pnpm install --frozen-lockfile --recursive && pnpm turbo lint test:unit", "forwardPorts": [
"customizations": { 3001
"jetbrains": { ],
"settings": { "remoteEnv": {
"com.intellij:app:HttpConfigurable.use_proxy_pac": true "FAIL_COMMANDS_ON_ERRORS": "${localEnv:FAIL_COMMANDS_ON_ERRORS}"
} },
} "updateContentCommand": "/commands/turbo-lint-unit.update-content.sh",
} "postAttachCommand": "/commands/turbo-lint-unit.post-attach.sh"
} }

View File

@@ -1,11 +1,14 @@
# .git # .git
.codecov .codecov
.github .github
.gitignore .gitignore
.dockerignore .dockerignore
**/Dockerfile **/Dockerfile
/k8s/ **/node_modules
/node_modules/ **/.pnpm-store
**/.turbo
**/.next
/console/src/app/proto/generated/ /console/src/app/proto/generated/
/console/.angular /console/.angular
/console/tmp/ /console/tmp/
@@ -24,5 +27,5 @@ console/.angular
console/node_modules console/node_modules
console/src/app/proto/generated/ console/src/app/proto/generated/
console/tmp/ console/tmp/
.vscode
build/*.Dockerfile build/*.Dockerfile

View File

@@ -102,6 +102,12 @@ jobs:
login_build_image_name: "ghcr.io/zitadel/zitadel-login-build" login_build_image_name: "ghcr.io/zitadel/zitadel-login-build"
node_version: "20" node_version: "20"
login-integration-test:
uses: ./.github/workflows/login-integration-test.yml
needs: [login-container]
with:
login_build_image: ${{ needs.login-container.outputs.login_build_image }}
e2e: e2e:
uses: ./.github/workflows/e2e.yml uses: ./.github/workflows/e2e.yml
needs: [compile] needs: [compile]
@@ -121,6 +127,7 @@ jobs:
lint, lint,
container, container,
login-container, login-container,
login-integration-test,
e2e, e2e,
] ]
if: ${{ github.event_name == 'workflow_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' }}

View File

@@ -77,6 +77,7 @@ jobs:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
path: executables path: executables
pattern: 'zitadel-*-*'
- name: move files one folder up - name: move files one folder up
run: mv */*.tar.gz . && find . -type d -empty -delete run: mv */*.tar.gz . && find . -type d -empty -delete
working-directory: executables working-directory: executables

View File

@@ -53,14 +53,21 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.80.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Run lint and unit tests in dev container - name: Lint and Unit Test All JavaScript Code
uses: devcontainers/ci@v0.3 run: npm run devcontainer:lint-unit
with: - name: Fix Failures
push: never if: failure()
configFile: .devcontainer/turbo-lint-unit/devcontainer.json run: |
runCmd: echo "Successfully ran lint and unit tests in dev container postStartCommand" echo "Reproduce this check locally:"
echo "npm run devcontainer:lint-unit"
echo "If you have pnpm installed, most linting errors can be fixed automatically:"
echo "pnpm turbo lint:fix"
echo "In other cases, you can open the dev container called \"Turbo Lint and Unit Tests\"."
echo "You will have the same environment as the pipeline check as well as some guidance on how to fix the errors."
core: core:
name: core name: core

View File

@@ -13,7 +13,7 @@ on:
outputs: outputs:
login_build_image: login_build_image:
description: 'The full image tag of the standalone login image' description: 'The full image tag of the standalone login image'
value: '${{ inputs.login_build_image_name }}:${{ github.sha }}' value: ${{ inputs.login_build_image_name }}:${{ github.sha }}
permissions: permissions:
packages: write packages: write
@@ -30,6 +30,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
outputs:
login_build_image: ${{ steps.short-sha.outputs.login_build_image }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Login meta - name: Login meta
@@ -41,7 +43,7 @@ jobs:
annotations: | annotations: |
manifest:org.opencontainers.image.licenses=MIT manifest:org.opencontainers.image.licenses=MIT
tags: | tags: |
type=sha,prefix=,suffix=,format=long type=sha,prefix=,format=long
- name: Login to Docker registry - name: Login to Docker registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -49,21 +51,19 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: setup-buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Bake login multi-arch - name: Bake login multi-arch
uses: docker/bake-action@v6 uses: docker/bake-action@v6
env: env:
NODE_VERSION: ${{ inputs.node_version }} NODE_VERSION: ${{ inputs.node_version }}
with: with:
source: .
push: true push: true
provenance: true provenance: true
sbom: true
targets: login-standalone targets: login-standalone
set: |
*.cache-from=type=gha
*.cache-to=type=gha,mode=max
files: | files: |
./apps/login/docker-bake.hcl ./apps/login/docker-bake.hcl
./apps/login/docker-bake-release.hcl ${{ github.event_name == 'workflow_dispatch' && './apps/login/docker-bake-release.hcl' || '' }}
./docker-bake.hcl ./docker-bake.hcl
cwd://${{ steps.login-meta.outputs.bake-file }} cwd://${{ steps.login-meta.outputs.bake-file }}

View File

@@ -0,0 +1,58 @@
name: Integration test core
on:
workflow_call:
inputs:
login_build_image:
required: true
type: string
permissions:
packages: write
jobs:
login-integration-test:
name: login-integration-test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.80.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Pull Login Build Image
run: docker compose --file .devcontainer/login-integration/docker-compose.yaml pull
env:
LOGIN_TAG: ${{ inputs.login_build_image }}
- name: Run Integration Tests against the Login and a Mocked Zitadel API
run: npm run devcontainer:integration:login
env:
LOGIN_TAG: ${{ inputs.login_build_image }}
DOCKER_BUILDKIT: 1
- name: Fix Failures
if: failure()
run: |
echo "Reproduce this check locally:"
echo "LOGIN_TAG=${{ inputs.login_build_image }} npm run devcontainer:integration:login"
echo "To fix the failures, open the dev container called \"Login Integration Tests\"."
echo "You will have the same environment as the pipeline check as well as some guidance on how to fix the errors."
- name: Show Compose Status
if: failure()
run: docker compose --file .devcontainer/base/docker-compose.yaml --file .devcontainer/login-integration-ci/docker-compose.yaml ps
- name: Print Config
if: failure()
run: COMPOSE_BAKE=1 docker compose --file .devcontainer/base/docker-compose.yaml --file .devcontainer/login-integration-ci/docker-compose.yaml config login-integration
env:
LOGIN_TAG: ${{ inputs.login_build_image }}
- name: Show Container Logs
if: failure()
run: docker compose --file .devcontainer/base/docker-compose.yaml --file .devcontainer/login-integration-ci/docker-compose.yaml logs --timestamps --no-color --tail 100 login-integration
- name: Inspect All Failed Containers
if: failure()
run: |
docker ps -a --filter "status=exited" --filter "status=created" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"
for container in $(docker ps -a --filter "status=exited" --filter "status=created" -q); do
echo "Inspecting container $container"
docker inspect $container || true
done

View File

@@ -100,9 +100,7 @@ linters:
- .keys - .keys
- .vscode - .vscode
- build - build
- console
- deploy - deploy
- docs
- guides - guides
- internal/api/ui/login/static - internal/api/ui/login/static
- openapi - openapi
@@ -111,6 +109,12 @@ linters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
- apps
- packages
- console
- docs
- load-test
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
@@ -135,9 +139,7 @@ formatters:
- .keys - .keys
- .vscode - .vscode
- build - build
- console
- deploy - deploy
- docs
- guides - guides
- internal/api/ui/login/static - internal/api/ui/login/static
- openapi - openapi
@@ -146,3 +148,8 @@ formatters:
- third_party$ - third_party$
- builtin$ - builtin$
- examples$ - examples$
- apps
- packages
- console
- docs
- load-test

View File

@@ -1,6 +1,5 @@
# Contributing to Zitadel # Contributing to Zitadel
## Introduction ## Introduction
Thank you for your interest about how to contribute! As you might know there is more than code to contribute. You can find all information needed to start contributing here. Thank you for your interest about how to contribute! As you might know there is more than code to contribute. You can find all information needed to start contributing here.
@@ -150,51 +149,19 @@ The API is designed to be used by different clients, such as web applications, m
Therefore, the API is designed to be easy to use, consistent, and reliable. Therefore, the API is designed to be easy to use, consistent, and reliable.
Please check out the dedicated [API guidelines](./API_DESIGN.md) page when contributing to the API. Please check out the dedicated [API guidelines](./API_DESIGN.md) page when contributing to the API.
#### <a name="dev-containers"></>Developing Zitadel with Dev Containers
You can use dev containers if you'd like to make sure you have the same development environment like the corresponding GitHub PR checks use.
The following dev containers are available:
- **.devcontainer/base/devcontainer.json**: Contains everything you need to run whatever you want.
- **.devcontainer/turbo-lint-unit/devcontainer.json**: Runs a dev container that executes frontent linting and unit tests and then exits. This is useful to reproduce the corresponding GitHub PR check.
- **.devcontainer/turbo-lint-unit-debug/devcontainer.json**: Runs a dev container that executes frontent linting and unit tests in watch mode. You can fix the errors right away and have immediate feedback.
- **.devcontainer/login-integration/devcontainer.json**: Runs a dev container that executes login integration tests and then exits. This is useful to reproduce the corresponding GitHub PR check.
- **.devcontainer/login-integration-debug/devcontainer.json**: Runs a dev container that spins up the login in a hot-reloading dev server and executes login integration tests interactively. You can fix the errors right away and have immediate feedback.
You can also run the GitHub PR checks locally in dev containers without having to connect to a dev container.
The following pnpm commands use the [devcontainer CLI](https://github.com/devcontainers/cli/) and exit when the checks are done.
The minimal system requirements are having Docker and the devcontainers CLI installed.
If you don't have the node_modules installed already, you need to install the devcontainers CLI manually. Run `npm i -g @devcontainers/cli`. Alternatively, the [official Microsoft VS Code extension for Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) offers a command `Dev Containers: Install devcontainer CLI`
```bash
npm run devcontainer:lint-unit
npm run devcontainer:integration:login
```
If you don't have NPM installed, copy and execute the scripts from the package.json directly.
To connect to a dev container to have full IDE support, follow the instructions provided by your code editor/IDE to initiate the dev container.
This typically involves opening the "Command Palette" or similar functionality and searching for commands related to "Dev Containers" or "Remote Containers".
The quick start guide for VS Code can found [here](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container)
For example, to build and run the Zitadel binary in a dev container, connect your IDE to the dev container described in .devcontainer/base/devcontainer.json.
Run the following commands inside the container to start Zitadel.
```bash
make compile && ./zitadel start-from-init --masterkey MasterkeyNeedsToHave32Characters --tlsMode disabled
```
Zitadel serves traffic as soon as you can see the following log line:
`INFO[0001] server is listening on [::]:8080`
## <a name="backend"></a>Contribute Backend Code ## <a name="backend"></a>Contribute Backend Code
### <a name="backend-requirements"></a> Backend Requirements
By executing the commands from this section, you run everything you need to develop the Zitadel backend locally. By executing the commands from this section, you run everything you need to develop the Zitadel backend locally.
> [!INFO]
> Some [dev containers are available](dev-containers) for remote development with docker and pipeline debugging in isolated environments.
> If you don't want to use one of the dev containers, you can develop the backend components directly on your local machine.
> To do so, proceed with installing the necessary dependencies.
Using [Docker Compose](https://docs.docker.com/compose/), you run a [PostgreSQL](https://www.postgresql.org/download/) on your local machine. Using [Docker Compose](https://docs.docker.com/compose/), you run a [PostgreSQL](https://www.postgresql.org/download/) on your local machine.
With [make](https://www.gnu.org/software/make/), you build a debuggable Zitadel binary and run it using [delve](https://github.com/go-delve/delve). With [make](https://www.gnu.org/software/make/), you build a debuggable Zitadel binary and run it using [delve](https://github.com/go-delve/delve).
Then, you test your changes via the console your binary is serving at http://<span because="breaks the link"></span>localhost:8080 and by verifying the database. Then, you test your changes via the console your binary is serving at http://<span because="breaks the link"></span>localhost:8080 and by verifying the database.
@@ -208,6 +175,8 @@ The commands in this section are tested against the following software versions:
- [Go version 1.22](https://go.dev/doc/install) - [Go version 1.22](https://go.dev/doc/install)
- [Delve 1.9.1](https://github.com/go-delve/delve/tree/v1.9.1/Documentation/installation) - [Delve 1.9.1](https://github.com/go-delve/delve/tree/v1.9.1/Documentation/installation)
### <a name="build-and-run-zitadel"></a>Build and Run Zitadel
Make some changes to the source code, then run the database locally. Make some changes to the source code, then run the database locally.
```bash ```bash
@@ -588,6 +557,48 @@ For the turbo commands, check your options with `pnpm turbo --help`
| `pnpm turbo down` | Remove containers and volumes | Shut down containers from the integration test setup `pnpm turbo down` | | `pnpm turbo down` | Remove containers and volumes | Shut down containers from the integration test setup `pnpm turbo down` |
| `pnpm turbo clean` | Remove downloaded dependencies and other generated files | Remove generated docs `pnpm turbo clean --filter zitadel-docs` | | `pnpm turbo clean` | Remove downloaded dependencies and other generated files | Remove generated docs `pnpm turbo clean --filter zitadel-docs` |
## <a name="dev-containers"></>Developing Zitadel with Dev Containers
You can use dev containers if you'd like to make sure you have the same development environment like the corresponding GitHub PR checks use.
The following dev containers are available:
- **.devcontainer/base/devcontainer.json**: Contains everything you need to run whatever you want.
- **.devcontainer/turbo-lint-unit/devcontainer.json**: Runs a dev container that executes frontent linting and unit tests and then exits. This is useful to reproduce the corresponding GitHub PR check.
- **.devcontainer/turbo-lint-unit-debug/devcontainer.json**: Runs a dev container that executes frontent linting and unit tests in watch mode. You can fix the errors right away and have immediate feedback.
- **.devcontainer/login-integration/devcontainer.json**: Runs a dev container that executes login integration tests and then exits. This is useful to reproduce the corresponding GitHub PR check.
- **.devcontainer/login-integration-debug/devcontainer.json**: Runs a dev container that spins up the login in a hot-reloading dev server and executes login integration tests interactively. You can fix the errors right away and have immediate feedback.
You can also run the GitHub PR checks locally in dev containers without having to connect to a dev container.
The following pnpm commands use the [devcontainer CLI](https://github.com/devcontainers/cli/) and exit when the checks are done.
The minimal system requirements are having Docker and the devcontainers CLI installed.
If you don't have the node_modules installed already, you need to install the devcontainers CLI manually. Run `npm i -g @devcontainers/cli@0.80.0`. Alternatively, the [official Microsoft VS Code extension for Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) offers a command `Dev Containers: Install devcontainer CLI`
```bash
npm run devcontainer:lint-unit
npm run devcontainer:integration:login
```
If you don't have NPM installed, copy and execute the scripts from the package.json directly.
To connect to a dev container to have full IDE support, follow the instructions provided by your code editor/IDE to initiate the dev container.
This typically involves opening the "Command Palette" or similar functionality and searching for commands related to "Dev Containers" or "Remote Containers".
The quick start guide for VS Code can found [here](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container)
For example, to build and run the Zitadel binary in a dev container, connect your IDE to the dev container described in .devcontainer/base/devcontainer.json.
Run the following commands inside the container to start Zitadel.
```bash
make compile && ./zitadel start-from-init --masterkey MasterkeyNeedsToHave32Characters --tlsMode disabled
```
Zitadel serves traffic as soon as you can see the following log line:
`INFO[0001] server is listening on [::]:8080`
## <a name="contribute-translations"></a>Contribute Translations ## <a name="contribute-translations"></a>Contribute Translations
Zitadel loads translations from four files: Zitadel loads translations from four files:
@@ -608,11 +619,6 @@ You also have to add some changes to the following files:
- [Customized Text Docs](./docs/docs/guides/manage/customize/texts.md) - [Customized Text Docs](./docs/docs/guides/manage/customize/texts.md)
- [Add language option](./internal/api/ui/login/static/templates/external_not_found_option.html) - [Add language option](./internal/api/ui/login/static/templates/external_not_found_option.html)
## Want to start Zitadel?
You can find an installation guide for all the different environments here:
[https://zitadel.com/docs/self-hosting/deploy/overview](https://zitadel.com/docs/self-hosting/deploy/overview)
## **Did you find a security flaw?** ## **Did you find a security flaw?**
- Please read [Security Policy](./SECURITY.md). - Please read [Security Policy](./SECURITY.md).

View File

@@ -1,5 +1,6 @@
NEXT_PUBLIC_BASE_PATH="/ui/v2/login" ZITADEL_API_URL=http://localhost:22222
ZITADEL_API_URL=http://mock-zitadel:22222
ZITADEL_SERVICE_USER_TOKEN="yolo" ZITADEL_SERVICE_USER_TOKEN="yolo"
EMAIL_VERIFICATION=true EMAIL_VERIFICATION=true
DEBUG=true DEBUG=true
PORT=3001
NEXT_PUBLIC_BASE_PATH=/ui/v2/login

View File

@@ -2,6 +2,7 @@ custom-config.js
.env*.local .env*.local
standalone standalone
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
cypress
.DS_Store .DS_Store
node_modules node_modules
@@ -11,6 +12,5 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
.vscode .vscode
/blob-report/ /blob-report/

View File

@@ -3,21 +3,21 @@ FROM node:20-alpine AS base
FROM base AS build FROM base AS build
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate && \ RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@10.13.1 --activate && \
apk update && apk add --no-cache && \ apk update && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
WORKDIR /app WORKDIR /app
COPY pnpm-lock.yaml ./ COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
COPY package.json ./ COPY package.json ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build:login:standalone RUN pnpm build:login:standalone
FROM scratch AS build-out FROM scratch AS build-out
COPY --from=build /app/.next/standalone / COPY --from=build /app/.next/standalone /
COPY --from=build /app/.next/static /.next/static COPY --from=build /app/.next/static /.next/static
COPY --from=build /app/public /public COPY public public
FROM base AS login-standalone FROM base AS login-standalone
WORKDIR /runtime WORKDIR /runtime
@@ -25,12 +25,13 @@ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs 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. # 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 RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file
COPY ./scripts/ ./ COPY --chown=nextjs:nodejs ./scripts/ ./
COPY --chown=nextjs:nodejs --from=build-out / ./ COPY --chown=nextjs:nodejs --from=build-out / ./
USER nextjs USER nextjs
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0" \
ENV PORT=3000 NEXT_PUBLIC_BASE_PATH="/ui/v2/login" \
PORT=3000
# TODO: Check healthy, not ready # TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/bin/sh", "-c", "node ./healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] CMD ["/bin/sh", "-c", "node /runtime/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"]
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["/runtime/entrypoint.sh"]

View File

@@ -8,7 +8,8 @@
!next.config.mjs !next.config.mjs
!next-env-vars.d.ts !next-env-vars.d.ts
!next-env.d.ts !next-env.d.ts
!tailwind.config.js !tailwind.config.mjs
!postcss.config.cjs
!tsconfig.json !tsconfig.json
!package.json !package.json
!pnpm-lock.yaml !pnpm-lock.yaml

View File

@@ -1,20 +0,0 @@
{
"name": "login-test-acceptance",
"private": true,
"scripts": {
"test:acceptance": "dotenv -e ../login/.env.test.local playwright",
"test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev",
"test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev",
"clean": "rm -rf .turbo node_modules"
},
"devDependencies": {
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"dotenv-cli": "^8.0.0",
"gaxios": "^7.1.0",
"typescript": "^5.8.3"
}
}

View File

@@ -2,11 +2,11 @@ import { defineConfig } from "cypress";
export default defineConfig({ export default defineConfig({
reporter: "list", reporter: "list",
video: true,
e2e: { e2e: {
baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3001/ui/v2/login", baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3001/ui/v2/login",
specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}", specPattern: "integration/integration/**/*.cy.{js,jsx,ts,tsx}",
supportFile: "support/e2e.{js,jsx,ts,tsx}", supportFile: "integration/support/e2e.{js,jsx,ts,tsx}",
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
// implement node event listeners here // implement node event listeners here
}, },

View File

@@ -1,8 +1,10 @@
FROM bufbuild/buf:1.54.0 AS proto-files FROM bufbuild/buf:1.54.0 AS dependencies
RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto && \
buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \ buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto && \
buf export https://github.com/googleapis/googleapis.git --path protos/zitadelgoogle/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files && \ buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto
buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto-files
FROM bufbuild/buf:1.54.0 AS zitadel-protos
RUN buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /zitadel
FROM golang:1.20.5-alpine3.18 AS mock-zitadel FROM golang:1.20.5-alpine3.18 AS mock-zitadel
@@ -10,6 +12,7 @@ RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178
COPY mocked-services.cfg . COPY mocked-services.cfg .
COPY initial-stubs initial-stubs COPY initial-stubs initial-stubs
COPY --from=proto-files /proto-files/ ./ COPY --from=dependencies /proto/ ./
COPY --from=zitadel-protos /zitadel/ ./zitadel/
ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ] ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ]

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -22,22 +22,22 @@ describe("verify invite", () => {
user: { user: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
human: { human: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
profile: { profile: {
givenName: "John", givenName: "John",
familyName: "Doe", familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg", avatarUrl: "https://example.com/avatar.jpg",
}, },
email: { email: {
email: "john@zitadel.com", email: "john@example.com",
isVerified: false, isVerified: false,
}, },
}, },
@@ -68,7 +68,7 @@ describe("verify invite", () => {
factors: { factors: {
user: { user: {
id: "221394658884845598", id: "221394658884845598",
loginName: "john@zitadel.com", loginName: "john@example.com",
}, },
password: undefined, password: undefined,
webAuthN: undefined, webAuthN: undefined,
@@ -93,7 +93,7 @@ describe("verify invite", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode"); stub("zitadel.user.v2.UserService", "VerifyInviteCode");
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); cy.url().should("include", Cypress.config().baseUrl + "/authenticator/set");
}); });
it("shows an error if invite code validation failed", () => { it("shows an error if invite code validation failed", () => {

View File

@@ -33,7 +33,7 @@ describe("login", () => {
factors: { factors: {
user: { user: {
id: "221394658884845598", id: "221394658884845598",
loginName: "john@zitadel.com", loginName: "john@example.com",
}, },
password: undefined, password: undefined,
webAuthN: undefined, webAuthN: undefined,
@@ -64,22 +64,22 @@ describe("login", () => {
{ {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
human: { human: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
profile: { profile: {
givenName: "John", givenName: "John",
familyName: "Doe", familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg", avatarUrl: "https://example.com/avatar.jpg",
}, },
email: { email: {
email: "john@zitadel.com", email: "john@example.com",
isVerified: true, isVerified: true,
}, },
}, },
@@ -94,8 +94,8 @@ describe("login", () => {
}); });
}); });
it("should redirect a user with password authentication to /password", () => { it("should redirect a user with password authentication to /password", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); cy.visit("/loginname?loginName=john%40example.com&submit=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); cy.url({ timeout: 5 * 60_000 }).should("include", Cypress.config().baseUrl + "/password");
}); });
describe("with passkey prompt", () => { describe("with passkey prompt", () => {
beforeEach(() => { beforeEach(() => {
@@ -112,8 +112,8 @@ describe("login", () => {
}); });
}); });
// it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => {
// cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); // cy.visit("/loginname?loginName=john%40example.com&submit=true");
// cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); // cy.location("pathname", { timeout: 5 * 60_000 }).should("eq", "/password");
// cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); // cy.get('input[type="password"]').focus().type("MyStrongPassword!1");
// cy.get('button[type="submit"]').click(); // cy.get('button[type="submit"]').click();
// cy.location("pathname", { timeout: 10_000 }).should( // cy.location("pathname", { timeout: 10_000 }).should(
@@ -134,22 +134,22 @@ describe("login", () => {
{ {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
human: { human: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
profile: { profile: {
givenName: "John", givenName: "John",
familyName: "Doe", familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg", avatarUrl: "https://example.com/avatar.jpg",
}, },
email: { email: {
email: "john@zitadel.com", email: "john@example.com",
isVerified: true, isVerified: true,
}, },
}, },
@@ -165,8 +165,8 @@ describe("login", () => {
}); });
it("should redirect a user with passwordless authentication to /passkey", () => { it("should redirect a user with passwordless authentication to /passkey", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); cy.visit("/loginname?loginName=john%40example.com&submit=true");
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); cy.url().should("include", Cypress.config().baseUrl + "/passkey");
}); });
}); });
}); });

View File

@@ -15,7 +15,7 @@ describe("register idps", () => {
cy.visit("/idp"); cy.visit("/idp");
cy.get('button[e2e="google"]').click(); cy.get('button[e2e="google"]').click();
cy.origin(IDP_URL, { args: IDP_URL }, (url) => { cy.origin(IDP_URL, { args: IDP_URL }, (url) => {
cy.location("href", { timeout: 10_000 }).should("eq", url); cy.location("href").should("eq", url);
}); });
}); });
}); });

View File

@@ -48,7 +48,7 @@ describe("register", () => {
factors: { factors: {
user: { user: {
id: "221394658884845598", id: "221394658884845598",
loginName: "john@zitadel.com", loginName: "john@example.com",
}, },
password: undefined, password: undefined,
webAuthN: undefined, webAuthN: undefined,
@@ -64,10 +64,10 @@ describe("register", () => {
cy.visit("/register"); cy.visit("/register");
cy.get('input[data-testid="firstname-text-input"]').focus().type("John"); cy.get('input[data-testid="firstname-text-input"]').focus().type("John");
cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe"); cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe");
cy.get('input[data-testid="email-text-input"]').focus().type("john@zitadel.com"); cy.get('input[data-testid="email-text-input"]').focus().type("john@example.com");
cy.get('input[type="checkbox"][value="privacypolicy"]').check(); cy.get('input[type="checkbox"][value="privacypolicy"]').check();
cy.get('input[type="checkbox"][value="tos"]').check(); cy.get('input[type="checkbox"][value="tos"]').check();
cy.get('button[type="submit"]').click(); cy.get('button[type="submit"]').click();
cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); cy.url().should("include", Cypress.config().baseUrl + "/passkey/set");
}); });
}); });

View File

@@ -24,22 +24,22 @@ describe("verify email", () => {
user: { user: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
human: { human: {
userId: "221394658884845598", userId: "221394658884845598",
state: 1, state: 1,
username: "john@zitadel.com", username: "john@example.com",
loginNames: ["john@zitadel.com"], loginNames: ["john@example.com"],
preferredLoginName: "john@zitadel.com", preferredLoginName: "john@example.com",
profile: { profile: {
givenName: "John", givenName: "John",
familyName: "Doe", familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg", avatarUrl: "https://example.com/avatar.jpg",
}, },
email: { email: {
email: "john@zitadel.com", email: "john@example.com",
isVerified: false, // email is not verified yet isVerified: false, // email is not verified yet
}, },
}, },
@@ -70,7 +70,7 @@ describe("verify email", () => {
factors: { factors: {
user: { user: {
id: "221394658884845598", id: "221394658884845598",
loginName: "john@zitadel.com", loginName: "john@example.com",
}, },
password: undefined, password: undefined,
webAuthN: undefined, webAuthN: undefined,
@@ -90,6 +90,6 @@ describe("verify email", () => {
// TODO: Avoid uncaught exception in application // TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false); cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc"); cy.visit("/verify?userId=221394658884845598&code=abc");
cy.contains("Could not verify email", { timeout: 10_000 }); cy.contains("Could not verify email");
}); });
}); });

View File

@@ -1,4 +1,4 @@
const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://mock-zitadel:22220/v1/stubs"; const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs";
function removeStub(service: string, method: string) { function removeStub(service: string, method: string) {
return cy.request({ return cy.request({

View File

@@ -4,5 +4,5 @@
"lib": ["es5", "dom"], "lib": ["es5", "dom"],
"types": ["cypress", "node"] "types": ["cypress", "node"]
}, },
"include": ["**/*.ts"] "include": ["**/*.ts", "../cypress.config.ts"]
} }

View File

@@ -27,6 +27,8 @@ declare namespace NodeJS {
/** /**
* Optional: custom request headers to be added to every request * Optional: custom request headers to be added to every request
* Split by comma, key value pairs separated by colon * Split by comma, key value pairs separated by colon
* For example: to call the Zitadel API at an internal address, you can set:
* `CUSTOM_REQUEST_HEADERS=Host:http://zitadel-internal:8080`
*/ */
CUSTOM_REQUEST_HEADERS?: string; CUSTOM_REQUEST_HEADERS?: string;
} }

View File

@@ -1,5 +1,5 @@
{ {
"packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7", "packageManager": "pnpm@10.13.1",
"name": "@zitadel/login", "name": "@zitadel/login",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -15,8 +15,7 @@
"test:unit": "vitest --run", "test:unit": "vitest --run",
"lint-staged": "lint-staged", "lint-staged": "lint-staged",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"test:integration:login": "cypress run", "test:integration:login": "wait-on --simultaneous 1 http://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc && cypress run",
"test:integration:login:debug": "cypress open",
"test:acceptance": "dotenv -e ../login/.env.test.local playwright", "test:acceptance": "dotenv -e ../login/.env.test.local playwright",
"test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev", "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev",
"test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev"
@@ -54,6 +53,11 @@
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.23.0", "@babel/eslint-parser": "^7.23.0",
"@bufbuild/buf": "^1.53.0", "@bufbuild/buf": "^1.53.0",
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/ms": "2.1.0", "@types/ms": "2.1.0",
@@ -67,34 +71,30 @@
"@vercel/git-hooks": "1.0.0", "@vercel/git-hooks": "1.0.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"concurrently": "^9.1.2",
"cypress": "^14.5.2",
"dotenv-cli": "^8.0.0",
"env-cmd": "^10.0.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "15.4.0-canary.86", "eslint-config-next": "15.4.0-canary.86",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"gaxios": "^7.1.0",
"grpc-tools": "1.13.0", "grpc-tools": "1.13.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lint-staged": "15.5.1", "lint-staged": "15.5.1",
"make-dir-cli": "4.0.0", "make-dir-cli": "4.0.0",
"nodemon": "^3.1.9",
"postcss": "8.5.3", "postcss": "8.5.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.0", "prettier-plugin-organize-imports": "^3.2.0",
"prettier-plugin-tailwindcss": "0.6.11", "prettier-plugin-tailwindcss": "0.6.11",
"sass": "^1.87.0", "sass": "^1.87.0",
"start-server-and-test": "^2.0.11",
"tailwindcss": "3.4.14", "tailwindcss": "3.4.14",
"ts-proto": "^2.7.0", "ts-proto": "^2.7.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.0.0", "vitest": "^2.0.0",
"concurrently": "^9.1.2", "wait-on": "^7.2.0"
"cypress": "^14.5.2",
"dotenv-cli": "^8.0.0",
"env-cmd": "^10.0.0",
"nodemon": "^3.1.9",
"start-server-and-test": "^2.0.11",
"@faker-js/faker": "^9.7.0",
"@otplib/core": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@playwright/test": "^1.52.0",
"gaxios": "^7.1.0"
} }
} }

9151
apps/login/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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}") export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}")
fi fi
exec node /runtime/apps/login/server.js
exec node /runtime/apps/login/apps/login/server.js

View File

@@ -1,22 +1,57 @@
{ {
"extends": ["//"], "extends": [
"//"
],
"tasks": { "tasks": {
"build": { "build": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"], "outputs": [
"dependsOn": ["@zitadel/client#build"] "dist/**",
".next/**",
"!.next/cache/**"
],
"dependsOn": [
"@zitadel/client#build"
]
}, },
"build:login:standalone": { "build:login:standalone": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"], "outputs": [
"dependsOn": ["@zitadel/client#build"] "dist/**",
".next/**",
"!.next/cache/**"
],
"dependsOn": [
"@zitadel/client#build"
]
}, },
"dev": { "dev": {
"dependsOn": ["@zitadel/client#build"] "persistent": true,
"cache": false,
"dependsOn": [
"@zitadel/client#build"
]
}, },
"test": { "test": {
"dependsOn": ["@zitadel/client#build"] "dependsOn": [
"@zitadel/client#build"
]
}, },
"test:unit": { "test:unit": {
"dependsOn": ["@zitadel/client#build"] "dependsOn": [
"@zitadel/client#build"
]
},
"test:integration:login": {
"inputs": [
".next/**",
"!.next/cache/**",
"integration/integration/**",
"integration/support/**",
"cypress.config.ts"
],
"outputs": [
"cypress/videos/**",
"cypress/screenshots/**"
]
} }
} }
} }

View File

@@ -1,45 +1,55 @@
FROM node:20-alpine AS base FROM node:20-alpine AS runtime
FROM base AS build FROM runtime AS pnpm-base
RUN apk add --no-cache libc6-compat
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate && \ RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
apk update && apk add --no-cache && \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
rm -rf /var/cache/apk/* pnpm add -g turbo@2.5.5
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml ./ FROM pnpm-base AS pruner
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile \ WORKDIR /prune
--filter @zitadel/login \
--filter @zitadel/client \
--filter @zitadel/proto
COPY package.json ./
COPY apps/login/package.json ./apps/login/package.json
COPY packages/zitadel-proto/package.json ./packages/zitadel-proto/package.json
COPY packages/zitadel-client/package.json ./packages/zitadel-client/package.json
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile \
--filter @zitadel/login \
--filter @zitadel/client \
--filter @zitadel/proto
COPY . . COPY . .
RUN pnpm turbo build:login:standalone RUN pnpm turbo prune @zitadel/login @zitadel/client @zitadel/proto --docker
FROM pnpm-base AS installer
WORKDIR /install
COPY --from=pruner /prune/out/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm fetch --frozen-lockfile
COPY --from=pruner /prune/out/json/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
FROM pnpm-base AS builder
WORKDIR /build
COPY --from=installer /install/ .
COPY --from=pruner /prune/out/full/ .
COPY proto ./proto
ENV CI=true
RUN --mount=type=cache,id=turbo,target=/build/.turbo/cache \
--mount=type=cache,id=next,target=/build/apps/login/.next/cache \
pnpm turbo build:login:standalone --cache-dir=/build/.turbo/cache
FROM scratch AS build-out FROM scratch AS build-out
COPY --from=build /app/apps/login/.next/standalone / COPY /apps/login/public ./apps/login/public
COPY --from=build /app/apps/login/.next/static /.next/static COPY --from=builder /build/apps/login/.next/standalone ./
COPY --from=build /app/apps/login/public /public COPY --from=builder /build/apps/login/.next/static ./apps/login/.next/static
FROM base AS login-standalone FROM runtime AS login-standalone
WORKDIR /runtime WORKDIR /runtime
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs 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. # 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 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 . . COPY --chown=nextjs:nodejs --from=build-out . .
# Debug the final structure
USER nextjs USER nextjs
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PORT=3000 ENV PORT=3000
# TODO: Check healthy, not ready # TODO: Check healthy, not ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/bin/sh", "-c", "node ./healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] CMD ["/bin/sh", "-c", "node /runtime/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"]
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["/runtime/entrypoint.sh"]

View File

@@ -8,7 +8,8 @@
!apps/login/next.config.mjs !apps/login/next.config.mjs
!apps/login/next-env-vars.d.ts !apps/login/next-env-vars.d.ts
!apps/login/next-env.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/tsconfig.json
!apps/login/package.json !apps/login/package.json
!apps/login/turbo.json !apps/login/turbo.json
@@ -23,6 +24,7 @@
!packages/zitadel-proto/turbo.json !packages/zitadel-proto/turbo.json
!packages/zitadel-client/package.json !packages/zitadel-client/package.json
!packages/zitadel-client/**/package.json
!packages/zitadel-client/src !packages/zitadel-client/src
!packages/zitadel-client/tsconfig.json !packages/zitadel-client/tsconfig.json
!packages/zitadel-client/tsup.config.ts !packages/zitadel-client/tsup.config.ts
@@ -30,8 +32,7 @@
!proto !proto
*.md **/*.md
*.png **/node_modules
node_modules **/*.test.ts
*.test.ts **/*.test.tsx
*.test.tsx

View File

@@ -524,7 +524,7 @@ OIDC:
PollInterval: 5s # ZITADEL_OIDC_DEVICEAUTH_POLLINTERVAL PollInterval: 5s # ZITADEL_OIDC_DEVICEAUTH_POLLINTERVAL
UserCode: UserCode:
CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET
CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARARMOUNT CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARAMOUNT
DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL
DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2
DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2

View File

@@ -34,6 +34,7 @@ import (
"github.com/zitadel/zitadel/internal/api" "github.com/zitadel/zitadel/internal/api"
"github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/assets"
internal_authz "github.com/zitadel/zitadel/internal/api/authz" 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" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta"
"github.com/zitadel/zitadel/internal/api/grpc/admin" "github.com/zitadel/zitadel/internal/api/grpc/admin"
app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" 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 { if err := apis.RegisterService(ctx, action_v2_beta.CreateServer(config.SystemDefaults, commands, queries, domain.AllActionFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
return nil, err 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 { if err := apis.RegisterService(ctx, project_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil {
return nil, err return nil, err
} }

View File

@@ -66,18 +66,24 @@
.type-icon { .type-icon {
color: $primary-color; color: $primary-color;
}
.type-button-icon,
.type-icon,
span {
margin-right: 1rem; margin-right: 1rem;
} }
.type-icon,
.type-button-icon { .type-button-icon {
position: relative; position: relative;
} }
> span {
margin-right: 1rem;
}
button[mat-icon-button] {
margin-right: 0;
.type-button-icon {
margin-right: 0;
}
}
} }
.trigger-wrapper { .trigger-wrapper {

View File

@@ -423,6 +423,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
if (allowed) { if (allowed) {
this.oidcForm.enable(); this.oidcForm.enable();
this.oidcForm.controls['clientId'].disable();
this.oidcTokenForm.enable(); this.oidcTokenForm.enable();
this.apiForm.enable(); this.apiForm.enable();
this.samlForm.enable(); this.samlForm.enable();

View File

@@ -6,8 +6,6 @@ target "login-standalone" {
target "login-standalone-out" { target "login-standalone-out" {
inherits = ["login-standalone"] inherits = ["login-standalone"]
target = "build-out" target = "build-out"
output = [ output = ["type=local,dest=.artifacts/login"]
"type=local,dest=.artifacts/login"
]
} }

View File

@@ -184,7 +184,7 @@ https://github.com/zitadel/actions/blob/main/examples/add_metadata.js
## Use provided fields of identity providers ## Use provided fields of identity providers
If you want to ensure that the data of a user are always update you can automatically update user fields during authentication and safe time of your customers and your team. 🤯 If you want to ensure that the data of a user are always up-to-date, you can automatically update user fields during authentication and save time of your customers and your team.
### Trigger ### Trigger

View File

@@ -145,7 +145,7 @@ This object contains context information about the request to the [authorization
- `requestedOrgDomain` *bool* - `requestedOrgDomain` *bool*
- `applicationResourceOwner` *string* - `applicationResourceOwner` *string*
- `privateLabelingSetting` *Number* - `privateLabelingSetting` *Number*
<ul><li>0: Unspecified</li><li>1: Enforce project resource owner policy</li><li>2: Allow login user resource owner policy</li></ul> <ul><li>0: Unspecified</li><li>1: Enforce project's policy</li><li>2: Allow user's organization login policy</li></ul>
- `selectedIdpConfigId` *string* - `selectedIdpConfigId` *string*
- `linkingUsers` Array of [*ExternalUser*](#external-user) - `linkingUsers` Array of [*ExternalUser*](#external-user)
- `passwordVerified` *bool* - `passwordVerified` *bool*

View File

@@ -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: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: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: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: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 name 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 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 primary domain 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 user's organization's primary domain. |

View File

@@ -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: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: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: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:{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: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: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. | | `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. |

View File

@@ -2,15 +2,6 @@
title: SCIM v2.0 (Preview) title: SCIM v2.0 (Preview)
--- ---
:::info
The SCIM v2 interface of Zitadel is currently in a [preview stage](/support/software-release-cycles-support#preview).
It is not yet feature-complete, may contain bugs, and is not generally available.
Do not use it for production yet.
As long as the feature is in a preview state, it will be available for free, it will be put behind a commercial license once it is fully available.
:::
The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and
access management (IAM) systems with Zitadel, access management (IAM) systems with Zitadel,
following the System for Cross-domain Identity Management (SCIM) v2.0 specification. following the System for Cross-domain Identity Management (SCIM) v2.0 specification.

View File

@@ -103,9 +103,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -129,9 +129,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -154,9 +154,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -114,9 +114,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -173,9 +173,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -107,9 +107,7 @@ curl -L -X PUT 'https://$CUSTOM-DOMAIN/v2beta/actions/executions' \
} }
}, },
"targets": [ "targets": [
{ "<TargetID returned>"
"target": "<TargetID returned>"
}
] ]
}' }'
``` ```

View File

@@ -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. 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 An execution can contain only a list of Targets, and Targets are comma separated string values.
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 not only contain a list of Targets, but also Includes. Here's an example of a Target defined on a service (e.g. `zitadel.user.v2.UserService`)
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:
```json ```json
{ {
@@ -426,13 +420,12 @@ If you define 2 Executions as follows:
} }
}, },
"targets": [ "targets": [
{ "<TargetID1>"
"target": "<TargetID1>"
}
] ]
} }
``` ```
Here's an example of a Target defined on a method (e.g. `/zitadel.user.v2.UserService/AddHumanUser`)
```json ```json
{ {
"condition": { "condition": {
@@ -441,21 +434,13 @@ If you define 2 Executions as follows:
} }
}, },
"targets": [ "targets": [
{ "<TargetID2>",
"target": "<TargetID2>" "<TargetID1>"
},
{
"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. `<TargetID2>` 1. `<TargetID2>`
2. `<TargetID1>` 2. `<TargetID1>`

View File

@@ -77,8 +77,8 @@ You can choose from
| Setting | Description | | Setting | Description |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Unspecified | If nothing is specified the default will trigger. (System settings) | | 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. | | 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 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. | | 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}`). 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}`).

View File

@@ -2,15 +2,6 @@
title: SCIM v2.0 (Preview) title: SCIM v2.0 (Preview)
--- ---
:::info
The SCIM v2 interface of Zitadel is currently in a [preview stage](/support/software-release-cycles-support#preview).
It is not yet feature-complete, may contain bugs, and is not generally available.
Do not use it for production yet.
As long as the feature is in a preview state, it will be available for free, it will be put behind a commercial license once it is fully available.
:::
The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and
access management (IAM) systems with Zitadel, access management (IAM) systems with Zitadel,
following the System for Cross-domain Identity Management (SCIM) v2.0 specification. following the System for Cross-domain Identity Management (SCIM) v2.0 specification.

View File

@@ -80,7 +80,7 @@ curl --request POST \
| Field | Type | Description | | 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 | | 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_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 | | with_otp | bool | to include the OTP-code of the users in the export |
@@ -143,7 +143,7 @@ curl --request POST \
| Field | Type | Description | | 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 | | 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_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 | | with_otp | bool | to include the OTP-code of the users in the export |

View File

@@ -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). 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). 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 ### Organizations

View File

@@ -293,7 +293,7 @@ Excitingly, v3 introduces the foundational elements for Actions V2, opening up a
### v4.x ### v4.x
**Current State**: Implementation **Current State**: General Availability / Stable
<details> <details>
@@ -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. 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: Resources integrated in this release:
- Instances - Applications (in beta)
- Authorizations (in beta)
- Instances (in beta)
- Organizations - Organizations
- Projects - Permissions (in beta)
- Projects (in beta)
- Settings (beta) now includes 3 new endpoints: `ListOrganizationSettings()`, `SetOrganizationSettings()` and `DeleteOrganizationSettings()`
- Users - Users
For more details read the [Github Issue](https://github.com/zitadel/zitadel/issues/6305) 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. 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. 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)
</details> </details>
<details> <details>
<summary>Web Keys</summary> <summary>Actions v2</summary>
Web Keys in ZITADEL are used to sign and verify JSON Web Tokens (JWT). This API enables you to manage custom executions and targets—formerly known as actions—across your entire ZITADEL instance.
ID tokens are created, signed and returned by ZITADEL when a OpenID connect (OIDC) or OAuth2 authorization flow completes and a user is authenticated. With Actions V2, you gain significantly more flexibility to tailor ZITADELs behavior compared to previous versions.
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. 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)
</details>
</details>
<details>
<summary>Deprecated endpoints</summary>
<Deprecated/>
<details>
<summary>Organization Objects V1 > Users V1</summary>
- `AddMachineKey()`
- `AddMachineUser()`
- `AddPersonalAccessToken()`
- `BulkRemoveUserMetadata()`
- `BulkSetUserMetadata()`
- `GenerateMachineSecret()`
- `GetMachineKeyByIDs()`
- `GetOrgByDomainGlobal()`
- `GetPersonalAccessTokenByIDs()`
- `GetUserMetadata()`
- `ListAppKeys()`
- `ListMachineKeys()`
- `ListPersonalAccessTokens()`
- `ListUserMetadata()`
- `RemoveMachineKey()`
- `RemoveMachineSecret()`
- `RemovePersonalAccessToken()`
- `RemoveUserMetadata()`
- `SetUserMetadata()`
- `UpdateHumanPhone()`
- `UpdateMachine()`
- `UpdateUserName()`
</details> </details>
<details> <details>
<summary>SCIM 2.0 Server - User Resource</summary> <summary>Projects V1</summary>
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. - `AddProject()`
This interface allows standardized management of IAM resources, making it easier to automate user provisioning and deprovisioning. - `AddProjectGrant()`
- `AddProjectRole()`
- [SCIM 2.0 API](https://zitadel.com/docs/apis/scim2) - `BulkAddProjectRoles()`
- [Manage Users Guide](https://zitadel.com/docs/guides/manage/user/scim2) - `DeactivateProject()`
- `DeactivateProjectGrant()`
- `GetGrantedProjectByID()`
- `GetProjectByID()`
- `GetProjectGrantByID()`
- `ListAllProjectGrants()`
- `ListGrantedProjectRoles()`
- `ListGrantedProjects()`
- `ListProjectGrants()`
- `ListProjectRoles()`
- `ListProjects()`
- `ReactivateProject()`
- `ReactivateProjectGrant()`
- `RemoveProject()`
- `RemoveProjectGrant()`
- `RemoveProjectRole()`
- `UpdateProject()`
- `UpdateProjectGrant()`
- `UpdateProjectRole()`
</details> </details>
<details> <details>
<summary>Caches</summary> <summary>Members V1</summary>
ZITADEL supports the use of a caches to speed up the lookup of frequently needed objects. - `AddIAMMember()`
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. - `AddOrgMember()`
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. - `AddProjectGrantMember()`
For example, each request made to ZITADEL needs to retrieve and set instance information in middleware. - `AddProjectMember()`
- `ListIAMMembers()`
- `ListOrgMembers()`
- `ListProjectGrantMembers()`
- `ListProjectMembers()`
- `ListUserMemberships()`
- `RemoveIAMMember()`
- `RemoveOrgMember()`
- `RemoveProjectGrantMember()`
- `RemoveProjectMember()`
- `UpdateIAMMember()`
- `UpdateOrgMember()`
- `UpdateProjectGrantMember()`
- `UpdateProjectMember()`
</details>
Read more about Zitadel Caches [here](https://zitadel.com/docs/self-hosting/manage/cache) <details>
<summary>Instance Lifecycle V1 > System Service V1</summary>
- `AddInstanceTrustedDomain()`
- `GetMyInstance()`
- `ListInstanceDomains()`
- `ListInstanceTrustedDomains()`
- `RemoveInstanceTrustedDomain()`
</details>
<details>
<summary>Instance Objects V1 > Organizations V1 </summary>
- `GetDefaultOrg()`
- `GetOrgByID()`
- `IsOrgUnique()`
</details> </details>
</details> </details>

View File

@@ -0,0 +1 @@
*.pat

View File

@@ -1,8 +1,6 @@
Open your favorite internet browser and navigate to [http://localhost:8080/ui/console](http://localhost:8080/ui/console). Open your favorite internet browser and navigate to http://localhost:8080/ui/console?login_hint=zitadel-admin@zitadel.localhost.
This is the default IAM admin users login: Enther the password *Password1!* to log in.
- **username**: *zitadel-admin@<span></span>zitadel.localhost*
- **password**: *Password1!*
:::info :::info
In the above login hint in the URL, replace localhost with your configured external domain, if any. e.g. with *zitadel-admin@<span></span>zitadel.sso.my.domain.tld*
In the above username, replace localhost with your configured external domain, if any. e.g. with *zitadel-admin@<span></span>zitadel.sso.my.domain.tld* :::

View File

@@ -1,18 +1,17 @@
--- ---
title: Set up ZITADEL with Docker Compose title: Set up Zitadel with Docker Compose
sidebar_label: Docker Compose sidebar_label: Docker Compose
--- ---
import CodeBlock from '@theme/CodeBlock'; import CodeBlock from '@theme/CodeBlock';
import DockerComposeSource from '!!raw-loader!./docker-compose.yaml' import DockerComposeSource from '!!raw-loader!./docker-compose.yaml'
import DockerComposeSaSource from '!!raw-loader!./docker-compose-sa.yaml'
import Disclaimer from './_disclaimer.mdx' import Disclaimer from './_disclaimer.mdx'
import DefaultUser from './_defaultuser.mdx' import DefaultUser from './_defaultuser.mdx'
import Next from './_next.mdx' import Next from './_next.mdx'
import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx'; import NoteInstanceNotFound from './troubleshooting/_note_instance_not_found.mdx';
The setup is tested against Docker version 20.10.17 and Docker Compose version v2.2.3 The setup is tested against Docker version 28.3.2 and Docker Compose version v2.38.2
## Docker compose ## Docker compose
@@ -27,41 +26,24 @@ By executing the commands below, you will download the following file:
# Download the docker compose example configuration. # Download the docker compose example configuration.
wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/docker-compose.yaml wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/deploy/docker-compose.yaml
# Run the database and application containers. # Make sure you have the latest image versions
docker compose up --detach docker compose pull
# Run the PostgreSQL database, the Zitadel API and the Zitadel login.
docker compose up
``` ```
<DefaultUser components={props.components} /> <DefaultUser components={props.components} />
<NoteInstanceNotFound/> :::info
If you ran these commands for an existing instance that still uses the login v1, [create a login client for it to the now running v2 login](/self-hosting/manage/login-client#create-login-client).
## VideoGuide Move the login client PAT to `./login-client.pat` and restart the login container.
<iframe width="100%" height="315" src="https://www.youtube.com/embed/-02FaoN9Fko" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Docker compose with service account
By executing the commands below, you will download the following file:
<details>
<summary>docker-compose-sa.yaml</summary>
<CodeBlock language="yaml">{DockerComposeSaSource}</CodeBlock>
</details>
```bash ```bash
# Download the docker compose example configuration. docker compose restart login
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
``` ```
Now, [enable the Login UI for all users](/self-hosting/manage/login-client#require-login-v2)
:::
This key can be used to provision resources with for example [Terraform](/docs/guides/manage/terraform-provider). <NoteInstanceNotFound/>
<Next components={props.components} /> <Next components={props.components} />
<Disclaimer components={props.components} /> <Disclaimer components={props.components} />

View File

@@ -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:

View File

@@ -1,11 +1,11 @@
services: services:
zitadel: zitadel:
restart: 'always' restart: unless-stopped
networks: image: ghcr.io/zitadel/zitadel:latest
- 'zitadel' command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
image: 'ghcr.io/zitadel/zitadel:latest'
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
environment: environment:
ZITADEL_EXTERNALSECURE: false
ZITADEL_TLS_ENABLED: false
ZITADEL_DATABASE_POSTGRES_HOST: db ZITADEL_DATABASE_POSTGRES_HOST: db
ZITADEL_DATABASE_POSTGRES_PORT: 5432 ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
@@ -15,27 +15,84 @@ services:
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALSECURE: false # By configuring a login client, the setup job creates a user of type machine with the role IAM_LOGIN_CLIENT.
# It writes a PAT to the path specified in ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH.
# The PAT is passed to the login container via the environment variable ZITADEL_SERVICE_USER_TOKEN_FILE.
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z'
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login
ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest=
# By configuring a machine, the setup job creates a user of type machine with the role IAM_OWNER.
# It writes a personal access token (PAT) to the path specified in ZITADEL_FIRSTINSTANCE_PATPATH.
# The PAT can be used to provision resources with [Terraform](/docs/guides/manage/terraform-provider), for example.
ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE: 1
healthcheck:
test:
- CMD
- /app/zitadel
- ready
interval: 10s
timeout: 60s
retries: 5
start_period: 10s
volumes:
- .:/current-dir:delegated
ports:
- 8080:8080
- 3000:3000
networks:
- zitadel
depends_on: depends_on:
db: db:
condition: 'service_healthy' condition: service_healthy
ports:
- '8080:8080' login:
restart: unless-stopped
image: ghcr.io/zitadel/zitadel-login:latest
# If you can't use the network_mode service:zitadel, you can pass the environment variable CUSTOM_REQUEST_HEADERS=Host:localhost instead.
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}"
network_mode: service:zitadel
volumes:
- .:/current-dir:ro
depends_on:
zitadel:
condition: service_healthy
restart: false
db: db:
restart: 'always' restart: unless-stopped
image: postgres:17-alpine image: postgres:17-alpine
environment: environment:
PGUSER: postgres PGUSER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
networks:
- 'zitadel'
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"] test:
interval: '10s' - CMD-SHELL
timeout: '30s' - pg_isready
- -d
- zitadel
- -U
- postgres
interval: 10s
timeout: 30s
retries: 5 retries: 5
start_period: '20s' start_period: 20s
networks:
- zitadel
networks: networks:
zitadel: zitadel:

View File

@@ -1 +0,0 @@
.env-file

View File

@@ -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:

View File

@@ -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

View File

@@ -1,29 +0,0 @@
# 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.
TLS.Enabled: false
# If not using the docker compose example, adjust these values for connecting ZITADEL to your PostgreSQL
Database:
postgres:
Host: 'db'
Port: 5432
Database: zitadel
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"

View File

@@ -1,12 +0,0 @@
# All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/setup/steps.yaml
FirstInstance:
PatPath: '/pat'
Org:
# We want to authenticate immediately at the console without changing the password
Human:
PasswordChangeRequired: false
Machine:
Machine:
Username: 'login-container'
Name: 'Login Container'
Pat.ExpirationDate: '2029-01-01T00:00:00Z'

View File

@@ -1,12 +0,0 @@
# All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml
# If not using the docker compose example, adjust these values for connecting ZITADEL to your PostgreSQL
Database:
postgres:
User:
# If the user doesn't exist already, it is created
Username: 'zitadel_user'
Password: 'zitadel'
Admin:
Username: 'root'
Password: 'postgres'

View File

@@ -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:
<details>
<summary>docker-compose.yaml</summary>
<CodeBlock language="yaml">{DockerComposeSource}</CodeBlock>
</details>
<details>
<summary>example-traefik.yaml</summary>
<CodeBlock language="yaml">{ExampleTraefikSource}</CodeBlock>
</details>
<details>
<summary>example-zitadel-config.yaml</summary>
<CodeBlock language="yaml">{ExampleZITADELConfigSource}</CodeBlock>
</details>
<details>
<summary>example-zitadel-secrets.yaml</summary>
<CodeBlock language="yaml">{ExampleZITADELSecretsSource}</CodeBlock>
</details>
<details>
<summary>example-zitadel-init-steps.yaml</summary>
<CodeBlock language="yaml">{ExampleZITADELInitStepsSource}</CodeBlock>
</details>
```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:]' </dev/urandom | head -c 32 > ./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).

View File

@@ -0,0 +1,47 @@
---
title: Connect your Self-Hosted Login UI to Zitadel
sidebar_label: Create a Login Client
---
To enable your self-hosted Login UI to connect to the Zitadel API, it needs a token for a user with the IAM_LOGIN_CLIENT role.
On new installations, the Zitadel setup job can be configured to automatically write a Personal Access Token (PAT) for the login client.
Check out [one of the deployment examples](https://zitadel.com/docs/self-hosting/deploy/overview) to learn how to do this.
However, if you want to replace the v1 login of an existing installation by a self-hosted v2 login, the setup job won't execute these steps.
In that case, you can create a new PAT for the login client manually.
## Create a Login Client User{#create-login-client}
In the following URLs, replace the base URL and the user ID according to your environment.
1. Create a new machine user, for example at http://localhost:8080/ui/console/users/create-machine
2. Create a PAT, for example at http://localhost:8080/ui/console/users/332169800719532035?new=true&id=pat
3. Save the PAT to a file, for example `/path/on/your/host/login-client.pat`
4. Make sure the user has the `Iam Login Client` role (internally called `IAM_LOGIN_CLIENT`), for example at http://localhost:8080/ui/console/instance/members
# Configure the Login UI
Make sure your Login UI has the environment variable `ZITADEL_SERVICE_USER_TOKEN` set with your PAT.
If you run the Login UI with Docker, you can also mount the file into the container and reference it by passing the environment variable `ZITADEL_SERVICE_USER_TOKEN_FILE`.
For example:
```bash
docker run -p 3000:3000 -v /path/on/your/host/login-client.pat:/path/in/container/login-client.pat:ro -e ZITADEL_SERVICE_USER_TOKEN_FILE=/path/in/container/login-client.pat ghcr.io/zitadel/zitadel-login:latest
```
# Enable the Login UI for all users{#require-login-v2}
:::caution
Before doing this, make sure you have a working PAT for an Iam Owner user.
In case something goes wrong and you lock yourself out from the login screen, you can revert the changes.
Create a machine user PAT like you created the [login client PAT above](#create-login-client), but give the user the Iam Owner role (internally called `IAM_OWNER`).
:::
Enable the `Login V2` feature flag, for example at the bottom of http://localhost:8080/ui/console/instance?id=features.
Enter the base URI of your Login UI, for example `http://localhost:3000/ui/v2/login`.
# Test
That's it!
Click your users avatar in the top right corner of the console and select `Log in With Another Account`.
You should see the new Login UI.

View File

@@ -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.

View File

@@ -23,6 +23,7 @@ module.exports = {
description: description:
"Documentation for ZITADEL - Identity infrastructure, simplified for you.", "Documentation for ZITADEL - Identity infrastructure, simplified for you.",
}, },
themeConfig: { themeConfig: {
metadata: [ metadata: [
{ {
@@ -450,9 +451,13 @@ module.exports = {
}; };
}, },
], ],
markdown: {
mermaid:true,
},
themes: [ themes: [
"docusaurus-theme-github-codeblock", "docusaurus-theme-github-codeblock",
"docusaurus-theme-openapi-docs", "docusaurus-theme-openapi-docs",
'@docusaurus/theme-mermaid',
], ],
future: { future: {
v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040

View File

@@ -67,6 +67,5 @@
"@docusaurus/module-type-aliases": "^3.8.1", "@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/types": "^3.8.1", "@docusaurus/types": "^3.8.1",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
}, }
"packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7"
} }

View File

@@ -751,10 +751,10 @@ module.exports = {
label: "Organization (Beta)", label: "Organization (Beta)",
link: { link: {
type: "generated-index", type: "generated-index",
title: "Organization Service beta API", title: "Organization Service Beta API",
slug: "/apis/resources/org_service/v2beta", slug: "/apis/resources/org_service/v2beta",
description: description:
"This API is intended to manage organizations for ZITADEL. \n", "This beta API is intended to manage organizations for ZITADEL. Expect breaking changes to occur. Please use the v2 version for a stable API. \n",
}, },
items: sidebar_api_org_service_v2beta, items: sidebar_api_org_service_v2beta,
}, },
@@ -1084,7 +1084,6 @@ module.exports = {
"self-hosting/deploy/devcontainer", "self-hosting/deploy/devcontainer",
"self-hosting/deploy/knative", "self-hosting/deploy/knative",
"self-hosting/deploy/kubernetes", "self-hosting/deploy/kubernetes",
"self-hosting/deploy/loadbalancing-example/loadbalancing-example",
"self-hosting/deploy/troubleshooting/troubleshooting", "self-hosting/deploy/troubleshooting/troubleshooting",
], ],
}, },
@@ -1095,6 +1094,7 @@ module.exports = {
items: [ items: [
"self-hosting/manage/production", "self-hosting/manage/production",
"self-hosting/manage/productionchecklist", "self-hosting/manage/productionchecklist",
"self-hosting/manage/login-client",
"self-hosting/manage/configure/configure", "self-hosting/manage/configure/configure",
{ {
type: "category", type: "category",
@@ -1118,6 +1118,7 @@ module.exports = {
"self-hosting/manage/tls_modes", "self-hosting/manage/tls_modes",
"self-hosting/manage/database/database", "self-hosting/manage/database/database",
"self-hosting/manage/cache", "self-hosting/manage/cache",
"self-hosting/manage/service_ping",
"self-hosting/manage/updating_scaling", "self-hosting/manage/updating_scaling",
"self-hosting/manage/usage_control", "self-hosting/manage/usage_control",
{ {

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useContext, useEffect, useState } from "react"; import { Fragment, useContext, useEffect, useState } from "react";
import { AuthRequestContext } from "../utils/authrequest"; import { AuthRequestContext } from "../utils/authrequest";
import { Listbox } from "@headlessui/react"; import { Listbox } from "@headlessui/react";
import { Transition } from "@headlessui/react"; import { Transition } from "@headlessui/react";
@@ -111,10 +111,18 @@ export function SetAuthRequest() {
"urn:zitadel:iam:org:project:id:zitadel:aud", "urn:zitadel:iam:org:project:id:zitadel:aud",
"urn:zitadel:iam:user:metadata", "urn:zitadel:iam:user:metadata",
`urn:zitadel:iam:org:id:${ `urn:zitadel:iam:org:id:${
organizationId ? organizationId : "[organizationId]" organizationId ? organizationId : "[Organization ID]"
}`, }`,
]; ];
const scopeExplanations = new Map([
['urn:zitadel:iam:org:project:id:zitadel:aud', 'Requested projectid will be added to the audience of the access token.'],
['urn:zitadel:iam:user:metadata', 'Metadata of the user will be included in the token. The values are base64 encoded.'],
[`urn:zitadel:iam:org:id:${
organizationId ? organizationId : "[organizationId]"
}`, 'Enforce that the user is a member of the selected organization.']
]);
const [scopeState, setScopeState] = useState( const [scopeState, setScopeState] = useState(
[true, true, true, false, false, false, false, false] [true, true, true, false, false, false, false, false]
// new Array(allScopes.length).fill(false) // new Array(allScopes.length).fill(false)
@@ -161,8 +169,13 @@ export function SetAuthRequest() {
return input; return input;
}; };
useEffect(async () => { useEffect(() => {
setCodeChallenge(await encodeCodeChallenge(codeVerifier)); const updateCodeChallange = async () => {
const newCodeChallange = await encodeCodeChallenge(codeVerifier)
setCodeChallenge(newCodeChallange);
}
updateCodeChallange();
}, [codeVerifier]); }, [codeVerifier]);
useEffect(() => { useEffect(() => {
@@ -525,7 +538,7 @@ export function SetAuthRequest() {
const value = event.target.value; const value = event.target.value;
setOrganizationId(value); setOrganizationId(value);
allScopes[7] = `urn:zitadel:iam:org:id:${ allScopes[7] = `urn:zitadel:iam:org:id:${
value ? value : "[organizationId]" value ? value : "[Organization ID]"
}`; }`;
toggleScope(8, true); toggleScope(8, true);
setScope( setScope(
@@ -559,6 +572,7 @@ export function SetAuthRequest() {
name="scopes" name="scopes"
value={`${scope}`} value={`${scope}`}
checked={scopeState[scopeIndex]} checked={scopeState[scopeIndex]}
disabled={scope === 'openid'}
onChange={() => { onChange={() => {
toggleScope(scopeIndex); toggleScope(scopeIndex);
}} }}
@@ -571,6 +585,11 @@ export function SetAuthRequest() {
</strong> </strong>
) : null} ) : null}
</label> </label>
{scopeExplanations.has(scope) && (
<span className={clsx(hintClasses, 'ml-1')}>
{scopeExplanations.get(scope)}
</span>
)}
</div> </div>
); );
})} })}

View File

@@ -4,7 +4,6 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout"; import Layout from "@theme/Layout";
import ThemedImage from "@theme/ThemedImage"; import ThemedImage from "@theme/ThemedImage";
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
import Column from "../components/column"; import Column from "../components/column";
import { import {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useEffect, useState } from "react";
export const AuthRequestContext = React.createContext(null); export const AuthRequestContext = React.createContext(null);
@@ -34,7 +34,7 @@ export default ({ children }) => {
const id_token_hint = params.get("id_token_hint"); const id_token_hint = params.get("id_token_hint");
const organization_id = params.get("organization_id"); const organization_id = params.get("organization_id");
setInstance(instance_param ?? "https://mydomain-xyza.zitadel.cloud/"); setInstance(instance_param ?? "http://localhost:8080/");
setClientId(client_id ?? "170086824411201793@yourapp"); setClientId(client_id ?? "170086824411201793@yourapp");
setRedirectUri( setRedirectUri(
redirect_uri ?? "http://localhost:8080/api/auth/callback/zitadel" redirect_uri ?? "http://localhost:8080/api/auth/callback/zitadel"

View File

@@ -17,7 +17,7 @@
"clean": "rm -rf .turbo node_modules" "clean": "rm -rf .turbo node_modules"
}, },
"private": true, "private": true,
"dependencies": { "devDependencies": {
"@types/pg": "^8.11.6", "@types/pg": "^8.11.6",
"cypress-wait-until": "^3.0.2", "cypress-wait-until": "^3.0.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -26,10 +26,8 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"wait-on": "^7.2.0" "wait-on": "^7.2.0",
},
"devDependencies": {
"@types/node": "^22.3.0", "@types/node": "^22.3.0",
"cypress": "^13.13.3" "cypress": "^14.5.3"
} }
} }

View File

@@ -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(),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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())
})
}
}

View File

@@ -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")
})
}
}

View File

@@ -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()
}())
}

View File

@@ -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)
}
}

View File

@@ -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, "/")}}}
}

View File

@@ -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
}

Some files were not shown because too many files have changed in this diff Show More